Skip to main content

lean_ctx/core/config/
setter.rs

1//! Generic config setter that works for ALL schema-known keys.
2//!
3//! Instead of a hardcoded match arm per key, this module:
4//! 1. Validates the key against `ConfigSchema`
5//! 2. Parses the value according to the schema type
6//! 3. Performs a TOML round-trip to set the value
7//! 4. Deserializes back into `Config` for full serde validation
8
9use super::Config;
10use super::schema::{ConfigSchema, KeySchema};
11
12/// Attempts to set a config key generically via schema-validated TOML round-trip.
13///
14/// Returns the updated `Config` on success, or a user-friendly error message.
15pub fn set_by_key(key: &str, value: &str) -> Result<Config, String> {
16    let schema = ConfigSchema::generate();
17    let key_schema = schema
18        .lookup(key)
19        .ok_or_else(|| format!("Unknown config key: {key}"))?;
20
21    let mut table = load_config_as_table()?;
22    let toml_value = parse_value(value, key_schema)?;
23    set_nested(&mut table, key, toml_value)?;
24
25    let cfg: Config = toml::Value::Table(table)
26        .try_into()
27        .map_err(|e| format!("Invalid value for '{key}': {e}"))?;
28    cfg.save()
29        .map_err(|e| format!("Error saving config: {e}"))?;
30    Ok(cfg)
31}
32
33/// Returns the current value of `key` from the on-disk `config.toml`, rendered
34/// as the string a user would type (no TOML quoting), or `None` if the key is
35/// unset (still at its schema default).
36///
37/// Powers the before→after review for consequential `config set` writes (#852).
38/// Reads only the user's file, so an unset key is reported as `None` rather than
39/// the default — the review then shows `(default) → <new>`.
40#[must_use]
41pub fn current_value(key: &str) -> Option<String> {
42    let table = load_config_as_table().ok()?;
43    let parts: Vec<&str> = key.split('.').collect();
44    let (parents, leaf) = parts.split_at(parts.len() - 1);
45
46    let mut current = &table;
47    for part in parents {
48        current = current.get(*part)?.as_table()?;
49    }
50    current.get(leaf[0]).map(display_toml_value)
51}
52
53/// Renders a scalar `toml::Value` the way a user types it on the CLI: strings
54/// without quotes, arrays comma-joined, everything else via its TOML form.
55fn display_toml_value(value: &toml::Value) -> String {
56    match value {
57        toml::Value::String(s) => s.clone(),
58        toml::Value::Array(items) => items
59            .iter()
60            .map(display_toml_value)
61            .collect::<Vec<_>>()
62            .join(", "),
63        other => other.to_string(),
64    }
65}
66
67/// Loads the current config file as a raw TOML table.
68/// If no file exists, returns an empty table (fresh config).
69fn load_config_as_table() -> Result<toml::Table, String> {
70    let path = Config::path().ok_or("Cannot determine config path")?;
71    if !path.exists() {
72        return Ok(toml::Table::new());
73    }
74    let raw = std::fs::read_to_string(&path).map_err(|e| format!("Cannot read config: {e}"))?;
75    raw.parse::<toml::Table>()
76        .map_err(|e| format!("Config parse error: {e}"))
77}
78
79/// Parses a string value into the appropriate `toml::Value` based on schema type.
80fn parse_value(value: &str, schema: &KeySchema) -> Result<toml::Value, String> {
81    match schema.ty.as_str() {
82        "bool" | "bool?" => match value {
83            "true" | "1" | "yes" => Ok(toml::Value::Boolean(true)),
84            "false" | "0" | "no" => Ok(toml::Value::Boolean(false)),
85            _ => Err(format!("Expected bool (true/false), got: {value}")),
86        },
87        "u8" | "u16" | "u32" | "u64" | "usize" | "u64?" => {
88            let n: i64 = value
89                .parse()
90                .map_err(|_| format!("Expected integer, got: {value}"))?;
91            if n < 0 {
92                return Err(format!("Expected unsigned integer, got: {value}"));
93            }
94            Ok(toml::Value::Integer(n))
95        }
96        "f32" | "f64" => {
97            let n: f64 = value
98                .parse()
99                .map_err(|_| format!("Expected number, got: {value}"))?;
100            Ok(toml::Value::Float(n))
101        }
102        "string" | "string?" => Ok(toml::Value::String(value.to_string())),
103        "enum" => {
104            if let Some(ref allowed) = schema.values
105                && !allowed.iter().any(|v| v == value)
106            {
107                return Err(format!(
108                    "Invalid value '{value}'. Allowed: {}",
109                    allowed.join(", ")
110                ));
111            }
112            Ok(toml::Value::String(value.to_string()))
113        }
114        "string[]" | "array" => {
115            let items: Vec<toml::Value> = value
116                .split(',')
117                .map(|s| toml::Value::String(s.trim().to_string()))
118                .filter(|v| v.as_str() != Some(""))
119                .collect();
120            Ok(toml::Value::Array(items))
121        }
122        "table" => Err(format!(
123            "Cannot set table '{value}' via CLI. Edit config.toml directly."
124        )),
125        other => {
126            // Fallback: treat as string (covers unknown future types gracefully)
127            tracing::debug!("Unknown schema type '{other}', treating value as string");
128            Ok(toml::Value::String(value.to_string()))
129        }
130    }
131}
132
133/// Sets a value in a nested TOML table using a dot-separated key path.
134/// Creates intermediate tables as needed. Returns an error (rather than
135/// panicking) if an intermediate key already holds a non-table value in the
136/// user's `config.toml` — e.g. `proxy = "x"` then `config set proxy.port 1`.
137fn set_nested(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<(), String> {
138    let parts: Vec<&str> = key.split('.').collect();
139    let (parents, leaf) = parts.split_at(parts.len() - 1);
140
141    let mut current = table;
142    for part in parents {
143        current = current
144            .entry(*part)
145            .or_insert_with(|| toml::Value::Table(toml::Table::new()))
146            .as_table_mut()
147            .ok_or_else(|| {
148                format!(
149                    "Cannot set '{key}': '{part}' already holds a non-table value in config.toml. \
150                     Fix or remove that key first."
151                )
152            })?;
153    }
154    current.insert(leaf[0].to_string(), value);
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn parse_bool_values() {
164        let schema = KeySchema {
165            ty: "bool".to_string(),
166            default: serde_json::json!(false),
167            description: String::new(),
168            values: None,
169            env_override: None,
170        };
171        assert_eq!(
172            parse_value("true", &schema).unwrap(),
173            toml::Value::Boolean(true)
174        );
175        assert_eq!(
176            parse_value("false", &schema).unwrap(),
177            toml::Value::Boolean(false)
178        );
179        assert!(parse_value("maybe", &schema).is_err());
180    }
181
182    #[test]
183    fn parse_integer_values() {
184        let schema = KeySchema {
185            ty: "u32".to_string(),
186            default: serde_json::json!(0),
187            description: String::new(),
188            values: None,
189            env_override: None,
190        };
191        assert_eq!(
192            parse_value("42", &schema).unwrap(),
193            toml::Value::Integer(42)
194        );
195        assert!(parse_value("-1", &schema).is_err());
196        assert!(parse_value("abc", &schema).is_err());
197    }
198
199    #[test]
200    fn parse_enum_validates_allowed() {
201        let schema = KeySchema {
202            ty: "enum".to_string(),
203            default: serde_json::json!("off"),
204            description: String::new(),
205            values: Some(vec!["off".into(), "lite".into(), "full".into()]),
206            env_override: None,
207        };
208        assert_eq!(
209            parse_value("lite", &schema).unwrap(),
210            toml::Value::String("lite".into())
211        );
212        assert!(parse_value("invalid", &schema).is_err());
213    }
214
215    #[test]
216    fn parse_string_array() {
217        let schema = KeySchema {
218            ty: "string[]".to_string(),
219            default: serde_json::json!([]),
220            description: String::new(),
221            values: None,
222            env_override: None,
223        };
224        let result = parse_value("a, b, c", &schema).unwrap();
225        let arr = result.as_array().unwrap();
226        assert_eq!(arr.len(), 3);
227        assert_eq!(arr[0].as_str().unwrap(), "a");
228        assert_eq!(arr[2].as_str().unwrap(), "c");
229    }
230
231    #[test]
232    fn set_nested_creates_intermediate_tables() {
233        let mut table = toml::Table::new();
234        set_nested(
235            &mut table,
236            "proxy.anthropic_upstream",
237            toml::Value::String("https://example.com".into()),
238        )
239        .unwrap();
240        let proxy = table["proxy"].as_table().unwrap();
241        assert_eq!(
242            proxy["anthropic_upstream"].as_str().unwrap(),
243            "https://example.com"
244        );
245    }
246
247    #[test]
248    fn set_nested_flat_key() {
249        let mut table = toml::Table::new();
250        set_nested(&mut table, "ultra_compact", toml::Value::Boolean(true)).unwrap();
251        assert!(table["ultra_compact"].as_bool().unwrap());
252    }
253
254    #[test]
255    fn set_nested_rejects_non_table_intermediate() {
256        let mut table = toml::Table::new();
257        table.insert("proxy".into(), toml::Value::String("oops".into()));
258        let err = set_nested(&mut table, "proxy.port", toml::Value::Integer(8080)).unwrap_err();
259        assert!(err.contains("non-table"), "got: {err}");
260    }
261
262    #[test]
263    fn display_toml_value_renders_user_facing_form() {
264        // Strings drop their quotes (what a user would type on the CLI).
265        assert_eq!(
266            display_toml_value(&toml::Value::String("enforce".into())),
267            "enforce"
268        );
269        assert_eq!(display_toml_value(&toml::Value::Boolean(false)), "false");
270        assert_eq!(display_toml_value(&toml::Value::Integer(8080)), "8080");
271        assert_eq!(
272            display_toml_value(&toml::Value::Array(vec![
273                toml::Value::String("a".into()),
274                toml::Value::String("b".into()),
275            ])),
276            "a, b"
277        );
278    }
279}