Skip to main content

linesmith/theme/
mod.rs

1//! Role-based theme system. Segments declare semantic roles; themes map
2//! roles to colors; the render path emits ANSI SGR around each segment
3//! based on the terminal's detected capability. Full contract in
4//! `docs/specs/theming.md`.
5//!
6//! What's here today: Role / Color / Style / Theme types, capability
7//! detection, truecolor→256→16 downgrade, and two built-ins (`default`,
8//! `minimal`). TOML theme files, style-string syntax, and the
9//! Catppuccin flavors are documented in the spec and ship separately.
10
11use std::fmt::Write;
12
13mod catppuccin;
14pub mod style_syntax;
15pub mod user;
16
17pub use style_syntax::{parse_style, StyleParseError};
18pub use user::{RegisteredTheme, ThemeRegistry, ThemeSource};
19
20/// Semantic color slot a segment targets. Themes map every role to a
21/// concrete color; segments never reference hex values directly.
22/// Variants are ordered to match the 16-slot role array themes store.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Role {
25    // Base roles — always present in every theme.
26    Foreground = 0,
27    Background = 1,
28    Muted = 2,
29    Primary = 3,
30    Accent = 4,
31    Success = 5,
32    Warning = 6,
33    Error = 7,
34    Info = 8,
35    // Extended roles — themes may omit them; `Theme::color` falls back
36    // to the base role named in the spec's extended-role table.
37    SuccessDim = 9,
38    WarningDim = 10,
39    ErrorDim = 11,
40    PrimaryDim = 12,
41    AccentDim = 13,
42    Surface = 14,
43    Border = 15,
44}
45
46// Compile-time guard: the last variant's discriminant must equal
47// `COUNT - 1` so theme color arrays stay in lockstep with the enum.
48// If a new variant is added without bumping `COUNT`, this trips.
49const _: () = assert!(Role::Border as usize == Role::COUNT - 1);
50
51impl Role {
52    /// Role count; the discriminants cover `0..ROLE_COUNT` densely so
53    /// themes store colors in a fixed-size array.
54    pub const COUNT: usize = 16;
55
56    /// The base role this role falls back to when a theme leaves the
57    /// extended slot unset. Base roles fall back to themselves.
58    #[must_use]
59    pub fn fallback(self) -> Role {
60        match self {
61            Self::SuccessDim => Self::Success,
62            Self::WarningDim => Self::Warning,
63            Self::ErrorDim => Self::Error,
64            Self::PrimaryDim => Self::Primary,
65            Self::AccentDim => Self::Accent,
66            Self::Surface => Self::Background,
67            Self::Border => Self::Muted,
68            other => other,
69        }
70    }
71}
72
73/// Standard 16-color ANSI palette. Stored as the foreground base (30..=37
74/// for normal, 90..=97 for bright); we add offsets at emit time.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum AnsiColor {
77    Black = 0,
78    Red = 1,
79    Green = 2,
80    Yellow = 3,
81    Blue = 4,
82    Magenta = 5,
83    Cyan = 6,
84    White = 7,
85    BrightBlack = 8,
86    BrightRed = 9,
87    BrightGreen = 10,
88    BrightYellow = 11,
89    BrightBlue = 12,
90    BrightMagenta = 13,
91    BrightCyan = 14,
92    BrightWhite = 15,
93}
94
95/// Concrete color value a theme resolves a role to.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum Color {
99    TrueColor {
100        r: u8,
101        g: u8,
102        b: u8,
103    },
104    Palette256(u8),
105    Palette16(AnsiColor),
106    /// Explicit "no color" — the role renders plain, even if the
107    /// terminal supports color. Used by the `minimal` theme to force
108    /// decoration-only styling everywhere.
109    NoColor,
110}
111
112impl Color {
113    /// Downgrade this color to the richest form the terminal supports.
114    /// `NoColor` is preserved; `Palette16` is already the lowest tier
115    /// and passes through.
116    #[must_use]
117    pub fn downgrade(self, cap: Capability) -> Color {
118        match (self, cap) {
119            (_, Capability::None) => Color::NoColor,
120            (Color::NoColor, _) => Color::NoColor,
121            // Truecolor input.
122            (Color::TrueColor { r, g, b }, Capability::Palette256) => {
123                Color::Palette256(rgb_to_256(r, g, b))
124            }
125            (Color::TrueColor { r, g, b }, Capability::Palette16) => {
126                Color::Palette16(rgb_to_ansi16(r, g, b))
127            }
128            // Palette256 input.
129            (Color::Palette256(n), Capability::Palette16) => {
130                Color::Palette16(palette256_to_ansi16(n))
131            }
132            // Same or richer capability: pass through.
133            (c, _) => c,
134        }
135    }
136}
137
138/// Text decorations a segment may layer over its theme color. `role` is
139/// the role the segment wants; `fg` is an explicit color override that
140/// wins over `role`. `bg` and `hyperlink` live in the spec but aren't
141/// wired through yet.
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct Style {
145    pub role: Option<Role>,
146    pub fg: Option<Color>,
147    pub bold: bool,
148    pub italic: bool,
149    pub underline: bool,
150    pub dim: bool,
151}
152
153impl Style {
154    /// Shorthand for a style that only carries a role.
155    #[must_use]
156    pub const fn role(role: Role) -> Self {
157        Self {
158            role: Some(role),
159            fg: None,
160            bold: false,
161            italic: false,
162            underline: false,
163            dim: false,
164        }
165    }
166}
167
168/// Terminal color capability detected from the environment. Truecolor
169/// is preferred when available; `None` strips all color and keeps only
170/// decorations.
171///
172/// Variants are ordered richest→poorest so `cap >= Capability::Palette256`
173/// reads "at least 256 colors." Downgrade logic can branch on ordering
174/// rather than re-matching the ladder each time.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
176pub enum Capability {
177    None,
178    Palette16,
179    Palette256,
180    TrueColor,
181}
182
183impl Capability {
184    /// Detect honoring `NO_COLOR` (per no-color.org). Kept for callers
185    /// that want a single-shot detect without threading the env through
186    /// a precedence chain.
187    #[must_use]
188    pub fn detect() -> Self {
189        if std::env::var_os("NO_COLOR").is_some() {
190            return Self::None;
191        }
192        Self::from_terminal()
193    }
194
195    /// Raw terminal capability from `supports-color`, ignoring
196    /// `NO_COLOR`. The color-policy precedence chain uses this so a
197    /// `--force-color` flag can outrank the env var. Returns `None`
198    /// when stdout isn't a TTY so `color = "auto"` stays plain-text
199    /// under pipes and redirects.
200    #[must_use]
201    pub fn from_terminal() -> Self {
202        match supports_color::on(supports_color::Stream::Stdout) {
203            Some(c) if c.has_16m => Self::TrueColor,
204            Some(c) if c.has_256 => Self::Palette256,
205            Some(_) => Self::Palette16,
206            None => Self::None,
207        }
208    }
209
210    /// Env-only capability probe from the `COLORTERM` and `TERM`
211    /// values carried on `CliEnv`. Used by the force-color path to
212    /// recover a tier when `supports-color` gives up (e.g. piped
213    /// stdout under Claude Code). Never reads the live process env;
214    /// callers must snapshot into `CliEnv::from_process` first so
215    /// tests and embedders stay hermetic.
216    ///
217    /// Returns `None` for `TERM=dumb`, empty `TERM`, or no `TERM` at
218    /// all.
219    #[must_use]
220    pub(crate) fn from_env_vars(colorterm: Option<&str>, term: Option<&str>) -> Self {
221        if let Some(c) = colorterm {
222            if c.eq_ignore_ascii_case("truecolor") || c.eq_ignore_ascii_case("24bit") {
223                return Self::TrueColor;
224            }
225        }
226        match term.map(str::trim) {
227            None | Some("") => Self::None,
228            Some(t) if t.eq_ignore_ascii_case("dumb") => Self::None,
229            Some(t) if t.contains("256color") => Self::Palette256,
230            _ => Self::Palette16,
231        }
232    }
233
234    /// Combine a TTY probe (from `supports-color`) and an env probe
235    /// (from `COLORTERM` / `TERM`) into a capability under force-color
236    /// intent. Picks the richer of the two, flooring at `Palette16`
237    /// so an explicit `color = "always"` / `--force-color` / `FORCE_COLOR`
238    /// never collapses to no-color. The `Palette16` floor overrides
239    /// `TERM=dumb` — the user explicitly asked for color, so a dumb
240    /// terminal signal loses.
241    #[must_use]
242    pub(crate) fn force_from(tty: Self, env: Self) -> Self {
243        tty.max(env).max(Self::Palette16)
244    }
245}
246
247/// Named color palette keyed by [`Role`]. `colors[role as usize]` is
248/// either `Some(color)` when the theme defines it or `None` so
249/// [`Theme::color`] falls back via [`Role::fallback`]. Fields stay
250/// crate-private so callers can't mint themes with arbitrary names or
251/// bypass the fallback contract; user-authored themes will go through
252/// a validated constructor when TOML loading lands.
253#[derive(Debug, Clone)]
254pub struct Theme {
255    name: &'static str,
256    colors: [Option<Color>; Role::COUNT],
257}
258
259impl Theme {
260    /// Name this theme is registered under. Callers use this to surface
261    /// a `--list-themes`-style view or to echo the active theme in
262    /// diagnostics.
263    #[must_use]
264    pub fn name(&self) -> &'static str {
265        self.name
266    }
267
268    /// Crate-internal constructor for themes loaded from user files.
269    /// Keeps the private-fields contract intact (so external callers
270    /// can't mint impostor themes) while letting the `user` loader
271    /// produce themes from parsed TOML.
272    #[must_use]
273    pub(super) fn from_user_parts(
274        name: &'static str,
275        colors: [Option<Color>; Role::COUNT],
276    ) -> Self {
277        Self { name, colors }
278    }
279
280    /// Resolve a role to its color, following the extended-role
281    /// fallback chain. Returns `Color::NoColor` when nothing in the
282    /// chain maps — which in practice only happens for the `minimal`
283    /// theme.
284    #[must_use]
285    pub fn color(&self, role: Role) -> Color {
286        let mut current = role;
287        loop {
288            if let Some(c) = self.colors[current as usize] {
289                return c;
290            }
291            let next = current.fallback();
292            if next == current {
293                return Color::NoColor;
294            }
295            current = next;
296        }
297    }
298}
299
300/// Built-in themes. `default` fully populates base roles so
301/// `Theme::color`'s fallback loop never returns `NoColor` for it;
302/// `minimal` leaves every role unset so everything falls through to
303/// `NoColor`, forcing decoration-only output.
304const BUILTIN_THEMES: &[Theme] = &[
305    Theme {
306        name: "default",
307        // Palette16 values render on every terminal without needing
308        // runtime downgrade; user themes use TrueColor and pay for it.
309        colors: {
310            let mut c = [None; Role::COUNT];
311            c[Role::Foreground as usize] = Some(Color::Palette16(AnsiColor::BrightWhite));
312            c[Role::Background as usize] = Some(Color::NoColor);
313            c[Role::Muted as usize] = Some(Color::Palette16(AnsiColor::BrightBlack));
314            c[Role::Primary as usize] = Some(Color::Palette16(AnsiColor::BrightMagenta));
315            c[Role::Accent as usize] = Some(Color::Palette16(AnsiColor::BrightBlue));
316            c[Role::Success as usize] = Some(Color::Palette16(AnsiColor::BrightGreen));
317            c[Role::Warning as usize] = Some(Color::Palette16(AnsiColor::BrightYellow));
318            c[Role::Error as usize] = Some(Color::Palette16(AnsiColor::BrightRed));
319            c[Role::Info as usize] = Some(Color::Palette16(AnsiColor::BrightCyan));
320            c
321        },
322    },
323    Theme {
324        name: "minimal",
325        colors: [None; Role::COUNT],
326    },
327    catppuccin::LATTE,
328    catppuccin::FRAPPE,
329    catppuccin::MACCHIATO,
330    catppuccin::MOCHA,
331];
332
333/// Look up a built-in theme by name. Unknown names return `None` so
334/// callers can warn and fall back to `default`.
335#[must_use]
336pub fn built_in(name: &str) -> Option<&'static Theme> {
337    BUILTIN_THEMES.iter().find(|t| t.name == name)
338}
339
340/// The default theme when config names none (or names an unknown one).
341/// Guaranteed to exist.
342#[must_use]
343pub fn default_theme() -> &'static Theme {
344    built_in("default").expect("`default` theme is always compiled in")
345}
346
347/// Names of all compiled-in themes, in registration order.
348pub fn builtin_names() -> impl Iterator<Item = &'static str> {
349    BUILTIN_THEMES.iter().map(|t| t.name)
350}
351
352/// Emit the ANSI SGR prefix for `style` under `cap`. Returns an empty
353/// string when the style is plain or the capability is `None` with no
354/// decorations.
355#[must_use]
356pub fn sgr_open(style: &Style, theme: &Theme, cap: Capability) -> String {
357    let mut params = Vec::<u16>::with_capacity(4);
358    if style.bold {
359        params.push(1);
360    }
361    if style.dim {
362        params.push(2);
363    }
364    if style.italic {
365        params.push(3);
366    }
367    if style.underline {
368        params.push(4);
369    }
370    let fg = resolve_fg(style, theme, cap);
371    emit_fg_params(fg, &mut params);
372    if params.is_empty() {
373        return String::new();
374    }
375    let mut out = String::from("\x1b[");
376    for (i, p) in params.iter().enumerate() {
377        if i > 0 {
378            out.push(';');
379        }
380        let _ = write!(out, "{p}");
381    }
382    out.push('m');
383    out
384}
385
386/// SGR reset. Emitted after every styled span so color and decorations
387/// don't leak across segment boundaries.
388#[must_use]
389pub const fn sgr_reset() -> &'static str {
390    "\x1b[0m"
391}
392
393/// Resolve the effective foreground color for a style: explicit `fg`
394/// wins over `role`; `role` is looked up in the theme and downgraded
395/// to the terminal's capability; absence → `NoColor`.
396fn resolve_fg(style: &Style, theme: &Theme, cap: Capability) -> Color {
397    let raw = style
398        .fg
399        .or_else(|| style.role.map(|r| theme.color(r)))
400        .unwrap_or(Color::NoColor);
401    raw.downgrade(cap)
402}
403
404/// Push the SGR params (38;2;r;g;b / 38;5;n / 30–37 / 90–97) for a
405/// foreground color. `NoColor` pushes nothing.
406fn emit_fg_params(color: Color, out: &mut Vec<u16>) {
407    match color {
408        Color::NoColor => {}
409        Color::Palette16(ansi) => {
410            let code = ansi_to_sgr_fg(ansi);
411            out.push(code);
412        }
413        Color::Palette256(n) => {
414            out.extend_from_slice(&[38, 5, u16::from(n)]);
415        }
416        Color::TrueColor { r, g, b } => {
417            out.extend_from_slice(&[38, 2, u16::from(r), u16::from(g), u16::from(b)]);
418        }
419    }
420}
421
422fn ansi_to_sgr_fg(c: AnsiColor) -> u16 {
423    let n = c as u8;
424    if n < 8 {
425        30 + u16::from(n)
426    } else {
427        // 90..=97 for bright variants; n is 8..=15.
428        90 + u16::from(n - 8)
429    }
430}
431
432// --- Downgrade algorithms ---
433
434/// Map an RGB triple to the nearest xterm-256 index. Uses the standard
435/// 6×6×6 color cube (16..=231) plus the 24-step grayscale ramp
436/// (232..=255). Picks grayscale when r≈g≈b to avoid muddy cube greys.
437fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
438    if r == g && g == b {
439        if r < 8 {
440            return 16;
441        }
442        // `238` is the last channel value whose mapped ramp index
443        // stays within `232..=255`: `232 + (238-8)/10 = 255`. Anything
444        // higher would overflow the ramp, so map it to palette-white.
445        if r > 238 {
446            return 231;
447        }
448        return 232 + (r - 8) / 10;
449    }
450    let r6 = scale_to_cube(r);
451    let g6 = scale_to_cube(g);
452    let b6 = scale_to_cube(b);
453    16 + 36 * r6 + 6 * g6 + b6
454}
455
456fn scale_to_cube(channel: u8) -> u8 {
457    // xterm cube levels: 0, 95, 135, 175, 215, 255.
458    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
459    let mut best = 0u8;
460    let mut best_dist = u16::MAX;
461    for (i, &level) in LEVELS.iter().enumerate() {
462        let d = u16::from(channel.abs_diff(level));
463        if d < best_dist {
464            best_dist = d;
465            best = u8::try_from(i).expect("cube index fits in u8");
466        }
467    }
468    best
469}
470
471/// Map an RGB triple to the nearest 16-color ANSI slot via luminance
472/// plus primary-channel heuristic. This is coarse on purpose: themes
473/// that care about 16-color output are expected to declare Palette16
474/// values directly; this path exists for truecolor-only themes
475/// running under a legacy terminal.
476fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> AnsiColor {
477    let max = r.max(g).max(b);
478    let min = r.min(g).min(b);
479    let bright = max >= 128;
480    // Achromatic: pick from black / grey / white by luminance.
481    if max - min < 32 {
482        return match max {
483            0..=42 => AnsiColor::Black,
484            43..=170 => {
485                if bright {
486                    AnsiColor::BrightBlack
487                } else {
488                    AnsiColor::White
489                }
490            }
491            _ => AnsiColor::BrightWhite,
492        };
493    }
494    // Dominant channel picks the hue.
495    let dominant_r = r >= g && r >= b;
496    let dominant_g = g >= r && g >= b;
497    let dominant_b = b >= r && b >= g;
498    let r_hi = r >= 128;
499    let g_hi = g >= 128;
500    let b_hi = b >= 128;
501    match (dominant_r, dominant_g, dominant_b, r_hi, g_hi, b_hi) {
502        (true, _, _, _, true, _) => {
503            if bright {
504                AnsiColor::BrightYellow
505            } else {
506                AnsiColor::Yellow
507            }
508        }
509        (true, _, _, _, _, true) => {
510            if bright {
511                AnsiColor::BrightMagenta
512            } else {
513                AnsiColor::Magenta
514            }
515        }
516        (_, true, _, _, _, true) => {
517            if bright {
518                AnsiColor::BrightCyan
519            } else {
520                AnsiColor::Cyan
521            }
522        }
523        (_, true, _, true, _, _) => {
524            if bright {
525                AnsiColor::BrightYellow
526            } else {
527                AnsiColor::Yellow
528            }
529        }
530        (true, _, _, _, _, _) => {
531            if bright {
532                AnsiColor::BrightRed
533            } else {
534                AnsiColor::Red
535            }
536        }
537        (_, true, _, _, _, _) => {
538            if bright {
539                AnsiColor::BrightGreen
540            } else {
541                AnsiColor::Green
542            }
543        }
544        (_, _, true, _, _, _) => {
545            if bright {
546                AnsiColor::BrightBlue
547            } else {
548                AnsiColor::Blue
549            }
550        }
551        _ => AnsiColor::White,
552    }
553}
554
555/// Route a Palette256 entry down to the nearest 16-color slot. For
556/// indices 0..=15 the mapping is identity; for the cube we decompose
557/// back to approximate RGB and hand off to [`rgb_to_ansi16`].
558fn palette256_to_ansi16(n: u8) -> AnsiColor {
559    if n < 16 {
560        return match n {
561            0 => AnsiColor::Black,
562            1 => AnsiColor::Red,
563            2 => AnsiColor::Green,
564            3 => AnsiColor::Yellow,
565            4 => AnsiColor::Blue,
566            5 => AnsiColor::Magenta,
567            6 => AnsiColor::Cyan,
568            7 => AnsiColor::White,
569            8 => AnsiColor::BrightBlack,
570            9 => AnsiColor::BrightRed,
571            10 => AnsiColor::BrightGreen,
572            11 => AnsiColor::BrightYellow,
573            12 => AnsiColor::BrightBlue,
574            13 => AnsiColor::BrightMagenta,
575            14 => AnsiColor::BrightCyan,
576            _ => AnsiColor::BrightWhite,
577        };
578    }
579    if n >= 232 {
580        // Grayscale ramp: map to bright/dim black or white by index.
581        let step = n - 232;
582        return match step {
583            0..=7 => AnsiColor::Black,
584            8..=15 => AnsiColor::BrightBlack,
585            16..=19 => AnsiColor::White,
586            _ => AnsiColor::BrightWhite,
587        };
588    }
589    // Color cube: decompose (n-16) into r6/g6/b6, expand to approx RGB.
590    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
591    let c = n - 16;
592    let r6 = usize::from(c / 36);
593    let g6 = usize::from((c / 6) % 6);
594    let b6 = usize::from(c % 6);
595    rgb_to_ansi16(LEVELS[r6], LEVELS[g6], LEVELS[b6])
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    // --- Role ---
603
604    #[test]
605    fn role_count_matches_enum_discriminant_range() {
606        assert_eq!(Role::Foreground as usize, 0);
607        assert_eq!(Role::Border as usize, Role::COUNT - 1);
608    }
609
610    #[test]
611    fn extended_role_fallbacks_match_spec_table() {
612        assert_eq!(Role::SuccessDim.fallback(), Role::Success);
613        assert_eq!(Role::WarningDim.fallback(), Role::Warning);
614        assert_eq!(Role::ErrorDim.fallback(), Role::Error);
615        assert_eq!(Role::PrimaryDim.fallback(), Role::Primary);
616        assert_eq!(Role::AccentDim.fallback(), Role::Accent);
617        assert_eq!(Role::Surface.fallback(), Role::Background);
618        assert_eq!(Role::Border.fallback(), Role::Muted);
619    }
620
621    #[test]
622    fn base_roles_fall_back_to_themselves() {
623        for role in [
624            Role::Foreground,
625            Role::Background,
626            Role::Muted,
627            Role::Primary,
628            Role::Accent,
629            Role::Success,
630            Role::Warning,
631            Role::Error,
632            Role::Info,
633        ] {
634            assert_eq!(role.fallback(), role);
635        }
636    }
637
638    // --- Theme::color ---
639
640    #[test]
641    fn default_theme_maps_every_base_role() {
642        let t = built_in("default").expect("default exists");
643        for role in [
644            Role::Foreground,
645            Role::Muted,
646            Role::Primary,
647            Role::Accent,
648            Role::Success,
649            Role::Warning,
650            Role::Error,
651            Role::Info,
652        ] {
653            let c = t.color(role);
654            assert!(
655                !matches!(c, Color::NoColor),
656                "default theme left {role:?} as NoColor",
657            );
658        }
659    }
660
661    #[test]
662    fn default_theme_extended_roles_fall_back_to_base() {
663        let t = built_in("default").expect("default exists");
664        assert_eq!(t.color(Role::SuccessDim), t.color(Role::Success));
665        assert_eq!(t.color(Role::PrimaryDim), t.color(Role::Primary));
666        assert_eq!(t.color(Role::Border), t.color(Role::Muted));
667    }
668
669    #[test]
670    fn minimal_theme_returns_no_color_for_every_role() {
671        let t = built_in("minimal").expect("minimal exists");
672        for role in [
673            Role::Foreground,
674            Role::Primary,
675            Role::Warning,
676            Role::SuccessDim,
677        ] {
678            assert_eq!(t.color(role), Color::NoColor);
679        }
680    }
681
682    #[test]
683    fn unknown_theme_name_returns_none() {
684        assert!(built_in("nope").is_none());
685    }
686
687    #[test]
688    fn default_theme_always_available() {
689        assert_eq!(default_theme().name, "default");
690    }
691
692    #[test]
693    fn builtin_names_lists_default_and_minimal() {
694        let names: Vec<&str> = builtin_names().collect();
695        assert!(names.contains(&"default"));
696        assert!(names.contains(&"minimal"));
697    }
698
699    // --- Downgrade ---
700
701    #[test]
702    fn downgrade_strips_color_under_no_capability() {
703        assert_eq!(
704            Color::TrueColor { r: 255, g: 0, b: 0 }.downgrade(Capability::None),
705            Color::NoColor
706        );
707        assert_eq!(
708            Color::Palette16(AnsiColor::Red).downgrade(Capability::None),
709            Color::NoColor
710        );
711    }
712
713    #[test]
714    fn downgrade_preserves_matching_or_richer_capability() {
715        let c = Color::Palette16(AnsiColor::Green);
716        assert_eq!(c.downgrade(Capability::Palette16), c);
717        assert_eq!(c.downgrade(Capability::Palette256), c);
718        assert_eq!(c.downgrade(Capability::TrueColor), c);
719    }
720
721    #[test]
722    fn downgrade_truecolor_to_256_grayscale_uses_gray_ramp() {
723        // Mid-grey should land in the 232..=255 ramp, not the cube.
724        let g = Color::TrueColor {
725            r: 128,
726            g: 128,
727            b: 128,
728        };
729        match g.downgrade(Capability::Palette256) {
730            Color::Palette256(n) => assert!((232..=255).contains(&n), "got {n}"),
731            other => panic!("expected Palette256, got {other:?}"),
732        }
733    }
734
735    #[test]
736    fn downgrade_truecolor_near_white_grayscale_saturates_without_overflow() {
737        // Regression: the ramp formula `232 + (r-8)/10` overflows u8 at
738        // r >= 239 (`(239-8)/10 = 23` is the last safe index mapping
739        // to 255). Channels above 238 must saturate to 231, not panic.
740        for v in [239u8, 240, 248, 249, 255] {
741            let c = Color::TrueColor { r: v, g: v, b: v };
742            match c.downgrade(Capability::Palette256) {
743                Color::Palette256(n) => assert!(
744                    (232..=255).contains(&n) || n == 231,
745                    "r={v}: n={n} out of ramp/white range"
746                ),
747                other => panic!("expected Palette256, got {other:?}"),
748            }
749        }
750    }
751
752    #[test]
753    fn downgrade_truecolor_to_256_uses_cube_for_color() {
754        // Pure red: (255, 0, 0). r6=5, g6=0, b6=0 → 16 + 180 = 196.
755        let red = Color::TrueColor { r: 255, g: 0, b: 0 };
756        assert_eq!(
757            red.downgrade(Capability::Palette256),
758            Color::Palette256(196)
759        );
760        // Pure green: r6=0, g6=5, b6=0 → 16 + 30 = 46.
761        let green = Color::TrueColor { r: 0, g: 255, b: 0 };
762        assert_eq!(
763            green.downgrade(Capability::Palette256),
764            Color::Palette256(46)
765        );
766        // Pure blue: r6=0, g6=0, b6=5 → 16 + 5 = 21.
767        let blue = Color::TrueColor { r: 0, g: 0, b: 255 };
768        assert_eq!(
769            blue.downgrade(Capability::Palette256),
770            Color::Palette256(21)
771        );
772    }
773
774    #[test]
775    fn downgrade_palette256_cube_to_16_picks_a_color() {
776        // Index 196 is pure red in the cube; must route to a red slot.
777        match Color::Palette256(196).downgrade(Capability::Palette16) {
778            Color::Palette16(ac) => assert!(
779                matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
780                "got {ac:?}"
781            ),
782            other => panic!("expected Palette16, got {other:?}"),
783        }
784    }
785
786    #[test]
787    fn downgrade_palette256_low_16_passes_through_identity() {
788        // Indices 0..=15 map to their AnsiColor counterparts.
789        assert_eq!(
790            Color::Palette256(1).downgrade(Capability::Palette16),
791            Color::Palette16(AnsiColor::Red),
792        );
793        assert_eq!(
794            Color::Palette256(15).downgrade(Capability::Palette16),
795            Color::Palette16(AnsiColor::BrightWhite),
796        );
797    }
798
799    #[test]
800    fn downgrade_palette256_grayscale_routes_to_blacks_or_whites() {
801        // Index 232 is darkest grey → Black; 255 is lightest → BrightWhite.
802        assert_eq!(
803            Color::Palette256(232).downgrade(Capability::Palette16),
804            Color::Palette16(AnsiColor::Black),
805        );
806        assert_eq!(
807            Color::Palette256(255).downgrade(Capability::Palette16),
808            Color::Palette16(AnsiColor::BrightWhite),
809        );
810    }
811
812    #[test]
813    fn downgrade_truecolor_red_to_16_picks_red_family() {
814        let red = Color::TrueColor {
815            r: 200,
816            g: 30,
817            b: 30,
818        };
819        match red.downgrade(Capability::Palette16) {
820            Color::Palette16(ac) => assert!(
821                matches!(ac, AnsiColor::Red | AnsiColor::BrightRed),
822                "got {ac:?}"
823            ),
824            other => panic!("expected Palette16, got {other:?}"),
825        }
826    }
827
828    // --- SGR emission ---
829
830    #[test]
831    fn sgr_open_plain_style_emits_nothing() {
832        let s = Style::default();
833        let t = default_theme();
834        assert_eq!(sgr_open(&s, t, Capability::TrueColor), "");
835    }
836
837    #[test]
838    fn sgr_open_bold_only_emits_sgr1() {
839        let s = Style {
840            bold: true,
841            ..Style::default()
842        };
843        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
844    }
845
846    #[test]
847    fn sgr_open_role_under_palette16_emits_ansi_code() {
848        let s = Style::role(Role::Success);
849        let out = sgr_open(&s, default_theme(), Capability::Palette16);
850        // Default theme maps Success → BrightGreen (SGR 92).
851        assert_eq!(out, "\x1b[92m");
852    }
853
854    #[test]
855    fn sgr_open_role_under_no_capability_drops_color_keeps_decoration() {
856        let s = Style {
857            role: Some(Role::Error),
858            bold: true,
859            ..Style::default()
860        };
861        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[1m");
862    }
863
864    #[test]
865    fn sgr_open_explicit_fg_wins_over_role() {
866        let s = Style {
867            role: Some(Role::Warning),
868            fg: Some(Color::Palette16(AnsiColor::Blue)),
869            ..Style::default()
870        };
871        // Blue is SGR 34; Warning would have been 93 (BrightYellow).
872        assert_eq!(
873            sgr_open(&s, default_theme(), Capability::Palette16),
874            "\x1b[34m"
875        );
876    }
877
878    #[test]
879    fn sgr_open_truecolor_fg_emits_38_2_sequence() {
880        let s = Style {
881            fg: Some(Color::TrueColor {
882                r: 12,
883                g: 34,
884                b: 56,
885            }),
886            ..Style::default()
887        };
888        assert_eq!(
889            sgr_open(&s, default_theme(), Capability::TrueColor),
890            "\x1b[38;2;12;34;56m"
891        );
892    }
893
894    #[test]
895    fn sgr_open_combines_decorations_and_color() {
896        let s = Style {
897            role: Some(Role::Primary),
898            bold: true,
899            italic: true,
900            ..Style::default()
901        };
902        // SGR params emit in order: bold(1), italic(3), color(95 = BrightMagenta).
903        assert_eq!(
904            sgr_open(&s, default_theme(), Capability::Palette16),
905            "\x1b[1;3;95m"
906        );
907    }
908
909    #[test]
910    fn sgr_open_dim_only_emits_sgr2() {
911        let s = Style {
912            dim: true,
913            ..Style::default()
914        };
915        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[2m");
916    }
917
918    #[test]
919    fn sgr_open_underline_only_emits_sgr4() {
920        let s = Style {
921            underline: true,
922            ..Style::default()
923        };
924        assert_eq!(sgr_open(&s, default_theme(), Capability::None), "\x1b[4m");
925    }
926
927    #[test]
928    fn sgr_open_full_decoration_stack_keeps_order() {
929        let s = Style {
930            role: Some(Role::Primary),
931            bold: true,
932            dim: true,
933            italic: true,
934            underline: true,
935            ..Style::default()
936        };
937        // Order: bold(1), dim(2), italic(3), underline(4), color(95).
938        assert_eq!(
939            sgr_open(&s, default_theme(), Capability::Palette16),
940            "\x1b[1;2;3;4;95m"
941        );
942    }
943
944    #[test]
945    fn sgr_reset_is_stable_and_short() {
946        assert_eq!(sgr_reset(), "\x1b[0m");
947    }
948
949    #[test]
950    fn minimal_theme_emits_only_decorations() {
951        let s = Style {
952            role: Some(Role::Success),
953            bold: true,
954            ..Style::default()
955        };
956        let t = built_in("minimal").expect("minimal");
957        assert_eq!(sgr_open(&s, t, Capability::TrueColor), "\x1b[1m");
958    }
959
960    // --- env-fallback capability probe ---
961
962    #[test]
963    fn from_env_vars_prefers_colorterm_truecolor() {
964        assert_eq!(
965            Capability::from_env_vars(Some("truecolor"), Some("xterm")),
966            Capability::TrueColor
967        );
968        assert_eq!(
969            Capability::from_env_vars(Some("24bit"), Some("xterm")),
970            Capability::TrueColor
971        );
972        assert_eq!(
973            Capability::from_env_vars(Some("TRUECOLOR"), None),
974            Capability::TrueColor
975        );
976    }
977
978    #[test]
979    fn from_env_vars_falls_back_to_term_256color() {
980        assert_eq!(
981            Capability::from_env_vars(None, Some("xterm-256color")),
982            Capability::Palette256
983        );
984        assert_eq!(
985            Capability::from_env_vars(None, Some("tmux-256color")),
986            Capability::Palette256
987        );
988    }
989
990    #[test]
991    fn from_env_vars_unknown_term_is_palette16() {
992        assert_eq!(
993            Capability::from_env_vars(None, Some("xterm")),
994            Capability::Palette16
995        );
996        assert_eq!(
997            Capability::from_env_vars(None, Some("vt100")),
998            Capability::Palette16
999        );
1000    }
1001
1002    #[test]
1003    fn from_env_vars_dumb_or_missing_term_is_none() {
1004        assert_eq!(
1005            Capability::from_env_vars(None, Some("dumb")),
1006            Capability::None
1007        );
1008        // Case-insensitive on TERM=dumb to mirror the COLORTERM check.
1009        assert_eq!(
1010            Capability::from_env_vars(None, Some("DUMB")),
1011            Capability::None
1012        );
1013        assert_eq!(
1014            Capability::from_env_vars(None, Some("  dumb  ")),
1015            Capability::None
1016        );
1017        assert_eq!(Capability::from_env_vars(None, Some("")), Capability::None);
1018        assert_eq!(Capability::from_env_vars(None, None), Capability::None);
1019    }
1020
1021    #[test]
1022    fn from_env_vars_colorterm_truecolor_overrides_term_dumb() {
1023        // COLORTERM=truecolor is the stronger signal and wins over TERM=dumb.
1024        assert_eq!(
1025            Capability::from_env_vars(Some("truecolor"), Some("dumb")),
1026            Capability::TrueColor
1027        );
1028    }
1029
1030    // --- force-color combinator ---
1031
1032    #[test]
1033    fn force_from_picks_max_of_both_inputs() {
1034        assert_eq!(
1035            Capability::force_from(Capability::Palette256, Capability::TrueColor),
1036            Capability::TrueColor
1037        );
1038        assert_eq!(
1039            Capability::force_from(Capability::TrueColor, Capability::Palette256),
1040            Capability::TrueColor
1041        );
1042        assert_eq!(
1043            Capability::force_from(Capability::Palette16, Capability::Palette256),
1044            Capability::Palette256
1045        );
1046    }
1047
1048    #[test]
1049    fn force_from_truecolor_from_either_side_wins() {
1050        assert_eq!(
1051            Capability::force_from(Capability::None, Capability::TrueColor),
1052            Capability::TrueColor
1053        );
1054        assert_eq!(
1055            Capability::force_from(Capability::TrueColor, Capability::None),
1056            Capability::TrueColor
1057        );
1058    }
1059
1060    #[test]
1061    fn force_from_floors_at_palette16_when_both_inputs_are_none() {
1062        // Force-color intent must emit something; the user opted in
1063        // explicitly via --force-color or `color = "always"`.
1064        assert_eq!(
1065            Capability::force_from(Capability::None, Capability::None),
1066            Capability::Palette16
1067        );
1068    }
1069
1070    #[test]
1071    fn force_from_floor_overrides_dumb_term_when_env_probe_returns_none() {
1072        // Even when TERM=dumb made the env probe return None, force-color
1073        // intent wins — the user knows what they're doing.
1074        let env = Capability::from_env_vars(None, Some("dumb"));
1075        assert_eq!(env, Capability::None);
1076        assert_eq!(
1077            Capability::force_from(Capability::None, env),
1078            Capability::Palette16
1079        );
1080    }
1081
1082    #[test]
1083    fn from_env_vars_colorterm_garbage_falls_through_to_term() {
1084        // COLORTERM=yes (some terminals set this as a boolean hint) does
1085        // not claim truecolor; fall through to the TERM probe.
1086        assert_eq!(
1087            Capability::from_env_vars(Some("yes"), Some("xterm-256color")),
1088            Capability::Palette256
1089        );
1090    }
1091}