1use super::Config;
10use super::schema::{ConfigSchema, KeySchema};
11
12pub 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#[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
53fn 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
67fn 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
79fn 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 tracing::debug!("Unknown schema type '{other}', treating value as string");
128 Ok(toml::Value::String(value.to_string()))
129 }
130 }
131}
132
133fn 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 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}