Skip to main content

tca_types/
theme.rs

1//! Core types for Terminal Colors Architecture (TCA)
2//!
3//! This crate provides the foundational type definitions used across
4//! the TCA ecosystem for theme representation and manipulation.
5
6#![warn(missing_docs)]
7#[cfg(feature = "fs")]
8use anyhow::Result;
9use serde::Serialize;
10#[cfg(feature = "fs")]
11use std::path::PathBuf;
12
13use crate::base24::{is_dark, normalize_hex, parse_base24};
14#[cfg(feature = "fs")]
15use crate::user_themes_path;
16
17/// Errors that can occur when parsing a hex color string.
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum HexColorError {
20    /// The hex string (excluding a leading `#`) was not exactly 6 characters.
21    #[error("hex color must be 6 characters (got {0})")]
22    InvalidLength(usize),
23    /// A hex digit could not be parsed.
24    #[error("invalid hex digit: {0}")]
25    InvalidHex(std::num::ParseIntError),
26}
27
28impl From<std::num::ParseIntError> for HexColorError {
29    fn from(e: std::num::ParseIntError) -> Self {
30        HexColorError::InvalidHex(e)
31    }
32}
33
34/// The raw base24 color slots (base00–base17) as `#rrggbb` hex strings.
35///
36/// These are the canonical values from which all TCA semantic fields are derived.
37/// base16-only themes leave base10–base17 set to their spec-defined fallbacks.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Base24Slots {
40    /// Darkest background / ANSI black background.
41    pub base00: String,
42    /// Secondary background.
43    pub base01: String,
44    /// Selection / border background.
45    pub base02: String,
46    /// Comments / bright black (ANSI 8).
47    pub base03: String,
48    /// Muted / dark foreground.
49    pub base04: String,
50    /// Primary foreground / white (ANSI 7).
51    pub base05: String,
52    /// Secondary foreground.
53    pub base06: String,
54    /// Bright white (ANSI 15).
55    pub base07: String,
56    /// Red / error (ANSI 1).
57    pub base08: String,
58    /// Orange / warning.
59    pub base09: String,
60    /// Yellow (ANSI 3).
61    pub base0a: String,
62    /// Green / success (ANSI 2).
63    pub base0b: String,
64    /// Cyan / info (ANSI 6).
65    pub base0c: String,
66    /// Blue / link (ANSI 4).
67    pub base0d: String,
68    /// Magenta / highlight (ANSI 5).
69    pub base0e: String,
70    /// Brown / deprecated.
71    pub base0f: String,
72    /// True black (ANSI 0 terminal color). Fallback: base00.
73    pub base10: String,
74    /// Reserved. Fallback: base00.
75    pub base11: String,
76    /// Bright red (ANSI 9). Fallback: base08.
77    pub base12: String,
78    /// Bright yellow (ANSI 11). Fallback: base0a.
79    pub base13: String,
80    /// Bright green (ANSI 10). Fallback: base0b.
81    pub base14: String,
82    /// Bright cyan (ANSI 14). Fallback: base0c.
83    pub base15: String,
84    /// Bright blue (ANSI 12). Fallback: base0d.
85    pub base16: String,
86    /// Bright magenta (ANSI 13). Fallback: base0e.
87    pub base17: String,
88}
89
90/// A complete TCA theme definition.
91///
92/// All color fields contain resolved `#rrggbb` hex strings.
93#[derive(Debug, Clone, Serialize)]
94pub struct Theme {
95    /// Theme metadata.
96    pub meta: Meta,
97    /// ANSI 16-color definitions derived from base24 slots.
98    pub ansi: Ansi,
99    /// Semantic color roles derived from base24 slots.
100    pub semantic: Semantic,
101    /// UI element colors derived from base24 slots.
102    pub ui: Ui,
103    /// Raw base24 color slots for direct interoperability.
104    pub base24: Base24Slots,
105}
106
107/// Theme metadata.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct Meta {
110    /// Human-readable theme name.
111    pub name: String,
112    /// Theme author name or contact. Empty string if not specified.
113    pub author: String,
114    /// `true` for dark themes, `false` for light themes.
115    ///
116    /// Derived from the luminance of `bg.primary` (base00 vs base07).
117    pub dark: bool,
118}
119
120/// ANSI 16-color definitions, derived from base24 slots per the TCA spec.
121///
122/// All values are resolved `#rrggbb` hex strings.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct Ansi {
125    /// ANSI color 0 — black.
126    pub black: String,
127    /// ANSI color 1 — red.
128    pub red: String,
129    /// ANSI color 2 — green.
130    pub green: String,
131    /// ANSI color 3 — yellow.
132    pub yellow: String,
133    /// ANSI color 4 — blue.
134    pub blue: String,
135    /// ANSI color 5 — magenta.
136    pub magenta: String,
137    /// ANSI color 6 — cyan.
138    pub cyan: String,
139    /// ANSI color 7 — white.
140    pub white: String,
141    /// ANSI color 8 — bright black (dark gray).
142    pub bright_black: String,
143    /// ANSI color 9 — bright red.
144    pub bright_red: String,
145    /// ANSI color 10 — bright green.
146    pub bright_green: String,
147    /// ANSI color 11 — bright yellow.
148    pub bright_yellow: String,
149    /// ANSI color 12 — bright blue.
150    pub bright_blue: String,
151    /// ANSI color 13 — bright magenta.
152    pub bright_magenta: String,
153    /// ANSI color 14 — bright cyan.
154    pub bright_cyan: String,
155    /// ANSI color 15 — bright white.
156    pub bright_white: String,
157}
158
159/// Semantic color roles (TOML section `[semantic]`).
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161pub struct Semantic {
162    /// Color for error states.
163    pub error: String,
164    /// Color for warning states.
165    pub warning: String,
166    /// Color for informational states.
167    pub info: String,
168    /// Color for success states.
169    pub success: String,
170    /// Color for highlighted text.
171    pub highlight: String,
172    /// Color for hyperlinks.
173    pub link: String,
174}
175
176/// Background colors (nested under `[ui.bg]`).
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct UiBg {
179    /// Primary application background.
180    pub primary: String,
181    /// Secondary / sidebar background.
182    pub secondary: String,
183}
184
185/// Foreground colors (nested under `[ui.fg]`).
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct UiFg {
188    /// Primary text color.
189    pub primary: String,
190    /// Secondary text color.
191    pub secondary: String,
192    /// De-emphasized / placeholder text color.
193    pub muted: String,
194}
195
196/// Border colors (nested under `[ui.border]`).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct UiBorder {
199    /// Active / focused border color.
200    pub primary: String,
201    /// Inactive / de-emphasized border color.
202    pub muted: String,
203}
204
205/// Cursor colors (nested under `[ui.cursor]`).
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct UiCursor {
208    /// Active cursor color.
209    pub primary: String,
210    /// Inactive cursor color.
211    pub muted: String,
212}
213
214/// Selection colors (nested under `[ui.selection]`).
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct UiSelection {
217    /// Selection background.
218    pub bg: String,
219    /// Selection foreground.
220    pub fg: String,
221}
222
223/// UI element colors (TOML section `[ui]` with nested sub-tables).
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct Ui {
226    /// Background colors (`[ui.bg]`).
227    pub bg: UiBg,
228    /// Foreground / text colors (`[ui.fg]`).
229    pub fg: UiFg,
230    /// Border colors (`[ui.border]`).
231    pub border: UiBorder,
232    /// Cursor colors (`[ui.cursor]`).
233    pub cursor: UiCursor,
234    /// Selection colors (`[ui.selection]`).
235    pub selection: UiSelection,
236}
237
238impl Theme {
239    /// Construct a `Theme` by deriving all fields from a set of base24 slots.
240    pub fn from_base24_slots(slots: Base24Slots) -> Self {
241        let dark = is_dark(&slots.base00, &slots.base07);
242        let name = String::new(); // caller must set via from_raw_slots
243        Theme {
244            meta: Meta {
245                name,
246                author: String::new(),
247                dark,
248            },
249            ansi: Ansi {
250                black: slots.base00.clone(),
251                red: slots.base08.clone(),
252                green: slots.base0b.clone(),
253                yellow: slots.base0a.clone(),
254                blue: slots.base0d.clone(),
255                magenta: slots.base0e.clone(),
256                cyan: slots.base0c.clone(),
257                white: slots.base05.clone(),
258                bright_black: slots.base03.clone(),
259                bright_red: slots.base12.clone(),
260                bright_green: slots.base14.clone(),
261                bright_yellow: slots.base13.clone(),
262                bright_blue: slots.base16.clone(),
263                bright_magenta: slots.base17.clone(),
264                bright_cyan: slots.base15.clone(),
265                bright_white: slots.base07.clone(),
266            },
267            semantic: Semantic {
268                error: slots.base08.clone(),
269                warning: slots.base09.clone(),
270                info: slots.base0c.clone(),
271                success: slots.base0b.clone(),
272                highlight: slots.base0e.clone(),
273                link: slots.base0d.clone(),
274            },
275            ui: Ui {
276                fg: UiFg {
277                    primary: slots.base05.clone(),
278                    secondary: slots.base06.clone(),
279                    muted: slots.base04.clone(),
280                },
281                bg: UiBg {
282                    primary: slots.base00.clone(),
283                    secondary: slots.base01.clone(),
284                },
285                border: UiBorder {
286                    primary: slots.base02.clone(),
287                    muted: slots.base01.clone(),
288                },
289                cursor: UiCursor {
290                    primary: slots.base05.clone(),
291                    muted: slots.base04.clone(),
292                },
293                selection: UiSelection {
294                    bg: slots.base02.clone(),
295                    fg: slots.base05.clone(),
296                },
297            },
298            base24: slots,
299        }
300    }
301
302    /// Parse a base24 YAML string and construct a `Theme`.
303    ///
304    /// Accepts the flat `key: "value"` YAML subset used by Base16/24 scheme files.
305    /// Theme name is read from the `scheme` key (with `name` as a fallback).
306    pub fn from_base24_str(src: &str) -> anyhow::Result<Self> {
307        let slots = parse_base24(src.as_bytes())?;
308        Self::from_raw_slots(&slots)
309    }
310
311    /// Build a `Theme` from a parsed [`RawSlots`] map (e.g. from [`parse_base24`]).
312    pub fn from_raw_slots(
313        slots: &std::collections::HashMap<String, String>,
314    ) -> anyhow::Result<Self> {
315        use anyhow::anyhow;
316
317        let b = |key: &str| -> anyhow::Result<String> {
318            let raw = slots
319                .get(key)
320                .ok_or_else(|| anyhow!("missing required slot '{key}'"))?;
321            normalize_hex(raw).map_err(|e| anyhow!("slot '{key}': {e}"))
322        };
323
324        let is_base24 = slots.contains_key("base10");
325
326        let base00 = b("base00")?;
327        let base01 = b("base01")?;
328        let base02 = b("base02")?;
329        let base03 = b("base03")?;
330        let base04 = b("base04")?;
331        let base05 = b("base05")?;
332        let base06 = b("base06")?;
333        let base07 = b("base07")?;
334        let base08 = b("base08")?;
335        let base09 = b("base09")?;
336        let base0a = b("base0a")?;
337        let base0b = b("base0b")?;
338        let base0c = b("base0c")?;
339        let base0d = b("base0d")?;
340        let base0e = b("base0e")?;
341        let base0f = b("base0f")?;
342
343        // Base24 extended slots — fall back to spec-defined base16 equivalents.
344        let base10 = if is_base24 {
345            b("base10")?
346        } else {
347            base00.clone()
348        };
349        let base11 = if is_base24 {
350            b("base11")?
351        } else {
352            base00.clone()
353        };
354        let base12 = if is_base24 {
355            b("base12")?
356        } else {
357            base08.clone()
358        };
359        let base13 = if is_base24 {
360            b("base13")?
361        } else {
362            base0a.clone()
363        };
364        let base14 = if is_base24 {
365            b("base14")?
366        } else {
367            base0b.clone()
368        };
369        let base15 = if is_base24 {
370            b("base15")?
371        } else {
372            base0c.clone()
373        };
374        let base16 = if is_base24 {
375            b("base16")?
376        } else {
377            base0d.clone()
378        };
379        let base17 = if is_base24 {
380            b("base17")?
381        } else {
382            base0e.clone()
383        };
384
385        let name = slots
386            .get("scheme")
387            .or_else(|| slots.get("name"))
388            .cloned()
389            .unwrap_or_else(|| "Imported Theme".to_string());
390        let author = slots.get("author").cloned().unwrap_or_default();
391
392        let raw = Base24Slots {
393            base00,
394            base01,
395            base02,
396            base03,
397            base04,
398            base05,
399            base06,
400            base07,
401            base08,
402            base09,
403            base0a,
404            base0b,
405            base0c,
406            base0d,
407            base0e,
408            base0f,
409            base10,
410            base11,
411            base12,
412            base13,
413            base14,
414            base15,
415            base16,
416            base17,
417        };
418
419        let mut theme = Self::from_base24_slots(raw);
420        theme.meta.name = name;
421        theme.meta.author = author;
422        Ok(theme)
423    }
424
425    /// Creates a `Theme` by name, searching in order:
426    ///
427    /// 1. User theme files (if `fs` feature enabled).
428    /// 2. Built-in themes.
429    /// 3. Built-in default (if not found).
430    ///
431    /// The name is case-insensitive and accepts any common case format
432    /// (`"Nord Dark"`, `"nord-dark"`, `"NordDark"` all work).
433    pub fn from_name(name: &str) -> Theme {
434        use crate::BuiltinTheme;
435        #[cfg(feature = "fs")]
436        {
437            use crate::util::load_theme_file;
438            load_theme_file(name)
439                .ok()
440                .as_deref()
441                .and_then(|s| Theme::from_base24_str(s).ok())
442                .or_else(|| {
443                    let slug = heck::AsKebabCase(name).to_string();
444                    slug.parse::<BuiltinTheme>().ok().map(|b| b.theme())
445                })
446                .unwrap_or_else(|| BuiltinTheme::default().theme())
447        }
448        #[cfg(not(feature = "fs"))]
449        {
450            let slug = heck::AsKebabCase(name).to_string();
451            slug.parse::<BuiltinTheme>()
452                .map(|b| b.theme())
453                .unwrap_or_else(|_| BuiltinTheme::default().theme())
454        }
455    }
456
457    /// Creates a `Theme` from the user's configured default.
458    ///
459    /// Reads `$XDG_CONFIG_HOME/tca/tca.toml` if the `fs` feature is enabled.
460    /// For a guaranteed no-I/O default, use `BuiltinTheme::default().theme()`.
461    ///
462    /// Fallback order:
463    /// 1. User config `default_theme` (if `fs` feature enabled).
464    /// 2. Built-in default.
465    pub fn from_default_cfg() -> Theme {
466        use crate::BuiltinTheme;
467        #[cfg(feature = "fs")]
468        {
469            use crate::TcaConfig;
470            match TcaConfig::load().tca.default_theme {
471                Some(name) => Theme::from_name(&name),
472                None => BuiltinTheme::default().theme(),
473            }
474        }
475        #[cfg(not(feature = "fs"))]
476        {
477            BuiltinTheme::default().theme()
478        }
479    }
480
481    /// Creates a dark `Theme` from the user's configured dark default.
482    ///
483    /// Fallback order:
484    /// 1. User config `default_dark_theme` (if `fs` feature enabled).
485    /// 2. Built-in default dark theme.
486    pub fn from_default_dark_cfg() -> Theme {
487        use crate::BuiltinTheme;
488        #[cfg(feature = "fs")]
489        {
490            use crate::TcaConfig;
491            match TcaConfig::load().tca.default_dark_theme {
492                Some(name) => Theme::from_name(&name),
493                None => BuiltinTheme::default().theme(),
494            }
495        }
496        #[cfg(not(feature = "fs"))]
497        {
498            BuiltinTheme::default().theme()
499        }
500    }
501
502    /// Creates a light `Theme` from the user's configured light default.
503    ///
504    /// Fallback order:
505    /// 1. User config `default_light_theme` (if `fs` feature enabled and theme is light).
506    /// 2. Built-in default light theme.
507    pub fn from_default_light_cfg() -> Theme {
508        use crate::BuiltinTheme;
509        #[cfg(feature = "fs")]
510        {
511            use crate::TcaConfig;
512            if let Some(name) = TcaConfig::load().tca.default_light_theme {
513                let theme = Theme::from_name(&name);
514                if !theme.meta.dark {
515                    return theme;
516                }
517            }
518            BuiltinTheme::default_light().theme()
519        }
520        #[cfg(not(feature = "fs"))]
521        {
522            BuiltinTheme::default_light().theme()
523        }
524    }
525
526    /// Returns the kebab-case slug for the theme name.
527    ///
528    /// e.g. `"Tokyo Night"` -> `"tokyo-night"`
529    pub fn name_slug(&self) -> String {
530        heck::AsKebabCase(&self.meta.name).to_string()
531    }
532
533    /// Returns the canonical file name for the theme.
534    ///
535    /// e.g. `"Tokyo Night"` -> `"tokyo-night.yaml"`
536    pub fn to_filename(&self) -> String {
537        let slug = self.name_slug();
538        if slug.ends_with(".yaml") {
539            slug
540        } else {
541            format!("{}.yaml", slug)
542        }
543    }
544
545    /// Returns the canonical install path for the theme in the user themes directory.
546    #[cfg(feature = "fs")]
547    pub fn to_pathbuf(&self) -> Result<PathBuf> {
548        let mut path = user_themes_path()?;
549        path.push(self.to_filename());
550        Ok(path)
551    }
552
553    /// Serialize this theme to a base24 YAML string.
554    ///
555    /// The output is a flat `key: "value"` YAML file compatible with the
556    /// [Tinted Theming base24](https://github.com/tinted-theming/base24/) format.
557    /// Hex values are written as 6-character lowercase strings without a leading `#`.
558    pub fn to_base24_str(&self) -> String {
559        let h = |s: &str| s.trim_start_matches('#').to_lowercase();
560        let variant = if self.meta.dark { "dark" } else { "light" };
561        let author = &self.meta.author;
562        let s = &self.base24;
563        format!(
564            "scheme: \"{name}\"\nauthor: \"{author}\"\nvariant: \"{variant}\"\n\
565             base00: \"{b00}\"\nbase01: \"{b01}\"\nbase02: \"{b02}\"\nbase03: \"{b03}\"\n\
566             base04: \"{b04}\"\nbase05: \"{b05}\"\nbase06: \"{b06}\"\nbase07: \"{b07}\"\n\
567             base08: \"{b08}\"\nbase09: \"{b09}\"\nbase0A: \"{b0a}\"\nbase0B: \"{b0b}\"\n\
568             base0C: \"{b0c}\"\nbase0D: \"{b0d}\"\nbase0E: \"{b0e}\"\nbase0F: \"{b0f}\"\n\
569             base10: \"{b10}\"\nbase11: \"{b11}\"\nbase12: \"{b12}\"\nbase13: \"{b13}\"\n\
570             base14: \"{b14}\"\nbase15: \"{b15}\"\nbase16: \"{b16}\"\nbase17: \"{b17}\"\n",
571            name = self.meta.name,
572            b00 = h(&s.base00),
573            b01 = h(&s.base01),
574            b02 = h(&s.base02),
575            b03 = h(&s.base03),
576            b04 = h(&s.base04),
577            b05 = h(&s.base05),
578            b06 = h(&s.base06),
579            b07 = h(&s.base07),
580            b08 = h(&s.base08),
581            b09 = h(&s.base09),
582            b0a = h(&s.base0a),
583            b0b = h(&s.base0b),
584            b0c = h(&s.base0c),
585            b0d = h(&s.base0d),
586            b0e = h(&s.base0e),
587            b0f = h(&s.base0f),
588            b10 = h(&s.base10),
589            b11 = h(&s.base11),
590            b12 = h(&s.base12),
591            b13 = h(&s.base13),
592            b14 = h(&s.base14),
593            b15 = h(&s.base15),
594            b16 = h(&s.base16),
595            b17 = h(&s.base17),
596        )
597    }
598}
599
600impl Default for Theme {
601    /// Returns the user's configured default theme, falling back to the built-in default.
602    ///
603    /// Reads `$XDG_CONFIG_HOME/tca/tca.toml` if the `fs` feature is enabled.
604    /// For a guaranteed no-I/O default, use `BuiltinTheme::default().theme()`.
605    fn default() -> Self {
606        Theme::from_default_cfg()
607    }
608}
609
610/// Two themes are equal if they have the same name slug, regardless of color values.
611/// This means `"Nord Dark"`, `"nord-dark"`, and `"nordDark"` are all considered equal.
612impl PartialEq for Theme {
613    fn eq(&self, other: &Self) -> bool {
614        self.name_slug() == other.name_slug()
615    }
616}
617impl Eq for Theme {}
618
619impl PartialOrd for Theme {
620    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
621        Some(self.cmp(other))
622    }
623}
624
625impl Ord for Theme {
626    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
627        self.name_slug().cmp(&other.name_slug())
628    }
629}
630
631/// Convert a hex color string to RGB components.
632///
633/// Accepts colors in format `#RRGGBB` or `RRGGBB`.
634///
635/// # Examples
636///
637/// ```
638/// use tca_types::hex_to_rgb;
639///
640/// let (r, g, b) = hex_to_rgb("#ff5533").unwrap();
641/// assert_eq!((r, g, b), (255, 85, 51));
642///
643/// ```
644///
645/// # Errors
646///
647/// Returns [`HexColorError::InvalidLength`] if the hex string is not exactly
648/// 6 characters (excluding a leading `#`), or [`HexColorError::InvalidHex`]
649/// if any character is not a valid hex digit.
650pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), HexColorError> {
651    let hex = hex.trim_start_matches('#');
652
653    if hex.len() != 6 {
654        return Err(HexColorError::InvalidLength(hex.len()));
655    }
656
657    let r = u8::from_str_radix(&hex[0..2], 16)?;
658    let g = u8::from_str_radix(&hex[2..4], 16)?;
659    let b = u8::from_str_radix(&hex[4..6], 16)?;
660    Ok((r, g, b))
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    const MINIMAL_BASE24: &str = r#"
668scheme: "Test Theme"
669author: "Test Author"
670base00: "1a1a1a"
671base01: "222222"
672base02: "333333"
673base03: "666666"
674base04: "888888"
675base05: "fafafa"
676base06: "e0e0e0"
677base07: "ffffff"
678base08: "cc0000"
679base09: "ff8800"
680base0a: "ffff00"
681base0b: "00ff00"
682base0c: "00ffff"
683base0d: "0000ff"
684base0e: "ff00ff"
685base0f: "aa5500"
686base10: "1a1a1a"
687base11: "000000"
688base12: "ff5555"
689base13: "ffff55"
690base14: "55ff55"
691base15: "55ffff"
692base16: "5555ff"
693base17: "ff55ff"
694"#;
695
696    fn test_theme() -> Theme {
697        Theme::from_base24_str(MINIMAL_BASE24).unwrap()
698    }
699
700    #[test]
701    fn test_from_base24_str_name_and_author() {
702        let t = test_theme();
703        assert_eq!(t.meta.name, "Test Theme");
704        assert_eq!(t.meta.author, "Test Author");
705    }
706
707    #[test]
708    fn test_from_base24_str_dark_detection() {
709        let t = test_theme();
710        assert!(t.meta.dark, "dark bg should be detected as dark theme");
711    }
712
713    #[test]
714    fn test_mapping() {
715        let t = test_theme();
716        assert_eq!(t.ansi.black, "#1a1a1a");
717        assert_eq!(t.ansi.red, "#cc0000");
718        assert_eq!(t.ansi.white, "#fafafa");
719        assert_eq!(t.ansi.bright_black, "#666666"); // base03
720        assert_eq!(t.ansi.bright_red, "#ff5555"); // base12
721        assert_eq!(t.semantic.error, "#cc0000"); // base08
722        assert_eq!(t.semantic.warning, "#ff8800"); // base09
723        assert_eq!(t.semantic.info, "#00ffff"); // base0c
724        assert_eq!(t.semantic.success, "#00ff00"); // base0b
725        assert_eq!(t.semantic.link, "#0000ff"); // base0d
726        assert_eq!(t.ui.bg.primary, "#1a1a1a"); // base00
727        assert_eq!(t.ui.bg.secondary, "#222222"); // base01
728        assert_eq!(t.ui.fg.primary, "#fafafa"); // base05
729        assert_eq!(t.ui.fg.secondary, "#e0e0e0"); // base06
730        assert_eq!(t.ui.fg.muted, "#888888"); // base04
731        assert_eq!(t.ui.border.primary, "#333333"); // base02
732        assert_eq!(t.ui.border.muted, "#222222"); // base01
733        assert_eq!(t.ui.cursor.primary, "#fafafa"); // base05
734        assert_eq!(t.ui.cursor.muted, "#888888"); // base04
735        assert_eq!(t.ui.selection.bg, "#333333"); // base02
736        assert_eq!(t.ui.selection.fg, "#fafafa"); // base05
737    }
738
739    #[test]
740    fn test_base16_fallbacks() {
741        // A base16-only theme (no base10-17) should use fallbacks
742        let src = MINIMAL_BASE24
743            .lines()
744            .filter(|l| !l.starts_with("base1"))
745            .collect::<Vec<_>>()
746            .join("\n");
747        let t = Theme::from_base24_str(&src).unwrap();
748        // bright_red (base12) should fall back to red (base08)
749        assert_eq!(t.ansi.bright_red, t.ansi.red);
750    }
751
752    #[test]
753    fn test_name_slug_and_filename() {
754        let t = test_theme();
755        assert_eq!(t.name_slug(), "test-theme");
756        assert_eq!(t.to_filename(), "test-theme.yaml");
757    }
758
759    #[test]
760    fn test_to_base24_str_round_trip() {
761        let original = test_theme();
762        let yaml = original.to_base24_str();
763        let reloaded = Theme::from_base24_str(&yaml).unwrap();
764        assert_eq!(reloaded.meta.name, original.meta.name);
765        assert_eq!(reloaded.meta.dark, original.meta.dark);
766        assert_eq!(reloaded.base24.base08, original.base24.base08);
767        assert_eq!(reloaded.ansi.red, original.ansi.red);
768        assert_eq!(reloaded.semantic.error, original.semantic.error);
769    }
770
771    #[test]
772    fn test_to_base24_str_format() {
773        let t = test_theme();
774        let yaml = t.to_base24_str();
775        // Should be valid flat key:value YAML parseable by our parser
776        assert!(yaml.contains("scheme: \"Test Theme\""));
777        assert!(yaml.contains("base00:"));
778        assert!(yaml.contains("base17:"));
779        // Hex values should be without '#'
780        assert!(!yaml.contains(": \"#"));
781    }
782
783    #[test]
784    fn test_hex_to_rgb_valid() {
785        // with and without '#' prefix
786        assert_eq!(hex_to_rgb("#ff5533").unwrap(), (255, 85, 51));
787        assert_eq!(hex_to_rgb("ff5533").unwrap(), (255, 85, 51));
788        // boundary values
789        assert_eq!(hex_to_rgb("#000000").unwrap(), (0, 0, 0));
790        assert_eq!(hex_to_rgb("#ffffff").unwrap(), (255, 255, 255));
791    }
792
793    #[test]
794    fn test_hex_to_rgb_invalid() {
795        // wrong length
796        assert!(hex_to_rgb("#fff").is_err());
797        assert!(hex_to_rgb("abc").is_err());
798        assert!(hex_to_rgb("#ff5533aa").is_err());
799        // bad chars
800        assert!(hex_to_rgb("#gggggg").is_err());
801        assert!(hex_to_rgb("#xyz123").is_err());
802        // empty
803        assert!(hex_to_rgb("").is_err());
804        assert!(hex_to_rgb("#").is_err());
805    }
806}