1use std::fmt::{self, Display};
2
3use console::{Color, Style};
4
5const ICON_OK: &str = "✓";
6const ICON_WARN: &str = "⚠";
7const ICON_FAIL: &str = "✗";
8const ICON_PENDING: &str = "○";
9const ICON_RUNNING: &str = "◐";
10const ICON_SKIPPED: &str = "—";
11const ICON_ARROW: &str = "→";
12
13#[derive(Debug, Clone, Default)]
20pub struct ThemedStyle {
21 inner: Style,
24 rgb: Option<(u8, u8, u8)>,
27 attrs: AttrSet,
31}
32
33#[derive(Debug, Clone, Copy, Default)]
34struct AttrSet {
35 bold: bool,
36 dim: bool,
37 italic: bool,
38 underline: bool,
39}
40
41impl AttrSet {
42 fn has_attrs(&self) -> bool {
46 self.bold || self.dim || self.italic || self.underline
47 }
48}
49
50impl Display for AttrSet {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 let mut first = true;
58 let mut push = |f: &mut fmt::Formatter<'_>, s: &str| -> fmt::Result {
59 if !first {
60 f.write_str(";")?;
61 }
62 f.write_str(s)?;
63 first = false;
64 Ok(())
65 };
66 if self.bold {
67 push(f, "1")?;
68 }
69 if self.dim {
70 push(f, "2")?;
71 }
72 if self.italic {
73 push(f, "3")?;
74 }
75 if self.underline {
76 push(f, "4")?;
77 }
78 Ok(())
79 }
80}
81
82impl ThemedStyle {
83 pub fn plain() -> Self {
85 Self::default()
86 }
87
88 pub fn from_hex(hex: &str) -> Self {
93 match parse_hex_rgb(hex) {
94 Some((r, g, b)) => Self {
95 inner: Style::new().fg(Color::Color256(ansi256_from_rgb(r, g, b))),
96 rgb: Some((r, g, b)),
97 attrs: AttrSet::default(),
98 },
99 None => Self::default(),
100 }
101 }
102
103 fn from_console_color(color: Color) -> Self {
106 Self {
107 inner: Style::new().fg(color),
108 rgb: None,
109 attrs: AttrSet::default(),
110 }
111 }
112
113 pub fn bold(mut self) -> Self {
114 self.inner = self.inner.bold();
115 self.attrs.bold = true;
116 self
117 }
118
119 pub fn dim(mut self) -> Self {
120 self.inner = self.inner.dim();
121 self.attrs.dim = true;
122 self
123 }
124
125 pub fn italic(mut self) -> Self {
126 self.inner = self.inner.italic();
127 self.attrs.italic = true;
128 self
129 }
130
131 pub fn underlined(mut self) -> Self {
132 self.inner = self.inner.underlined();
133 self.attrs.underline = true;
134 self
135 }
136
137 pub fn cyan(self) -> Self {
138 Self::from_console_color(Color::Cyan).with_attrs(self.attrs)
139 }
140
141 pub fn red(self) -> Self {
142 Self::from_console_color(Color::Red).with_attrs(self.attrs)
143 }
144
145 pub fn green(self) -> Self {
146 Self::from_console_color(Color::Green).with_attrs(self.attrs)
147 }
148
149 pub fn yellow(self) -> Self {
150 Self::from_console_color(Color::Yellow).with_attrs(self.attrs)
151 }
152
153 fn with_attrs(mut self, attrs: AttrSet) -> Self {
154 if attrs.bold {
155 self.inner = self.inner.bold();
156 }
157 if attrs.dim {
158 self.inner = self.inner.dim();
159 }
160 if attrs.italic {
161 self.inner = self.inner.italic();
162 }
163 if attrs.underline {
164 self.inner = self.inner.underlined();
165 }
166 self.attrs = attrs;
167 self
168 }
169
170 pub fn apply_to<D: Display>(&self, text: D) -> StyledText<'_, D> {
185 StyledText { style: self, text }
186 }
187}
188
189pub struct StyledText<'a, D> {
193 style: &'a ThemedStyle,
194 text: D,
195}
196
197impl<D: Display> Display for StyledText<'_, D> {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 let attrs = &self.style.attrs;
200
201 if !console::colors_enabled() {
202 if !attrs.has_attrs() {
208 return write!(f, "{}", self.text);
209 }
210 return write!(f, "\x1b[{attrs}m{}\x1b[0m", self.text);
211 }
212
213 if let Some((r, g, b)) = self.style.rgb
214 && supports_truecolor()
215 {
216 if !attrs.has_attrs() {
217 return write!(f, "\x1b[38;2;{r};{g};{b}m{}\x1b[0m", self.text);
218 }
219 return write!(f, "\x1b[{attrs};38;2;{r};{g};{b}m{}\x1b[0m", self.text);
220 }
221
222 write!(f, "{}", self.style.inner.apply_to(&self.text))
223 }
224}
225
226pub struct Theme {
227 pub header: ThemedStyle,
229 pub success: ThemedStyle,
230 pub warning: ThemedStyle,
231 pub error: ThemedStyle,
232 pub info: ThemedStyle,
233 pub muted: ThemedStyle,
234 pub running: ThemedStyle,
235 pub diff_add: ThemedStyle,
236 pub diff_remove: ThemedStyle,
237 pub diff_context: ThemedStyle,
238 pub accent: ThemedStyle,
241 pub secondary: ThemedStyle,
245
246 pub icon_ok: String,
248 pub icon_warn: String,
249 pub icon_fail: String,
250 pub icon_pending: String,
251 pub icon_running: String,
252 pub icon_skipped: String,
253 pub icon_arrow: String,
254}
255
256impl Default for Theme {
257 fn default() -> Self {
258 Self {
259 header: ThemedStyle::plain().bold().cyan(),
260 success: ThemedStyle::plain().green(),
261 warning: ThemedStyle::plain().yellow(),
262 error: ThemedStyle::plain().red().bold(),
263 info: ThemedStyle::plain().cyan(),
264 muted: ThemedStyle::plain().dim(),
265 running: ThemedStyle::plain().cyan(),
266 diff_add: ThemedStyle::plain().green(),
267 diff_remove: ThemedStyle::plain().red(),
268 diff_context: ThemedStyle::plain().dim(),
269 accent: hex("#d78700").italic(),
273 secondary: hex("#af5fd7"),
274 icon_ok: ICON_OK.into(),
275 icon_warn: ICON_WARN.into(),
276 icon_fail: ICON_FAIL.into(),
277 icon_pending: ICON_PENDING.into(),
278 icon_running: ICON_RUNNING.into(),
279 icon_skipped: ICON_SKIPPED.into(),
280 icon_arrow: ICON_ARROW.into(),
281 }
282 }
283}
284
285impl Theme {
286 pub fn from_preset(name: &str) -> Self {
287 match name {
288 "dracula" => Self::dracula(),
289 "solarized-dark" => Self::solarized_dark(),
290 "solarized-light" => Self::solarized_light(),
291 "minimal" => Self::minimal(),
292 _ => Self::default(),
293 }
294 }
295
296 fn dracula() -> Self {
297 Self {
298 header: hex("#bd93f9").bold(),
299 success: hex("#50fa7b"),
300 warning: hex("#f1fa8c"),
301 error: hex("#ff5555").bold(),
302 info: hex("#8be9fd"),
303 muted: hex("#6272a4"),
304 running: hex("#8be9fd"),
305 diff_add: hex("#50fa7b"),
306 diff_remove: hex("#ff5555"),
307 diff_context: hex("#6272a4"),
308 accent: hex("#ffb86c"),
309 secondary: hex("#ff79c6"),
310 ..Self::default()
311 }
312 }
313
314 fn solarized_dark() -> Self {
315 Self {
316 header: hex("#268bd2").bold(),
317 success: hex("#859900"),
318 warning: hex("#b58900"),
319 error: hex("#dc322f").bold(),
320 info: hex("#268bd2"),
321 muted: hex("#586e75"),
322 running: hex("#2aa198"),
323 diff_add: hex("#859900"),
324 diff_remove: hex("#dc322f"),
325 diff_context: hex("#586e75"),
326 accent: hex("#cb4b16"),
327 secondary: hex("#d33682"),
328 ..Self::default()
329 }
330 }
331
332 fn solarized_light() -> Self {
333 Self {
334 header: hex("#268bd2").bold(),
335 success: hex("#859900"),
336 warning: hex("#b58900"),
337 error: hex("#dc322f").bold(),
338 info: hex("#268bd2"),
339 muted: hex("#93a1a1"),
340 running: hex("#2aa198"),
341 diff_add: hex("#859900"),
342 diff_remove: hex("#dc322f"),
343 diff_context: hex("#93a1a1"),
344 accent: hex("#cb4b16"),
345 secondary: hex("#d33682"),
346 ..Self::default()
347 }
348 }
349
350 pub fn from_config(config: Option<&crate::config::ThemeConfig>) -> Self {
351 let Some(cfg) = config else {
352 return Self::default();
353 };
354 let mut t = Self::from_preset(&cfg.name);
355 let ov = &cfg.overrides;
356 if let Some(c) = &ov.header {
358 apply_color(&mut t.header, c);
359 }
360 if let Some(c) = &ov.success {
361 apply_color(&mut t.success, c);
362 }
363 if let Some(c) = &ov.warning {
364 apply_color(&mut t.warning, c);
365 }
366 if let Some(c) = &ov.error {
367 apply_color(&mut t.error, c);
368 }
369 if let Some(c) = &ov.info {
370 apply_color(&mut t.info, c);
371 }
372 if let Some(c) = &ov.muted {
373 apply_color(&mut t.muted, c);
374 }
375 if let Some(c) = &ov.running {
376 apply_color(&mut t.running, c);
377 }
378 if let Some(c) = &ov.diff_add {
379 apply_color(&mut t.diff_add, c);
380 }
381 if let Some(c) = &ov.diff_remove {
382 apply_color(&mut t.diff_remove, c);
383 }
384 if let Some(c) = &ov.diff_context {
385 apply_color(&mut t.diff_context, c);
386 }
387 if let Some(c) = &ov.accent {
388 apply_color(&mut t.accent, c);
389 }
390 if let Some(c) = &ov.secondary {
391 apply_color(&mut t.secondary, c);
392 }
393 if let Some(v) = &ov.icon_ok {
395 t.icon_ok = v.clone();
396 }
397 if let Some(v) = &ov.icon_warn {
398 t.icon_warn = v.clone();
399 }
400 if let Some(v) = &ov.icon_fail {
401 t.icon_fail = v.clone();
402 }
403 if let Some(v) = &ov.icon_pending {
404 t.icon_pending = v.clone();
405 }
406 if let Some(v) = &ov.icon_running {
407 t.icon_running = v.clone();
408 }
409 if let Some(v) = &ov.icon_skipped {
410 t.icon_skipped = v.clone();
411 }
412 if let Some(v) = &ov.icon_arrow {
413 t.icon_arrow = v.clone();
414 }
415 t
416 }
417
418 fn minimal() -> Self {
419 Self {
420 header: ThemedStyle::plain().bold(),
421 success: ThemedStyle::plain(),
422 warning: ThemedStyle::plain(),
423 error: ThemedStyle::plain().bold(),
424 info: ThemedStyle::plain(),
425 muted: ThemedStyle::plain().dim(),
426 running: ThemedStyle::plain(),
427 diff_add: ThemedStyle::plain(),
428 diff_remove: ThemedStyle::plain(),
429 diff_context: ThemedStyle::plain().dim(),
430 accent: ThemedStyle::plain().italic(),
434 secondary: ThemedStyle::plain().underlined(),
435 icon_ok: "+".into(),
436 icon_warn: "!".into(),
437 icon_fail: "x".into(),
438 icon_pending: " ".into(),
439 icon_running: ".".into(),
440 icon_skipped: "-".into(),
441 icon_arrow: ">".into(),
442 }
443 }
444}
445
446pub fn supports_truecolor() -> bool {
451 if std::env::var_os("NO_COLOR").is_some() {
452 return false;
453 }
454 matches!(
455 std::env::var("COLORTERM").as_deref(),
456 Ok("truecolor") | Ok("24bit")
457 )
458}
459
460pub(super) fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
463 let hex = hex.strip_prefix('#').unwrap_or(hex);
464 if hex.len() != 6 {
465 return None;
466 }
467 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
468 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
469 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
470 Some((r, g, b))
471}
472
473pub(super) fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
477 if r == g && g == b {
478 if r < 8 {
479 return 16;
480 }
481 if r > 248 {
482 return 231;
483 }
484 return (((r as u16 - 8) * 24 / 247) as u8) + 232;
485 }
486 let ri = (r as u16 * 5 / 255) as u8;
487 let gi = (g as u16 * 5 / 255) as u8;
488 let bi = (b as u16 * 5 / 255) as u8;
489 16 + 36 * ri + 6 * gi + bi
490}
491
492fn hex(s: &str) -> ThemedStyle {
493 ThemedStyle::from_hex(s)
494}
495
496fn apply_color(style: &mut ThemedStyle, hex: &str) {
497 if let Some((r, g, b)) = parse_hex_rgb(hex) {
498 let attrs = style.attrs;
499 *style = ThemedStyle {
500 inner: Style::new().fg(Color::Color256(ansi256_from_rgb(r, g, b))),
501 rgb: Some((r, g, b)),
502 attrs: AttrSet::default(),
503 }
504 .with_attrs(attrs);
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::output::test_support::ColorsEnabledGuard;
512 use crate::test_helpers::EnvVarGuard;
513 use serial_test::serial;
514
515 #[test]
516 fn default_has_seven_icons() {
517 let t = Theme::default();
518 assert_eq!(t.icon_ok, "✓");
519 assert_eq!(t.icon_warn, "⚠");
520 assert_eq!(t.icon_fail, "✗");
521 assert_eq!(t.icon_pending, "○");
522 assert_eq!(t.icon_running, "◐");
523 assert_eq!(t.icon_skipped, "—");
524 assert_eq!(t.icon_arrow, "→");
525 }
526
527 #[test]
528 fn presets_are_distinct() {
529 let d = Theme::default();
530 let dr = Theme::from_preset("dracula");
531 let m = Theme::from_preset("minimal");
532 assert!(d.success.rgb.is_none());
534 assert!(dr.success.rgb.is_some());
535 assert_eq!(m.icon_ok, "+");
536 }
537
538 #[test]
539 fn unknown_preset_falls_back_to_default() {
540 let t = Theme::from_preset("not-a-real-preset");
541 assert_eq!(t.icon_ok, "✓"); }
543
544 #[test]
545 fn hex_parses_six_chars() {
546 assert!(parse_hex_rgb("#abcdef").is_some());
547 assert!(parse_hex_rgb("abcdef").is_some());
548 assert!(parse_hex_rgb("#abc").is_none());
549 assert!(parse_hex_rgb("#zzzzzz").is_none());
550 }
551
552 #[test]
553 #[serial]
554 fn supports_truecolor_detects_colorterm_truecolor() {
555 let _no_color = EnvVarGuard::unset("NO_COLOR");
556 let _g = EnvVarGuard::set("COLORTERM", "truecolor");
557 assert!(supports_truecolor());
558 }
559
560 #[test]
561 #[serial]
562 fn supports_truecolor_detects_colorterm_24bit() {
563 let _no_color = EnvVarGuard::unset("NO_COLOR");
564 let _g = EnvVarGuard::set("COLORTERM", "24bit");
565 assert!(supports_truecolor());
566 }
567
568 #[test]
569 #[serial]
570 fn supports_truecolor_rejects_other_colorterm_values() {
571 let _no_color = EnvVarGuard::unset("NO_COLOR");
572 let _g = EnvVarGuard::set("COLORTERM", "yes");
573 assert!(!supports_truecolor());
574 }
575
576 #[test]
577 #[serial]
578 fn supports_truecolor_rejects_when_no_color_set() {
579 let _g = EnvVarGuard::set("COLORTERM", "truecolor");
580 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
581 assert!(!supports_truecolor());
582 }
583
584 #[test]
585 #[serial]
586 fn supports_truecolor_returns_false_when_colorterm_unset() {
587 let _no_color = EnvVarGuard::unset("NO_COLOR");
588 let _g = EnvVarGuard::unset("COLORTERM");
589 assert!(!supports_truecolor());
590 }
591
592 #[test]
593 #[serial]
594 fn hex_style_emits_truecolor_escape_when_supported() {
595 let _no_color = EnvVarGuard::unset("NO_COLOR");
596 let _ct = EnvVarGuard::set("COLORTERM", "truecolor");
597 let _colors = ColorsEnabledGuard::set(true);
598 let style = ThemedStyle::from_hex("#bd93f9");
599 let out = style.apply_to("hi").to_string();
600 assert_eq!(out, "\x1b[38;2;189;147;249mhi\x1b[0m", "got: {out:?}");
601 }
602
603 #[test]
604 #[serial]
605 fn hex_style_with_bold_emits_truecolor_with_attr() {
606 let _no_color = EnvVarGuard::unset("NO_COLOR");
607 let _ct = EnvVarGuard::set("COLORTERM", "truecolor");
608 let _colors = ColorsEnabledGuard::set(true);
609 let style = ThemedStyle::from_hex("#bd93f9").bold();
610 let out = style.apply_to("hi").to_string();
611 assert_eq!(out, "\x1b[1;38;2;189;147;249mhi\x1b[0m", "got: {out:?}");
612 }
613
614 #[test]
615 #[serial]
616 fn hex_style_falls_back_to_256_when_no_truecolor() {
617 let _no_color = EnvVarGuard::unset("NO_COLOR");
618 let _ct = EnvVarGuard::unset("COLORTERM");
619 let _colors = ColorsEnabledGuard::set(true);
620 let style = ThemedStyle::from_hex("#bd93f9");
621 let out = style.apply_to("hi").to_string();
622 let (r, g, b) = (0xbd, 0x93, 0xf9);
624 let expected_slot = ansi256_from_rgb(r, g, b);
625 let needle = format!("38;5;{expected_slot}");
626 assert!(
627 out.contains(&needle),
628 "expected fallback to contain {needle:?}, got: {out:?}"
629 );
630 assert!(
631 !out.contains("38;2;"),
632 "must not emit truecolor SGR in fallback: {out:?}"
633 );
634 }
635
636 #[test]
637 #[serial]
638 fn no_color_strips_color_keeps_attrs() {
639 let _ct = EnvVarGuard::set("COLORTERM", "truecolor");
640 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
641 let _colors = ColorsEnabledGuard::set(false);
643 let style = ThemedStyle::from_hex("#bd93f9").bold();
645 let out = style.apply_to("hi").to_string();
646 assert_eq!(out, "\x1b[1mhi\x1b[0m", "got: {out:?}");
647 }
648
649 #[test]
650 #[serial]
651 fn no_color_keeps_italic_for_default_accent() {
652 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
653 let _colors = ColorsEnabledGuard::set(false);
654 let style = ThemedStyle::from_hex("#d78700").italic();
656 let out = style.apply_to("x").to_string();
657 assert_eq!(out, "\x1b[3mx\x1b[0m", "got: {out:?}");
658 }
659
660 #[test]
661 #[serial]
662 fn no_color_keeps_bold_on_plain_style() {
663 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
664 let _colors = ColorsEnabledGuard::set(false);
665 let out = ThemedStyle::plain().bold().apply_to("x").to_string();
666 assert_eq!(out, "\x1b[1mx\x1b[0m", "got: {out:?}");
667 }
668
669 #[test]
670 #[serial]
671 fn no_color_keeps_underline_for_minimal_secondary() {
672 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
673 let _colors = ColorsEnabledGuard::set(false);
674 let out = ThemedStyle::plain().underlined().apply_to("x").to_string();
676 assert_eq!(out, "\x1b[4mx\x1b[0m", "got: {out:?}");
677 }
678
679 #[test]
680 #[serial]
681 fn no_color_emits_no_escapes_when_no_attrs() {
682 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
683 let _colors = ColorsEnabledGuard::set(false);
684 let out = ThemedStyle::plain().apply_to("x").to_string();
685 assert_eq!(out, "x", "got: {out:?}");
686 let out2 = ThemedStyle::from_hex("#bd93f9").apply_to("y").to_string();
688 assert_eq!(out2, "y", "got: {out2:?}");
689 }
690
691 #[test]
692 #[serial]
693 fn no_color_joins_multiple_attrs() {
694 let _no_color = EnvVarGuard::set("NO_COLOR", "1");
695 let _colors = ColorsEnabledGuard::set(false);
696 let out = ThemedStyle::plain()
698 .bold()
699 .italic()
700 .apply_to("x")
701 .to_string();
702 assert_eq!(out, "\x1b[1;3mx\x1b[0m", "got: {out:?}");
703 }
704
705 #[test]
706 fn from_hex_invalid_returns_plain_default() {
707 let s = ThemedStyle::from_hex("not-a-color");
708 assert!(s.rgb.is_none(), "invalid hex must not carry an rgb triple");
709 assert!(!s.attrs.has_attrs(), "invalid hex must not carry any attrs");
710 }
711
712 #[test]
713 fn from_hex_three_char_short_form_rejected() {
714 assert!(parse_hex_rgb("#abc").is_none());
717 let s = ThemedStyle::from_hex("#abc");
718 assert!(s.rgb.is_none());
719 }
720
721 #[test]
722 fn with_attrs_preserves_italic_and_underline_through_color_swap() {
723 let s = ThemedStyle::plain().italic().underlined().cyan();
728 assert!(s.attrs.italic, "italic should survive color swap");
729 assert!(s.attrs.underline, "underline should survive color swap");
730 assert!(!s.attrs.bold);
731 assert!(!s.attrs.dim);
732 }
733
734 #[test]
735 fn with_attrs_preserves_dim_through_color_swap() {
736 let s = ThemedStyle::plain().dim().red();
739 assert!(s.attrs.dim, "dim attr should survive color swap");
740 assert!(!s.attrs.bold);
741 }
742
743 #[test]
744 fn with_attrs_preserves_all_attrs_through_yellow_swap() {
745 let s = ThemedStyle::plain()
746 .bold()
747 .dim()
748 .italic()
749 .underlined()
750 .yellow();
751 assert!(s.attrs.bold);
752 assert!(s.attrs.dim);
753 assert!(s.attrs.italic);
754 assert!(s.attrs.underline);
755 }
756
757 #[test]
758 fn ansi256_grayscale_low_clamps_to_pure_black() {
759 assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
761 assert_eq!(ansi256_from_rgb(7, 7, 7), 16);
762 }
763
764 #[test]
765 fn ansi256_grayscale_high_clamps_to_pure_white() {
766 assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
768 assert_eq!(ansi256_from_rgb(249, 249, 249), 231);
769 }
770
771 #[test]
772 fn ansi256_grayscale_ramp_midrange_maps_into_232_to_255() {
773 let mid = ansi256_from_rgb(128, 128, 128);
775 assert!(
776 (232..=255).contains(&mid),
777 "expected grayscale-ramp slot for #808080, got: {mid}"
778 );
779 assert_eq!(ansi256_from_rgb(8, 8, 8), 232);
781 let high = ansi256_from_rgb(248, 248, 248);
783 assert!(
784 (232..=255).contains(&high),
785 "r==248 should still be in the ramp, got: {high}"
786 );
787 }
788
789 #[test]
790 fn ansi256_non_gray_lands_in_color_cube() {
791 let red = ansi256_from_rgb(255, 0, 0);
794 assert_eq!(red, 16 + 36 * 5);
795 let green = ansi256_from_rgb(0, 255, 0);
796 assert_eq!(green, 16 + 6 * 5);
797 let blue = ansi256_from_rgb(0, 0, 255);
798 assert_eq!(blue, 16 + 5);
799 }
800
801 #[test]
802 fn from_config_none_yields_default_theme() {
803 let t = Theme::from_config(None);
804 assert_eq!(t.icon_ok, "✓");
805 assert!(
806 t.success.rgb.is_none(),
807 "default success uses console color"
808 );
809 }
810
811 #[test]
812 fn from_config_picks_named_preset_via_name() {
813 let cfg = crate::config::ThemeConfig {
814 name: "dracula".to_string(),
815 overrides: crate::config::ThemeOverrides::default(),
816 };
817 let t = Theme::from_config(Some(&cfg));
818 assert_eq!(t.success.rgb, Some((0x50, 0xfa, 0x7b)));
820 }
821
822 #[test]
823 fn from_config_unknown_preset_falls_back_to_default() {
824 let cfg = crate::config::ThemeConfig {
825 name: "no-such-preset".to_string(),
826 overrides: crate::config::ThemeOverrides::default(),
827 };
828 let t = Theme::from_config(Some(&cfg));
829 assert!(t.success.rgb.is_none(), "fallback to default → no rgb");
830 }
831
832 #[test]
833 fn from_config_style_overrides_apply_all_twelve_slots() {
834 let cfg = crate::config::ThemeConfig {
837 name: "minimal".to_string(),
838 overrides: crate::config::ThemeOverrides {
839 header: Some("#010203".into()),
840 success: Some("#040506".into()),
841 warning: Some("#070809".into()),
842 error: Some("#0a0b0c".into()),
843 info: Some("#0d0e0f".into()),
844 muted: Some("#101112".into()),
845 running: Some("#131415".into()),
846 diff_add: Some("#161718".into()),
847 diff_remove: Some("#191a1b".into()),
848 diff_context: Some("#1c1d1e".into()),
849 accent: Some("#1f2021".into()),
850 secondary: Some("#222324".into()),
851 ..Default::default()
852 },
853 };
854 let t = Theme::from_config(Some(&cfg));
855 assert_eq!(t.header.rgb, Some((0x01, 0x02, 0x03)));
856 assert_eq!(t.success.rgb, Some((0x04, 0x05, 0x06)));
857 assert_eq!(t.warning.rgb, Some((0x07, 0x08, 0x09)));
858 assert_eq!(t.error.rgb, Some((0x0a, 0x0b, 0x0c)));
859 assert_eq!(t.info.rgb, Some((0x0d, 0x0e, 0x0f)));
860 assert_eq!(t.muted.rgb, Some((0x10, 0x11, 0x12)));
861 assert_eq!(t.running.rgb, Some((0x13, 0x14, 0x15)));
862 assert_eq!(t.diff_add.rgb, Some((0x16, 0x17, 0x18)));
863 assert_eq!(t.diff_remove.rgb, Some((0x19, 0x1a, 0x1b)));
864 assert_eq!(t.diff_context.rgb, Some((0x1c, 0x1d, 0x1e)));
865 assert_eq!(t.accent.rgb, Some((0x1f, 0x20, 0x21)));
866 assert_eq!(t.secondary.rgb, Some((0x22, 0x23, 0x24)));
867 }
868
869 #[test]
870 fn from_config_style_override_preserves_preset_attrs() {
871 let cfg = crate::config::ThemeConfig {
874 name: "minimal".to_string(),
875 overrides: crate::config::ThemeOverrides {
876 error: Some("#abcdef".into()),
877 ..Default::default()
878 },
879 };
880 let t = Theme::from_config(Some(&cfg));
881 assert_eq!(t.error.rgb, Some((0xab, 0xcd, 0xef)));
882 assert!(
883 t.error.attrs.bold,
884 "minimal preset's error slot is bold; override must preserve it"
885 );
886 }
887
888 #[test]
889 fn from_config_icon_overrides_apply_all_seven_slots() {
890 let cfg = crate::config::ThemeConfig {
891 name: "default".to_string(),
892 overrides: crate::config::ThemeOverrides {
893 icon_ok: Some("[ok]".into()),
894 icon_warn: Some("[!]".into()),
895 icon_fail: Some("[X]".into()),
896 icon_pending: Some("[.]".into()),
897 icon_running: Some("[*]".into()),
898 icon_skipped: Some("[-]".into()),
899 icon_arrow: Some("=>".into()),
900 ..Default::default()
901 },
902 };
903 let t = Theme::from_config(Some(&cfg));
904 assert_eq!(t.icon_ok, "[ok]");
905 assert_eq!(t.icon_warn, "[!]");
906 assert_eq!(t.icon_fail, "[X]");
907 assert_eq!(t.icon_pending, "[.]");
908 assert_eq!(t.icon_running, "[*]");
909 assert_eq!(t.icon_skipped, "[-]");
910 assert_eq!(t.icon_arrow, "=>");
911 }
912
913 #[test]
914 fn from_config_invalid_hex_override_leaves_slot_unchanged() {
915 let preset = Theme::from_preset("dracula");
918 let original_rgb = preset.header.rgb;
919 let cfg = crate::config::ThemeConfig {
920 name: "dracula".to_string(),
921 overrides: crate::config::ThemeOverrides {
922 header: Some("not-a-hex-string".into()),
923 ..Default::default()
924 },
925 };
926 let t = Theme::from_config(Some(&cfg));
927 assert_eq!(
928 t.header.rgb, original_rgb,
929 "invalid override must not mutate the slot"
930 );
931 }
932
933 #[test]
934 fn from_config_partial_override_only_touches_specified_slots() {
935 let cfg = crate::config::ThemeConfig {
937 name: "dracula".to_string(),
938 overrides: crate::config::ThemeOverrides {
939 header: Some("#112233".into()),
940 ..Default::default()
941 },
942 };
943 let t = Theme::from_config(Some(&cfg));
944 assert_eq!(t.header.rgb, Some((0x11, 0x22, 0x33)));
945 assert_eq!(t.success.rgb, Some((0x50, 0xfa, 0x7b)));
947 assert_eq!(t.icon_ok, "✓");
949 }
950
951 #[test]
952 fn solarized_dark_preset_has_expected_palette() {
953 let t = Theme::from_preset("solarized-dark");
954 assert_eq!(t.success.rgb, Some((0x85, 0x99, 0x00)));
955 assert_eq!(t.muted.rgb, Some((0x58, 0x6e, 0x75)));
956 }
957
958 #[test]
959 fn solarized_light_preset_distinct_muted_from_dark() {
960 let dark = Theme::from_preset("solarized-dark");
961 let light = Theme::from_preset("solarized-light");
962 assert_ne!(dark.muted.rgb, light.muted.rgb);
965 assert_eq!(light.muted.rgb, Some((0x93, 0xa1, 0xa1)));
966 assert_eq!(dark.success.rgb, light.success.rgb);
967 }
968}