lean_ctx/core/config/
render.rs1use 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
26pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
28 let mut out = String::from(HEADER);
29
30 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, §ion, 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
66fn 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
80fn 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 #[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 #[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}