1use ratatui::style::Color;
7use toml::Value;
8
9use crate::agent::{AgentModel, Effort};
10use crate::config::schema::{ConfigLayer, SubmoduleInit};
11use crate::error::{Error, Result};
12use crate::keys::{KeyAction, KeyChord};
13use crate::model::Column;
14use crate::output::color::ColorChoice;
15use crate::tui::theme::ThemePreset;
16
17fn cfg_err(file: &str, key: &str, reason: impl Into<String>) -> Error {
19 Error::Config {
20 file: file.to_string(),
21 key: key.to_string(),
22 reason: reason.into(),
23 }
24}
25
26fn as_string(file: &str, key: &str, value: &Value) -> Result<String> {
28 value
29 .as_str()
30 .map(str::to_string)
31 .ok_or_else(|| cfg_err(file, key, "expected a string"))
32}
33
34fn as_bool(file: &str, key: &str, value: &Value) -> Result<bool> {
36 value
37 .as_bool()
38 .ok_or_else(|| cfg_err(file, key, "expected a boolean"))
39}
40
41fn as_string_array(file: &str, key: &str, value: &Value) -> Result<Vec<String>> {
43 let array = value
44 .as_array()
45 .ok_or_else(|| cfg_err(file, key, "expected an array of strings"))?;
46 array
47 .iter()
48 .map(|item| as_string(file, key, item))
49 .collect()
50}
51
52fn as_table<'a>(file: &str, key: &str, value: &'a Value) -> Result<&'a toml::Table> {
54 value
55 .as_table()
56 .ok_or_else(|| cfg_err(file, key, "expected a table"))
57}
58
59pub fn parse_layer(text: &str, file: &str) -> Result<ConfigLayer> {
61 let value: Value =
62 toml::from_str(text).map_err(|e| cfg_err(file, "", format!("invalid TOML: {e}")))?;
63 let table = as_table(file, "", &value)?;
64 let mut layer = ConfigLayer::default();
65 for (key, val) in table {
66 match key.as_str() {
67 "path_template" => layer.path_template = Some(as_string(file, key, val)?),
68 "default_base" => layer.default_base = Some(as_string(file, key, val)?),
69 "copy" => layer.copy = Some(as_string_array(file, key, val)?),
70 "editor" => layer.editor = Some(as_string(file, key, val)?),
71 "hooks" => parse_hooks(file, val, &mut layer)?,
72 "remove" => parse_remove(file, val, &mut layer)?,
73 "pr" => parse_pr(file, val, &mut layer)?,
74 "submodules" => parse_submodules(file, val, &mut layer)?,
75 "agent" => parse_agent(file, val, &mut layer)?,
76 "list" => parse_list(file, val, &mut layer)?,
77 "ui" => parse_ui(file, val, &mut layer)?,
78 other => return Err(cfg_err(file, other, "unknown configuration key")),
79 }
80 }
81 Ok(layer)
82}
83
84fn parse_hooks(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
86 for (sub, val) in as_table(file, "hooks", value)? {
87 let key = format!("hooks.{sub}");
88 match sub.as_str() {
89 "post_create" => layer.hooks_post_create = Some(as_string(file, &key, val)?),
90 "pre_remove" => layer.hooks_pre_remove = Some(as_string(file, &key, val)?),
91 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
92 }
93 }
94 Ok(())
95}
96
97fn parse_remove(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
99 for (sub, val) in as_table(file, "remove", value)? {
100 let key = format!("remove.{sub}");
101 match sub.as_str() {
102 "delete_merged_branch" => {
103 layer.remove_delete_merged_branch = Some(as_bool(file, &key, val)?);
104 }
105 "untracked_blocks" => {
106 layer.remove_untracked_blocks = Some(as_bool(file, &key, val)?);
107 }
108 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
109 }
110 }
111 Ok(())
112}
113
114fn parse_pr(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
116 for (sub, val) in as_table(file, "pr", value)? {
117 let key = format!("pr.{sub}");
118 match sub.as_str() {
119 "default_remote" => layer.pr_default_remote = Some(as_string(file, &key, val)?),
120 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
121 }
122 }
123 Ok(())
124}
125
126fn parse_submodules(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
128 for (sub, val) in as_table(file, "submodules", value)? {
129 let key = format!("submodules.{sub}");
130 match sub.as_str() {
131 "init" => {
132 let text = as_string(file, &key, val)?;
133 let policy = SubmoduleInit::parse(&text)
134 .ok_or_else(|| cfg_err(file, &key, "expected one of: prompt, never, always"))?;
135 layer.submodules_init = Some(policy);
136 }
137 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
138 }
139 }
140 Ok(())
141}
142
143fn parse_agent(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
145 for (sub, val) in as_table(file, "agent", value)? {
146 let key = format!("agent.{sub}");
147 match sub.as_str() {
148 "model" => {
149 let text = as_string(file, &key, val)?;
150 let model = AgentModel::parse(&text)
151 .ok_or_else(|| cfg_err(file, &key, "expected one of: opus, sonnet, haiku"))?;
152 layer.agent_model = Some(model);
153 }
154 "effort" => {
155 let text = as_string(file, &key, val)?;
156 let effort = Effort::parse(&text)
157 .ok_or_else(|| cfg_err(file, &key, "expected one of: low, medium, high"))?;
158 layer.agent_effort = Some(effort);
159 }
160 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
161 }
162 }
163 Ok(())
164}
165
166fn parse_list(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
168 for (sub, val) in as_table(file, "list", value)? {
169 let key = format!("list.{sub}");
170 match sub.as_str() {
171 "show_untracked" => layer.list_show_untracked = Some(as_bool(file, &key, val)?),
172 "columns" => {
173 let names = as_string_array(file, &key, val)?;
174 let mut columns = Vec::with_capacity(names.len());
175 for name in names {
176 let column = Column::parse(&name).ok_or_else(|| {
177 cfg_err(file, &key, format!("unknown column identifier: {name:?}"))
178 })?;
179 columns.push(column);
180 }
181 layer.list_columns = Some(columns);
182 }
183 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
184 }
185 }
186 Ok(())
187}
188
189fn parse_ui(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
191 for (sub, val) in as_table(file, "ui", value)? {
192 let key = format!("ui.{sub}");
193 match sub.as_str() {
194 "nerd_fonts" => layer.ui_nerd_fonts = Some(as_bool(file, &key, val)?),
195 "mouse" => layer.ui_mouse = Some(as_bool(file, &key, val)?),
196 "color" => {
197 let text = as_string(file, &key, val)?;
198 let choice = ColorChoice::parse(&text)
199 .ok_or_else(|| cfg_err(file, &key, "expected one of: auto, always, never"))?;
200 layer.ui_color = Some(choice);
201 }
202 "theme" => parse_theme(file, val, layer)?,
203 "keybindings" => parse_keybindings(file, val, layer)?,
204 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
205 }
206 }
207 Ok(())
208}
209
210fn parse_theme(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
214 if let Some(name) = value.as_str() {
216 let preset = ThemePreset::parse(name)
217 .ok_or_else(|| cfg_err(file, "ui.theme", "expected one of: one-dark, solarized"))?;
218 layer.ui_theme = Some(preset);
219 return Ok(());
220 }
221 let o = &mut layer.theme_overrides;
222 for (sub, val) in as_table(file, "ui.theme", value)? {
223 let key = format!("ui.theme.{sub}");
224 match sub.as_str() {
225 "preset" => {
226 let text = as_string(file, &key, val)?;
227 let preset = ThemePreset::parse(&text)
228 .ok_or_else(|| cfg_err(file, &key, "expected one of: one-dark, solarized"))?;
229 layer.ui_theme = Some(preset);
230 }
231 "accent" => o.accent = Some(parse_color(file, &key, val)?),
232 "green" => o.green = Some(parse_color(file, &key, val)?),
233 "red" => o.red = Some(parse_color(file, &key, val)?),
234 "yellow" => o.yellow = Some(parse_color(file, &key, val)?),
235 "orange" => o.orange = Some(parse_color(file, &key, val)?),
236 "cyan" => o.cyan = Some(parse_color(file, &key, val)?),
237 "magenta" => o.magenta = Some(parse_color(file, &key, val)?),
238 "gray" => o.gray = Some(parse_color(file, &key, val)?),
239 "selection_bg" => o.selection_bg = Some(parse_color(file, &key, val)?),
240 "chip_fg" => o.chip_fg = Some(parse_color(file, &key, val)?),
241 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
242 }
243 }
244 Ok(())
245}
246
247fn parse_color(file: &str, key: &str, value: &Value) -> Result<Color> {
250 let text = as_string(file, key, value)?;
251 text.parse::<Color>()
252 .map_err(|_| cfg_err(file, key, format!("invalid color: {text:?}")))
253}
254
255fn parse_keybindings(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
257 for (action_name, val) in as_table(file, "ui.keybindings", value)? {
258 let key = format!("ui.keybindings.{action_name}");
259 let action = KeyAction::parse(action_name)
260 .ok_or_else(|| cfg_err(file, &key, "unknown keybinding action"))?;
261 let key_string = as_string(file, &key, val)?;
262 let chord = KeyChord::parse(&key_string)
263 .ok_or_else(|| cfg_err(file, &key, format!("invalid key string: {key_string:?}")))?;
264 layer.ui_keybindings.push((action, chord));
265 }
266 Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crossterm::event::KeyCode;
273
274 fn parse(text: &str) -> Result<ConfigLayer> {
275 parse_layer(text, "test.toml")
276 }
277
278 fn config_reason(err: Error) -> (String, String) {
279 match err {
280 Error::Config { key, reason, .. } => (key, reason),
281 other => panic!("expected config error, got {other:?}"),
282 }
283 }
284
285 #[test]
286 fn parses_a_full_valid_file() {
287 let text = r#"
288 path_template = "{home}/wt/{branch_slug}"
289 default_base = "develop"
290 copy = [".env", ".envrc"]
291 editor = "nvim"
292
293 [hooks]
294 post_create = "direnv allow"
295 pre_remove = "echo bye"
296
297 [remove]
298 delete_merged_branch = false
299 untracked_blocks = true
300
301 [pr]
302 default_remote = "upstream"
303
304 [submodules]
305 init = "always"
306
307 [agent]
308 model = "opus"
309 effort = "high"
310
311 [list]
312 show_untracked = false
313 columns = ["branch", "pr"]
314
315 [ui]
316 nerd_fonts = true
317 mouse = false
318 color = "always"
319
320 [ui.keybindings]
321 quit = "ctrl+c"
322 navigate-up = "w"
323 "#;
324 let layer = parse(text).unwrap();
325 assert_eq!(
326 layer.path_template.as_deref(),
327 Some("{home}/wt/{branch_slug}")
328 );
329 assert_eq!(layer.default_base.as_deref(), Some("develop"));
330 assert_eq!(layer.copy, Some(vec![".env".into(), ".envrc".into()]));
331 assert_eq!(layer.editor.as_deref(), Some("nvim"));
332 assert_eq!(layer.hooks_post_create.as_deref(), Some("direnv allow"));
333 assert_eq!(layer.hooks_pre_remove.as_deref(), Some("echo bye"));
334 assert_eq!(layer.remove_delete_merged_branch, Some(false));
335 assert_eq!(layer.remove_untracked_blocks, Some(true));
336 assert_eq!(layer.pr_default_remote.as_deref(), Some("upstream"));
337 assert_eq!(layer.submodules_init, Some(SubmoduleInit::Always));
338 assert_eq!(layer.agent_model, Some(AgentModel::Opus));
339 assert_eq!(layer.agent_effort, Some(Effort::High));
340 assert_eq!(layer.list_show_untracked, Some(false));
341 assert_eq!(layer.list_columns, Some(vec![Column::Branch, Column::Pr]));
342 assert_eq!(layer.ui_nerd_fonts, Some(true));
343 assert_eq!(layer.ui_mouse, Some(false));
344 assert_eq!(layer.ui_color, Some(ColorChoice::Always));
345 assert_eq!(
346 layer.ui_keybindings,
347 vec![
348 (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w'))),
349 (KeyAction::Quit, KeyChord::ctrl('c')),
350 ]
351 );
352 }
353
354 #[test]
355 fn empty_file_is_empty_layer() {
356 assert_eq!(parse("").unwrap(), ConfigLayer::default());
357 }
358
359 #[test]
360 fn unknown_top_level_key_rejected() {
361 let (key, reason) = config_reason(parse("bogus = 1").unwrap_err());
362 assert_eq!(key, "bogus");
363 assert!(reason.contains("unknown"));
364 }
365
366 #[test]
367 fn unknown_nested_key_rejected_with_dotted_path() {
368 let (key, _) = config_reason(parse("[ui]\nwiggle = true").unwrap_err());
369 assert_eq!(key, "ui.wiggle");
370 let (key, _) = config_reason(parse("[hooks]\nmid = \"x\"").unwrap_err());
371 assert_eq!(key, "hooks.mid");
372 }
373
374 #[test]
375 fn type_mismatches_rejected() {
376 assert!(parse("path_template = 5").is_err());
377 assert!(parse("[ui]\nmouse = \"yes\"").is_err());
378 assert!(parse("copy = \"single\"").is_err());
379 let (key, reason) = config_reason(parse("[remove]\nuntracked_blocks = 1").unwrap_err());
380 assert_eq!(key, "remove.untracked_blocks");
381 assert!(reason.contains("boolean"));
382 }
383
384 #[test]
385 fn invalid_column_identifier_rejected() {
386 let (key, reason) =
387 config_reason(parse("[list]\ncolumns = [\"branch\", \"bogus\"]").unwrap_err());
388 assert_eq!(key, "list.columns");
389 assert!(reason.contains("bogus"));
390 }
391
392 #[test]
393 fn invalid_color_rejected() {
394 let (key, _) = config_reason(parse("[ui]\ncolor = \"rainbow\"").unwrap_err());
395 assert_eq!(key, "ui.color");
396 }
397
398 #[test]
399 fn invalid_agent_model_and_effort_rejected() {
400 let (key, reason) = config_reason(parse("[agent]\nmodel = \"gpt\"").unwrap_err());
401 assert_eq!(key, "agent.model");
402 assert!(reason.contains("opus, sonnet, haiku"));
403 let (key, reason) = config_reason(parse("[agent]\neffort = \"max\"").unwrap_err());
404 assert_eq!(key, "agent.effort");
405 assert!(reason.contains("low, medium, high"));
406 let (key, _) = config_reason(parse("[agent]\nwiggle = true").unwrap_err());
407 assert_eq!(key, "agent.wiggle");
408 }
409
410 #[test]
411 fn submodules_init_parses_and_validates() {
412 assert_eq!(
413 parse("[submodules]\ninit = \"never\"")
414 .unwrap()
415 .submodules_init,
416 Some(SubmoduleInit::Never)
417 );
418 assert_eq!(
419 parse("[submodules]\ninit = \"prompt\"")
420 .unwrap()
421 .submodules_init,
422 Some(SubmoduleInit::Prompt)
423 );
424 let (key, reason) = config_reason(parse("[submodules]\ninit = \"sometimes\"").unwrap_err());
425 assert_eq!(key, "submodules.init");
426 assert!(reason.contains("prompt, never, always"));
427 let (key, _) = config_reason(parse("[submodules]\nwiggle = true").unwrap_err());
428 assert_eq!(key, "submodules.wiggle");
429 }
430
431 #[test]
432 fn invalid_keybinding_action_and_key_rejected() {
433 let (key, reason) = config_reason(parse("[ui.keybindings]\nfly = \"x\"").unwrap_err());
434 assert_eq!(key, "ui.keybindings.fly");
435 assert!(reason.contains("unknown keybinding action"));
436 let (key, reason) =
437 config_reason(parse("[ui.keybindings]\nquit = \"nope+z\"").unwrap_err());
438 assert_eq!(key, "ui.keybindings.quit");
439 assert!(reason.contains("invalid key string"));
440 }
441
442 #[test]
443 fn malformed_toml_is_config_error() {
444 let (_, reason) = config_reason(parse("this is not = = toml").unwrap_err());
445 assert!(reason.contains("invalid TOML"));
446 }
447
448 #[test]
449 fn parses_theme_table_with_preset_and_overrides() {
450 let layer =
451 parse("[ui.theme]\npreset = \"solarized\"\naccent = \"#ff8800\"\nred = \"red\"")
452 .unwrap();
453 assert_eq!(layer.ui_theme, Some(ThemePreset::Solarized));
454 assert_eq!(
455 layer.theme_overrides.accent,
456 Some(Color::Rgb(0xff, 0x88, 0x00))
457 );
458 assert_eq!(layer.theme_overrides.red, Some(Color::Red));
459 assert_eq!(layer.theme_overrides.green, None);
461 }
462
463 #[test]
464 fn parses_theme_string_shorthand() {
465 let layer = parse("[ui]\ntheme = \"solarized\"").unwrap();
466 assert_eq!(layer.ui_theme, Some(ThemePreset::Solarized));
467 assert_eq!(layer.theme_overrides, Default::default());
468 }
469
470 #[test]
471 fn invalid_theme_preset_rejected() {
472 let (key, reason) = config_reason(parse("[ui.theme]\npreset = \"dracula\"").unwrap_err());
473 assert_eq!(key, "ui.theme.preset");
474 assert!(reason.contains("one-dark, solarized"));
475 let (key, _) = config_reason(parse("[ui]\ntheme = \"dracula\"").unwrap_err());
477 assert_eq!(key, "ui.theme");
478 }
479
480 #[test]
481 fn invalid_theme_color_rejected() {
482 let (key, reason) = config_reason(parse("[ui.theme]\naccent = \"notacolor\"").unwrap_err());
483 assert_eq!(key, "ui.theme.accent");
484 assert!(reason.contains("invalid color"));
485 }
486
487 #[test]
488 fn unknown_theme_key_rejected() {
489 let (key, _) = config_reason(parse("[ui.theme]\nsparkle = \"#fff\"").unwrap_err());
490 assert_eq!(key, "ui.theme.sparkle");
491 }
492}