1use super::file::config_schema;
35use super::yaml;
36use serde_json::{Map, Value};
37use std::collections::HashMap;
38
39pub const ENV_PREFIXES: [&str; 3] = ["AGENTD_", "AGENT_", ""];
42
43#[derive(Debug, Clone, PartialEq)]
45pub enum Kind {
46 String,
47 Integer,
48 Number,
49 Boolean,
50 Enum(Vec<String>),
52 Array(Box<Kind>),
54 Object,
57 Any,
59}
60
61impl Kind {
62 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#[derive(Debug, Clone, PartialEq)]
79pub struct Binding {
80 pub path: String,
82 pub kind: Kind,
83 pub description: Option<String>,
84 pub entry_kind: Option<Kind>,
88}
89
90impl Binding {
91 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 pub fn flag(&self) -> String {
99 format!("--{}", canonical_flag_body(&self.path))
100 }
101
102 pub fn coerce(&self, raw: &str) -> Result<Value, String> {
104 coerce(&self.kind, raw)
105 }
106}
107
108fn canonical_flag_body(s: &str) -> String {
110 s.replace(['.', '_'], "-")
111}
112
113pub fn bindings() -> Vec<Binding> {
116 bindings_of(&config_schema())
117}
118
119pub 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 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
169fn 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
208pub 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 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
281pub 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
301pub fn env_document(env: &HashMap<&str, &str>) -> Result<(Value, Vec<(String, String)>), String> {
307 env_document_in(&bindings(), env)
308}
309
310pub 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#[derive(Debug, Clone, PartialEq)]
333pub struct FlagTarget {
334 pub binding: Binding,
335 pub entry: Option<String>,
338}
339
340impl FlagTarget {
341 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 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
367pub fn resolve_flag(arg: &str) -> Result<Option<FlagTarget>, String> {
376 resolve_flag_in(&bindings(), arg)
377}
378
379pub 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 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
419pub fn help_section() -> String {
421 help_section_in(&bindings())
422}
423
424pub 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 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 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 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 assert!(resolve_flag("--limits").unwrap().is_none());
521 }
522
523 #[test]
524 fn dotted_flags_reach_into_free_form_maps_with_exact_keys() {
525 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 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 let t = resolve_flag("--intelligence-headers").unwrap().unwrap();
548 assert!(t.entry.is_none());
549 assert_eq!(*t.value_kind(), Kind::Object);
550 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 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 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 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 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}