Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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,
    }
}