Skip to main content

active_core/convert/
doc.rs

1//! Documentation generation: schema + intent + comments →
2//! Markdown, searchable HTML, and man pages (C-048, spec/docgen.md).
3//! Secret values render redacted in every format (C-036).
4
5use crate::ir::{AnnotationKind, Attachment, Module, Node, NodeId, Value};
6use crate::types::{ActiveType, Unit};
7use std::collections::HashMap;
8
9/// One documented configuration item: a dotted value path with its
10/// type, value, and prose.
11#[derive(Debug, Clone)]
12pub struct DocItem {
13    pub path: String,
14    pub depth: usize,
15    pub section: String,
16    pub is_table: bool,
17    pub type_text: String,
18    pub required: bool,
19    pub default_text: Option<String>,
20    pub value_text: String,
21    pub description: String,
22    pub constraints: Vec<String>,
23    pub deprecated: Option<String>,
24}
25
26/// Collect documentation items depth-first in source order from the
27/// evaluated value tree, enriched with schema data, annotations,
28/// and attached comments.
29pub fn collect_items(module: &Module, value: &Value) -> Vec<DocItem> {
30    // Node per dotted path (tables win ties, like resolution) for
31    // annotations and comment matching.
32    let mut by_path: HashMap<String, NodeId> = HashMap::new();
33    for (name, id) in &module.bindings {
34        by_path.insert(name.clone(), *id);
35    }
36    for (id, path) in module.value_paths() {
37        by_path.insert(path, id);
38    }
39    let mut comments: HashMap<String, Vec<String>> = HashMap::new();
40    // Keyed by path only (no file): intended — an included schema's
41    // contract comments and the document's own notes on the same key
42    // render together. Attachment itself already matched (line, file)
43    // at parse time, so same-path entries from different files are
44    // genuinely about the same key.
45    for trivia in &module.trivia {
46        let key = match &trivia.attachment {
47            Attachment::Leading(key) | Attachment::Trailing(key) => key.clone(),
48            _ => continue,
49        };
50        let text = strip_comment(&trivia.text);
51        // Annotation payloads (`# @intent: …`) already render from
52        // the parsed annotations — echoing their source comments
53        // would duplicate them verbatim.
54        if text.starts_with('@') {
55            continue;
56        }
57        comments.entry(key).or_default().push(text);
58    }
59    let mut items = Vec::new();
60    if let Value::Table(map) = value {
61        for (key, val) in map {
62            walk_value(module, val, key, 0, &by_path, &comments, &mut items);
63        }
64    }
65    items
66}
67
68/// One value plus, for containers, its descendants: tables recurse
69/// by key, arrays by index (`items[0]`). Sections group by the
70/// first segment (`General` for root-level scalars).
71fn walk_value(
72    module: &Module,
73    val: &Value,
74    path: &str,
75    depth: usize,
76    by_path: &HashMap<String, NodeId>,
77    comments: &HashMap<String, Vec<String>>,
78    items: &mut Vec<DocItem>,
79) {
80    let section = if depth == 0 && !matches!(val, Value::Table(_) | Value::Array(_)) {
81        "General".to_string()
82    } else {
83        path.split('.')
84            .next()
85            .unwrap_or(path)
86            .split('[')
87            .next()
88            .unwrap_or(path)
89            .to_string()
90    };
91    let node = by_path.get(path).copied();
92    let schema = schema_node_at(module, path);
93    let is_table = matches!(val, Value::Table(_));
94    let (type_text, required, default_text, constraints, deprecated) =
95        describe_schema(module, schema, val);
96    let mut description_parts: Vec<String> = Vec::new();
97    if let Some(id) = node {
98        if let Some(anns) = module.annotations.get(&id) {
99            for ann in anns {
100                match &ann.kind {
101                    AnnotationKind::Intent(text) => description_parts.push(text.clone()),
102                    AnnotationKind::Constraint { source, .. } => {
103                        description_parts.push(format!("Constraint: `{source}`."));
104                    }
105                    AnnotationKind::AiImmutable(true) => {
106                        description_parts.push("Agent-immutable.".to_string());
107                    }
108                    AnnotationKind::AiImmutable(false) => {}
109                }
110            }
111        }
112    }
113    if let Some(lines) = comments.get(path) {
114        description_parts.extend(lines.iter().cloned());
115    }
116    // Bare `#` markers contribute nothing; drop empties so they
117    // never widen paragraph spacing.
118    description_parts.retain(|part| !part.trim().is_empty());
119    items.push(DocItem {
120        path: path.to_string(),
121        depth,
122        section,
123        is_table,
124        type_text,
125        required,
126        default_text,
127        value_text: render_value(val),
128        description: description_parts.join("\n\n"),
129        constraints,
130        deprecated,
131    });
132    match val {
133        Value::Table(nested) => {
134            for (key, child) in nested {
135                walk_value(
136                    module,
137                    child,
138                    &format!("{path}.{key}"),
139                    depth + 1,
140                    by_path,
141                    comments,
142                    items,
143                );
144            }
145        }
146        Value::Array(elements) => {
147            for (i, element) in elements.iter().enumerate() {
148                walk_value(
149                    module,
150                    element,
151                    &format!("{path}[{i}]"),
152                    depth + 1,
153                    by_path,
154                    comments,
155                    items,
156                );
157            }
158        }
159        _ => {}
160    }
161}
162
163/// Schema data for an item: (type text, required, default,
164/// constraints, deprecated). Falls back to the value kind without a
165/// schema; dynamic constraint expressions are skipped (validation
166/// still enforces them).
167fn describe_schema(
168    module: &Module,
169    schema: Option<NodeId>,
170    val: &Value,
171) -> (String, bool, Option<String>, Vec<String>, Option<String>) {
172    let fallback = (value_kind_name(val), true, None, Vec::new(), None);
173    let Some(id) = schema else {
174        return fallback;
175    };
176    match module.get(id) {
177        Some(Node::SchemaTable { closed, .. }) => (
178            "Table".to_string(),
179            true,
180            None,
181            if *closed {
182                vec!["closed table".to_string()]
183            } else {
184                Vec::new()
185            },
186            None,
187        ),
188        Some(Node::SchemaArray { element }) => {
189            let inner = match module.get(*element) {
190                Some(Node::SchemaField { field }) => field.typ.name(),
191                Some(Node::SchemaTable { .. }) => "Table".to_string(),
192                Some(Node::SchemaArray { .. }) => "Array".to_string(),
193                _ => "Any".to_string(),
194            };
195            (format!("Array<{inner}>"), true, None, Vec::new(), None)
196        }
197        Some(Node::SchemaField { field }) => {
198            let mut constraints = Vec::new();
199            match &field.typ {
200                ActiveType::Int => {
201                    push_bound(module, field.min, "min", &mut constraints);
202                    push_bound(module, field.max, "max", &mut constraints);
203                }
204                ActiveType::Float => {
205                    push_bound(module, field.min, "min", &mut constraints);
206                    push_bound(module, field.max, "max", &mut constraints);
207                }
208                ActiveType::String => {
209                    push_bound(module, field.min_len, "min_len", &mut constraints);
210                    push_bound(module, field.max_len, "max_len", &mut constraints);
211                    if let Some(pattern) = static_string(module, field.pattern) {
212                        constraints.push(format!("pattern `{pattern}`"));
213                    }
214                }
215                _ => {}
216            }
217            let default_text = match field.default.and_then(|d| module.get(d)) {
218                Some(Node::Literal(value)) => Some(render_value(value)),
219                _ => None,
220            };
221            let required = !matches!(field.typ, ActiveType::Null) && default_text.is_none();
222            (
223                field.typ.name(),
224                required,
225                default_text,
226                constraints,
227                field.deprecated.clone(),
228            )
229        }
230        _ => fallback,
231    }
232}
233
234/// Schema node for a dotted value path: table segments index
235/// `SchemaTable` fields; a `key[i]` segment descends into the array
236/// element, so `flags[0]` resolves to the element type (`Bool`,
237/// not `Array<Bool>`).
238fn schema_node_at(module: &Module, path: &str) -> Option<NodeId> {
239    let mut id = module.schema?;
240    for segment in path.split('.') {
241        let (key, indexed) = match segment.split_once('[') {
242            Some((key, _)) => (key, true),
243            None => (segment, false),
244        };
245        match module.get(id) {
246            Some(Node::SchemaTable { fields, .. }) => {
247                id = *fields.get(key)?;
248                if indexed {
249                    match module.get(id) {
250                        Some(Node::SchemaArray { element }) => id = *element,
251                        _ => return None,
252                    }
253                }
254            }
255            // Bare indices only arise from hand-built paths; the
256            // walker always brackets them onto the key.
257            Some(Node::SchemaArray { element }) => {
258                id = *element;
259            }
260            _ => return None,
261        }
262    }
263    Some(id)
264}
265
266fn push_bound(module: &Module, bound: Option<NodeId>, name: &str, constraints: &mut Vec<String>) {
267    let rendered = match bound.and_then(|id| module.get(id)) {
268        Some(Node::Literal(crate::ir::Value::Int(i))) => Some(i.to_string()),
269        Some(Node::Literal(crate::ir::Value::Float(f))) => Some(f.to_string()),
270        _ => None,
271    };
272    if let Some(text) = rendered {
273        constraints.push(format!("{name} {text}"));
274    }
275}
276
277fn static_string(module: &Module, id: Option<NodeId>) -> Option<String> {
278    match id.and_then(|node| module.get(node)) {
279        Some(Node::Literal(crate::ir::Value::String(s))) => Some(s.clone()),
280        _ => None,
281    }
282}
283
284fn value_kind_name(val: &Value) -> String {
285    match val {
286        Value::Null => "Null".to_string(),
287        Value::Bool(_) => "Bool".to_string(),
288        Value::Int(_) => "Int".to_string(),
289        Value::Float(_) => "Float".to_string(),
290        Value::String(_) => "String".to_string(),
291        Value::DurationMs(_) => "Duration".to_string(),
292        Value::Quantity(_, _) => "Quantity".to_string(),
293        Value::Array(_) => "Array".to_string(),
294        Value::Table(map) => format!("Table({})", map.len()),
295        Value::Secret(_) => "Secret".to_string(),
296    }
297}
298
299/// Short safe rendering for documentation values: scalars inline,
300/// composites by kind, secrets redacted.
301fn render_value(val: &Value) -> String {
302    match val {
303        Value::Secret(_) => "***SECRET***".to_string(),
304        Value::String(s) => s.clone(),
305        Value::Int(i) => i.to_string(),
306        Value::Float(f) => f.to_string(),
307        Value::Bool(b) => b.to_string(),
308        Value::Null => "null".to_string(),
309        Value::DurationMs(ms) => format!("{ms} ms"),
310        Value::Quantity(n, unit) => match unit {
311            Unit::Percent => format!("{}%", n * 100.0),
312            _ => format!("{n} {}", unit.suffix()),
313        },
314        Value::Array(items) => format!("array[{}]", items.len()),
315        Value::Table(map) => format!("table{{{}}}", map.len()),
316    }
317}
318
319fn strip_comment(text: &str) -> String {
320    let stripped = text.trim_start_matches('#');
321    stripped.strip_prefix(' ').unwrap_or(stripped).to_string()
322}
323
324// ------------------------------------------------------------------
325// Markdown.
326// ------------------------------------------------------------------
327
328/// Reference page: `# Title`, one `## table` section per top-level
329/// table (`## General` for root scalars), `### path` items with a
330/// type/required/default/value line, description paragraphs,
331/// constraint bullets, and deprecation quotes.
332pub fn doc_to_markdown(title: &str, items: &[DocItem]) -> String {
333    let mut out = format!("# {title}\n\n");
334    let mut section: Option<&str> = None;
335    for item in items {
336        if section != Some(item.section.as_str()) {
337            out.push_str(&format!("## {}\n\n", item.section));
338            section = Some(item.section.as_str());
339        }
340        out.push_str(&format!("### {}\n\n", item.path));
341        let mut line = format!("**Type:** {}", item.type_text);
342        line.push_str(&format!(
343            " · **Required:** {}",
344            if item.required { "yes" } else { "no" }
345        ));
346        if let Some(default) = &item.default_text {
347            line.push_str(&format!(" · **Default:** `{default}`"));
348        }
349        line.push_str(&format!(" · **Value:** `{}`", item.value_text));
350        out.push_str(&line);
351        out.push_str("\n\n");
352        if !item.description.is_empty() {
353            out.push_str(&item.description);
354            out.push_str("\n\n");
355        }
356        for constraint in &item.constraints {
357            out.push_str(&format!("- {constraint}\n"));
358        }
359        if !item.constraints.is_empty() {
360            out.push('\n');
361        }
362        if let Some(reason) = &item.deprecated {
363            if reason.is_empty() {
364                out.push_str("> Deprecated.\n\n");
365            } else {
366                out.push_str(&format!("> Deprecated: {reason}\n\n"));
367            }
368        }
369    }
370    out
371}
372
373// ------------------------------------------------------------------
374// HTML (single self-contained searchable page).
375// ------------------------------------------------------------------
376
377fn escape_html(s: &str) -> String {
378    let mut out = String::with_capacity(s.len());
379    for c in s.chars() {
380        match c {
381            '&' => out.push_str("&amp;"),
382            '<' => out.push_str("&lt;"),
383            '>' => out.push_str("&gt;"),
384            '"' => out.push_str("&quot;"),
385            _ => out.push(c),
386        }
387    }
388    out
389}
390
391/// One page with inline CSS and a filter box: typing hides
392/// non-matching `.field` blocks (substring, case-insensitive).
393pub fn doc_to_html(title: &str, items: &[DocItem]) -> String {
394    let mut out = String::new();
395    out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
396    out.push_str(&format!("<title>{}</title>\n", escape_html(title)));
397    out.push_str(
398        "<style>body{font-family:sans-serif;max-width:60em;margin:2em auto;padding:0 1em}\
399        .field{border-top:1px active #ccc;padding:.5em 0}code{background:#f4f4f4;padding:0 .3em}\
400        #q{width:100%;font-size:1.1em;padding:.3em;margin:1em 0}</style>\n",
401    );
402    out.push_str("</head>\n<body>\n");
403    out.push_str(&format!("<h1>{}</h1>\n", escape_html(title)));
404    out.push_str(
405        "<input type=\"search\" id=\"q\" placeholder=\"Filter fields…\" oninput=\"\
406        const q=this.value.toLowerCase();\
407        document.querySelectorAll('.field').forEach(el=>{\
408        el.style.display=el.textContent.toLowerCase().includes(q)?'':'none'})\">\n",
409    );
410    let mut section: Option<&str> = None;
411    for item in items {
412        if section != Some(item.section.as_str()) {
413            out.push_str(&format!("<h2>{}</h2>\n", escape_html(&item.section)));
414            section = Some(item.section.as_str());
415        }
416        out.push_str("<div class=\"field\">\n");
417        out.push_str(&format!(
418            "<h3><code>{}</code></h3>\n",
419            escape_html(&item.path)
420        ));
421        out.push_str("<p>");
422        out.push_str(&format!(
423            "<code>{}</code> · {} · <code>{}</code>",
424            escape_html(&item.type_text),
425            if item.required {
426                "required"
427            } else {
428                "optional"
429            },
430            escape_html(&item.value_text),
431        ));
432        if let Some(default) = &item.default_text {
433            out.push_str(&format!(" · default <code>{}</code>", escape_html(default)));
434        }
435        out.push_str("</p>\n");
436        for para in item.description.split("\n\n") {
437            if !para.is_empty() {
438                out.push_str(&format!("<p>{}</p>\n", escape_html(para)));
439            }
440        }
441        if !item.constraints.is_empty() {
442            out.push_str("<ul>\n");
443            for constraint in &item.constraints {
444                out.push_str(&format!(
445                    "<li><code>{}</code></li>\n",
446                    escape_html(constraint)
447                ));
448            }
449            out.push_str("</ul>\n");
450        }
451        if let Some(reason) = &item.deprecated {
452            if reason.is_empty() {
453                out.push_str("<p><strong>Deprecated.</strong></p>\n");
454            } else {
455                out.push_str(&format!(
456                    "<p><strong>Deprecated:</strong> {}</p>\n",
457                    escape_html(reason)
458                ));
459            }
460        }
461        out.push_str("</div>\n");
462    }
463    out.push_str("</body>\n</html>\n");
464    out
465}
466
467// ------------------------------------------------------------------
468// Man page.
469// ------------------------------------------------------------------
470
471/// Escape one output line for man(7): backslashes doubled, and a
472/// `\&` guard before leading `'` or `.` (which would otherwise open
473/// a request or macro).
474fn escape_man_line(line: &str) -> String {
475    let mut out = line.replace('\\', "\\\\");
476    if out.starts_with('\'') || out.starts_with('.') {
477        out.insert_str(0, "\\&");
478    }
479    out
480}
481
482/// Man page: `.TH` header, `.SH NAME` summary, one `.TP` entry per
483/// item with type/required/value/description body lines.
484pub fn doc_to_man(title: &str, items: &[DocItem]) -> String {
485    let mut out = format!(".TH \"{}\" 5\n", title.to_uppercase().replace('"', ""));
486    out.push_str(".SH NAME\n");
487    out.push_str(&escape_man_line(&format!(
488        "{title} \\- configuration reference"
489    )));
490    out.push('\n');
491    out.push_str(".SH FIELDS\n");
492    for item in items {
493        out.push_str(".TP\n");
494        out.push_str(&escape_man_line(&item.path));
495        out.push('\n');
496        let mut body = format!(
497            "Type: {}. Required: {}. Value: {}.",
498            item.type_text,
499            if item.required { "yes" } else { "no" },
500            item.value_text
501        );
502        if let Some(default) = &item.default_text {
503            body.push_str(&format!(" Default: {default}."));
504        }
505        if !item.constraints.is_empty() {
506            body.push_str(&format!(" Constraints: {}.", item.constraints.join("; ")));
507        }
508        if let Some(reason) = &item.deprecated {
509            if reason.is_empty() {
510                body.push_str(" Deprecated.");
511            } else {
512                body.push_str(&format!(" Deprecated: {reason}."));
513            }
514        }
515        for line in body.split('\n') {
516            out.push_str(&escape_man_line(line));
517            out.push('\n');
518        }
519        for para in item.description.split("\n\n") {
520            if para.is_empty() {
521                continue;
522            }
523            for line in para.split('\n') {
524                out.push_str(&escape_man_line(line));
525                out.push('\n');
526            }
527            out.push_str(".br\n");
528        }
529    }
530    out
531}