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 =
20 "<!-- GENERATED FILE — do not edit by hand. Run: `cargo run --example gen_docs --features dev-tools` -->";
21
22pub 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
29pub 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
38pub 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
50pub 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
107fn 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
138pub 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 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 §ion.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
209fn 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 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 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 assert!(content_matches("a\r\nb\r\n", "a\nb\n"));
258 assert!(content_matches("a\nb\n", "a\nb\n"));
259 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}