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_", ""];
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum Kind {
47 String,
48 Integer,
49 Number,
50 Boolean,
51 Enum(Vec<String>),
53 Array(Box<Kind>),
55 Object,
58 Any,
60}
61
62impl Kind {
63 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#[derive(Debug, Clone, PartialEq)]
80pub struct Binding {
81 pub path: String,
83 pub kind: Kind,
84 pub description: Option<String>,
85 pub entry_kind: Option<Kind>,
89}
90
91impl Binding {
92 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 pub fn flag(&self) -> String {
100 format!("--{}", canonical_flag_body(&self.path))
101 }
102
103 pub fn coerce(&self, raw: &str) -> Result<Value, String> {
105 coerce(&self.kind, raw)
106 }
107}
108
109fn canonical_flag_body(s: &str) -> String {
111 s.replace(['.', '_'], "-")
112}
113
114pub fn bindings() -> Vec<Binding> {
117 bindings_of(&config_schema())
118}
119
120pub 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 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
170fn 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
209pub 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 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
282pub 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
302pub fn env_document(env: &HashMap<&str, &str>) -> Result<(Value, Vec<(String, String)>), String> {
308 env_document_in(&bindings(), env)
309}
310
311pub 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#[derive(Debug, Clone, PartialEq)]
334pub struct FlagTarget {
335 pub binding: Binding,
336 pub entry: Option<String>,
339}
340
341impl FlagTarget {
342 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 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
368pub fn resolve_flag(arg: &str) -> Result<Option<FlagTarget>, String> {
377 resolve_flag_in(&bindings(), arg)
378}
379
380pub 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 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
420pub fn help_section() -> String {
422 help_section_in(&bindings())
423}
424
425pub 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 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 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 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 assert!(resolve_flag("--limits").unwrap().is_none());
522 }
523
524 #[test]
525 fn dotted_flags_reach_into_free_form_maps_with_exact_keys() {
526 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 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 let t = resolve_flag("--intelligence-headers").unwrap().unwrap();
549 assert!(t.entry.is_none());
550 assert_eq!(*t.value_kind(), Kind::Object);
551 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 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 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 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 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}