1use super::Config;
10use super::schema::{ConfigSchema, KeySchema};
11
12pub 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#[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
63fn 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 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 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}