lean_ctx/core/
reference_docs.rs1use 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
21pub 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
28pub 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
44pub 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
56pub 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
113fn 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
144pub 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 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 §ion.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
215fn 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 assert!(content_matches("a\r\nb\r\n", "a\nb\n"));
246 assert!(content_matches("a\nb\n", "a\nb\n"));
247 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}