Skip to main content

cfgd_core/output/
theme.rs

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/// Single style slot held by `Theme`. Wraps `console::Style` (used for the
14/// 256-color fallback path and for non-color attributes like bold/dim) and
15/// optionally carries an `(r, g, b)` triple for high-fidelity rendering on
16/// truecolor-capable terminals. The decision between truecolor and 256-color
17/// is taken at render time inside `apply_to`, so existing call sites are
18/// unaffected by the upgrade.
19#[derive(Debug, Clone, Default)]
20pub struct ThemedStyle {
21    /// `console::Style` carrying attrs and (when no `rgb` is present) the
22    /// 256-color foreground.
23    inner: Style,
24    /// Original truecolor triple, populated by `from_hex`. Read by `apply_to`
25    /// when the terminal advertises truecolor support.
26    rgb: Option<(u8, u8, u8)>,
27    /// Attribute set, kept separately so the truecolor render path can emit
28    /// SGR parameters without re-deriving them from `inner` (which only
29    /// exposes its attrs via its `Debug` impl).
30    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    /// Whether any SGR attribute is set. Predicate guard for the
43    /// `Display`-into-formatter path so callers can branch without
44    /// pre-rendering an empty parameter string.
45    fn has_attrs(&self) -> bool {
46        self.bold || self.dim || self.italic || self.underline
47    }
48}
49
50/// Writes SGR attribute parameters (without leading `\x1b[`, without
51/// trailing `m`) joined by `;` directly into the formatter — no
52/// intermediate `String` allocation on the styled-write hot path.
53/// Always preceded by `\x1b[` and (optionally) followed by `;38;...` +
54/// `m` by the caller.
55impl 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    /// Plain style — no color, no attrs. Matches `console::Style::new()`.
84    pub fn plain() -> Self {
85        Self::default()
86    }
87
88    /// Build a style from a `#rrggbb` hex string. On terminals that advertise
89    /// truecolor support (`COLORTERM=truecolor|24bit`), `apply_to` emits the
90    /// exact 24-bit color. Otherwise the color is quantized to the nearest
91    /// ANSI 256-color slot for compatibility.
92    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    /// Build a style from a `console::Color`. Used for named-color presets
104    /// (`Color::Cyan`, `Color::Red`, ...) where no RGB triple is available.
105    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    /// Wrap `text` for `Display` rendering. Resolved at format-time:
171    ///
172    /// - `console::colors_enabled()` is false (NO_COLOR / TERM=dumb / not a
173    ///   tty) AND no attrs → emit `text` with no escapes.
174    /// - `console::colors_enabled()` is false AND attrs are set → emit
175    ///   `\x1b[<attrs>m{text}\x1b[0m`. NO_COLOR (per no-color.org) governs
176    ///   color only — bold/dim/italic/underline are independent SGR signals
177    ///   load-bearing for the `default` (italic accent) and `minimal`
178    ///   (italic accent, underlined secondary) presets that intentionally
179    ///   carry the accent/secondary distinction in non-color attrs.
180    /// - `supports_truecolor()` is true AND an RGB triple is present → emit
181    ///   `\x1b[<attrs>;38;2;R;G;Bm{text}\x1b[0m`.
182    /// - Otherwise → delegate to `console::Style::apply_to`, which yields
183    ///   the 256-color fallback path (existing behavior).
184    pub fn apply_to<D: Display>(&self, text: D) -> StyledText<'_, D> {
185        StyledText { style: self, text }
186    }
187}
188
189/// `Display`-wrapper returned by `ThemedStyle::apply_to`. Stays generic over
190/// the inner payload so callers can format `&str`, `String`, or anything else
191/// `Display` without extra allocation up front.
192pub 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            // NO_COLOR / TERM=dumb / not a tty: emit attrs-only SGR (bold,
203            // dim, italic, underlined are independent of color per
204            // no-color.org) so the `default` italic accent and the
205            // `minimal` italic accent / underlined secondary keep their
206            // non-color differentiator. No allocation when no attrs set.
207            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    // Style slots (12)
228    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    /// "Attention without alarm" — orange-family in Dracula/Solarized, italic
239    /// non-color signal in `default` and `minimal`. Drives `Role::Accent`.
240    pub accent: ThemedStyle,
241    /// "Structural pivot / label / identifier" — pink/magenta family in
242    /// Dracula/Solarized, underlined non-color signal in `minimal`. Drives
243    /// `Role::Secondary`.
244    pub secondary: ThemedStyle,
245
246    // Icon slots (7)
247    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            // Italic keeps an honest non-color signal under NO_COLOR; the hex
270            // gives truecolor terminals an orange-leaning accent that does not
271            // collide with the yellow `warning` slot.
272            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        // Style overrides
357        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        // Icon overrides
394        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            // Italic vs underlined keeps the two accent axes distinguishable
431            // without any color budget — orthogonal to bold/dim already used by
432            // header/error/muted.
433            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
446/// Detect 24-bit color support via the standard `COLORTERM` signal, matching
447/// the convention used by `bat`, `delta`, `git diff --color`, `lsd`, `eza`,
448/// and friends. Honors `NO_COLOR` so the signal can't override an explicit
449/// opt-out.
450pub 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
460/// Parse `#rrggbb` (or `rrggbb`) into an `(r, g, b)` triple. `None` for any
461/// malformed input.
462pub(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
473/// Quantize an RGB triple to the closest ANSI 256-color slot. Used for the
474/// 256-color fallback path when the terminal does not advertise truecolor
475/// support. Algorithm matches xterm's 6×6×6 cube + 24-step grayscale ramp.
476pub(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        // Default success is plain green; dracula uses hex (carries rgb).
533        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, "✓"); // matches default
542    }
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        // Output must contain the 256-color SGR for the quantized slot.
623        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        // Simulate the colors-disabled state for this test.
642        let _colors = ColorsEnabledGuard::set(false);
643        // Attrs are independent of color per no-color.org: bold survives.
644        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        // Matches the `default` preset's accent slot: hex("#d78700").italic()
655        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        // Matches the `minimal` preset's secondary slot.
675        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        // Hex without attrs also emits no escapes when colors are off.
687        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        // bold + italic share the attrs path.
697        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        // The parser requires six hex chars; the three-char short form is
715        // not accepted and must round-trip to the default style.
716        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        // `cyan()` reconstructs the style from a console color and then calls
724        // `with_attrs` to re-apply the prior attribute set. This exercises the
725        // italic + underlined branches inside `with_attrs` that the existing
726        // bold-only tests don't reach.
727        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        // `red()`/`green()`/etc. all funnel through `with_attrs`; verify the
737        // `dim` branch (line 158) is reached and preserved.
738        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        // r == g == b, with r < 8 → ANSI slot 16 (pure black).
760        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        // r == g == b, with r > 248 → ANSI slot 231 (pure white).
767        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        // r == g == b, with 8 <= r <= 248 → grayscale ramp 232..=255.
774        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        // Edge: r == 8 lands at 232 (first ramp slot).
780        assert_eq!(ansi256_from_rgb(8, 8, 8), 232);
781        // Edge: r == 248 maps near the top of the ramp.
782        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        // Color cube spans 16..=231 (16 + 6*6*6 - 1 = 231); pure red lands at
792        // the cube's max-red plane.
793        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        // Dracula's success is the green hex #50fa7b.
819        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        // Each slot gets a distinct hex; verify the resolved Theme carries
835        // back the exact rgb triple for each.
836        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        // Minimal's `error` slot is plain().bold(); overriding the color via
872        // apply_color must not strip the bold attr.
873        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        // apply_color's parse_hex_rgb returns None for malformed input; the
916        // slot stays as the preset's value, including its rgb triple.
917        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        // Override `header` only; the rest of the dracula preset slots stay.
936        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        // Dracula's success stays at #50fa7b.
946        assert_eq!(t.success.rgb, Some((0x50, 0xfa, 0x7b)));
947        // And the icons stay at the default.
948        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        // Only the muted/diff_context slot differs between solarized-dark and
963        // solarized-light; everything else matches.
964        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}