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 } 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
68fn 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
82fn 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
141fn 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 #[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 #[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}