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