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