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}
36
37pub 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
49pub 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
106fn 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
137pub 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 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 §ion.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
208fn 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 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 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 assert!(content_matches("a\r\nb\r\n", "a\nb\n"));
257 assert!(content_matches("a\nb\n", "a\nb\n"));
258 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}