lean_ctx/core/config/schema/
mod.rs1use 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 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 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
136fn 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 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 #[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 #[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 #[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}