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