Skip to main content

lean_ctx/core/
reference_docs.rs

1//! Generated reference documentation.
2//!
3//! Renders Markdown appendices directly from the in-code single sources of
4//! truth (the MCP tool registry and the `Config` schema) so the published
5//! reference can never drift from the actual feature surface.
6//!
7//! - `mcp-tools.md`   — every registered MCP tool, from `manifest_value()`.
8//! - `config-keys.md` — every recognized `config.toml` key, from `ConfigSchema`.
9//!
10//! Used by the `gen_docs` example (writes the files) and by drift tests /
11//! the CI gate (compare on-disk vs. freshly rendered).
12
13use std::path::PathBuf;
14
15use serde_json::Value;
16
17use crate::core::config::schema::ConfigSchema;
18
19const DO_NOT_EDIT: &str = "<!-- GENERATED FILE — do not edit by hand. Run: `cargo run --example gen_docs --features dev-tools` -->";
20
21/// Directory the generated reference docs live in (`docs/reference/generated`).
22pub fn generated_dir() -> PathBuf {
23    let rust_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
24    let repo_root = rust_dir.parent().unwrap_or(&rust_dir);
25    repo_root.join("docs/reference/generated")
26}
27
28/// Every generated reference document as `(filename, markdown)` pairs.
29/// This is the canonical list shared by the writer and the drift tests.
30pub fn generated_docs() -> Vec<(&'static str, String)> {
31    vec![
32        ("mcp-tools.md", mcp_tools_markdown()),
33        ("config-keys.md", config_keys_markdown()),
34        (
35            "host-coverage.md",
36            format!(
37                "{DO_NOT_EDIT}\n\n{}",
38                crate::core::host_coverage::matrix_markdown()
39            ),
40        ),
41    ]
42}
43
44/// True when on-disk content equals freshly generated content, ignoring
45/// line-ending differences. Windows checkouts may store the committed docs
46/// with CRLF while the generator emits LF; the drift gate compares *content*,
47/// not byte-exact line endings.
48pub fn content_matches(on_disk: &str, generated: &str) -> bool {
49    normalize_newlines(on_disk) == normalize_newlines(generated)
50}
51
52fn normalize_newlines(s: &str) -> String {
53    s.replace("\r\n", "\n")
54}
55
56// ---------------------------------------------------------------------------
57// MCP tools
58// ---------------------------------------------------------------------------
59
60/// Markdown reference for every registered MCP tool (granular profile),
61/// rendered from the same manifest the editors consume.
62pub fn mcp_tools_markdown() -> String {
63    let manifest = crate::core::mcp_manifest::manifest_value();
64    let mut tools: Vec<&Value> = manifest
65        .get("tools")
66        .and_then(|t| t.get("granular"))
67        .and_then(|g| g.as_array())
68        .map(|a| a.iter().collect())
69        .unwrap_or_default();
70    tools.sort_by(|a, b| tool_name(a).cmp(tool_name(b)));
71
72    let mut out = String::new();
73    out.push_str("# Appendix — MCP Tools (generated)\n\n");
74    out.push_str(DO_NOT_EDIT);
75    out.push_str("\n\n");
76    out.push_str(
77        "Source of truth: `rust/crates/lean-ctx-server/src/server/registry.rs` and the tool definitions it registers.\n\n",
78    );
79    out.push_str(&format!(
80        "lean-ctx registers **{} MCP tools** (granular profile). Each entry below lists the \
81         tool name, what it does, and its parameters (`*` marks required).\n\n",
82        tools.len()
83    ));
84
85    for tool in tools {
86        let name = tool_name(tool);
87        out.push_str(&format!("## `{name}`\n\n"));
88
89        let desc = tool
90            .get("description")
91            .and_then(|d| d.as_str())
92            .unwrap_or("")
93            .trim();
94        if !desc.is_empty() {
95            out.push_str(desc);
96            out.push_str("\n\n");
97        }
98
99        let params = render_tool_params(tool);
100        if params.is_empty() {
101            out.push_str("Parameters: _none_\n\n");
102        } else {
103            out.push_str(&format!("Parameters: {params}\n\n"));
104        }
105    }
106    out
107}
108
109fn tool_name(tool: &Value) -> &str {
110    tool.get("name").and_then(|n| n.as_str()).unwrap_or("")
111}
112
113/// Render the parameter list of a tool as inline code spans, sorted, with
114/// required parameters marked by a trailing `*`.
115fn render_tool_params(tool: &Value) -> String {
116    let schema = tool.get("input_schema");
117    let props = schema
118        .and_then(|s| s.get("properties"))
119        .and_then(|p| p.as_object());
120    let Some(props) = props else {
121        return String::new();
122    };
123    let required: Vec<&str> = schema
124        .and_then(|s| s.get("required"))
125        .and_then(|r| r.as_array())
126        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
127        .unwrap_or_default();
128
129    let mut names: Vec<&String> = props.keys().collect();
130    names.sort();
131    names
132        .iter()
133        .map(|n| {
134            if required.contains(&n.as_str()) {
135                format!("`{n}`*")
136            } else {
137                format!("`{n}`")
138            }
139        })
140        .collect::<Vec<_>>()
141        .join(", ")
142}
143
144// ---------------------------------------------------------------------------
145// Config keys
146// ---------------------------------------------------------------------------
147
148/// Markdown reference for every recognized `config.toml` key, rendered from
149/// the `Config` schema (types, defaults, allowed values, env overrides).
150pub fn config_keys_markdown() -> String {
151    let schema = ConfigSchema::generate();
152
153    let mut out = String::new();
154    out.push_str("# Appendix — Configuration Keys (generated)\n\n");
155    out.push_str(DO_NOT_EDIT);
156    out.push_str("\n\n");
157    out.push_str("Source of truth: `rust/src/core/config/schema/mod.rs`.\n\n");
158    out.push_str(
159        "lean-ctx reads `~/.lean-ctx/config.toml` (and a project `.lean-ctx.toml` overlay). \
160         Below is every recognized key with its type, default, and environment-variable \
161         override where one exists.\n\n",
162    );
163
164    // `root` first (top-level keys), then named sections alphabetically.
165    if let Some(root) = schema.sections.get("root") {
166        out.push_str("## Top-level keys\n\n");
167        if !root.description.trim().is_empty() {
168            out.push_str(&format!("{}\n\n", root.description.trim()));
169        }
170        out.push_str(&render_section_keys(root));
171    }
172
173    for (name, section) in &schema.sections {
174        if name == "root" {
175            continue;
176        }
177        out.push_str(&format!("## `[{name}]`\n\n"));
178        if !section.description.trim().is_empty() {
179            out.push_str(&format!("{}\n\n", section.description.trim()));
180        }
181        let keys = render_section_keys(section);
182        if keys.is_empty() {
183            out.push_str("_No sub-keys (presence of the section toggles the feature)._\n\n");
184        } else {
185            out.push_str(&keys);
186        }
187    }
188    out
189}
190
191fn render_section_keys(section: &crate::core::config::schema::SectionSchema) -> String {
192    let mut out = String::new();
193    for (key, ks) in &section.keys {
194        let mut ty = ks.ty.clone();
195        if let Some(values) = &ks.values {
196            ty = format!("{ty}: {}", values.join(" | "));
197        }
198        let default = value_to_inline(&ks.default);
199        let env = ks
200            .env_override
201            .as_ref()
202            .map(|e| format!(" — env `{e}`"))
203            .unwrap_or_default();
204        let desc = ks.description.trim();
205        out.push_str(&format!(
206            "- `{key}` ({ty}, default `{default}`{env}) — {desc}\n"
207        ));
208    }
209    if !out.is_empty() {
210        out.push('\n');
211    }
212    out
213}
214
215/// Compact inline rendering of a JSON default value for docs.
216fn value_to_inline(v: &Value) -> String {
217    match v {
218        Value::Null => "null".to_string(),
219        Value::String(s) if s.is_empty() => "\"\"".to_string(),
220        Value::String(s) => s.clone(),
221        Value::Array(a) if a.is_empty() => "[]".to_string(),
222        other => other.to_string(),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn config_keys_doc_covers_all_known_keys() {
232        let md = config_keys_markdown();
233        let schema = ConfigSchema::generate();
234        for key in schema.sections.values().flat_map(|s| s.keys.keys()) {
235            assert!(
236                md.contains(&format!("`{key}`")),
237                "config key `{key}` missing from generated doc"
238            );
239        }
240    }
241
242    #[test]
243    fn content_matches_ignores_line_endings() {
244        // A CRLF checkout (Windows) must still match LF-generated content.
245        assert!(content_matches("a\r\nb\r\n", "a\nb\n"));
246        assert!(content_matches("a\nb\n", "a\nb\n"));
247        // Real content differences still fail.
248        assert!(!content_matches("a\nb\n", "a\nc\n"));
249    }
250
251    #[test]
252    fn generated_docs_are_nonempty_and_named() {
253        for (name, body) in generated_docs() {
254            assert!(
255                std::path::Path::new(name)
256                    .extension()
257                    .is_some_and(|ext| ext.eq_ignore_ascii_case("md")),
258                "{name} should be a .md file"
259            );
260            assert!(body.len() > 100, "{name} should not be trivial");
261            assert!(body.contains("GENERATED FILE"), "{name} needs the banner");
262        }
263    }
264}