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
14const HEADER: &str = "\
15# lean-ctx configuration — full annotated reference
16#
17# Generated by `lean-ctx config init --full`. Every key is documented with its
18# purpose and, where applicable, its default, allowed values, and the environment
19# variable that overrides it. The values below reflect your current configuration.
20#
21# A key left at its default may be deleted — lean-ctx falls back to the documented
22# default. After editing, run `lean-ctx config apply` to reload.
23
24";
25
26/// Builds the annotated `config.toml` text for `cfg`, documented via `schema`.
27pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
28    let mut out = String::from(HEADER);
29
30    // Serializing the full config is what guarantees a lossless round-trip; the
31    // annotation pass below only ever *inserts* comment lines. A `Config` always
32    // serializes, so the error path is unreachable in practice — we still return
33    // a valid (header-only) document rather than panicking.
34    let Ok(body) = toml::to_string_pretty(cfg) else {
35        return out;
36    };
37
38    let mut current_section = String::from("root");
39    for line in body.lines() {
40        let trimmed = line.trim_start();
41
42        if let Some(section) = section_header(trimmed) {
43            append_section(&mut out, schema, &section, line);
44            current_section = section;
45            continue;
46        }
47
48        if let Some(field) = leading_key(trimmed) {
49            let path = if current_section == "root" {
50                field.to_string()
51            } else {
52                format!("{current_section}.{field}")
53            };
54            if let Some(key_schema) = schema.lookup(&path) {
55                append_key_comment(&mut out, key_schema);
56            }
57        }
58
59        out.push_str(line);
60        out.push('\n');
61    }
62
63    out
64}
65
66/// Returns the section name for a `[section]` header line, or `None`. Array-of-
67/// tables headers (`[[…]]`) are deliberately ignored (Config has none).
68fn section_header(trimmed: &str) -> Option<String> {
69    if trimmed.starts_with("[[") || !trimmed.starts_with('[') || !trimmed.ends_with(']') {
70        return None;
71    }
72    let inner = trimmed[1..trimmed.len() - 1].trim();
73    if inner.is_empty() {
74        None
75    } else {
76        Some(inner.to_string())
77    }
78}
79
80/// Extracts the bare key of a `key = value` line. Returns `None` for array
81/// continuation lines, closing brackets, or quoted/dotted dynamic-map keys, all
82/// of which are passed through verbatim (keeping the round-trip exact).
83fn leading_key(trimmed: &str) -> Option<&str> {
84    let eq = trimmed.find('=')?;
85    let key = trimmed[..eq].trim();
86    if !key.is_empty()
87        && key
88            .chars()
89            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
90    {
91        Some(key)
92    } else {
93        None
94    }
95}
96
97fn append_section(out: &mut String, schema: &ConfigSchema, section: &str, header_line: &str) {
98    if !out.ends_with("\n\n") {
99        out.push('\n');
100    }
101    if let Some(section_schema) = schema.sections.get(section)
102        && !section_schema.description.is_empty()
103    {
104        for comment in section_schema.description.lines() {
105            out.push_str("# ");
106            out.push_str(comment);
107            out.push('\n');
108        }
109    }
110    out.push_str(header_line);
111    out.push('\n');
112}
113
114fn append_key_comment(out: &mut String, key_schema: &KeySchema) {
115    for comment in key_schema.description.lines() {
116        out.push_str("# ");
117        out.push_str(comment);
118        out.push('\n');
119    }
120    if !key_schema.default.is_null() {
121        out.push_str("#   default: ");
122        out.push_str(&key_schema.default.to_string());
123        out.push('\n');
124    }
125    if let Some(values) = &key_schema.values
126        && !values.is_empty()
127    {
128        out.push_str("#   values: ");
129        out.push_str(&values.join(", "));
130        out.push('\n');
131    }
132    if let Some(env) = &key_schema.env_override {
133        out.push_str("#   env: ");
134        out.push_str(env);
135        out.push('\n');
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::core::config::CompressionLevel;
143
144    fn customized() -> Config {
145        let mut cfg = Config {
146            max_ram_percent: 30,
147            compression_level: CompressionLevel::Standard,
148            theme: "neon".to_string(),
149            ..Config::default()
150        };
151        cfg.proxy.anthropic_upstream = Some("https://upstream.example".to_string());
152        cfg.gain.display_name = Some("alice".to_string());
153        cfg
154    }
155
156    // #443 core safety net: rendering then parsing must reproduce the config
157    // exactly, regardless of how complete the schema is. The comparison goes
158    // through `toml::Value` (map equality is order-independent) so it is robust
159    // against `HashMap` iteration order in fields like `tool_total_limits`.
160    #[test]
161    fn render_round_trips_to_identical_config() {
162        let cfg = customized();
163        let schema = ConfigSchema::generate();
164
165        let rendered = render_annotated_config(&cfg, &schema);
166
167        let expected: toml::Value = toml::from_str(&toml::to_string_pretty(&cfg).unwrap()).unwrap();
168        let actual: toml::Value = toml::from_str(&rendered).expect("rendered config must parse");
169
170        assert_eq!(
171            actual, expected,
172            "render → parse must reproduce the config exactly (#443)"
173        );
174    }
175
176    #[test]
177    fn render_preserves_customized_values() {
178        let cfg = customized();
179        let schema = ConfigSchema::generate();
180
181        let parsed: Config = toml::from_str(&render_annotated_config(&cfg, &schema)).unwrap();
182
183        assert_eq!(parsed.max_ram_percent, 30);
184        assert_eq!(parsed.compression_level, CompressionLevel::Standard);
185        assert_eq!(parsed.theme, "neon");
186    }
187
188    // Output must be a deterministic function of its inputs (#498) and actually
189    // carry documentation.
190    #[test]
191    fn render_is_deterministic_and_annotated() {
192        let cfg = Config::default();
193        let schema = ConfigSchema::generate();
194
195        let first = render_annotated_config(&cfg, &schema);
196        let second = render_annotated_config(&cfg, &schema);
197
198        assert_eq!(first, second, "render must be deterministic (#498)");
199        assert!(first.contains("# lean-ctx configuration"));
200        assert!(
201            first.contains("max_ram_percent"),
202            "documented keys must appear"
203        );
204        assert!(
205            first.matches("# ").count() > 5,
206            "rendered config must be annotated with comments"
207        );
208    }
209}