Skip to main content

agentd/config/
paths.rs

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