1#[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
20fn 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
29fn 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
37fn 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
44fn 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
55fn 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
62pub 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
88fn 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
101fn 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
118fn 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
135fn 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
147fn 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
171fn 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 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
243fn 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
274fn 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
282fn 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
288fn 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
311fn 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 #[cfg(not(feature = "tui"))]
332 "theme" | "keybindings" => {}
333 _ => return Err(cfg_err(file, &key, "unknown configuration key")),
334 }
335 }
336 Ok(())
337}
338
339#[cfg(feature = "tui")]
343fn parse_theme(file: &str, value: &Value, layer: &mut ConfigLayer) -> Result<()> {
344 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#[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#[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 #[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 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 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 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 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 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 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}