1use std::fmt::Write as _;
2
3use serde_json::{Map, Value};
4
5pub fn channel_streaming_matrix(root: &Value) -> String {
15 let empty = Map::new();
16 let defs = root
17 .get("$defs")
18 .and_then(Value::as_object)
19 .unwrap_or(&empty);
20 let root = resolve(root, defs);
21 let Some(channels) = root
22 .get("properties")
23 .and_then(Value::as_object)
24 .and_then(|p| p.get("channels"))
25 .map(|c| resolve(c, defs))
26 else {
27 return String::new();
28 };
29 let Some(props) = channels.get("properties").and_then(Value::as_object) else {
30 return String::new();
31 };
32
33 let mut rows: Vec<(String, &'static str, &'static str)> = Vec::new();
34 for (key, schema) in props {
35 let mut node = resolve(schema, defs);
36 if let Some(add) = node.get("additionalProperties")
38 && add.is_object()
39 {
40 node = resolve(add, defs);
41 }
42 let Some(fields) = node.get("properties").and_then(Value::as_object) else {
43 continue;
44 };
45 let has = |f: &str| fields.contains_key(f);
46 let (draft, multi) = if has("stream_mode") {
47 ("yes", "yes")
48 } else if has("stream_drafts") {
49 ("yes", "no")
50 } else {
51 continue; };
53 rows.push((key.clone(), draft, multi));
54 }
55 rows.sort_by(|a, b| a.0.cmp(&b.0));
56
57 let mut out = String::new();
58 out.push_str("| Channel | Draft updates | Multi-message |\n|---|:---:|:---:|\n");
59 for (ch, draft, multi) in rows {
60 let cell = |v: &str| if v == "yes" { "โ" } else { "" };
61 let _ = writeln!(out, "| `{ch}` | {} | {} |", cell(draft), cell(multi));
62 }
63 out
64}
65
66pub fn field_table_for_path(
97 root: &Value,
98 path: &str,
99 include_enabled: bool,
100 defaults: Option<&Value>,
101) -> Result<String, String> {
102 let empty = Map::new();
103 let defs = root
104 .get("$defs")
105 .and_then(Value::as_object)
106 .unwrap_or(&empty);
107
108 let mut node = resolve(root, defs);
109 let mut display_segments: Vec<String> = Vec::new();
110
111 for seg in path.split('.') {
112 let props = node.get("properties").and_then(Value::as_object);
115 let next = props.and_then(|p| p.get(seg)).map(|s| resolve(s, defs));
116 let Some(next) = next else {
117 return Err(format!(
118 "config-fields: path segment `{seg}` not found in `{path}`"
119 ));
120 };
121 display_segments.push(seg.to_string());
122 node = next;
123 if let Some(add) = node.get("additionalProperties")
125 && add.is_object()
126 {
127 node = resolve(add, defs);
128 display_segments.push("<alias>".to_string());
129 }
130 else if let Some(items) = node.get("items")
135 && items.is_object()
136 {
137 node = resolve(items, defs);
138 }
139 }
140
141 if node.get("properties").and_then(Value::as_object).is_none() {
142 return Err(format!("config-fields: `{path}` has no fields to render"));
143 }
144
145 let prefix = display_segments.join(".");
146 Ok(field_table(node, include_enabled, Some(&prefix), defaults))
147}
148
149pub fn section_field_names(root: &Value, path: &str) -> std::collections::BTreeSet<String> {
154 let empty = Map::new();
155 let defs = root
156 .get("$defs")
157 .and_then(Value::as_object)
158 .unwrap_or(&empty);
159 let mut node = resolve(root, defs);
160 for seg in path.split('.') {
161 let Some(next) = node
162 .get("properties")
163 .and_then(Value::as_object)
164 .and_then(|p| p.get(seg))
165 .map(|s| resolve(s, defs))
166 else {
167 return Default::default();
168 };
169 node = next;
170 if let Some(add) = node.get("additionalProperties")
171 && add.is_object()
172 {
173 node = resolve(add, defs);
174 } else if let Some(items) = node.get("items")
175 && items.is_object()
176 {
177 node = resolve(items, defs);
178 }
179 }
180 node.get("properties")
181 .and_then(Value::as_object)
182 .map(|p| p.keys().cloned().collect())
183 .unwrap_or_default()
184}
185
186pub fn field_table_for_path_excluding(
192 root: &Value,
193 path: &str,
194 include_enabled: bool,
195 defaults: Option<&Value>,
196 exclude: &std::collections::BTreeSet<String>,
197) -> Result<String, String> {
198 let empty = Map::new();
199 let defs = root
200 .get("$defs")
201 .and_then(Value::as_object)
202 .unwrap_or(&empty);
203
204 let mut node = resolve(root, defs).clone();
205 let mut display_segments: Vec<String> = Vec::new();
206 {
207 let mut cur = &node as &Value;
208 for seg in path.split('.') {
209 let Some(next) = cur
210 .get("properties")
211 .and_then(Value::as_object)
212 .and_then(|p| p.get(seg))
213 .map(|s| resolve(s, defs).clone())
214 else {
215 return Err(format!(
216 "model-provider-fields: path segment `{seg}` not found in `{path}`"
217 ));
218 };
219 display_segments.push(seg.to_string());
220 node = next;
221 if let Some(add) = node.get("additionalProperties").cloned()
222 && add.is_object()
223 {
224 node = resolve(&add, defs).clone();
225 display_segments.push("<alias>".to_string());
226 } else if let Some(items) = node.get("items").cloned()
227 && items.is_object()
228 {
229 node = resolve(&items, defs).clone();
230 }
231 cur = &node;
232 }
233 }
234
235 if let Some(props) = node.get_mut("properties").and_then(Value::as_object_mut) {
237 props.retain(|k, _| !exclude.contains(k));
238 if props.is_empty() {
239 return Ok(String::new());
240 }
241 } else {
242 return Ok(String::new());
243 }
244
245 if let (Some(node_obj), Some(defs_val)) = (node.as_object_mut(), root.get("$defs")) {
247 node_obj.insert("$defs".to_string(), defs_val.clone());
248 }
249
250 let prefix = display_segments.join(".");
251 Ok(field_table(&node, include_enabled, Some(&prefix), defaults))
252}
253
254pub fn field_table(
269 root: &Value,
270 include_enabled: bool,
271 prefix: Option<&str>,
272 defaults: Option<&Value>,
273) -> String {
274 let empty = Map::new();
275 let defs = root
276 .get("$defs")
277 .and_then(Value::as_object)
278 .unwrap_or(&empty);
279 let Some(props) = root.get("properties").and_then(Value::as_object) else {
280 return String::new();
281 };
282 let required: Vec<&str> = root
283 .get("required")
284 .and_then(Value::as_array)
285 .map(|arr| arr.iter().filter_map(Value::as_str).collect())
286 .unwrap_or_default();
287
288 let Some(prefix) = prefix else {
289 return plain_field_table(props, &required, defs, include_enabled);
290 };
291 let section_owned = {
297 let segs: Vec<&str> = prefix.split('.').collect();
298 if let Some(alias_idx) = segs.iter().position(|s| *s == "<alias>") {
299 let type_idx = alias_idx.saturating_sub(1);
300 let head = segs[..type_idx].join(".");
301 if head.is_empty() {
302 segs[type_idx].to_string()
303 } else {
304 format!("{head}/{}", segs[type_idx])
305 }
306 } else {
307 prefix.to_string()
308 }
309 };
310 let section = section_owned.as_str();
311
312 let mut rows = String::new();
313 for (key, prop_schema) in props {
314 if key == "enabled" && !include_enabled {
315 continue;
316 }
317 let resolved = resolve(prop_schema, defs);
318 let is_secret = resolved.get("x-secret").and_then(Value::as_bool) == Some(true);
319 let ty = if is_secret {
320 "secret".to_owned()
321 } else {
322 type_label(resolved, defs)
323 };
324 let fallback = defaults.and_then(|d| d.get(key));
325 let default = fmt_default_for(resolved, fallback);
326 let req = if required.contains(&key.as_str()) {
327 "*"
328 } else {
329 ""
330 };
331 let secret_mark = if is_secret { " ๐" } else { "" };
332 let full_path = format!("{prefix}.{key}");
333 let set_cmd = if is_secret {
334 format!("zeroclaw config set {full_path} # masked input, stored encrypted")
335 } else {
336 format!("zeroclaw config set {full_path} <value>")
337 };
338 let env_var = format!("ZEROCLAW_{}", full_path.replace('.', "__"));
343 let full_desc = resolved
344 .get("description")
345 .and_then(Value::as_str)
346 .unwrap_or("");
347
348 let _ = write!(
356 rows,
357 concat!(
358 "<details class=\"cfg-field\">\n",
359 "<summary><code>{key}</code>{req}{secret_mark} ",
360 "<span class=\"cfg-field-meta\"><code>{ty}</code> ยท default {default}</span>",
361 "</summary>\n\n",
362 "{full_desc}\n\n",
363 "**Set it on any surface:**\n\n",
364 "<div class=\"os-tabs-src\">\n\n",
365 "#### Gateway dashboard\n\n",
366 "Open [`/config/{section}`](http://127.0.0.1:42617/config/{section}) and set the `{full_path}` field.\n\n",
367 "#### zerocode\n\n",
368 "In the **Config** pane, set the `{full_path}` field.\n\n",
369 "#### zeroclaw config\n\n",
370 "```sh\n{set_cmd}\n```\n\n",
371 "#### Environment variable\n\n",
372 "Export the override (POSIX shells; drop into `~/.bashrc`, `~/.zshrc`, `.env`, or a Dockerfile). Replace `<alias>` with the literal alias:\n\n",
373 "```sh\nexport {env_var}=\n```\n\n",
374 "</div>\n",
375 "</details>\n\n",
376 ),
377 key = html_escape(key),
378 req = req,
379 secret_mark = secret_mark,
380 ty = html_escape(&ty),
381 default = inline_code_html(&default),
382 section = section,
383 full_path = full_path,
384 set_cmd = set_cmd,
385 env_var = env_var,
386 full_desc = markdown_prose(full_desc),
387 );
388 }
389
390 format!("<div class=\"cfg-fields\">\n\n{rows}</div>\n")
391}
392
393fn plain_field_table(
396 props: &Map<String, Value>,
397 required: &[&str],
398 defs: &Map<String, Value>,
399 include_enabled: bool,
400) -> String {
401 let mut out = String::new();
402 out.push_str("| field | type | default | meaning |\n");
403 out.push_str("|---|---|---|---|\n");
404 for (key, prop_schema) in props {
405 if key == "enabled" && !include_enabled {
406 continue;
407 }
408 let resolved = resolve(prop_schema, defs);
409 let is_secret = resolved.get("x-secret").and_then(Value::as_bool) == Some(true);
410 let ty = if is_secret {
411 "secret".to_owned()
412 } else {
413 type_label(resolved, defs)
414 };
415 let default = fmt_default(resolved);
416 let desc =
417 first_line(resolved.get("description").and_then(Value::as_str)).replace('|', "\\|");
418 let req = if required.contains(&key.as_str()) {
419 "\\*"
420 } else {
421 ""
422 };
423 let secret = if is_secret { " ๐" } else { "" };
424 let _ = writeln!(out, "| `{key}`{req}{secret} | {ty} | {default} | {desc} |");
425 }
426 out
427}
428
429fn html_escape(s: &str) -> String {
431 s.replace('&', "&")
432 .replace('<', "<")
433 .replace('>', ">")
434 .replace('"', """)
435}
436
437fn markdown_prose(s: &str) -> String {
442 s.split_whitespace().collect::<Vec<_>>().join(" ")
443}
444
445fn inline_code_html(s: &str) -> String {
448 let trimmed = s.trim();
449 if let Some(inner) = trimmed.strip_prefix('`').and_then(|t| t.strip_suffix('`')) {
450 format!("<code>{}</code>", html_escape(inner))
451 } else {
452 html_escape(trimmed)
453 }
454}
455
456pub fn generate(root: &Value) -> String {
459 let empty = Map::new();
460 let defs = root
461 .get("$defs")
462 .and_then(Value::as_object)
463 .unwrap_or(&empty);
464
465 let mut out = String::new();
466 out.push_str("# Config Reference\n\n");
467 out.push_str(
468 "ZeroClaw is configured via a TOML file. All fields are optional unless noted.\n\n",
469 );
470
471 let Some(props) = root.get("properties").and_then(Value::as_object) else {
472 return out;
473 };
474
475 out.push_str("| Section | Description |\n");
480 out.push_str("|---------|-------------|\n");
481 for (key, schema) in props {
482 let resolved = resolve(schema, defs);
483 let desc = first_line(resolved.get("description").and_then(Value::as_str));
484 let _ = writeln!(out, "| [`{key}`](#{key}) | {desc} |");
485 }
486 out.push('\n');
487
488 for (key, schema) in props {
490 let resolved = resolve(schema, defs);
491 write_section(&mut out, &[key.as_str()], resolved, defs);
492 }
493
494 out
495}
496
497fn write_section(out: &mut String, path: &[&str], schema: &Value, defs: &Map<String, Value>) {
498 let hashes = "#".repeat(path.len() + 1);
499 let path_str = path.join(".");
500 let _ = writeln!(out, "{hashes} `{path_str}`\n");
501
502 if let Some(desc) = schema.get("description").and_then(Value::as_str) {
503 out.push_str(desc);
504 out.push_str("\n\n");
505 }
506
507 let empty = Map::new();
508 let props = schema
509 .get("properties")
510 .and_then(Value::as_object)
511 .unwrap_or(&empty);
512 if props.is_empty() {
513 return;
514 }
515
516 let required: Vec<&str> = schema
517 .get("required")
518 .and_then(Value::as_array)
519 .map(|arr| arr.iter().filter_map(Value::as_str).collect())
520 .unwrap_or_default();
521
522 let all_maps = !props.is_empty()
529 && props.values().all(|v| {
530 resolve(v, defs)
531 .get("additionalProperties")
532 .map(Value::is_object)
533 .unwrap_or(false)
534 });
535 if all_maps {
536 let slots: Vec<String> = props.keys().map(|k| format!("`{k}`")).collect();
537 let _ = writeln!(
538 out,
539 "One slot per family ({}). Each slot is a `[{path_str}.<slot>.<alias>]` map; \
540 see the dedicated section page for the per-field reference.\n",
541 slots.join(", ")
542 );
543 return;
544 }
545
546 out.push_str("| Key | Type | Default | Description |\n");
547 out.push_str("|-----|------|---------|-------------|\n");
548
549 let mut recurse: Vec<(Vec<String>, Value)> = Vec::new();
550
551 for (key, prop_schema) in props {
552 let resolved = resolve(prop_schema, defs);
553 let ty = type_label(resolved, defs);
554 let default = fmt_default(resolved);
555 let desc =
556 first_line(resolved.get("description").and_then(Value::as_str)).replace('|', "\\|");
557 let req = if required.contains(&key.as_str()) {
558 "\\*"
559 } else {
560 ""
561 };
562 let secret = if resolved.get("x-secret").and_then(Value::as_bool) == Some(true) {
563 " ๐"
564 } else {
565 ""
566 };
567
568 let has_sub = resolved
569 .get("properties")
570 .and_then(Value::as_object)
571 .map(|p| !p.is_empty())
572 .unwrap_or(false);
573
574 let _ = writeln!(out, "| `{key}`{req}{secret} | {ty} | {default} | {desc} |");
575
576 if has_sub && path.len() < 3 {
578 let mut sub_path: Vec<String> = path.iter().map(|s| (*s).to_owned()).collect();
579 sub_path.push(key.clone());
580 recurse.push((sub_path, resolved.clone()));
581 }
582 }
583 out.push('\n');
584
585 for (sub_path_owned, sub_schema) in &recurse {
586 let refs: Vec<&str> = sub_path_owned.iter().map(String::as_str).collect();
587 write_section(out, &refs, sub_schema, defs);
588 }
589}
590
591fn resolve<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
593 if let Some(ref_str) = schema.get("$ref").and_then(Value::as_str) {
594 let name = ref_str
595 .trim_start_matches("#/$defs/")
596 .trim_start_matches("#/definitions/");
597 if let Some(def) = defs.get(name) {
598 return resolve(def, defs);
599 }
600 }
601 if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) {
602 let non_null: Vec<&Value> = any_of
603 .iter()
604 .filter(|s| s.get("type").and_then(Value::as_str) != Some("null"))
605 .collect();
606 if non_null.len() == 1 {
607 return resolve(non_null[0], defs);
608 }
609 }
610 schema
611}
612
613fn type_label(schema: &Value, defs: &Map<String, Value>) -> String {
614 if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) {
615 let non_null: Vec<&Value> = any_of
616 .iter()
617 .filter(|s| s.get("type").and_then(Value::as_str) != Some("null"))
618 .collect();
619 if non_null.len() == 1 {
620 return format!("{}?", type_label(non_null[0], defs));
621 }
622 return non_null
623 .iter()
624 .map(|s| type_label(s, defs))
625 .collect::<Vec<_>>()
626 .join(" \\| ");
627 }
628
629 if let Some(types) = schema.get("type").and_then(Value::as_array) {
633 let non_null: Vec<&str> = types
634 .iter()
635 .filter_map(Value::as_str)
636 .filter(|t| *t != "null")
637 .collect();
638 if non_null.len() == 1 {
639 let mut inner = schema.clone();
640 inner["type"] = Value::String(non_null[0].to_owned());
641 return format!("{}?", type_label(&inner, defs));
642 }
643 }
644
645 if let Some(ref_str) = schema.get("$ref").and_then(Value::as_str) {
646 let name = ref_str
647 .trim_start_matches("#/$defs/")
648 .trim_start_matches("#/definitions/");
649 if let Some(def) = defs.get(name) {
650 return type_label(def, defs);
651 }
652 return name.to_owned();
653 }
654
655 if schema.get("oneOf").is_some() || schema.get("enum").is_some() {
656 if let Some(title) = schema.get("title").and_then(Value::as_str) {
657 return title.to_owned();
658 }
659 if let Some(vals) = schema.get("enum").and_then(Value::as_array) {
660 let s: Vec<String> = vals
661 .iter()
662 .filter_map(Value::as_str)
663 .map(|v| format!("`{v}`"))
664 .collect();
665 if !s.is_empty() {
666 return s.join(" \\| ");
667 }
668 }
669 }
670
671 match schema.get("type").and_then(Value::as_str) {
672 Some("boolean") => "bool".to_owned(),
673 Some("string") => "string".to_owned(),
674 Some("integer") => "integer".to_owned(),
675 Some("number") => "number".to_owned(),
676 Some("array") => {
677 let item_type = schema
678 .get("items")
679 .map(|i| type_label(i, defs))
680 .unwrap_or_else(|| "any".to_owned());
681 format!("{item_type}[]")
682 }
683 Some("object") => {
684 if schema.get("additionalProperties").is_some() {
685 "map".to_owned()
686 } else {
687 "object".to_owned()
688 }
689 }
690 _ => {
691 if schema.get("properties").is_some() {
692 "object".to_owned()
693 } else {
694 schema
699 .get("title")
700 .and_then(Value::as_str)
701 .unwrap_or("table")
702 .to_owned()
703 }
704 }
705 }
706}
707
708fn fmt_default_for(schema: &Value, fallback: Option<&Value>) -> String {
713 let value = schema.get("default").or(fallback);
714 fmt_value(value)
715}
716
717fn fmt_default(schema: &Value) -> String {
718 fmt_value(schema.get("default"))
719}
720
721fn fmt_value(value: Option<&Value>) -> String {
722 match value {
723 Some(Value::Bool(b)) => format!("`{b}`"),
724 Some(Value::String(s)) if s.is_empty() => "`\"\"`".to_owned(),
725 Some(Value::String(s)) => format!("`\"{s}\"`"),
726 Some(Value::Number(n)) => format!("`{n}`"),
727 Some(Value::Null) => "`null`".to_owned(),
728 Some(Value::Array(a)) if a.is_empty() => "`[]`".to_owned(),
729 Some(Value::Object(o)) if o.is_empty() => "`{}`".to_owned(),
730 Some(v) => format!("`{v}`"),
731 None => "`โ`".to_owned(),
732 }
733}
734
735fn first_line(s: Option<&str>) -> String {
736 s.and_then(|d| d.lines().next()).unwrap_or("").to_owned()
737}
738
739#[cfg(all(test, feature = "schema-export"))]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn index_links_each_section_to_its_anchor() {
745 let schema = schemars::schema_for!(crate::schema::Config);
746 let md = generate(&schema.to_value());
747
748 let mut linked = 0usize;
753 for line in md.lines() {
754 if let Some(rest) = line.strip_prefix("| [`") {
756 let key = rest.split('`').next().unwrap_or("");
757 assert!(
758 line.contains(&format!("](#{key})")),
759 "index row for `{key}` is not linked to its anchor: {line}"
760 );
761 assert!(
763 md.contains(&format!("# `{key}`")),
764 "no detail heading for indexed section `{key}`"
765 );
766 linked += 1;
767 }
768 }
769 assert!(linked > 10, "expected many linked sections, got {linked}");
770 }
771
772 #[test]
773 fn config_fields_descends_array_section_to_entry_struct() {
774 let schema = schemars::schema_for!(crate::schema::Config);
779 let table = field_table_for_path(&schema.to_value(), "mcp.servers", false, None)
780 .expect("mcp.servers should resolve to its entry struct fields");
781 for field in [
782 "transport",
783 "command",
784 "url",
785 "headers",
786 "tool_timeout_secs",
787 ] {
788 assert!(
789 table.contains(&format!("<code>{field}</code>")),
790 "mcp.servers field table missing `{field}`"
791 );
792 }
793 }
794}