lean_ctx/core/config/
render.rs1use super::Config;
12use super::schema::{ConfigSchema, KeySchema};
13
14pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
16# Full reference: https://leanctx.com/docs/configuration
17# For all settings: lean-ctx config init --full
18#
19# Optional named overlay. LEAN_CTX_CONFIG_PROFILE overrides this selection:
20# config_profile = "cloud"
21# [profiles.local]
22# compression_level = "lite"
23# [profiles.cloud]
24# compression_level = "standard"
25
26# ── High-Level Knobs ─────────────────────────────────────────────────
27# These auto-adjust advanced settings. Override individual values below
28# only if you need fine-grained control.
29
30# Output style for the model's prose (not tool-output compression):
31# off — no style guidance
32# lite — plain-English concise (default; readable, still token-saving)
33# standard / max — denser symbolic "power modes" (opt-in)
34compression_level = "lite"
35
36# RAM/feature trade-off: low | balanced | performance
37memory_profile = "balanced"
38
39# Maximum % of system RAM lean-ctx may use (1-50)
40max_ram_percent = 5
41
42# Per-process RSS target (MB) of each stdio MCP server; the lower of this and
43# max_ram_percent applies (0 = percent only).
44# mcp_max_rss_mb = 512
45
46# Process RSS target (MB) of the background daemon; the lower of this and
47# max_ram_percent applies (0 = percent only).
48# daemon_max_rss_mb = 2048
49
50# Exit a stdio MCP server after N minutes without a tool call (0 = never).
51# Codex does not restart exited servers, so leave this off for Codex.
52# mcp_idle_exit_minutes = 0
53
54# Total disk budget in MB (0 = use individual limits).
55# Distributes proportionally: archive ~25%, BM25 cache ~10%.
56# max_disk_mb = 2000
57
58# Auto-purge data older than N days (0 = disabled).
59# Flows into archive.max_age_hours.
60# max_staleness_days = 30
61
62# Session retention for `lean-ctx sessions cleanup` and `ctx_session cleanup`.
63# This is independent from archive retention and preserves the newest session
64# for every project root.
65# session_retention_days = 7
66
67# Explicit project paths to scan/index (default: auto-detect).
68# [ide_paths]
69# cursor = ["/home/user/projects/app1"]
70
71# ── Proxy ────────────────────────────────────────────────────────────
72# proxy_enabled = false
73# proxy_port = 3128
74"#;
75
76const HEADER: &str = "\
77# lean-ctx configuration — full annotated reference
78#
79# Generated by `lean-ctx config init --full`. Every key is documented with its
80# purpose and, where applicable, its default, allowed values, and the environment
81# variable that overrides it. The values below reflect your current configuration.
82#
83# A key left at its default may be deleted — lean-ctx falls back to the documented
84# default. After editing, run `lean-ctx config apply` to reload.
85
86";
87
88pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
90 let mut out = String::from(HEADER);
91
92 let Ok(body) = toml::to_string_pretty(cfg) else {
97 return out;
98 };
99
100 let mut current_section = String::from("root");
101 for line in body.lines() {
102 let trimmed = line.trim_start();
103
104 if let Some(section) = section_header(trimmed) {
105 append_section(&mut out, schema, §ion, line);
106 current_section = section;
107 continue;
108 }
109
110 if let Some(field) = leading_key(trimmed) {
111 let path = if current_section == "root" {
112 field.to_string()
113 } else {
114 format!("{current_section}.{field}")
115 };
116 if let Some(key_schema) = schema.lookup(&path) {
117 append_key_comment(&mut out, key_schema);
118 } else {
119 append_fallback_key_comment(&mut out, field);
120 }
121 }
122
123 out.push_str(line);
124 out.push('\n');
125 }
126
127 out
128}
129
130fn section_header(trimmed: &str) -> Option<String> {
133 if trimmed.starts_with("[[") || !trimmed.starts_with('[') || !trimmed.ends_with(']') {
134 return None;
135 }
136 let inner = trimmed[1..trimmed.len() - 1].trim();
137 if inner.is_empty() {
138 None
139 } else {
140 Some(inner.to_string())
141 }
142}
143
144fn leading_key(trimmed: &str) -> Option<&str> {
148 let eq = trimmed.find('=')?;
149 let key = trimmed[..eq].trim();
150 if !key.is_empty()
151 && key
152 .chars()
153 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
154 {
155 Some(key)
156 } else {
157 None
158 }
159}
160
161fn append_section(out: &mut String, schema: &ConfigSchema, section: &str, header_line: &str) {
162 if !out.ends_with("\n\n") {
163 out.push('\n');
164 }
165 if let Some(section_schema) = schema.sections.get(section)
166 && !section_schema.description.is_empty()
167 {
168 for comment in section_schema.description.lines() {
169 out.push_str("# ");
170 out.push_str(comment);
171 out.push('\n');
172 }
173 }
174 out.push_str(header_line);
175 out.push('\n');
176}
177
178fn append_key_comment(out: &mut String, key_schema: &KeySchema) {
179 for comment in key_schema.description.lines() {
180 out.push_str("# ");
181 out.push_str(comment);
182 out.push('\n');
183 }
184 if !key_schema.default.is_null() {
185 out.push_str("# default: ");
186 out.push_str(&key_schema.default.to_string());
187 out.push('\n');
188 }
189 if let Some(values) = &key_schema.values
190 && !values.is_empty()
191 {
192 out.push_str("# values: ");
193 out.push_str(&values.join(", "));
194 out.push('\n');
195 }
196 if let Some(env) = &key_schema.env_override {
197 out.push_str("# env: ");
198 out.push_str(env);
199 out.push('\n');
200 }
201}
202
203fn append_fallback_key_comment(out: &mut String, field: &str) {
207 let comment = if let Some(name) = field
208 .strip_prefix("enable_")
209 .or_else(|| field.strip_suffix("_enabled"))
210 {
211 format!("Enables or disables {}.", humanize(name))
212 } else if let Some(name) = field.strip_prefix("max_") {
213 format!("Sets maximum {}.", humanize(name))
214 } else if let Some(name) = field.strip_suffix("_ms") {
215 format!("Sets {} in milliseconds.", humanize(name))
216 } else if let Some(name) = field.strip_suffix("_percent") {
217 format!("Sets {} as a percentage.", humanize(name))
218 } else {
219 format!("Configures {}.", humanize(field))
220 };
221 out.push_str("# ");
222 out.push_str(&comment);
223 out.push('\n');
224}
225
226fn humanize(field: &str) -> String {
227 field.replace(['_', '-'], " ")
228}
229
230#[cfg(test)]
231mod tests {
232 use std::collections::BTreeMap;
233
234 use super::*;
235 use crate::core::config::CompressionLevel;
236
237 fn customized() -> Config {
238 let mut cfg = Config {
239 max_ram_percent: 30,
240 compression_level: CompressionLevel::Standard,
241 theme: "neon".to_string(),
242 ..Config::default()
243 };
244 cfg.proxy.anthropic_upstream = Some("https://upstream.example".to_string());
245 cfg
246 }
247
248 #[test]
253 fn render_round_trips_to_identical_config() {
254 let cfg = customized();
255 let schema = ConfigSchema::generate();
256
257 let rendered = render_annotated_config(&cfg, &schema);
258
259 let expected: toml::Value = toml::from_str(&toml::to_string_pretty(&cfg).unwrap()).unwrap();
260 let actual: toml::Value = toml::from_str(&rendered).expect("rendered config must parse");
261
262 assert_eq!(
263 actual, expected,
264 "render → parse must reproduce the config exactly (#443)"
265 );
266 }
267
268 #[test]
269 fn render_preserves_customized_values() {
270 let cfg = customized();
271 let schema = ConfigSchema::generate();
272
273 let parsed: Config = toml::from_str(&render_annotated_config(&cfg, &schema)).unwrap();
274
275 assert_eq!(parsed.max_ram_percent, 30);
276 assert_eq!(parsed.compression_level, CompressionLevel::Standard);
277 assert_eq!(parsed.theme, "neon");
278 }
279
280 #[test]
283 fn render_is_deterministic_and_annotated() {
284 let cfg = Config::default();
285 let schema = ConfigSchema::generate();
286
287 let first = render_annotated_config(&cfg, &schema);
288 let second = render_annotated_config(&cfg, &schema);
289
290 assert_eq!(first, second, "render must be deterministic (#498)");
291 assert!(first.contains("# lean-ctx configuration"));
292 assert!(
293 first.contains("max_ram_percent"),
294 "documented keys must appear"
295 );
296 assert!(
297 first.matches("# ").count() > 5,
298 "rendered config must be annotated with comments"
299 );
300 }
301
302 #[test]
303 fn every_generated_parameter_has_an_explainer_comment() {
304 let rendered = render_annotated_config(&Config::default(), &ConfigSchema::generate());
305 let mut previous_nonempty = "";
306
307 for line in rendered.lines() {
308 let trimmed = line.trim();
309 if leading_key(trimmed).is_some() {
310 assert!(
311 previous_nonempty.starts_with('#'),
312 "generated parameter lacks an explainer comment: {trimmed}"
313 );
314 }
315 if !trimmed.is_empty() {
316 previous_nonempty = trimmed;
317 }
318 }
319 }
320
321 #[test]
322 fn undocumented_schema_fields_get_readable_fallback_comments() {
323 let cfg = Config {
324 theme: "neon".to_string(),
325 ..Config::default()
326 };
327 let schema = ConfigSchema {
328 version: 1,
329 sections: BTreeMap::default(),
330 };
331
332 let rendered = render_annotated_config(&cfg, &schema);
333
334 assert!(rendered.contains("# Sets maximum ram percent.\nmax_ram_percent = "));
335 assert!(rendered.contains("# Configures theme.\ntheme = \"neon\""));
336 }
337}