Skip to main content

agentd/config/
paths.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Schema-derived **path bindings**: every path in the config-file schema is
3//! also settable as an env var and as a generic `--<path>` flag, with names
4//! derived mechanically from the path — so a re-defined parameter set needs no
5//! per-field plumbing here.
6//!
7//! For a config path `limits.max_steps`:
8//!
9//! | source | name                                                    |
10//! |--------|---------------------------------------------------------|
11//! | file   | `limits: { max_steps: 5 }` (YAML or JSON)               |
12//! | env    | `AGENTD_LIMITS_MAX_STEPS` > `AGENT_LIMITS_MAX_STEPS` > `LIMITS_MAX_STEPS` |
13//! | flag   | `--limits.max_steps 5` / `--limits.max-steps 5` / `--limits-max-steps 5` |
14//!
15//! The env candidates are the branded, the neutral, and the bare spelling of
16//! the upper-cased path with `.` → `_`; the first present wins.
17//! A flag is the path with `.`/`_` → `-` (any of the three spellings above
18//! canonicalizes to the same flag). Values are typed by the schema's declared
19//! type ([`Kind`]): integers/numbers/booleans parse, enums are checked against
20//! their allowed set, arrays take a `[a, b]` literal or a comma-separated list,
21//! objects take a `{k: v}` / JSON literal — everything else is the verbatim
22//! string. The typed [`super::file::ConfigFile`] then re-validates the merged
23//! document exactly as it does the file (unknown keys, ranges).
24//!
25//! A dotted flag may also reach INTO a free-form map (a schema object with
26//! `additionalProperties`): `--intelligence_headers.x-team ops` sets ONE key of
27//! that map (the key keeps its exact spelling — no canonicalization past the
28//! schema path), typed by the map's value type. Array elements are not
29//! addressable by path (set the whole list, or use the named repeatable flag).
30//!
31//! The single source of truth is [`super::file::config_schema`] — the same
32//! JSON Schema `--config-schema` prints — walked once at startup.
33
34use super::file::config_schema;
35use super::yaml;
36use serde_json::{Map, Value};
37use std::collections::HashMap;
38
39/// The env-name prefixes tried for every path, most-specific first: branded
40/// (`AGENTD_`), neutral (`AGENT_`), then the bare path. Most-specific-first is
41/// what makes a branded name beat a neutral one that happens to collide.
42pub const ENV_PREFIXES: [&str; 3] = ["AGENTD_", "AGENT_", ""];
43
44/// The value type a config path takes, per its JSON Schema.
45#[derive(Debug, Clone, PartialEq)]
46pub enum Kind {
47    String,
48    Integer,
49    Number,
50    Boolean,
51    /// A closed string set — the allowed values, checked at coercion time.
52    Enum(Vec<String>),
53    /// A list; the item kind types each comma-separated / literal element.
54    Array(Box<Kind>),
55    /// A free-form object (a map with `additionalProperties`, or an array item
56    /// object): set from a `{…}` literal.
57    Object,
58    /// Untyped — parsed as an inline YAML/JSON value.
59    Any,
60}
61
62impl Kind {
63    /// The `<TYPE>` hint shown in `--help`.
64    pub fn hint(&self) -> String {
65        match self {
66            Kind::String => "<string>".into(),
67            Kind::Integer => "<int>".into(),
68            Kind::Number => "<number>".into(),
69            Kind::Boolean => "<bool>".into(),
70            Kind::Enum(vs) => format!("<{}>", vs.join("|")),
71            Kind::Array(k) => format!("<list of {}>", k.hint().trim_matches(['<', '>'])),
72            Kind::Object => "<object literal>".into(),
73            Kind::Any => "<value>".into(),
74        }
75    }
76}
77
78/// One config-file path with its schema type and (optional) description.
79#[derive(Debug, Clone, PartialEq)]
80pub struct Binding {
81    /// Dotted path from the document root, e.g. `limits.max_steps`.
82    pub path: String,
83    pub kind: Kind,
84    pub description: Option<String>,
85    /// For a free-form map leaf ([`Kind::Object`] with `additionalProperties`):
86    /// the type of each entry, so a `--<path>.<key> <value>` flag can type the
87    /// single entry it sets. `None` for every other kind.
88    pub entry_kind: Option<Kind>,
89}
90
91impl Binding {
92    /// The env-var names that set this path, most-specific first.
93    pub fn env_names(&self) -> Vec<String> {
94        let base = self.path.to_ascii_uppercase().replace('.', "_");
95        ENV_PREFIXES.iter().map(|p| format!("{p}{base}")).collect()
96    }
97
98    /// The canonical generic flag: `--<path>` with `.`/`_` → `-`.
99    pub fn flag(&self) -> String {
100        format!("--{}", canonical_flag_body(&self.path))
101    }
102
103    /// Type a raw string (an env value / a flag value) per this path's kind.
104    pub fn coerce(&self, raw: &str) -> Result<Value, String> {
105        coerce(&self.kind, raw)
106    }
107}
108
109/// `limits.max_steps` / `limits-max-steps` / `limits.max-steps` → `limits-max-steps`.
110fn canonical_flag_body(s: &str) -> String {
111    s.replace(['.', '_'], "-")
112}
113
114/// Every path in the (v1) config-file schema, in schema order (nested objects
115/// are walked; arrays and free-form maps are leaves).
116pub fn bindings() -> Vec<Binding> {
117    bindings_of(&config_schema())
118}
119
120/// Every path of an arbitrary JSON Schema document (the same walk, for the v2
121/// settings schema or any future one).
122pub fn bindings_of(schema: &Value) -> Vec<Binding> {
123    let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
124    let mut out = Vec::new();
125    walk_object(schema, &defs, "", &mut out);
126    out
127}
128
129fn walk_object(obj_schema: &Value, defs: &Value, prefix: &str, out: &mut Vec<Binding>) {
130    let Some(props) = obj_schema.get("properties").and_then(Value::as_object) else {
131        return;
132    };
133    for (name, prop) in props {
134        let prop = resolve_ref(prop, defs);
135        let path = if prefix.is_empty() {
136            name.clone()
137        } else {
138            format!("{prefix}.{name}")
139        };
140        let description = prop
141            .get("description")
142            .and_then(Value::as_str)
143            .map(str::to_string);
144        let ty = prop.get("type").and_then(Value::as_str);
145        // A nested object WITH declared properties is walked into paths; one
146        // without (a free-form map) is a leaf.
147        if ty == Some("object") && prop.get("properties").is_some() {
148            walk_object(&prop, defs, &path, out);
149            continue;
150        }
151        let kind = kind_of(&prop, defs);
152        let entry_kind = match kind {
153            Kind::Object => Some(
154                prop.get("additionalProperties")
155                    .filter(|ap| ap.is_object())
156                    .map(|ap| kind_of(&resolve_ref(ap, defs), defs))
157                    .unwrap_or(Kind::Any),
158            ),
159            _ => None,
160        };
161        out.push(Binding {
162            path,
163            kind,
164            description,
165            entry_kind,
166        });
167    }
168}
169
170/// Follow a local `$ref: "#/$defs/Name"`; anything else is returned as-is.
171fn resolve_ref(prop: &Value, defs: &Value) -> Value {
172    if let Some(r) = prop.get("$ref").and_then(Value::as_str)
173        && let Some(name) = r.strip_prefix("#/$defs/")
174        && let Some(def) = defs.get(name)
175    {
176        return def.clone();
177    }
178    prop.clone()
179}
180
181fn kind_of(prop: &Value, defs: &Value) -> Kind {
182    if let Some(vals) = prop.get("enum").and_then(Value::as_array) {
183        return Kind::Enum(
184            vals.iter()
185                .map(|v| match v {
186                    Value::String(s) => s.clone(),
187                    other => other.to_string(),
188                })
189                .collect(),
190        );
191    }
192    match prop.get("type").and_then(Value::as_str) {
193        Some("string") => Kind::String,
194        Some("integer") => Kind::Integer,
195        Some("number") => Kind::Number,
196        Some("boolean") => Kind::Boolean,
197        Some("object") => Kind::Object,
198        Some("array") => {
199            let item = prop
200                .get("items")
201                .map(|i| kind_of(&resolve_ref(i, defs), defs))
202                .unwrap_or(Kind::Any);
203            Kind::Array(Box::new(item))
204        }
205        _ => Kind::Any,
206    }
207}
208
209/// Type a raw string per `kind` (see the module docs for the rules).
210pub fn coerce(kind: &Kind, raw: &str) -> Result<Value, String> {
211    match kind {
212        Kind::String => Ok(Value::String(raw.to_string())),
213        Kind::Enum(allowed) => {
214            let t = raw.trim();
215            if allowed.iter().any(|a| a == t) {
216                Ok(Value::String(t.to_string()))
217            } else {
218                Err(format!("{t:?} is not one of {}", allowed.join("|")))
219            }
220        }
221        Kind::Integer => {
222            let t = raw.trim();
223            if let Ok(i) = t.parse::<i64>() {
224                return Ok(Value::from(i));
225            }
226            if let Ok(u) = t.parse::<u64>() {
227                return Ok(Value::from(u));
228            }
229            Err(format!("expected an integer, got {t:?}"))
230        }
231        Kind::Number => {
232            let t = raw.trim();
233            match t.parse::<f64>() {
234                Ok(f) if f.is_finite() => serde_json::Number::from_f64(f)
235                    .map(Value::Number)
236                    .ok_or_else(|| format!("expected a number, got {t:?}")),
237                _ => Err(format!("expected a number, got {t:?}")),
238            }
239        }
240        Kind::Boolean => match raw.trim().to_ascii_lowercase().as_str() {
241            "1" | "true" | "yes" | "on" => Ok(Value::Bool(true)),
242            "0" | "false" | "no" | "off" => Ok(Value::Bool(false)),
243            other => Err(format!("expected a boolean (true|false), got {other:?}")),
244        },
245        Kind::Array(item) => {
246            let t = raw.trim();
247            if t.is_empty() {
248                return Ok(Value::Array(Vec::new()));
249            }
250            if t.starts_with('[') {
251                return match yaml::parse_inline(t) {
252                    Ok(Value::Array(a)) => Ok(Value::Array(a)),
253                    Ok(_) => Err("expected a list literal".into()),
254                    Err(e) => Err(format!("bad list literal: {e}")),
255                };
256            }
257            // Comma-separated items, each typed by the item kind. Object items
258            // must use the literal form.
259            if matches!(**item, Kind::Object) {
260                return Err("expected a `[{...}, ...]` list literal".into());
261            }
262            t.split(',')
263                .map(|s| coerce(item, s.trim()))
264                .collect::<Result<Vec<_>, _>>()
265                .map(Value::Array)
266        }
267        Kind::Object => {
268            let t = raw.trim();
269            if !t.starts_with('{') {
270                return Err("expected a `{key: value, ...}` object literal".into());
271            }
272            match yaml::parse_inline(t) {
273                Ok(Value::Object(o)) => Ok(Value::Object(o)),
274                Ok(_) => Err("expected an object literal".into()),
275                Err(e) => Err(format!("bad object literal: {e}")),
276            }
277        }
278        Kind::Any => yaml::parse_inline(raw).map_err(|e| format!("bad value: {e}")),
279    }
280}
281
282/// Set `value` at the dotted `path` inside `root`, creating intermediate
283/// objects (a non-object in the way is replaced).
284pub fn set_path(root: &mut Value, path: &str, value: Value) {
285    let mut cur = root;
286    let segs: Vec<&str> = path.split('.').collect();
287    for (i, seg) in segs.iter().enumerate() {
288        if !cur.is_object() {
289            *cur = Value::Object(Map::new());
290        }
291        let map = cur.as_object_mut().expect("just ensured an object");
292        if i + 1 == segs.len() {
293            map.insert((*seg).to_string(), value);
294            return;
295        }
296        cur = map
297            .entry((*seg).to_string())
298            .or_insert_with(|| Value::Object(Map::new()));
299    }
300}
301
302/// The env layer as a config DOCUMENT: for every schema path, the first present
303/// env candidate (`AGENTD_…` > `AGENT_…` > bare) is coerced and set at its
304/// path. Returns the document (an empty object when nothing is set) plus the
305/// `(env name, path)` pairs that were applied. An untypeable value is an
306/// error naming the variable.
307pub fn env_document(env: &HashMap<&str, &str>) -> Result<(Value, Vec<(String, String)>), String> {
308    env_document_in(&bindings(), env)
309}
310
311/// [`env_document`] over a given binding set (a schema other than v1's).
312pub fn env_document_in(
313    bindings: &[Binding],
314    env: &HashMap<&str, &str>,
315) -> Result<(Value, Vec<(String, String)>), String> {
316    let mut doc = Value::Object(Map::new());
317    let mut applied = Vec::new();
318    for b in bindings {
319        for name in b.env_names() {
320            if let Some(raw) = env.get(name.as_str()) {
321                let v = b.coerce(raw).map_err(|e| format!("invalid {name}: {e}"))?;
322                set_path(&mut doc, &b.path, v);
323                applied.push((name, b.path.clone()));
324                break;
325            }
326        }
327    }
328    Ok((doc, applied))
329}
330
331/// A resolved `--<path>[.<key>]` flag: the schema binding it addresses and, when
332/// the flag reaches into a free-form map, the entry key (exact spelling).
333#[derive(Debug, Clone, PartialEq)]
334pub struct FlagTarget {
335    pub binding: Binding,
336    /// `Some(key)` for `--intelligence_headers.x-team` (key = `x-team`); `None`
337    /// when the flag names the schema path itself.
338    pub entry: Option<String>,
339}
340
341impl FlagTarget {
342    /// The kind the flag's VALUE is typed by: the map's entry type when an
343    /// entry is addressed, else the path's own type.
344    pub fn value_kind(&self) -> &Kind {
345        match (&self.entry, &self.binding.entry_kind) {
346            (Some(_), Some(k)) => k,
347            _ => &self.binding.kind,
348        }
349    }
350
351    /// The document `{…: value}` this flag sets: the value at the schema path,
352    /// or — for a map entry — `{path: {key: value}}` (the key is one map key,
353    /// dots and all).
354    pub fn document(&self, value: Value) -> Value {
355        let mut doc = Value::Object(Map::new());
356        match &self.entry {
357            Some(key) => {
358                let mut entry = Map::new();
359                entry.insert(key.clone(), value);
360                set_path(&mut doc, &self.binding.path, Value::Object(entry));
361            }
362            None => set_path(&mut doc, &self.binding.path, value),
363        }
364        doc
365    }
366}
367
368/// Resolve a `--flag` (with or without the leading dashes) to the schema path it
369/// addresses — canonicalizing `.`/`_`/`-` — or, for a dotted flag whose longest
370/// schema-path prefix is a free-form map, to that map plus the remaining
371/// segments as ONE entry key with its exact spelling (`--intelligence_headers.x-team`
372/// ⇒ path `intelligence_headers`, key `x-team`). `Ok(None)` when it is not a
373/// config path at all (the caller reports an unknown argument); `Err` when it
374/// names a config path but reaches into something that is not a map (an array
375/// element, a scalar).
376pub fn resolve_flag(arg: &str) -> Result<Option<FlagTarget>, String> {
377    resolve_flag_in(&bindings(), arg)
378}
379
380/// [`resolve_flag`] over a given binding set.
381pub fn resolve_flag_in(all: &[Binding], arg: &str) -> Result<Option<FlagTarget>, String> {
382    let body = arg.strip_prefix("--").unwrap_or(arg);
383    if body.is_empty() {
384        return Ok(None);
385    }
386    let segments: Vec<&str> = body.split('.').collect();
387    // Longest schema-path prefix first (whole flag, then one segment fewer…).
388    for k in (1..=segments.len()).rev() {
389        let prefix = segments[..k].join(".");
390        let want = canonical_flag_body(&prefix);
391        let Some(binding) = all.iter().find(|b| canonical_flag_body(&b.path) == want) else {
392            continue;
393        };
394        if k == segments.len() {
395            return Ok(Some(FlagTarget {
396                binding: binding.clone(),
397                entry: None,
398            }));
399        }
400        let rest = segments[k..].join(".");
401        return match binding.kind {
402            Kind::Object => Ok(Some(FlagTarget {
403                binding: binding.clone(),
404                entry: Some(rest),
405            })),
406            Kind::Array(_) => Err(format!(
407                "{arg}: array elements cannot be addressed by path (set the whole list `--{} '[…]'`, or use the named repeatable flag)",
408                canonical_flag_body(&binding.path)
409            )),
410            _ => Err(format!(
411                "{arg}: `{}` is a {} value, not an object — nothing to set at `.{rest}`",
412                binding.path,
413                binding.kind.hint().trim_matches(['<', '>'])
414            )),
415        };
416    }
417    Ok(None)
418}
419
420/// The `--help` section listing every config path with its flag and env name.
421pub fn help_section() -> String {
422    help_section_in(&bindings())
423}
424
425/// [`help_section`] over a given binding set.
426pub fn help_section_in(bindings: &[Binding]) -> String {
427    let mut out = String::from(
428        "CONFIG PATHS (every config-file path is also a flag and an env var; \
429         env: AGENTD_<PATH> > AGENT_<PATH> > <PATH>; a named flag above with the \
430         same spelling keeps its own semantics):\n",
431    );
432    for b in bindings {
433        let flag = format!("{} {}", b.flag(), b.kind.hint());
434        out.push_str(&format!(
435            "  {:<26} {:<44} {}\n",
436            b.path,
437            flag,
438            b.env_names()[0]
439        ));
440    }
441    out
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use serde_json::json;
448
449    fn paths() -> Vec<String> {
450        bindings().into_iter().map(|b| b.path).collect()
451    }
452
453    #[test]
454    fn bindings_walk_the_schema_into_dotted_paths() {
455        let p = paths();
456        // Top-level scalars, a nested object (walked), lists + maps (leaves).
457        for want in [
458            "config_version",
459            "intelligence",
460            "model_swap",
461            "model",
462            "max_tokens",
463            "limits.max_steps",
464            "limits.max_depth",
465            "limits.deadline_secs",
466            "limits.lifetime_tokens",
467            "mcp_servers",
468            "subscribe",
469            "a2a_peers",
470            "log_level",
471            "intelligence_headers",
472        ] {
473            assert!(p.contains(&want.to_string()), "missing path {want}: {p:?}");
474        }
475        assert!(
476            !p.contains(&"limits".to_string()),
477            "walked objects are not leaves"
478        );
479        // Kinds follow the schema.
480        let by: HashMap<String, Kind> = bindings().into_iter().map(|b| (b.path, b.kind)).collect();
481        assert_eq!(by["model"], Kind::String);
482        assert_eq!(by["max_tokens"], Kind::Integer);
483        assert_eq!(by["limits.max_steps"], Kind::Integer);
484        assert_eq!(by["subscribe"], Kind::Array(Box::new(Kind::String)));
485        assert_eq!(by["mcp_servers"], Kind::Array(Box::new(Kind::Object)));
486        assert_eq!(by["intelligence_headers"], Kind::Object);
487        assert!(matches!(&by["log_level"], Kind::Enum(v) if v.contains(&"info".to_string())));
488        assert!(matches!(&by["model_swap"], Kind::Enum(v) if v.len() == 2));
489    }
490
491    #[test]
492    fn env_and_flag_names_derive_from_the_path() {
493        let b = bindings()
494            .into_iter()
495            .find(|b| b.path == "limits.max_steps")
496            .unwrap();
497        assert_eq!(
498            b.env_names(),
499            vec![
500                "AGENTD_LIMITS_MAX_STEPS".to_string(),
501                "AGENT_LIMITS_MAX_STEPS".to_string(),
502                "LIMITS_MAX_STEPS".to_string()
503            ]
504        );
505        assert_eq!(b.flag(), "--limits-max-steps");
506        // Every spelling resolves to the same binding.
507        for spelling in [
508            "--limits.max_steps",
509            "--limits.max-steps",
510            "--limits-max-steps",
511            "--limits_max_steps",
512            "limits.max_steps",
513        ] {
514            let t = resolve_flag(spelling).unwrap().expect(spelling);
515            assert_eq!(t.binding.path, "limits.max_steps", "{spelling}");
516            assert!(t.entry.is_none());
517        }
518        assert!(resolve_flag("--no-such-path").unwrap().is_none());
519        assert!(resolve_flag("--").unwrap().is_none());
520        // A nested object is not itself addressable (only its leaves are).
521        assert!(resolve_flag("--limits").unwrap().is_none());
522    }
523
524    #[test]
525    fn dotted_flags_reach_into_free_form_maps_with_exact_keys() {
526        // `intelligence_headers` is a map: a dotted flag past it names ONE entry,
527        // spelling preserved (dashes/underscores/dots inside the key are data).
528        let t = resolve_flag("--intelligence_headers.x-team")
529            .unwrap()
530            .unwrap();
531        assert_eq!(t.binding.path, "intelligence_headers");
532        assert_eq!(t.entry.as_deref(), Some("x-team"));
533        assert_eq!(
534            *t.value_kind(),
535            Kind::String,
536            "typed by additionalProperties"
537        );
538        assert_eq!(
539            t.document(json!("ops")),
540            json!({"intelligence_headers": {"x-team": "ops"}})
541        );
542        // The schema-path part still canonicalizes; the key never does.
543        let t = resolve_flag("--intelligence-headers.Anthropic_Version.v2")
544            .unwrap()
545            .unwrap();
546        assert_eq!(t.entry.as_deref(), Some("Anthropic_Version.v2"));
547        // The whole-map form has no entry.
548        let t = resolve_flag("--intelligence-headers").unwrap().unwrap();
549        assert!(t.entry.is_none());
550        assert_eq!(*t.value_kind(), Kind::Object);
551        // Reaching into a list or a scalar is a clear error, not a guess.
552        let e = resolve_flag("--mcp-servers.0.aauth").unwrap_err();
553        assert!(e.contains("array elements"), "{e}");
554        let e = resolve_flag("--model.sub").unwrap_err();
555        assert!(e.contains("not an object"), "{e}");
556    }
557
558    #[test]
559    fn derived_names_are_unique_across_the_schema() {
560        // Two paths canonicalizing to the same flag/env would be ambiguous —
561        // guard the schema against it.
562        let bs = bindings();
563        let mut flags = std::collections::HashSet::new();
564        let mut envs = std::collections::HashSet::new();
565        for b in &bs {
566            assert!(flags.insert(b.flag()), "duplicate flag {}", b.flag());
567            assert!(
568                envs.insert(b.env_names()[0].clone()),
569                "duplicate env {}",
570                b.env_names()[0]
571            );
572        }
573    }
574
575    #[test]
576    fn coercion_types_by_kind() {
577        assert_eq!(coerce(&Kind::String, " x ").unwrap(), json!(" x "));
578        assert_eq!(coerce(&Kind::Integer, "42").unwrap(), json!(42));
579        assert_eq!(coerce(&Kind::Integer, "-1").unwrap(), json!(-1));
580        assert!(coerce(&Kind::Integer, "4.2").is_err());
581        assert!(coerce(&Kind::Integer, "abc").is_err());
582        assert_eq!(coerce(&Kind::Number, "1.5").unwrap(), json!(1.5));
583        assert!(coerce(&Kind::Number, "nan").is_err());
584        assert_eq!(coerce(&Kind::Boolean, "on").unwrap(), json!(true));
585        assert_eq!(coerce(&Kind::Boolean, "False").unwrap(), json!(false));
586        assert!(coerce(&Kind::Boolean, "maybe").is_err());
587        let en = Kind::Enum(vec!["a".into(), "b".into()]);
588        assert_eq!(coerce(&en, "b").unwrap(), json!("b"));
589        let e = coerce(&en, "c").unwrap_err();
590        assert!(e.contains("a|b"), "{e}");
591        let strs = Kind::Array(Box::new(Kind::String));
592        assert_eq!(coerce(&strs, "a, b ,c").unwrap(), json!(["a", "b", "c"]));
593        assert_eq!(coerce(&strs, "[x, \"y z\"]").unwrap(), json!(["x", "y z"]));
594        assert_eq!(coerce(&strs, "").unwrap(), json!([]));
595        let ints = Kind::Array(Box::new(Kind::Integer));
596        assert_eq!(coerce(&ints, "1,2").unwrap(), json!([1, 2]));
597        assert!(coerce(&ints, "1,x").is_err());
598        let objs = Kind::Array(Box::new(Kind::Object));
599        assert_eq!(
600            coerce(&objs, r#"[{name: a, endpoint: "https://x"}]"#).unwrap(),
601            json!([{"name": "a", "endpoint": "https://x"}])
602        );
603        assert!(coerce(&objs, "a,b").is_err());
604        assert_eq!(
605            coerce(&Kind::Object, "{k: v, n: 1}").unwrap(),
606            json!({"k": "v", "n": 1})
607        );
608        assert!(coerce(&Kind::Object, "not-an-object").is_err());
609        assert_eq!(coerce(&Kind::Any, "[1, two]").unwrap(), json!([1, "two"]));
610    }
611
612    #[test]
613    fn set_path_builds_nested_objects() {
614        let mut doc = Value::Object(Map::new());
615        set_path(&mut doc, "limits.max_steps", json!(5));
616        set_path(&mut doc, "limits.max_depth", json!(2));
617        set_path(&mut doc, "model", json!("m"));
618        assert_eq!(
619            doc,
620            json!({"limits": {"max_steps": 5, "max_depth": 2}, "model": "m"})
621        );
622        // A scalar in the way of a nested path is replaced.
623        set_path(&mut doc, "model.sub", json!(1));
624        assert_eq!(doc["model"], json!({"sub": 1}));
625    }
626
627    #[test]
628    fn env_document_prefers_branded_then_neutral_then_bare() {
629        let mut env: HashMap<&str, &str> = HashMap::new();
630        env.insert("LIMITS_MAX_STEPS", "1");
631        env.insert("AGENT_LIMITS_MAX_STEPS", "2");
632        env.insert("AGENTD_LIMITS_MAX_STEPS", "3");
633        env.insert("MODEL", "bare-model");
634        env.insert("AGENTD_SUBSCRIBE", "a,b");
635        env.insert("UNRELATED", "x");
636        let (doc, applied) = env_document(&env).unwrap();
637        assert_eq!(doc["limits"]["max_steps"], json!(3));
638        assert_eq!(doc["model"], json!("bare-model"));
639        assert_eq!(doc["subscribe"], json!(["a", "b"]));
640        assert!(
641            applied
642                .iter()
643                .any(|(n, p)| n == "AGENTD_LIMITS_MAX_STEPS" && p == "limits.max_steps")
644        );
645        assert!(applied.iter().any(|(n, _)| n == "MODEL"));
646        assert!(!applied.iter().any(|(n, _)| n == "UNRELATED"));
647        // A bad value names the variable.
648        env.insert("AGENTD_MAX_TOKENS", "lots");
649        let e = env_document(&env).unwrap_err();
650        assert!(e.contains("AGENTD_MAX_TOKENS"), "{e}");
651    }
652
653    #[test]
654    fn every_binding_deserializes_into_the_typed_config_file() {
655        // The schema (bindings) and the typed struct must agree at every path:
656        // a sample value per kind, set at the path, must deserialize.
657        for b in bindings() {
658            let sample = match &b.kind {
659                Kind::String => json!("x"),
660                Kind::Integer => json!(1),
661                Kind::Number => json!(1.5),
662                Kind::Boolean => json!(true),
663                Kind::Enum(vs) => json!(vs[0]),
664                Kind::Array(item) => match **item {
665                    Kind::Object if b.path == "mcp_servers" => {
666                        json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
667                    }
668                    Kind::Object if b.path == "a2a_peers" => {
669                        json!([{"name": "p", "endpoint": "https://p.example"}])
670                    }
671                    Kind::Object => json!([{}]),
672                    _ => json!(["s"]),
673                },
674                Kind::Object => json!({"k": "v"}),
675                Kind::Any => json!(null),
676            };
677            let mut doc = Value::Object(Map::new());
678            set_path(&mut doc, &b.path, sample);
679            super::super::file::ConfigFile::from_document(doc, "test")
680                .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
681        }
682    }
683
684    #[test]
685    fn help_section_lists_every_path() {
686        let h = help_section();
687        for b in bindings() {
688            assert!(h.contains(&b.path), "help lacks {}", b.path);
689            assert!(h.contains(&b.flag()), "help lacks {}", b.flag());
690            assert!(
691                h.contains(&b.env_names()[0]),
692                "help lacks {}",
693                b.env_names()[0]
694            );
695        }
696    }
697}