Skip to main content

wt/config/
parse.rs

1//! Parsing and validation of a single config file into a [`ConfigLayer`]
2//! (spec §11). Validation runs on every invocation: unknown keys, invalid
3//! `list.columns` identifiers, and bad `ui.keybindings` action names or key
4//! strings are rejected with a precise `{file, key, reason}` error.
5
6use 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
17/// Builds a configuration error with file/key/reason context.
18fn 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
26/// Reads a string value, or errors with the expected type.
27fn 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
34/// Reads a boolean value, or errors with the expected type.
35fn 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
41/// Reads an array of strings, or errors with the expected type.
42fn 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
52/// Reads a sub-table, or errors with the expected type.
53fn 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
59/// Parses and validates a config file's text into a [`ConfigLayer`].
60pub 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
84/// Parses the `[hooks]` table.
85fn 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
97/// Parses the `[remove]` table.
98fn 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
114/// Parses the `[pr]` table.
115fn 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
126/// Parses the `[submodules]` table, validating the `init` policy (issue #50).
127fn 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
143/// Parses the `[agent]` table, validating the model and effort identifiers.
144fn 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
166/// Parses the `[list]` table, validating column identifiers.
167fn 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
189/// Parses the `[ui]` table, including `ui.color` and `ui.keybindings`.
190fn 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
210/// Parses `ui.theme`: either a string shorthand selecting a preset
211/// (`theme = "solarized"`) or a `[ui.theme]` table with a `preset` key and
212/// per-color overrides.
213fn parse_theme(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
214    // String shorthand selects the base preset.
215    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
247/// Parses a color string: `#rrggbb` hex, a named color (e.g. `cyan`,
248/// `light-blue`), or a 0–255 ANSI index, via ratatui's [`Color`] parser.
249fn 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
255/// Parses the `[ui.keybindings]` table (action name → key string).
256fn 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        // Untouched slots stay None.
460        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        // The string shorthand validates the preset too.
476        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}