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
6#[cfg(feature = "tui")]
7use ratatui::style::Color;
8use toml::Value;
9
10use crate::agent::{AgentKind, AgentModel, Effort};
11use crate::config::schema::{ConfigLayer, CreateReflink, SubmoduleInit, SubmoduleSeed};
12use crate::error::{Error, Result};
13#[cfg(feature = "tui")]
14use crate::keys::{KeyAction, KeyChord};
15use crate::model::Column;
16use crate::output::color::ColorChoice;
17#[cfg(feature = "tui")]
18use crate::tui::theme::ThemePreset;
19
20/// Builds a configuration error with file/key/reason context.
21fn cfg_err(file: &str, key: &str, reason: impl Into<String>) -> Error {
22    Error::Config {
23        file: file.to_string(),
24        key: key.to_string(),
25        reason: reason.into(),
26    }
27}
28
29/// Reads a string value, or errors with the expected type.
30fn as_string(file: &str, key: &str, value: &Value) -> Result<String> {
31    value
32        .as_str()
33        .map(str::to_string)
34        .ok_or_else(|| cfg_err(file, key, "expected a string"))
35}
36
37/// Reads a boolean value, or errors with the expected type.
38fn as_bool(file: &str, key: &str, value: &Value) -> Result<bool> {
39    value
40        .as_bool()
41        .ok_or_else(|| cfg_err(file, key, "expected a boolean"))
42}
43
44/// Reads an array of strings, or errors with the expected type.
45fn as_string_array(file: &str, key: &str, value: &Value) -> Result<Vec<String>> {
46    let array = value
47        .as_array()
48        .ok_or_else(|| cfg_err(file, key, "expected an array of strings"))?;
49    array
50        .iter()
51        .map(|item| as_string(file, key, item))
52        .collect()
53}
54
55/// Reads a sub-table, or errors with the expected type.
56fn as_table<'a>(file: &str, key: &str, value: &'a Value) -> Result<&'a toml::Table> {
57    value
58        .as_table()
59        .ok_or_else(|| cfg_err(file, key, "expected a table"))
60}
61
62/// Parses and validates a config file's text into a [`ConfigLayer`].
63pub fn parse_layer(text: &str, file: &str) -> Result<ConfigLayer> {
64    let value: Value =
65        toml::from_str(text).map_err(|e| cfg_err(file, "", format!("invalid TOML: {e}")))?;
66    let table = as_table(file, "", &value)?;
67    let mut layer = ConfigLayer::default();
68    for (key, val) in table {
69        match key.as_str() {
70            "path_template" => layer.path_template = Some(as_string(file, key, val)?),
71            "default_base" => layer.default_base = Some(as_string(file, key, val)?),
72            "copy" => layer.copy = Some(as_string_array(file, key, val)?),
73            "editor" => layer.editor = Some(as_string(file, key, val)?),
74            "hooks" => parse_hooks(file, val, &mut layer)?,
75            "remove" => parse_remove(file, val, &mut layer)?,
76            "pr" => parse_pr(file, val, &mut layer)?,
77            "create" => parse_create(file, val, &mut layer)?,
78            "submodules" => parse_submodules(file, val, &mut layer)?,
79            "agent" => parse_agent(file, val, &mut layer)?,
80            "list" => parse_list(file, val, &mut layer)?,
81            "ui" => parse_ui(file, val, &mut layer)?,
82            other => return Err(cfg_err(file, other, "unknown configuration key")),
83        }
84    }
85    Ok(layer)
86}
87
88/// Parses the `[hooks]` table.
89fn parse_hooks(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
90    for (sub, val) in as_table(file, "hooks", value)? {
91        let key = format!("hooks.{sub}");
92        match sub.as_str() {
93            "post_create" => layer.hooks_post_create = Some(as_string(file, &key, val)?),
94            "pre_remove" => layer.hooks_pre_remove = Some(as_string(file, &key, val)?),
95            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
96        }
97    }
98    Ok(())
99}
100
101/// Parses the `[remove]` table.
102fn parse_remove(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
103    for (sub, val) in as_table(file, "remove", value)? {
104        let key = format!("remove.{sub}");
105        match sub.as_str() {
106            "delete_merged_branch" => {
107                layer.remove_delete_merged_branch = Some(as_bool(file, &key, val)?);
108            }
109            "untracked_blocks" => {
110                layer.remove_untracked_blocks = Some(as_bool(file, &key, val)?);
111            }
112            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
113        }
114    }
115    Ok(())
116}
117
118/// Parses the `[create]` table, validating the `reflink` strategy.
119fn parse_create(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
120    for (sub, val) in as_table(file, "create", value)? {
121        let key = format!("create.{sub}");
122        match sub.as_str() {
123            "reflink" => {
124                let text = as_string(file, &key, val)?;
125                let strategy = CreateReflink::parse(&text)
126                    .ok_or_else(|| cfg_err(file, &key, "expected one of: auto, never"))?;
127                layer.create_reflink = Some(strategy);
128            }
129            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
130        }
131    }
132    Ok(())
133}
134
135/// Parses the `[pr]` table.
136fn parse_pr(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
137    for (sub, val) in as_table(file, "pr", value)? {
138        let key = format!("pr.{sub}");
139        match sub.as_str() {
140            "default_remote" => layer.pr_default_remote = Some(as_string(file, &key, val)?),
141            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
142        }
143    }
144    Ok(())
145}
146
147/// Parses the `[submodules]` table, validating the `init` policy (issue #50) and
148/// the `seed` strategy.
149fn parse_submodules(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
150    for (sub, val) in as_table(file, "submodules", value)? {
151        let key = format!("submodules.{sub}");
152        match sub.as_str() {
153            "init" => {
154                let text = as_string(file, &key, val)?;
155                let policy = SubmoduleInit::parse(&text)
156                    .ok_or_else(|| cfg_err(file, &key, "expected one of: prompt, never, always"))?;
157                layer.submodules_init = Some(policy);
158            }
159            "seed" => {
160                let text = as_string(file, &key, val)?;
161                let strategy = SubmoduleSeed::parse(&text)
162                    .ok_or_else(|| cfg_err(file, &key, "expected one of: auto, never"))?;
163                layer.submodules_seed = Some(strategy);
164            }
165            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
166        }
167    }
168    Ok(())
169}
170
171/// Parses the `[agent]` table.
172///
173/// `[agent.generation]` is the canonical home for the generation profile. The
174/// flat `agent.model` / `agent.effort` keys are kept as deprecated aliases for
175/// the same two settings, so existing configurations keep working; setting a
176/// key both ways in one file is rejected rather than silently resolved by
177/// table order.
178///
179/// `[agent.work]` is refused outright: `wt` does not run coding agents, so
180/// there is no work profile to configure here.
181fn parse_agent(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
182    let mut flat_model = false;
183    let mut flat_effort = false;
184    let mut generation_model = false;
185    let mut generation_effort = false;
186
187    for (sub, val) in as_table(file, "agent", value)? {
188        let key = format!("agent.{sub}");
189        match sub.as_str() {
190            "model" => {
191                layer.agent_generation_model = Some(parse_agent_model(file, &key, val)?);
192                flat_model = true;
193                tracing::warn!("{file}: `agent.model` is deprecated; use `agent.generation.model`");
194            }
195            "effort" => {
196                layer.agent_generation_effort = Some(parse_agent_effort(file, &key, val)?);
197                flat_effort = true;
198                tracing::warn!(
199                    "{file}: `agent.effort` is deprecated; use `agent.generation.effort`"
200                );
201            }
202            "generation" => {
203                parse_agent_generation(
204                    file,
205                    val,
206                    layer,
207                    &mut generation_model,
208                    &mut generation_effort,
209                )?;
210            }
211            "work" => {
212                return Err(cfg_err(
213                    file,
214                    &key,
215                    "`wt` does not run coding agents, so it has no work profile; \
216                     agent execution belongs to karet (https://github.com/getkono/karet). \
217                     `wt` owns only `[agent.generation]`",
218                ));
219            }
220            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
221        }
222    }
223
224    // Order within a table is not something a user should have to reason about,
225    // so a key set both ways is an error rather than a last-one-wins race.
226    if flat_model && generation_model {
227        return Err(cfg_err(
228            file,
229            "agent.model",
230            "conflicts with `agent.generation.model` in the same file; set only one",
231        ));
232    }
233    if flat_effort && generation_effort {
234        return Err(cfg_err(
235            file,
236            "agent.effort",
237            "conflicts with `agent.generation.effort` in the same file; set only one",
238        ));
239    }
240    Ok(())
241}
242
243/// Parses the `[agent.generation]` sub-table.
244fn parse_agent_generation(
245    file: &str,
246    value: &Value,
247    layer: &mut ConfigLayer,
248    saw_model: &mut bool,
249    saw_effort: &mut bool,
250) -> Result<()> {
251    for (sub, val) in as_table(file, "agent.generation", value)? {
252        let key = format!("agent.generation.{sub}");
253        match sub.as_str() {
254            "provider" => {
255                let text = as_string(file, &key, val)?;
256                let provider = AgentKind::parse(&text)
257                    .ok_or_else(|| cfg_err(file, &key, "expected one of: claude"))?;
258                layer.agent_generation_provider = Some(provider);
259            }
260            "model" => {
261                layer.agent_generation_model = Some(parse_agent_model(file, &key, val)?);
262                *saw_model = true;
263            }
264            "effort" => {
265                layer.agent_generation_effort = Some(parse_agent_effort(file, &key, val)?);
266                *saw_effort = true;
267            }
268            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
269        }
270    }
271    Ok(())
272}
273
274/// Validates a model identifier, shared by the canonical key and its alias so
275/// the two cannot drift.
276fn parse_agent_model(file: &str, key: &str, value: &Value) -> Result<AgentModel> {
277    let text = as_string(file, key, value)?;
278    AgentModel::parse(&text)
279        .ok_or_else(|| cfg_err(file, key, "expected one of: opus, sonnet, haiku"))
280}
281
282/// Validates an effort identifier, shared by the canonical key and its alias.
283fn parse_agent_effort(file: &str, key: &str, value: &Value) -> Result<Effort> {
284    let text = as_string(file, key, value)?;
285    Effort::parse(&text).ok_or_else(|| cfg_err(file, key, "expected one of: low, medium, high"))
286}
287
288/// Parses the `[list]` table, validating column identifiers.
289fn parse_list(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
290    for (sub, val) in as_table(file, "list", value)? {
291        let key = format!("list.{sub}");
292        match sub.as_str() {
293            "show_untracked" => layer.list_show_untracked = Some(as_bool(file, &key, val)?),
294            "columns" => {
295                let names = as_string_array(file, &key, val)?;
296                let mut columns = Vec::with_capacity(names.len());
297                for name in names {
298                    let column = Column::parse(&name).ok_or_else(|| {
299                        cfg_err(file, &key, format!("unknown column identifier: {name:?}"))
300                    })?;
301                    columns.push(column);
302                }
303                layer.list_columns = Some(columns);
304            }
305            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
306        }
307    }
308    Ok(())
309}
310
311/// Parses the `[ui]` table, including `ui.color` and `ui.keybindings`.
312fn parse_ui(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
313    for (sub, val) in as_table(file, "ui", value)? {
314        let key = format!("ui.{sub}");
315        match sub.as_str() {
316            "nerd_fonts" => layer.ui_nerd_fonts = Some(as_bool(file, &key, val)?),
317            "mouse" => layer.ui_mouse = Some(as_bool(file, &key, val)?),
318            "color" => {
319                let text = as_string(file, &key, val)?;
320                let choice = ColorChoice::parse(&text)
321                    .ok_or_else(|| cfg_err(file, &key, "expected one of: auto, always, never"))?;
322                layer.ui_color = Some(choice);
323            }
324            #[cfg(feature = "tui")]
325            "theme" => parse_theme(file, val, layer)?,
326            #[cfg(feature = "tui")]
327            "keybindings" => parse_keybindings(file, val, layer)?,
328            // Without the TUI feature these keys belong to a fuller build of
329            // `wt`; accept and ignore them so an embedder can still read a
330            // repository configured for the complete tool.
331            #[cfg(not(feature = "tui"))]
332            "theme" | "keybindings" => {}
333            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
334        }
335    }
336    Ok(())
337}
338
339/// Parses `ui.theme`: either a string shorthand selecting a preset
340/// (`theme = "solarized"`) or a `[ui.theme]` table with a `preset` key and
341/// per-color overrides.
342#[cfg(feature = "tui")]
343fn parse_theme(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
344    // String shorthand selects the base preset.
345    if let Some(name) = value.as_str() {
346        let preset = ThemePreset::parse(name)
347            .ok_or_else(|| cfg_err(file, "ui.theme", "expected one of: one-dark, solarized"))?;
348        layer.ui_theme = Some(preset);
349        return Ok(());
350    }
351    let o = &mut layer.theme_overrides;
352    for (sub, val) in as_table(file, "ui.theme", value)? {
353        let key = format!("ui.theme.{sub}");
354        match sub.as_str() {
355            "preset" => {
356                let text = as_string(file, &key, val)?;
357                let preset = ThemePreset::parse(&text)
358                    .ok_or_else(|| cfg_err(file, &key, "expected one of: one-dark, solarized"))?;
359                layer.ui_theme = Some(preset);
360            }
361            "accent" => o.accent = Some(parse_color(file, &key, val)?),
362            "green" => o.green = Some(parse_color(file, &key, val)?),
363            "red" => o.red = Some(parse_color(file, &key, val)?),
364            "yellow" => o.yellow = Some(parse_color(file, &key, val)?),
365            "orange" => o.orange = Some(parse_color(file, &key, val)?),
366            "cyan" => o.cyan = Some(parse_color(file, &key, val)?),
367            "magenta" => o.magenta = Some(parse_color(file, &key, val)?),
368            "gray" => o.gray = Some(parse_color(file, &key, val)?),
369            "selection_bg" => o.selection_bg = Some(parse_color(file, &key, val)?),
370            "chip_fg" => o.chip_fg = Some(parse_color(file, &key, val)?),
371            _ => return Err(cfg_err(file, &key, "unknown configuration key")),
372        }
373    }
374    Ok(())
375}
376
377/// Parses a color string: `#rrggbb` hex, a named color (e.g. `cyan`,
378/// `light-blue`), or a 0–255 ANSI index, via ratatui's [`Color`] parser.
379#[cfg(feature = "tui")]
380fn parse_color(file: &str, key: &str, value: &Value) -> Result<Color> {
381    let text = as_string(file, key, value)?;
382    text.parse::<Color>()
383        .map_err(|_| cfg_err(file, key, format!("invalid color: {text:?}")))
384}
385
386/// Parses the `[ui.keybindings]` table (action name → key string).
387#[cfg(feature = "tui")]
388fn parse_keybindings(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
389    for (action_name, val) in as_table(file, "ui.keybindings", value)? {
390        let key = format!("ui.keybindings.{action_name}");
391        let action = KeyAction::parse(action_name)
392            .ok_or_else(|| cfg_err(file, &key, "unknown keybinding action"))?;
393        let key_string = as_string(file, &key, val)?;
394        let chord = KeyChord::parse(&key_string)
395            .ok_or_else(|| cfg_err(file, &key, format!("invalid key string: {key_string:?}")))?;
396        layer.ui_keybindings.push((action, chord));
397    }
398    Ok(())
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    #[cfg(feature = "tui")]
405    use crossterm::event::KeyCode;
406
407    fn parse(text: &str) -> Result<ConfigLayer> {
408        parse_layer(text, "test.toml")
409    }
410
411    fn config_reason(err: Error) -> (String, String) {
412        match err {
413            Error::Config { key, reason, .. } => (key, reason),
414            other => panic!("expected config error, got {other:?}"),
415        }
416    }
417
418    #[cfg(feature = "tui")]
419    #[test]
420    fn parses_a_full_valid_file() {
421        let text = r#"
422            path_template = "{home}/wt/{branch_slug}"
423            default_base = "develop"
424            copy = [".env", ".envrc"]
425            editor = "nvim"
426
427            [hooks]
428            post_create = "direnv allow"
429            pre_remove = "echo bye"
430
431            [remove]
432            delete_merged_branch = false
433            untracked_blocks = true
434
435            [pr]
436            default_remote = "upstream"
437
438            [submodules]
439            init = "always"
440
441            [agent]
442            model = "opus"
443            effort = "high"
444
445            [list]
446            show_untracked = false
447            columns = ["branch", "pr"]
448
449            [ui]
450            nerd_fonts = true
451            mouse = false
452            color = "always"
453
454            [ui.keybindings]
455            quit = "ctrl+c"
456            navigate-up = "w"
457        "#;
458        let layer = parse(text).unwrap();
459        assert_eq!(
460            layer.path_template.as_deref(),
461            Some("{home}/wt/{branch_slug}")
462        );
463        assert_eq!(layer.default_base.as_deref(), Some("develop"));
464        assert_eq!(layer.copy, Some(vec![".env".into(), ".envrc".into()]));
465        assert_eq!(layer.editor.as_deref(), Some("nvim"));
466        assert_eq!(layer.hooks_post_create.as_deref(), Some("direnv allow"));
467        assert_eq!(layer.hooks_pre_remove.as_deref(), Some("echo bye"));
468        assert_eq!(layer.remove_delete_merged_branch, Some(false));
469        assert_eq!(layer.remove_untracked_blocks, Some(true));
470        assert_eq!(layer.pr_default_remote.as_deref(), Some("upstream"));
471        assert_eq!(layer.submodules_init, Some(SubmoduleInit::Always));
472        assert_eq!(layer.agent_generation_model, Some(AgentModel::Opus));
473        assert_eq!(layer.agent_generation_effort, Some(Effort::High));
474        assert_eq!(layer.list_show_untracked, Some(false));
475        assert_eq!(layer.list_columns, Some(vec![Column::Branch, Column::Pr]));
476        assert_eq!(layer.ui_nerd_fonts, Some(true));
477        assert_eq!(layer.ui_mouse, Some(false));
478        assert_eq!(layer.ui_color, Some(ColorChoice::Always));
479        assert_eq!(
480            layer.ui_keybindings,
481            vec![
482                (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w'))),
483                (KeyAction::Quit, KeyChord::ctrl('c')),
484            ]
485        );
486    }
487
488    /// A repository configured for the full tool must still parse in a core
489    /// (no-`tui`) build: the TUI-owned keys are accepted and ignored.
490    #[cfg(not(feature = "tui"))]
491    #[test]
492    fn theme_and_keybindings_are_tolerated_without_the_tui() {
493        let layer =
494            parse("[ui.theme]\naccent = \"#ff8800\"\n[ui.keybindings]\nquit = \"ctrl+c\"").unwrap();
495        assert_eq!(layer, ConfigLayer::default());
496    }
497
498    #[test]
499    fn empty_file_is_empty_layer() {
500        assert_eq!(parse("").unwrap(), ConfigLayer::default());
501    }
502
503    #[test]
504    fn unknown_top_level_key_rejected() {
505        let (key, reason) = config_reason(parse("bogus = 1").unwrap_err());
506        assert_eq!(key, "bogus");
507        assert!(reason.contains("unknown"));
508    }
509
510    #[test]
511    fn unknown_nested_key_rejected_with_dotted_path() {
512        let (key, _) = config_reason(parse("[ui]\nwiggle = true").unwrap_err());
513        assert_eq!(key, "ui.wiggle");
514        let (key, _) = config_reason(parse("[hooks]\nmid = \"x\"").unwrap_err());
515        assert_eq!(key, "hooks.mid");
516    }
517
518    #[test]
519    fn type_mismatches_rejected() {
520        assert!(parse("path_template = 5").is_err());
521        assert!(parse("[ui]\nmouse = \"yes\"").is_err());
522        assert!(parse("copy = \"single\"").is_err());
523        let (key, reason) = config_reason(parse("[remove]\nuntracked_blocks = 1").unwrap_err());
524        assert_eq!(key, "remove.untracked_blocks");
525        assert!(reason.contains("boolean"));
526    }
527
528    #[test]
529    fn invalid_column_identifier_rejected() {
530        let (key, reason) =
531            config_reason(parse("[list]\ncolumns = [\"branch\", \"bogus\"]").unwrap_err());
532        assert_eq!(key, "list.columns");
533        assert!(reason.contains("bogus"));
534    }
535
536    #[test]
537    fn invalid_color_rejected() {
538        let (key, _) = config_reason(parse("[ui]\ncolor = \"rainbow\"").unwrap_err());
539        assert_eq!(key, "ui.color");
540    }
541
542    #[test]
543    fn invalid_agent_model_and_effort_rejected() {
544        let (key, reason) = config_reason(parse("[agent]\nmodel = \"gpt\"").unwrap_err());
545        assert_eq!(key, "agent.model");
546        assert!(reason.contains("opus, sonnet, haiku"));
547        let (key, reason) = config_reason(parse("[agent]\neffort = \"max\"").unwrap_err());
548        assert_eq!(key, "agent.effort");
549        assert!(reason.contains("low, medium, high"));
550        let (key, _) = config_reason(parse("[agent]\nwiggle = true").unwrap_err());
551        assert_eq!(key, "agent.wiggle");
552    }
553
554    #[test]
555    fn generation_profile_parses_every_key() {
556        let layer =
557            parse("[agent.generation]\nprovider = \"claude\"\nmodel = \"opus\"\neffort = \"high\"")
558                .unwrap();
559        assert_eq!(layer.agent_generation_provider, Some(AgentKind::Claude));
560        assert_eq!(layer.agent_generation_model, Some(AgentModel::Opus));
561        assert_eq!(layer.agent_generation_effort, Some(Effort::High));
562    }
563
564    #[test]
565    fn flat_agent_keys_are_aliases_for_the_generation_profile() {
566        // The released flat keys keep working and land on exactly the same
567        // layer fields, so an existing config needs no migration.
568        let layer = parse("[agent]\nmodel = \"opus\"\neffort = \"high\"").unwrap();
569        assert_eq!(layer.agent_generation_model, Some(AgentModel::Opus));
570        assert_eq!(layer.agent_generation_effort, Some(Effort::High));
571    }
572
573    #[test]
574    fn a_key_set_both_ways_in_one_file_is_rejected() {
575        // Table iteration order is not something a user should have to reason
576        // about, so this is an error rather than a last-one-wins race.
577        let (key, reason) = config_reason(
578            parse("[agent]\nmodel = \"opus\"\n[agent.generation]\nmodel = \"haiku\"").unwrap_err(),
579        );
580        assert_eq!(key, "agent.model");
581        assert!(reason.contains("agent.generation.model"), "{reason}");
582        let (key, reason) = config_reason(
583            parse("[agent]\neffort = \"low\"\n[agent.generation]\neffort = \"high\"").unwrap_err(),
584        );
585        assert_eq!(key, "agent.effort");
586        assert!(reason.contains("agent.generation.effort"), "{reason}");
587    }
588
589    #[test]
590    fn agent_work_is_rejected_with_a_pointer_to_karet() {
591        // `wt` keeps the generation profile; running the agent is karet's job.
592        let (key, reason) =
593            config_reason(parse("[agent.work]\nprovider = \"claude\"").unwrap_err());
594        assert_eq!(key, "agent.work");
595        assert!(reason.contains("karet"), "{reason}");
596    }
597
598    #[test]
599    fn unknown_generation_keys_are_rejected_with_the_dotted_path() {
600        let (key, _) = config_reason(parse("[agent.generation]\nwiggle = true").unwrap_err());
601        assert_eq!(key, "agent.generation.wiggle");
602        let (key, reason) =
603            config_reason(parse("[agent.generation]\nprovider = \"codex\"").unwrap_err());
604        assert_eq!(key, "agent.generation.provider");
605        assert!(reason.contains("claude"), "{reason}");
606    }
607
608    #[test]
609    fn submodules_init_parses_and_validates() {
610        assert_eq!(
611            parse("[submodules]\ninit = \"never\"")
612                .unwrap()
613                .submodules_init,
614            Some(SubmoduleInit::Never)
615        );
616        assert_eq!(
617            parse("[submodules]\ninit = \"prompt\"")
618                .unwrap()
619                .submodules_init,
620            Some(SubmoduleInit::Prompt)
621        );
622        let (key, reason) = config_reason(parse("[submodules]\ninit = \"sometimes\"").unwrap_err());
623        assert_eq!(key, "submodules.init");
624        assert!(reason.contains("prompt, never, always"));
625        let (key, _) = config_reason(parse("[submodules]\nwiggle = true").unwrap_err());
626        assert_eq!(key, "submodules.wiggle");
627    }
628
629    #[test]
630    fn submodules_seed_parses_and_validates() {
631        assert_eq!(
632            parse("[submodules]\nseed = \"never\"")
633                .unwrap()
634                .submodules_seed,
635            Some(SubmoduleSeed::Never)
636        );
637        assert_eq!(
638            parse("[submodules]\nseed = \"auto\"")
639                .unwrap()
640                .submodules_seed,
641            Some(SubmoduleSeed::Auto)
642        );
643        // Unset in a layer, so a layer that only sets `init` does not silently
644        // reset the strategy when merged.
645        assert_eq!(
646            parse("[submodules]\ninit = \"always\"")
647                .unwrap()
648                .submodules_seed,
649            None
650        );
651        let (key, reason) = config_reason(parse("[submodules]\nseed = \"maybe\"").unwrap_err());
652        assert_eq!(key, "submodules.seed");
653        assert!(reason.contains("auto, never"));
654    }
655
656    #[cfg(feature = "tui")]
657    #[test]
658    fn invalid_keybinding_action_and_key_rejected() {
659        let (key, reason) = config_reason(parse("[ui.keybindings]\nfly = \"x\"").unwrap_err());
660        assert_eq!(key, "ui.keybindings.fly");
661        assert!(reason.contains("unknown keybinding action"));
662        let (key, reason) =
663            config_reason(parse("[ui.keybindings]\nquit = \"nope+z\"").unwrap_err());
664        assert_eq!(key, "ui.keybindings.quit");
665        assert!(reason.contains("invalid key string"));
666    }
667
668    #[test]
669    fn malformed_toml_is_config_error() {
670        let (_, reason) = config_reason(parse("this is not = = toml").unwrap_err());
671        assert!(reason.contains("invalid TOML"));
672    }
673
674    #[cfg(feature = "tui")]
675    #[test]
676    fn parses_theme_table_with_preset_and_overrides() {
677        let layer =
678            parse("[ui.theme]\npreset = \"solarized\"\naccent = \"#ff8800\"\nred = \"red\"")
679                .unwrap();
680        assert_eq!(layer.ui_theme, Some(ThemePreset::Solarized));
681        assert_eq!(
682            layer.theme_overrides.accent,
683            Some(Color::Rgb(0xff, 0x88, 0x00))
684        );
685        assert_eq!(layer.theme_overrides.red, Some(Color::Red));
686        // Untouched slots stay None.
687        assert_eq!(layer.theme_overrides.green, None);
688    }
689
690    #[cfg(feature = "tui")]
691    #[test]
692    fn parses_theme_string_shorthand() {
693        let layer = parse("[ui]\ntheme = \"solarized\"").unwrap();
694        assert_eq!(layer.ui_theme, Some(ThemePreset::Solarized));
695        assert_eq!(layer.theme_overrides, Default::default());
696    }
697
698    #[cfg(feature = "tui")]
699    #[test]
700    fn invalid_theme_preset_rejected() {
701        let (key, reason) = config_reason(parse("[ui.theme]\npreset = \"dracula\"").unwrap_err());
702        assert_eq!(key, "ui.theme.preset");
703        assert!(reason.contains("one-dark, solarized"));
704        // The string shorthand validates the preset too.
705        let (key, _) = config_reason(parse("[ui]\ntheme = \"dracula\"").unwrap_err());
706        assert_eq!(key, "ui.theme");
707    }
708
709    #[cfg(feature = "tui")]
710    #[test]
711    fn invalid_theme_color_rejected() {
712        let (key, reason) = config_reason(parse("[ui.theme]\naccent = \"notacolor\"").unwrap_err());
713        assert_eq!(key, "ui.theme.accent");
714        assert!(reason.contains("invalid color"));
715    }
716
717    #[cfg(feature = "tui")]
718    #[test]
719    fn unknown_theme_key_rejected() {
720        let (key, _) = config_reason(parse("[ui.theme]\nsparkle = \"#fff\"").unwrap_err());
721        assert_eq!(key, "ui.theme.sparkle");
722    }
723}