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` from an optional name, searching in order:
426    ///
427    /// 1. User theme files (if `fs` feature enabled).
428    /// 2. Built-in themes.
429    /// 3. User config default (if `fs` feature enabled).
430    /// 4. Built-in dark/light default based on terminal mode.
431    ///
432    /// The name is case-insensitive and accepts any common case format
433    /// (`"Nord Dark"`, `"nord-dark"`, `"NordDark"` all work).
434    pub fn from_name(name: Option<&str>) -> Theme {
435        use crate::BuiltinTheme;
436        #[cfg(feature = "fs")]
437        {
438            use crate::util::load_theme_file;
439            use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
440            name.and_then(|n| load_theme_file(n).ok())
441                .as_deref()
442                .and_then(|s| Theme::from_base24_str(s).ok())
443                .or_else(|| {
444                    name.and_then(|n| {
445                        let slug = heck::AsKebabCase(n).to_string();
446                        slug.parse::<BuiltinTheme>().ok().map(|b| b.theme())
447                    })
448                })
449                .or_else(|| {
450                    crate::util::mode_aware_theme_name()
451                        .and_then(|n| n.parse::<BuiltinTheme>().ok().map(|b| b.theme()))
452                })
453                .unwrap_or_else(|| match theme_mode(QueryOptions::default()).ok() {
454                    Some(ThemeMode::Light) => BuiltinTheme::default_light().theme(),
455                    _ => BuiltinTheme::default().theme(),
456                })
457        }
458        #[cfg(not(feature = "fs"))]
459        {
460            name.and_then(|n| n.parse::<BuiltinTheme>().ok().map(|b| b.theme()))
461                .unwrap_or_else(|| BuiltinTheme::default().theme())
462        }
463    }
464
465    /// Returns the kebab-case slug for the theme name.
466    ///
467    /// e.g. `"Tokyo Night"` -> `"tokyo-night"`
468    pub fn name_slug(&self) -> String {
469        heck::AsKebabCase(&self.meta.name).to_string()
470    }
471
472    /// Returns the canonical file name for the theme.
473    ///
474    /// e.g. `"Tokyo Night"` -> `"tokyo-night.yaml"`
475    pub fn to_filename(&self) -> String {
476        let slug = self.name_slug();
477        if slug.ends_with(".yaml") {
478            slug
479        } else {
480            format!("{}.yaml", slug)
481        }
482    }
483
484    /// Returns the canonical install path for the theme in the user themes directory.
485    #[cfg(feature = "fs")]
486    pub fn to_pathbuf(&self) -> Result<PathBuf> {
487        let mut path = user_themes_path()?;
488        path.push(self.to_filename());
489        Ok(path)
490    }
491
492    /// Serialize this theme to a base24 YAML string.
493    ///
494    /// The output is a flat `key: "value"` YAML file compatible with the
495    /// [Tinted Theming base24](https://github.com/tinted-theming/base24/) format.
496    /// Hex values are written as 6-character lowercase strings without a leading `#`.
497    pub fn to_base24_str(&self) -> String {
498        let h = |s: &str| s.trim_start_matches('#').to_lowercase();
499        let variant = if self.meta.dark { "dark" } else { "light" };
500        let author = &self.meta.author;
501        let s = &self.base24;
502        format!(
503            "scheme: \"{name}\"\nauthor: \"{author}\"\nvariant: \"{variant}\"\n\
504             base00: \"{b00}\"\nbase01: \"{b01}\"\nbase02: \"{b02}\"\nbase03: \"{b03}\"\n\
505             base04: \"{b04}\"\nbase05: \"{b05}\"\nbase06: \"{b06}\"\nbase07: \"{b07}\"\n\
506             base08: \"{b08}\"\nbase09: \"{b09}\"\nbase0A: \"{b0a}\"\nbase0B: \"{b0b}\"\n\
507             base0C: \"{b0c}\"\nbase0D: \"{b0d}\"\nbase0E: \"{b0e}\"\nbase0F: \"{b0f}\"\n\
508             base10: \"{b10}\"\nbase11: \"{b11}\"\nbase12: \"{b12}\"\nbase13: \"{b13}\"\n\
509             base14: \"{b14}\"\nbase15: \"{b15}\"\nbase16: \"{b16}\"\nbase17: \"{b17}\"\n",
510            name = self.meta.name,
511            b00 = h(&s.base00),
512            b01 = h(&s.base01),
513            b02 = h(&s.base02),
514            b03 = h(&s.base03),
515            b04 = h(&s.base04),
516            b05 = h(&s.base05),
517            b06 = h(&s.base06),
518            b07 = h(&s.base07),
519            b08 = h(&s.base08),
520            b09 = h(&s.base09),
521            b0a = h(&s.base0a),
522            b0b = h(&s.base0b),
523            b0c = h(&s.base0c),
524            b0d = h(&s.base0d),
525            b0e = h(&s.base0e),
526            b0f = h(&s.base0f),
527            b10 = h(&s.base10),
528            b11 = h(&s.base11),
529            b12 = h(&s.base12),
530            b13 = h(&s.base13),
531            b14 = h(&s.base14),
532            b15 = h(&s.base15),
533            b16 = h(&s.base16),
534            b17 = h(&s.base17),
535        )
536    }
537}
538
539impl PartialEq for Theme {
540    fn eq(&self, other: &Self) -> bool {
541        self.name_slug() == other.name_slug()
542    }
543}
544impl Eq for Theme {}
545
546impl PartialOrd for Theme {
547    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
548        Some(self.cmp(other))
549    }
550}
551
552impl Ord for Theme {
553    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
554        self.name_slug().cmp(&other.name_slug())
555    }
556}
557
558/// Convert a hex color string to RGB components.
559///
560/// Accepts colors in format `#RRGGBB` or `RRGGBB`.
561///
562/// # Examples
563///
564/// ```
565/// use tca_types::hex_to_rgb;
566///
567/// let (r, g, b) = hex_to_rgb("#ff5533").unwrap();
568/// assert_eq!((r, g, b), (255, 85, 51));
569///
570/// ```
571///
572/// # Errors
573///
574/// Returns [`HexColorError::InvalidLength`] if the hex string is not exactly
575/// 6 characters (excluding a leading `#`), or [`HexColorError::InvalidHex`]
576/// if any character is not a valid hex digit.
577pub fn hex_to_rgb(hex: &str) -> Result<(u8, u8, u8), HexColorError> {
578    let hex = hex.trim_start_matches('#');
579
580    if hex.len() != 6 {
581        return Err(HexColorError::InvalidLength(hex.len()));
582    }
583
584    let r = u8::from_str_radix(&hex[0..2], 16)?;
585    let g = u8::from_str_radix(&hex[2..4], 16)?;
586    let b = u8::from_str_radix(&hex[4..6], 16)?;
587    Ok((r, g, b))
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    const MINIMAL_BASE24: &str = r#"
595scheme: "Test Theme"
596author: "Test Author"
597base00: "1a1a1a"
598base01: "222222"
599base02: "333333"
600base03: "666666"
601base04: "888888"
602base05: "fafafa"
603base06: "e0e0e0"
604base07: "ffffff"
605base08: "cc0000"
606base09: "ff8800"
607base0a: "ffff00"
608base0b: "00ff00"
609base0c: "00ffff"
610base0d: "0000ff"
611base0e: "ff00ff"
612base0f: "aa5500"
613base10: "1a1a1a"
614base11: "000000"
615base12: "ff5555"
616base13: "ffff55"
617base14: "55ff55"
618base15: "55ffff"
619base16: "5555ff"
620base17: "ff55ff"
621"#;
622
623    fn test_theme() -> Theme {
624        Theme::from_base24_str(MINIMAL_BASE24).unwrap()
625    }
626
627    #[test]
628    fn test_from_base24_str_name_and_author() {
629        let t = test_theme();
630        assert_eq!(t.meta.name, "Test Theme");
631        assert_eq!(t.meta.author, "Test Author");
632    }
633
634    #[test]
635    fn test_from_base24_str_dark_detection() {
636        let t = test_theme();
637        assert!(t.meta.dark, "dark bg should be detected as dark theme");
638    }
639
640    #[test]
641    fn test_mapping() {
642        let t = test_theme();
643        assert_eq!(t.ansi.black, "#1a1a1a");
644        assert_eq!(t.ansi.red, "#cc0000");
645        assert_eq!(t.ansi.white, "#fafafa");
646        assert_eq!(t.ansi.bright_black, "#666666"); // base03
647        assert_eq!(t.ansi.bright_red, "#ff5555"); // base12
648        assert_eq!(t.semantic.error, "#cc0000"); // base08
649        assert_eq!(t.semantic.warning, "#ff8800"); // base09
650        assert_eq!(t.semantic.info, "#00ffff"); // base0c
651        assert_eq!(t.semantic.success, "#00ff00"); // base0b
652        assert_eq!(t.semantic.link, "#0000ff"); // base0d
653        assert_eq!(t.ui.bg.primary, "#1a1a1a"); // base00
654        assert_eq!(t.ui.bg.secondary, "#222222"); // base01
655        assert_eq!(t.ui.fg.primary, "#fafafa"); // base05
656        assert_eq!(t.ui.fg.secondary, "#e0e0e0"); // base06
657        assert_eq!(t.ui.fg.muted, "#888888"); // base04
658        assert_eq!(t.ui.border.primary, "#333333"); // base02
659        assert_eq!(t.ui.border.muted, "#222222"); // base01
660        assert_eq!(t.ui.cursor.primary, "#fafafa"); // base05
661        assert_eq!(t.ui.cursor.muted, "#888888"); // base04
662        assert_eq!(t.ui.selection.bg, "#333333"); // base02
663        assert_eq!(t.ui.selection.fg, "#fafafa"); // base05
664    }
665
666    #[test]
667    fn test_base16_fallbacks() {
668        // A base16-only theme (no base10-17) should use fallbacks
669        let src = MINIMAL_BASE24
670            .lines()
671            .filter(|l| !l.starts_with("base1"))
672            .collect::<Vec<_>>()
673            .join("\n");
674        let t = Theme::from_base24_str(&src).unwrap();
675        // bright_red (base12) should fall back to red (base08)
676        assert_eq!(t.ansi.bright_red, t.ansi.red);
677    }
678
679    #[test]
680    fn test_name_slug_and_filename() {
681        let t = test_theme();
682        assert_eq!(t.name_slug(), "test-theme");
683        assert_eq!(t.to_filename(), "test-theme.yaml");
684    }
685
686    #[test]
687    fn test_to_base24_str_round_trip() {
688        let original = test_theme();
689        let yaml = original.to_base24_str();
690        let reloaded = Theme::from_base24_str(&yaml).unwrap();
691        assert_eq!(reloaded.meta.name, original.meta.name);
692        assert_eq!(reloaded.meta.dark, original.meta.dark);
693        assert_eq!(reloaded.base24.base08, original.base24.base08);
694        assert_eq!(reloaded.ansi.red, original.ansi.red);
695        assert_eq!(reloaded.semantic.error, original.semantic.error);
696    }
697
698    #[test]
699    fn test_to_base24_str_format() {
700        let t = test_theme();
701        let yaml = t.to_base24_str();
702        // Should be valid flat key:value YAML parseable by our parser
703        assert!(yaml.contains("scheme: \"Test Theme\""));
704        assert!(yaml.contains("base00:"));
705        assert!(yaml.contains("base17:"));
706        // Hex values should be without '#'
707        assert!(!yaml.contains(": \"#"));
708    }
709
710    #[test]
711    fn test_hex_to_rgb_valid() {
712        // with and without '#' prefix
713        assert_eq!(hex_to_rgb("#ff5533").unwrap(), (255, 85, 51));
714        assert_eq!(hex_to_rgb("ff5533").unwrap(), (255, 85, 51));
715        // boundary values
716        assert_eq!(hex_to_rgb("#000000").unwrap(), (0, 0, 0));
717        assert_eq!(hex_to_rgb("#ffffff").unwrap(), (255, 255, 255));
718    }
719
720    #[test]
721    fn test_hex_to_rgb_invalid() {
722        // wrong length
723        assert!(hex_to_rgb("#fff").is_err());
724        assert!(hex_to_rgb("abc").is_err());
725        assert!(hex_to_rgb("#ff5533aa").is_err());
726        // bad chars
727        assert!(hex_to_rgb("#gggggg").is_err());
728        assert!(hex_to_rgb("#xyz123").is_err());
729        // empty
730        assert!(hex_to_rgb("").is_err());
731        assert!(hex_to_rgb("#").is_err());
732    }
733}