crates/gunnar-forge/src/json.rs
//! **Reading six keys out of an object that has ninety.**
//!
//! A GitHub repository object is ~100 fields, a GitLab project ~80, a Forgejo
//! repository ~60. This crate wants six of them. The two ways to do that are a
//! `#[derive(Deserialize)]` struct that declares the shape, and these
//! accessors over a `serde_json::Value`.
//!
//! The struct is what one reaches for, and it is the wrong tool here for a
//! reason that only shows up in production: **a forge changes its JSON and the
//! picker goes empty**. GitHub adds fields constantly; GitLab renamed
//! `visibility_level` to `visibility`; Forgejo has removed Gitea fields in its
//! fork. With a strict struct, one unexpected `null` in one field of one
//! repository fails the whole page — an error message about a serde field
//! path, shown to someone who only wanted to pick a repository to mirror.
//!
//! With these, an unreadable field degrades to `None`/`false`/`""` and the
//! repository is still listed and still clonable, because `full_name` and the
//! two clone URLs are the only fields the crate actually *requires* — and those
//! are the ones a missing value genuinely makes the row useless, so those alone
//! are hard errors.
//!
//! Skipping `serde_derive` also keeps `syn`, `quote` and `proc-macro2` out of
//! `cargo tree -e normal`, which is the number this crate is measured on.
use serde_json::Value;
/// A required string field. `None` means the row cannot be used at all.
pub(crate) fn req_str<'a>(obj: &'a Value, key: &str) -> Option<&'a str> {
obj.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
}
/// An optional string, empty treated as absent — every one of these forges
/// spells "no description" as both `null` and `""` depending on version.
pub(crate) fn opt_string(obj: &Value, key: &str) -> Option<String> {
obj.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// A string that is allowed to be missing, flattened to `""`.
///
/// Used for `default_branch`, which every forge reports as `null` or `""` on a
/// repository with no commits. That is a real and common state (a freshly
/// created repository someone wants to mirror *into*), not an error.
pub(crate) fn str_or_empty(obj: &Value, key: &str) -> String {
obj.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
/// A boolean, missing treated as `false`.
///
/// `archived` is absent on old Gitea versions. Absent means "this forge does
/// not have the concept", which for a picker is the same as "not archived" —
/// the alternative, refusing the row, would make gunnar unable to list a
/// repository on an older self-hosted Forgejo at all.
pub(crate) fn bool_or_false(obj: &Value, key: &str) -> bool {
obj.get(key).and_then(Value::as_bool).unwrap_or(false)
}
/// A non-negative integer, if present and actually a number.
pub(crate) fn opt_u64(obj: &Value, key: &str) -> Option<u64> {
obj.get(key).and_then(Value::as_u64)
}
/// The top-level array of a list response.
///
/// GitLab and Forgejo answer a bare JSON array. GitHub answers a bare array for
/// `/user/repos` too, but wraps it in `{"total_count":…,"items":[…]}` for
/// `/search/repositories` — and someone will point a self-hosted base URL at a
/// proxy that does the same. Accepting either costs four lines and turns a
/// class of "it returns nothing and does not say why" into a working list.
pub(crate) fn as_array(v: &Value) -> Option<&Vec<Value>> {
match v {
Value::Array(a) => Some(a),
Value::Object(o) => o.get("items").and_then(Value::as_array),
_ => None,
}
}