ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod cost;
pub(crate) mod gemini;
pub(crate) mod host;
pub(crate) mod host_loop;
pub(crate) mod opencode;

use serde_json::Value;

/// Recursively search a JSON value for the first matching string by candidate
/// key names. Descends into objects and arrays depth-first.
pub(crate) fn string_key(value: &Value, keys: &[&str]) -> Option<String> {
    match value {
        Value::Object(map) => {
            for key in keys {
                if let Some(Value::String(found)) = map.get(*key) {
                    return Some(found.clone());
                }
            }
            for child in map.values() {
                if let Some(found) = string_key(child, keys) {
                    return Some(found);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(|item| string_key(item, keys)),
        _ => None,
    }
}

/// Recursively search a JSON value for the first matching numeric value by
/// candidate key names. Accepts both JSON numbers and stringified integers.
pub(crate) fn number_key(value: &Value, keys: &[&str]) -> Option<u64> {
    match value {
        Value::Object(map) => {
            for key in keys {
                if let Some(found) = map.get(*key) {
                    if let Some(number) = as_u64(found) {
                        return Some(number);
                    }
                }
            }
            for child in map.values() {
                if let Some(found) = number_key(child, keys) {
                    return Some(found);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(|item| number_key(item, keys)),
        _ => None,
    }
}

/// Coerce a JSON value to u64, accepting both numbers and parseable strings.
pub(crate) fn as_u64(value: &Value) -> Option<u64> {
    match value {
        Value::Number(number) => number.as_u64(),
        Value::String(text) => text.parse().ok(),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // ── as_u64 ──────────────────────────────────────────────────────

    #[test]
    fn as_u64_from_number() {
        assert_eq!(as_u64(&json!(42)), Some(42));
    }

    #[test]
    fn as_u64_from_zero() {
        assert_eq!(as_u64(&json!(0)), Some(0));
    }

    #[test]
    fn as_u64_from_string() {
        assert_eq!(as_u64(&json!("1024")), Some(1024));
    }

    #[test]
    fn as_u64_rejects_negative() {
        assert_eq!(as_u64(&json!(-1)), None);
    }

    #[test]
    fn as_u64_rejects_float() {
        assert_eq!(as_u64(&json!(1.5)), None);
    }

    #[test]
    fn as_u64_rejects_non_numeric_string() {
        assert_eq!(as_u64(&json!("hello")), None);
    }

    #[test]
    fn as_u64_rejects_bool() {
        assert_eq!(as_u64(&json!(true)), None);
    }

    #[test]
    fn as_u64_rejects_null() {
        assert_eq!(as_u64(&json!(null)), None);
    }

    // ── string_key ──────────────────────────────────────────────────

    #[test]
    fn string_key_flat_object() {
        let v = json!({"name": "alice", "role": "admin"});
        assert_eq!(string_key(&v, &["name"]), Some("alice".into()));
    }

    #[test]
    fn string_key_first_candidate_wins() {
        let v = json!({"a": "first", "b": "second"});
        assert_eq!(string_key(&v, &["a", "b"]), Some("first".into()));
    }

    #[test]
    fn string_key_fallback_candidate() {
        let v = json!({"b": "fallback"});
        assert_eq!(string_key(&v, &["a", "b"]), Some("fallback".into()));
    }

    #[test]
    fn string_key_nested_object() {
        let v = json!({"outer": {"inner": {"target": "found"}}});
        assert_eq!(string_key(&v, &["target"]), Some("found".into()));
    }

    #[test]
    fn string_key_inside_array() {
        let v = json!([{"x": 1}, {"name": "bob"}]);
        assert_eq!(string_key(&v, &["name"]), Some("bob".into()));
    }

    #[test]
    fn string_key_skips_non_string_match() {
        let v = json!({"count": 42, "nested": {"count": "forty-two"}});
        // Top-level "count" is a number, not a string — should descend and
        // find the nested string value.
        assert_eq!(string_key(&v, &["count"]), Some("forty-two".into()));
    }

    #[test]
    fn string_key_missing_returns_none() {
        let v = json!({"a": "b"});
        assert_eq!(string_key(&v, &["missing"]), None);
    }

    #[test]
    fn string_key_empty_candidates() {
        let v = json!({"a": "b"});
        assert_eq!(string_key(&v, &[]), None);
    }

    #[test]
    fn string_key_scalar_value() {
        assert_eq!(string_key(&json!("plain"), &["x"]), None);
        assert_eq!(string_key(&json!(42), &["x"]), None);
    }

    // ── number_key ──────────────────────────────────────────────────

    #[test]
    fn number_key_flat_object() {
        let v = json!({"tokens": 500});
        assert_eq!(number_key(&v, &["tokens"]), Some(500));
    }

    #[test]
    fn number_key_string_coercion() {
        let v = json!({"tokens": "1024"});
        assert_eq!(number_key(&v, &["tokens"]), Some(1024));
    }

    #[test]
    fn number_key_nested() {
        let v = json!({"usage": {"prompt_tokens": 200}});
        assert_eq!(number_key(&v, &["prompt_tokens"]), Some(200));
    }

    #[test]
    fn number_key_inside_array() {
        let v = json!([{"other": "x"}, {"count": 7}]);
        assert_eq!(number_key(&v, &["count"]), Some(7));
    }

    #[test]
    fn number_key_first_candidate_wins() {
        let v = json!({"a": 1, "b": 2});
        assert_eq!(number_key(&v, &["a", "b"]), Some(1));
    }

    #[test]
    fn number_key_missing_returns_none() {
        let v = json!({"a": 1});
        assert_eq!(number_key(&v, &["missing"]), None);
    }

    #[test]
    fn number_key_skips_non_numeric_string() {
        let v = json!({"val": "hello"});
        assert_eq!(number_key(&v, &["val"]), None);
    }

    #[test]
    fn number_key_zero() {
        let v = json!({"n": 0});
        assert_eq!(number_key(&v, &["n"]), Some(0));
    }
}