Skip to main content

zeroclaw_config/
schema_markdown.rs

1use std::fmt::Write as _;
2
3use serde_json::{Map, Value};
4
5/// Build the channel streaming-capability table by walking the `channels`
6/// section of the `Config` schema. Capability is derived from each channel
7/// struct's fields, never hand-listed:
8///   - has `stream_mode` (the off/partial/multi_message enum) -> draft updates
9///     and multi-message streaming are both supported.
10///   - has `stream_drafts` (a partial-only boolean) -> draft updates only.
11///   - neither -> no streaming.
12///
13/// Returns a Markdown table sorted by channel key.
14pub 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        // Descend a map (HashMap) to the per-alias struct.
37        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; // no streaming -> omit from the table
52        };
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
66/// Navigate the full `Config` schema (`schema_for!(Config)`) to the section at
67/// `path` (dotted, e.g. `channels.matrix`, `providers.models`, `acp`) and
68/// render that section's fields via [`field_table`]. Map nodes (Rust
69/// `HashMap<String, T>`, rendered by schemars with `additionalProperties`) are
70/// transparently descended into their value type, and an `<alias>` placeholder
71/// is inserted into the displayed config prefix at each crossing so the
72/// per-field deep-links and `config set` commands carry the right path
73/// (`channels.matrix` -> `channels.matrix.<alias>`).
74///
75/// Returns an error string (as a visible HTML comment) when the path does not
76/// resolve, so a typo in a directive fails loudly in the rendered page rather
77/// than silently emitting nothing.
78/// Navigate the full `Config` schema (`schema_for!(Config)`) to the section at
79/// `path` (dotted, e.g. `channels.matrix`, `providers.models`, `acp`) and
80/// render that section's fields via [`field_table`]. Map nodes (Rust
81/// `HashMap<String, T>`, rendered by schemars with `additionalProperties`) are
82/// transparently descended into their value type, and an `<alias>` placeholder
83/// is inserted into the displayed config prefix at each crossing so the
84/// per-field deep-links and `config set` commands carry the right path
85/// (`channels.matrix` -> `channels.matrix.<alias>`).
86///
87/// `defaults` is the serialized `Default::default()` of the section struct that
88/// `path` resolves to (for a map section, the map's *value* type). It lets a
89/// field's real default (`false`, `[]`, `{}`, `null`) surface even when
90/// schemars omits the schema `default` key for `skip_serializing_if` fields.
91/// Pass `None` to fall back to schema-only defaults.
92///
93/// Returns an error string when the path does not resolve, so a typo in a
94/// directive fails loudly in the rendered page rather than silently emitting
95/// nothing.
96pub 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        // Descend a map (HashMap) before matching the next key: the segment
113        // names a concrete entry, and crossing the map adds an `<alias>` level.
114        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 this node is a map, step into its value type and record `<alias>`.
124        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        // If this node is an array (Vec<T> -> schemars `items`), step into the
131        // element type so a list section (e.g. `mcp.servers`) renders its
132        // entry struct's fields. List entries have no named key; the prefix is
133        // left at the section name (TOML `[[section]]` blocks).
134        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
149/// The set of field names a section path resolves to (after descending any map
150/// to its value type). Used to compute the shared base field set across many
151/// sibling sections (e.g. every model-provider slot) so a directive can render
152/// the common fields once and only the per-section extras per entry.
153pub 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
186/// Like [`field_table_for_path`] but omits every field whose name is in
187/// `exclude`. Lets a directive render a shared base table once and then only
188/// the per-section extras, instead of repeating the common fields for every
189/// sibling section. Returns an empty string (not an error) when nothing remains
190/// after exclusion, so callers can render a "no extra fields" note.
191pub 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    // Strip excluded fields from the resolved node's properties.
236    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    // Carry `$defs` into the detached node so `$ref` field types still resolve.
246    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
254/// Renders a single struct's fields as an interactive config table from that
255/// struct's `schema_for!` JSON value. Top-level `enabled` is skipped by default
256/// since channel pages document it separately; pass `include_enabled = true` to
257/// keep it. `$ref` types resolve against the schema's own `$defs`. This is the
258/// same type/default/description extraction used by [`generate`], so a
259/// per-channel field table can never drift from the global config reference.
260///
261/// When `prefix` is `Some` (the struct's dotted config path, e.g.
262/// `channels.mattermost.<alias>`), the table is emitted as raw HTML with each
263/// field name as an accordion trigger: clicking a field expands a detail row
264/// directly beneath it carrying the per-field gateway-dashboard deep-link,
265/// zerocode location, and `zeroclaw config set` command. The
266/// `pc-enhance.js` `installConfigFieldRows` handler wires the toggle. When
267/// `prefix` is `None`, a plain Markdown table is emitted (no accordion).
268pub 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    // Dashboard deep-link path. The web dashboard routes `/config/<section>/
292    // <type>` where `<type>` is the map key and `<section>` is the dot-joined
293    // prefix before it. `channels.mattermost.<alias>` -> `channels/mattermost`;
294    // `providers.models.venice.<alias>` -> `providers.models/venice`; a bare
295    // `acp` section (no `<alias>`) stays `acp`.
296    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        // Env-var override form: `ZEROCLAW_` + dotted path with `.` -> `__`,
339        // lowercase tail (config-tree override). Mirrors the runtime resolver in
340        // `crate::env_overrides`, so the rendered example and the value the
341        // runtime accepts cannot disagree.
342        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        // Detail is a `<div>` wrapping Markdown, not raw HTML table cells, so
349        // mdbook-i18n-helpers extracts the prose (field description and the
350        // tab guidance) for translation. Everything that must stay verbatim
351        // (field name, dotted path, `config set` command, env-var name) is in
352        // inline `code` spans or fenced blocks, which i18n-helpers leaves
353        // untouched. The `os-tabs-src` widget is the same one used elsewhere;
354        // `pc-enhance.js` turns it into the tab strip client-side.
355        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
393/// Plain Markdown field table (no accordion), used when no config prefix is
394/// supplied.
395fn 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
429/// Escape text for inclusion in HTML body content.
430fn html_escape(s: &str) -> String {
431    s.replace('&', "&amp;")
432        .replace('<', "&lt;")
433        .replace('>', "&gt;")
434        .replace('"', "&quot;")
435}
436
437/// Collapse a multi-line schema doc-comment into a single Markdown paragraph,
438/// preserving inline `` `code` `` spans verbatim. The result is emitted as
439/// Markdown (not HTML), so mdbook-i18n-helpers extracts the prose for
440/// translation while leaving the code spans untouched.
441fn markdown_prose(s: &str) -> String {
442    s.split_whitespace().collect::<Vec<_>>().join(" ")
443}
444
445/// Render a `fmt_default`-style value (which may be wrapped in backticks) as
446/// inline-code HTML, escaping the inner text.
447fn 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
456/// Generates a markdown config reference by walking the schemars JSON Schema value in memory.
457/// No intermediate JSON file, no external tools.
458pub 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    // Index table. Each section name links to its detail heading below; the
476    // mdBook anchor for a `## `<key>`` heading is the key verbatim (keys are
477    // lowercase ASCII with underscores, which slugify to themselves), so a
478    // `#<key>` fragment resolves without computing the slug.
479    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    // Per-section details
489    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    // Family-map container (e.g. `providers.models`, `channels`): every field
523    // is a `HashMap<String, T>` slot. Listing all slots here, each an empty
524    // `map | โ€”` row, then recursing into every one, duplicates the per-slot
525    // detail that already lives on the dedicated section page. Collapse to a
526    // single note instead. Detected structurally (all fields are maps), not by
527    // a hardcoded path, so it can never drift.
528    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        // Only recurse up to depth 3 (e.g. agent.auto_classify.something)
577        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
591/// Resolves a `$ref` to its definition. Also unwraps single-type `anyOf` (Option<T>).
592fn 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    // schemars 1.x renders `Option<T>` as `{"type": ["T", "null"]}`. Unwrap the
630    // nullable wrapper to `T?` so the table shows the real underlying type
631    // instead of falling through to `any`.
632    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                // A field with no `type`/`properties` is a free-form
695                // `serde_json::Value` (TOML inline table), e.g. `provider_extra`
696                // or `chat_template_kwargs`. Prefer the explicit title if the
697                // schema carries one, else label it `table` rather than `any`.
698                schema
699                    .get("title")
700                    .and_then(Value::as_str)
701                    .unwrap_or("table")
702                    .to_owned()
703            }
704        }
705    }
706}
707
708/// Format a default value for the table. Prefers the schema's own `default`
709/// key; when absent (schemars omits it for `skip_serializing_if` fields), falls
710/// back to the field's value in the struct's `Default::default()` instance, so
711/// `false`, `[]`, `{}`, and `null` defaults still surface instead of `โ€”`.
712fn 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        // Every section in the index table must be a link to the detail
749        // heading below. mdBook slugs a `## `<key>`` heading to the bare key,
750        // so the index cell links `[`<key>`](#<key>)`. A plain `` `<key>` ``
751        // cell (no link) is the regression this guards against.
752        let mut linked = 0usize;
753        for line in md.lines() {
754            // Index rows look like: | [`acp`](#acp) | ... |
755            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                // The matching detail heading must exist verbatim.
762                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        // `mcp.servers` is a Vec<McpServerConfig> (schemars `items`), not a map.
775        // The path walker must step into the element struct so a `[[mcp.servers]]`
776        // list section renders its entry fields instead of erroring with
777        // "no fields to render".
778        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}