Skip to main content

lean_ctx/core/config/schema/
mod.rs

1//! Auto-generated config schema from `Config` struct metadata.
2//!
3//! Used by `lean-ctx config schema` to emit JSON and by
4//! `lean-ctx config validate` to check user config.toml files.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8mod sections_advanced;
9mod sections_core;
10mod sections_features;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ConfigSchema {
14    pub version: u32,
15    pub sections: BTreeMap<String, SectionSchema>,
16}
17
18#[derive(Debug, Clone, Serialize)]
19pub struct SectionSchema {
20    pub description: String,
21    pub keys: BTreeMap<String, KeySchema>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct KeySchema {
26    #[serde(rename = "type")]
27    pub ty: String,
28    pub default: serde_json::Value,
29    pub description: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub values: Option<Vec<String>>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub env_override: Option<String>,
34}
35
36fn clean_f32(v: f32) -> serde_json::Value {
37    let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
38    serde_json::json!(clean)
39}
40
41fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
42    KeySchema {
43        ty: ty.to_string(),
44        default,
45        description: desc.to_string(),
46        values: None,
47        env_override: None,
48    }
49}
50
51fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
52    KeySchema {
53        ty: "enum".to_string(),
54        default: serde_json::Value::String(default.to_string()),
55        description: desc.to_string(),
56        values: Some(values.iter().map(ToString::to_string).collect()),
57        env_override: None,
58    }
59}
60
61fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
62    KeySchema {
63        ty: ty.to_string(),
64        default,
65        description: desc.to_string(),
66        values: None,
67        env_override: Some(env.to_string()),
68    }
69}
70
71fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
72    KeySchema {
73        ty: "enum".to_string(),
74        default: serde_json::Value::String(default.to_string()),
75        description: desc.to_string(),
76        values: Some(values.iter().map(ToString::to_string).collect()),
77        env_override: Some(env.to_string()),
78    }
79}
80
81impl ConfigSchema {
82    pub fn generate() -> Self {
83        let mut sections = BTreeMap::new();
84        sections_core::build(&mut sections);
85        sections_features::build(&mut sections);
86        sections_advanced::build(&mut sections);
87
88        ConfigSchema {
89            version: 1,
90            sections,
91        }
92    }
93
94    /// Looks up a key schema by its dot-separated TOML path.
95    /// Returns `None` if the key is not part of the schema.
96    pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
97        if let Some(dot_pos) = key.find('.') {
98            let section = &key[..dot_pos];
99            let field = &key[dot_pos + 1..];
100            self.sections.get(section)?.keys.get(field)
101        } else {
102            self.sections.get("root")?.keys.get(key)
103        }
104    }
105
106    /// All known TOML keys (dot-separated) for validation.
107    ///
108    /// Combines the hand-written schema (which carries descriptions, types and
109    /// help text) with the keys derived from the live `Config` struct. The
110    /// struct is the source of truth for *what is valid*, so a field added to
111    /// `Config` is recognised by `config apply` / `config validate` immediately,
112    /// without anyone remembering to mirror it into `sections_*.rs` (#456).
113    pub fn known_keys(&self) -> Vec<String> {
114        let mut keys = Vec::new();
115        for (section, schema) in &self.sections {
116            if section == "root" {
117                for key_name in schema.keys.keys() {
118                    keys.push(key_name.clone());
119                }
120            } else {
121                if schema.keys.is_empty() {
122                    keys.push(section.clone());
123                }
124                for key_name in schema.keys.keys() {
125                    keys.push(format!("{section}.{key_name}"));
126                }
127            }
128        }
129        keys.extend(config_derived_keys());
130        keys.sort();
131        keys.dedup();
132        keys
133    }
134}
135
136/// Every TOML key the `Config` struct serialises to, in dot-separated form
137/// (e.g. `proxy_require_token`, `memory`, `memory.episodic`). Derived from
138/// `Config::default()` so validation tracks the struct automatically (#456).
139///
140/// Option fields that default to `None` are omitted by serde and therefore not
141/// listed here; those keys still come from the hand-written schema. Emitting the
142/// bare section name (e.g. `memory`) lets the `starts_with("section.")` rule in
143/// the validators accept the whole section, matching how empty schema sections
144/// already behave.
145fn config_derived_keys() -> Vec<String> {
146    fn walk(table: &toml::value::Table, prefix: &str, out: &mut Vec<String>) {
147        for (k, v) in table {
148            let full = if prefix.is_empty() {
149                k.clone()
150            } else {
151                format!("{prefix}.{k}")
152            };
153            if let toml::Value::Table(sub) = v {
154                out.push(full.clone());
155                walk(sub, &full, out);
156            } else {
157                out.push(full);
158            }
159        }
160    }
161
162    let mut out = Vec::new();
163    if let Ok(toml::Value::Table(table)) =
164        toml::Value::try_from(crate::core::config::Config::default())
165    {
166        walk(&table, "", &mut out);
167    }
168    out
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// Mirrors the acceptance rule used by `config validate` / `config apply`:
176    /// a key is valid if it is listed verbatim or sits under a known section.
177    fn accepted(known: &[String], key: &str) -> bool {
178        known.iter().any(|k| k == key) || known.iter().any(|k| key.starts_with(&format!("{k}.")))
179    }
180
181    /// #456: every field the `Config` struct actually serialises must be
182    /// accepted by validation. Before the fix, 38 real keys/sections
183    /// (`proxy_require_token`, `memory.*`, `providers.*`, `proxy`, …) were
184    /// flagged "unknown" because the hand-written schema had drifted.
185    #[test]
186    fn known_keys_cover_every_config_struct_field() {
187        let known = ConfigSchema::generate().known_keys();
188        let missing: Vec<_> = config_derived_keys()
189            .into_iter()
190            .filter(|k| !accepted(&known, k))
191            .collect();
192        assert!(
193            missing.is_empty(),
194            "config struct fields not recognised by validation (schema drift): {missing:?}"
195        );
196    }
197
198    /// Spot-check the concrete keys from the #456 report so a future schema/struct
199    /// refactor that reintroduces the drift fails loudly.
200    #[test]
201    fn known_keys_recognise_reported_456_keys() {
202        let known = ConfigSchema::generate().known_keys();
203        for key in [
204            "proxy_require_token",
205            "allow_ide_config_dirs",
206            "memory.episodic",
207            "providers.github",
208            "proxy",
209        ] {
210            assert!(
211                accepted(&known, key),
212                "validation must recognise '{key}' (#456)"
213            );
214        }
215    }
216
217    /// `config set` resolves keys via [`ConfigSchema::lookup`] — the hand-written
218    /// schema only, NOT `known_keys()` (which also folds in `config_derived_keys`).
219    /// An `Option<_>` scalar field defaults to `None`, so serde omits it from
220    /// `Config::default()` and it never appears in `config_derived_keys`: such a
221    /// field is settable via `config set` **only** if it was hand-added to a
222    /// `sections_*.rs` schema. Forgetting that is the `Unknown config key: <x>`
223    /// regression a user hit for `path_jail` before #507 (and `persona` /
224    /// `bypass_hints` here). Guard the whole class so a new `Option` knob can't
225    /// silently become un-settable again — if you add an `Option` scalar to
226    /// `Config`, register it in `sections_*.rs` and list it here.
227    #[test]
228    fn option_scalar_keys_are_cli_settable() {
229        let schema = ConfigSchema::generate();
230        for key in [
231            "path_jail",
232            "persona",
233            "bypass_hints",
234            "shell_security",
235            "cache_policy",
236            "profile",
237            "tool_profile",
238            "rules_scope",
239            "rules_injection",
240            "permission_inheritance",
241            "proxy_enabled",
242            "proxy_port",
243            "proxy_timeout_ms",
244        ] {
245            assert!(
246                schema.lookup(key).is_some(),
247                "`lean-ctx config set {key} <v>` fails with 'Unknown config key' — \
248                 add `{key}` to a sections_*.rs schema"
249            );
250        }
251    }
252
253    #[test]
254    fn proxy_require_token_is_cli_settable() {
255        let schema = ConfigSchema::generate();
256        assert!(
257            schema.lookup("proxy_require_token").is_some(),
258            "`lean-ctx config set proxy_require_token <bool>` must be accepted"
259        );
260    }
261}