lean-ctx 3.9.8

Context Runtime for AI Agents with CCP. 71 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
//! Renders a fully annotated `config.toml` for `lean-ctx config init --full`.
//!
//! The body is the verbatim [`toml::to_string_pretty`] serialization of the
//! supplied [`Config`], so every field round-trips exactly — independent of
//! schema completeness (#443). Schema descriptions, allowed values, defaults,
//! and env overrides are then woven in as `#` comment lines *above* each key and
//! section. Only comment lines are inserted; key/value lines are never touched,
//! which guarantees `parse(render(cfg)) == cfg` and keeps the output a
//! deterministic function of `(cfg, schema)` (#498).

use super::Config;
use super::schema::{ConfigSchema, KeySchema};

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.

";

/// Builds the annotated `config.toml` text for `cfg`, documented via `schema`.
pub fn render_annotated_config(cfg: &Config, schema: &ConfigSchema) -> String {
    let mut out = String::from(HEADER);

    // Serializing the full config is what guarantees a lossless round-trip; the
    // annotation pass below only ever *inserts* comment lines. A `Config` always
    // serializes, so the error path is unreachable in practice — we still return
    // a valid (header-only) document rather than panicking.
    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, &section, 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);
            }
        }

        out.push_str(line);
        out.push('\n');
    }

    out
}

/// Returns the section name for a `[section]` header line, or `None`. Array-of-
/// tables headers (`[[…]]`) are deliberately ignored (Config has none).
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())
    }
}

/// Extracts the bare key of a `key = value` line. Returns `None` for array
/// continuation lines, closing brackets, or quoted/dotted dynamic-map keys, all
/// of which are passed through verbatim (keeping the round-trip exact).
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');
    }
}

#[cfg(test)]
mod tests {
    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.gain.display_name = Some("alice".to_string());
        cfg
    }

    // #443 core safety net: rendering then parsing must reproduce the config
    // exactly, regardless of how complete the schema is. The comparison goes
    // through `toml::Value` (map equality is order-independent) so it is robust
    // against `HashMap` iteration order in fields like `tool_total_limits`.
    #[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");
    }

    // Output must be a deterministic function of its inputs (#498) and actually
    // carry documentation.
    #[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"
        );
    }
}