use super::Config;
use super::schema::{ConfigSchema, KeySchema};
pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
# Full reference: https://leanctx.com/docs/configuration
# For all settings: lean-ctx config init --full
#
# Optional named overlay. LEAN_CTX_CONFIG_PROFILE overrides this selection:
# config_profile = "cloud"
# [profiles.local]
# compression_level = "lite"
# [profiles.cloud]
# compression_level = "standard"
# ── High-Level Knobs ─────────────────────────────────────────────────
# These auto-adjust advanced settings. Override individual values below
# only if you need fine-grained control.
# Output style for the model's prose (not tool-output compression):
# off — no style guidance
# lite — plain-English concise (default; readable, still token-saving)
# standard / max — denser symbolic "power modes" (opt-in)
compression_level = "lite"
# RAM/feature trade-off: low | balanced | performance
memory_profile = "balanced"
# Maximum % of system RAM lean-ctx may use (1-50)
max_ram_percent = 5
# Per-process RSS target (MB) of each stdio MCP server; the lower of this and
# max_ram_percent applies (0 = percent only).
# mcp_max_rss_mb = 512
# Process RSS target (MB) of the background daemon; the lower of this and
# max_ram_percent applies (0 = percent only).
# daemon_max_rss_mb = 2048
# Exit a stdio MCP server after N minutes without a tool call (0 = never).
# Codex does not restart exited servers, so leave this off for Codex.
# mcp_idle_exit_minutes = 0
# Total disk budget in MB (0 = use individual limits).
# Distributes proportionally: archive ~25%, BM25 cache ~10%.
# max_disk_mb = 2000
# Auto-purge data older than N days (0 = disabled).
# Flows into archive.max_age_hours.
# max_staleness_days = 30
# Session retention for `lean-ctx sessions cleanup` and `ctx_session cleanup`.
# This is independent from archive retention and preserves the newest session
# for every project root.
# session_retention_days = 7
# Explicit project paths to scan/index (default: auto-detect).
# [ide_paths]
# cursor = ["/home/user/projects/app1"]
# ── Proxy ────────────────────────────────────────────────────────────
# proxy_enabled = false
# proxy_port = 3128
"#;
const HEADER: &str = "\
# lean-ctx configuration — full annotated reference
#
# Generated by `lean-ctx config init --full`. Every key is documented with its
# purpose and, where applicable, its default, allowed values, and the environment
# variable that overrides it. The values below reflect your current configuration.
#
# A key left at its default may be deleted — lean-ctx falls back to the documented
# default. After editing, run `lean-ctx config apply` to reload.
";
pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
let mut out = String::from(HEADER);
let Ok(body) = toml::to_string_pretty(cfg) else {
return out;
};
let mut current_section = String::from("root");
for line in body.lines() {
let trimmed = line.trim_start();
if let Some(section) = section_header(trimmed) {
append_section(&mut out, schema, §ion, line);
current_section = section;
continue;
}
if let Some(field) = leading_key(trimmed) {
let path = if current_section == "root" {
field.to_string()
} else {
format!("{current_section}.{field}")
};
if let Some(key_schema) = schema.lookup(&path) {
append_key_comment(&mut out, key_schema);
} else {
append_fallback_key_comment(&mut out, field);
}
}
out.push_str(line);
out.push('\n');
}
out
}
fn section_header(trimmed: &str) -> Option<String> {
if trimmed.starts_with("[[") || !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return None;
}
let inner = trimmed[1..trimmed.len() - 1].trim();
if inner.is_empty() {
None
} else {
Some(inner.to_string())
}
}
fn leading_key(trimmed: &str) -> Option<&str> {
let eq = trimmed.find('=')?;
let key = trimmed[..eq].trim();
if !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
Some(key)
} else {
None
}
}
fn append_section(out: &mut String, schema: &ConfigSchema, section: &str, header_line: &str) {
if !out.ends_with("\n\n") {
out.push('\n');
}
if let Some(section_schema) = schema.sections.get(section)
&& !section_schema.description.is_empty()
{
for comment in section_schema.description.lines() {
out.push_str("# ");
out.push_str(comment);
out.push('\n');
}
}
out.push_str(header_line);
out.push('\n');
}
fn append_key_comment(out: &mut String, key_schema: &KeySchema) {
for comment in key_schema.description.lines() {
out.push_str("# ");
out.push_str(comment);
out.push('\n');
}
if !key_schema.default.is_null() {
out.push_str("# default: ");
out.push_str(&key_schema.default.to_string());
out.push('\n');
}
if let Some(values) = &key_schema.values
&& !values.is_empty()
{
out.push_str("# values: ");
out.push_str(&values.join(", "));
out.push('\n');
}
if let Some(env) = &key_schema.env_override {
out.push_str("# env: ");
out.push_str(env);
out.push('\n');
}
}
fn append_fallback_key_comment(out: &mut String, field: &str) {
let comment = if let Some(name) = field
.strip_prefix("enable_")
.or_else(|| field.strip_suffix("_enabled"))
{
format!("Enables or disables {}.", humanize(name))
} else if let Some(name) = field.strip_prefix("max_") {
format!("Sets maximum {}.", humanize(name))
} else if let Some(name) = field.strip_suffix("_ms") {
format!("Sets {} in milliseconds.", humanize(name))
} else if let Some(name) = field.strip_suffix("_percent") {
format!("Sets {} as a percentage.", humanize(name))
} else {
format!("Configures {}.", humanize(field))
};
out.push_str("# ");
out.push_str(&comment);
out.push('\n');
}
fn humanize(field: &str) -> String {
field.replace(['_', '-'], " ")
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::core::config::CompressionLevel;
fn customized() -> Config {
let mut cfg = Config {
max_ram_percent: 30,
compression_level: CompressionLevel::Standard,
theme: "neon".to_string(),
..Config::default()
};
cfg.proxy.anthropic_upstream = Some("https://upstream.example".to_string());
cfg
}
#[test]
fn render_round_trips_to_identical_config() {
let cfg = customized();
let schema = ConfigSchema::generate();
let rendered = render_annotated_config(&cfg, &schema);
let expected: toml::Value = toml::from_str(&toml::to_string_pretty(&cfg).unwrap()).unwrap();
let actual: toml::Value = toml::from_str(&rendered).expect("rendered config must parse");
assert_eq!(
actual, expected,
"render → parse must reproduce the config exactly (#443)"
);
}
#[test]
fn render_preserves_customized_values() {
let cfg = customized();
let schema = ConfigSchema::generate();
let parsed: Config = toml::from_str(&render_annotated_config(&cfg, &schema)).unwrap();
assert_eq!(parsed.max_ram_percent, 30);
assert_eq!(parsed.compression_level, CompressionLevel::Standard);
assert_eq!(parsed.theme, "neon");
}
#[test]
fn render_is_deterministic_and_annotated() {
let cfg = Config::default();
let schema = ConfigSchema::generate();
let first = render_annotated_config(&cfg, &schema);
let second = render_annotated_config(&cfg, &schema);
assert_eq!(first, second, "render must be deterministic (#498)");
assert!(first.contains("# lean-ctx configuration"));
assert!(
first.contains("max_ram_percent"),
"documented keys must appear"
);
assert!(
first.matches("# ").count() > 5,
"rendered config must be annotated with comments"
);
}
#[test]
fn every_generated_parameter_has_an_explainer_comment() {
let rendered = render_annotated_config(&Config::default(), &ConfigSchema::generate());
let mut previous_nonempty = "";
for line in rendered.lines() {
let trimmed = line.trim();
if leading_key(trimmed).is_some() {
assert!(
previous_nonempty.starts_with('#'),
"generated parameter lacks an explainer comment: {trimmed}"
);
}
if !trimmed.is_empty() {
previous_nonempty = trimmed;
}
}
}
#[test]
fn undocumented_schema_fields_get_readable_fallback_comments() {
let cfg = Config {
theme: "neon".to_string(),
..Config::default()
};
let schema = ConfigSchema {
version: 1,
sections: BTreeMap::default(),
};
let rendered = render_annotated_config(&cfg, &schema);
assert!(rendered.contains("# Sets maximum ram percent.\nmax_ram_percent = "));
assert!(rendered.contains("# Configures theme.\ntheme = \"neon\""));
}
}