Skip to main content

lean_ctx/core/config/
render.rs

1//! Renders a fully annotated `config.toml` for `lean-ctx config init --full`.
2//!
3//! The body is the verbatim [`toml::to_string_pretty`] serialization of the
4//! supplied [`Config`], so every field round-trips exactly — independent of
5//! schema completeness (#443). Schema descriptions, allowed values, defaults,
6//! and env overrides are then woven in as `#` comment lines *above* each key and
7//! section. Only comment lines are inserted; key/value lines are never touched,
8//! which guarantees `parse(render(cfg)) == cfg` and keeps the output a
9//! deterministic function of `(cfg, schema)` (#498).
10
11use super::Config;
12use super::schema::{ConfigSchema, KeySchema};
13
14/// The short starter `config.toml` written by `lean-ctx config init`.
15pub 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
88/// Builds the annotated `config.toml` text for `cfg`, documented via `schema`.
89pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
90    let mut out = String::from(HEADER);
91
92    // Serializing the full config is what guarantees a lossless round-trip; the
93    // annotation pass below only ever *inserts* comment lines. A `Config` always
94    // serializes, so the error path is unreachable in practice — we still return
95    // a valid (header-only) document rather than panicking.
96    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, &section, 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
130/// Returns the section name for a `[section]` header line, or `None`. Array-of-
131/// tables headers (`[[…]]`) are deliberately ignored (Config has none).
132fn 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
144/// Extracts the bare key of a `key = value` line. Returns `None` for array
145/// continuation lines, closing brackets, or quoted/dotted dynamic-map keys, all
146/// of which are passed through verbatim (keeping the round-trip exact).
147fn 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
203/// Documents fields that predate the schema registry. Keeping this fallback in
204/// the renderer guarantees that newly serialized parameters are never emitted
205/// without an explanation while their richer schema metadata is being added.
206fn 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    // #443 core safety net: rendering then parsing must reproduce the config
249    // exactly, regardless of how complete the schema is. The comparison goes
250    // through `toml::Value` (map equality is order-independent) so it is robust
251    // against `HashMap` iteration order in fields like `tool_total_limits`.
252    #[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    // Output must be a deterministic function of its inputs (#498) and actually
281    // carry documentation.
282    #[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}