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            } else {
57                append_fallback_key_comment(&mut out, field);
58            }
59        }
60
61        out.push_str(line);
62        out.push('\n');
63    }
64
65    out
66}
67
68/// Returns the section name for a `[section]` header line, or `None`. Array-of-
69/// tables headers (`[[…]]`) are deliberately ignored (Config has none).
70fn section_header(trimmed: &str) -> Option<String> {
71    if trimmed.starts_with("[[") || !trimmed.starts_with('[') || !trimmed.ends_with(']') {
72        return None;
73    }
74    let inner = trimmed[1..trimmed.len() - 1].trim();
75    if inner.is_empty() {
76        None
77    } else {
78        Some(inner.to_string())
79    }
80}
81
82/// Extracts the bare key of a `key = value` line. Returns `None` for array
83/// continuation lines, closing brackets, or quoted/dotted dynamic-map keys, all
84/// of which are passed through verbatim (keeping the round-trip exact).
85fn leading_key(trimmed: &str) -> Option<&str> {
86    let eq = trimmed.find('=')?;
87    let key = trimmed[..eq].trim();
88    if !key.is_empty()
89        && key
90            .chars()
91            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
92    {
93        Some(key)
94    } else {
95        None
96    }
97}
98
99fn append_section(out: &mut String, schema: &ConfigSchema, section: &str, header_line: &str) {
100    if !out.ends_with("\n\n") {
101        out.push('\n');
102    }
103    if let Some(section_schema) = schema.sections.get(section)
104        && !section_schema.description.is_empty()
105    {
106        for comment in section_schema.description.lines() {
107            out.push_str("# ");
108            out.push_str(comment);
109            out.push('\n');
110        }
111    }
112    out.push_str(header_line);
113    out.push('\n');
114}
115
116fn append_key_comment(out: &mut String, key_schema: &KeySchema) {
117    for comment in key_schema.description.lines() {
118        out.push_str("# ");
119        out.push_str(comment);
120        out.push('\n');
121    }
122    if !key_schema.default.is_null() {
123        out.push_str("#   default: ");
124        out.push_str(&key_schema.default.to_string());
125        out.push('\n');
126    }
127    if let Some(values) = &key_schema.values
128        && !values.is_empty()
129    {
130        out.push_str("#   values: ");
131        out.push_str(&values.join(", "));
132        out.push('\n');
133    }
134    if let Some(env) = &key_schema.env_override {
135        out.push_str("#   env: ");
136        out.push_str(env);
137        out.push('\n');
138    }
139}
140
141/// Documents fields that predate the schema registry. Keeping this fallback in
142/// the renderer guarantees that newly serialized parameters are never emitted
143/// without an explanation while their richer schema metadata is being added.
144fn append_fallback_key_comment(out: &mut String, field: &str) {
145    let comment = if let Some(name) = field
146        .strip_prefix("enable_")
147        .or_else(|| field.strip_suffix("_enabled"))
148    {
149        format!("Enables or disables {}.", humanize(name))
150    } else if let Some(name) = field.strip_prefix("max_") {
151        format!("Sets maximum {}.", humanize(name))
152    } else if let Some(name) = field.strip_suffix("_ms") {
153        format!("Sets {} in milliseconds.", humanize(name))
154    } else if let Some(name) = field.strip_suffix("_percent") {
155        format!("Sets {} as a percentage.", humanize(name))
156    } else {
157        format!("Configures {}.", humanize(field))
158    };
159    out.push_str("# ");
160    out.push_str(&comment);
161    out.push('\n');
162}
163
164fn humanize(field: &str) -> String {
165    field.replace(['_', '-'], " ")
166}
167
168#[cfg(test)]
169mod tests {
170    use std::collections::BTreeMap;
171
172    use super::*;
173    use crate::core::config::CompressionLevel;
174
175    fn customized() -> Config {
176        let mut cfg = Config {
177            max_ram_percent: 30,
178            compression_level: CompressionLevel::Standard,
179            theme: "neon".to_string(),
180            ..Config::default()
181        };
182        cfg.proxy.anthropic_upstream = Some("https://upstream.example".to_string());
183        cfg.gain.display_name = Some("alice".to_string());
184        cfg
185    }
186
187    // #443 core safety net: rendering then parsing must reproduce the config
188    // exactly, regardless of how complete the schema is. The comparison goes
189    // through `toml::Value` (map equality is order-independent) so it is robust
190    // against `HashMap` iteration order in fields like `tool_total_limits`.
191    #[test]
192    fn render_round_trips_to_identical_config() {
193        let cfg = customized();
194        let schema = ConfigSchema::generate();
195
196        let rendered = render_annotated_config(&cfg, &schema);
197
198        let expected: toml::Value = toml::from_str(&toml::to_string_pretty(&cfg).unwrap()).unwrap();
199        let actual: toml::Value = toml::from_str(&rendered).expect("rendered config must parse");
200
201        assert_eq!(
202            actual, expected,
203            "render → parse must reproduce the config exactly (#443)"
204        );
205    }
206
207    #[test]
208    fn render_preserves_customized_values() {
209        let cfg = customized();
210        let schema = ConfigSchema::generate();
211
212        let parsed: Config = toml::from_str(&render_annotated_config(&cfg, &schema)).unwrap();
213
214        assert_eq!(parsed.max_ram_percent, 30);
215        assert_eq!(parsed.compression_level, CompressionLevel::Standard);
216        assert_eq!(parsed.theme, "neon");
217    }
218
219    // Output must be a deterministic function of its inputs (#498) and actually
220    // carry documentation.
221    #[test]
222    fn render_is_deterministic_and_annotated() {
223        let cfg = Config::default();
224        let schema = ConfigSchema::generate();
225
226        let first = render_annotated_config(&cfg, &schema);
227        let second = render_annotated_config(&cfg, &schema);
228
229        assert_eq!(first, second, "render must be deterministic (#498)");
230        assert!(first.contains("# lean-ctx configuration"));
231        assert!(
232            first.contains("max_ram_percent"),
233            "documented keys must appear"
234        );
235        assert!(
236            first.matches("# ").count() > 5,
237            "rendered config must be annotated with comments"
238        );
239    }
240
241    #[test]
242    fn every_generated_parameter_has_an_explainer_comment() {
243        let rendered = render_annotated_config(&Config::default(), &ConfigSchema::generate());
244        let mut previous_nonempty = "";
245
246        for line in rendered.lines() {
247            let trimmed = line.trim();
248            if leading_key(trimmed).is_some() {
249                assert!(
250                    previous_nonempty.starts_with('#'),
251                    "generated parameter lacks an explainer comment: {trimmed}"
252                );
253            }
254            if !trimmed.is_empty() {
255                previous_nonempty = trimmed;
256            }
257        }
258    }
259
260    #[test]
261    fn undocumented_schema_fields_get_readable_fallback_comments() {
262        let cfg = Config {
263            theme: "neon".to_string(),
264            ..Config::default()
265        };
266        let schema = ConfigSchema {
267            version: 1,
268            sections: BTreeMap::default(),
269        };
270
271        let rendered = render_annotated_config(&cfg, &schema);
272
273        assert!(rendered.contains("# Sets maximum ram percent.\nmax_ram_percent = "));
274        assert!(rendered.contains("# Configures theme.\ntheme = \"neon\""));
275    }
276}