Skip to main content

linesmith_core/theme/
user.rs

1//! User-authored theme TOML loading. Parses files from
2//! `~/.config/linesmith/themes/*.toml` per `docs/specs/theming.md`
3//! §Theme file format and merges them with the compiled-in themes
4//! into a [`ThemeRegistry`]. The registry is the single source of
5//! truth the driver consults for `theme = "..."` resolution.
6
7use super::{AnsiColor, Color, Role, Theme};
8use serde::Deserialize;
9use std::path::{Path, PathBuf};
10
11/// One theme in the registry, paired with its source for
12/// `themes list` output.
13#[derive(Debug, Clone)]
14pub struct RegisteredTheme {
15    pub theme: Theme,
16    pub source: ThemeSource,
17}
18
19/// Where a registered theme came from. User files carry their path
20/// so diagnostics can point the user at the right TOML to edit.
21#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum ThemeSource {
24    BuiltIn,
25    UserFile(PathBuf),
26}
27
28/// Built-in + user themes combined. User themes override built-ins
29/// of the same name (with a warning). When two user files declare
30/// the same `name`, the first one loaded wins and the later one is
31/// warned + skipped — matches `docs/specs/theming.md` §Edge cases.
32/// Files are processed in filename-sorted order, so "first" means
33/// alphabetically first.
34#[derive(Debug, Clone, Default)]
35pub struct ThemeRegistry {
36    themes: Vec<RegisteredTheme>,
37}
38
39impl ThemeRegistry {
40    /// Start with the compiled-in themes; no file I/O.
41    #[must_use]
42    pub fn with_built_ins() -> Self {
43        Self {
44            themes: super::BUILTIN_THEMES
45                .iter()
46                .map(|t| RegisteredTheme {
47                    theme: t.clone(),
48                    source: ThemeSource::BuiltIn,
49                })
50                .collect(),
51        }
52    }
53
54    /// Load every `*.toml` in `dir` and merge into the registry. One
55    /// bad file never aborts the walk; each parse error emits a
56    /// diagnostic via `warn` and the file is skipped. Files are
57    /// processed in filename-sorted order. A user theme whose name
58    /// collides with a built-in replaces the built-in (with a
59    /// warning); a user theme whose name collides with an already-
60    /// loaded user theme is warned + skipped — per the spec's
61    /// "first-user-theme-found wins" rule.
62    pub fn with_user_themes(mut self, dir: &Path, mut warn: impl FnMut(&str)) -> Self {
63        let entries = match std::fs::read_dir(dir) {
64            Ok(e) => e,
65            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return self,
66            Err(e) => {
67                // Breadcrumb the likely downstream symptom so a later
68                // "unknown theme 'X'" warning isn't puzzling in
69                // isolation.
70                warn(&format!(
71                    "user themes dir {}: {} (themes referenced by name won't resolve)",
72                    dir.display(),
73                    e
74                ));
75                return self;
76            }
77        };
78        let mut files: Vec<PathBuf> = entries
79            .filter_map(|entry| match entry {
80                Ok(e) => Some(e.path()),
81                Err(e) => {
82                    warn(&format!("skipping entry in {}: {e}", dir.display()));
83                    None
84                }
85            })
86            .filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
87            .collect();
88        files.sort(); // deterministic order so collision warnings reproduce
89        for path in files {
90            match load_theme_file(&path, &mut warn) {
91                Ok(theme) => self.insert(theme, ThemeSource::UserFile(path), &mut warn),
92                Err(err) => warn(&format!("theme {}: {err}", path.display())),
93            }
94        }
95        self
96    }
97
98    fn insert(&mut self, theme: Theme, source: ThemeSource, warn: &mut impl FnMut(&str)) {
99        let Some(existing) = self
100            .themes
101            .iter_mut()
102            .find(|r| r.theme.name() == theme.name())
103        else {
104            self.themes.push(RegisteredTheme { theme, source });
105            return;
106        };
107        match &existing.source {
108            ThemeSource::BuiltIn => {
109                // User theme wins over built-in.
110                warn(&format!(
111                    "theme '{}' from {} overrides built-in",
112                    theme.name(),
113                    describe(&source),
114                ));
115                existing.theme = theme;
116                existing.source = source;
117            }
118            ThemeSource::UserFile(_) => {
119                // Per docs/specs/theming.md §Edge cases: first user
120                // theme wins, later duplicates warn + skip.
121                warn(&format!(
122                    "theme '{}' from {} skipped; already loaded from {}",
123                    theme.name(),
124                    describe(&source),
125                    describe(&existing.source),
126                ));
127            }
128        }
129    }
130
131    /// Look up a theme by its registered `name`. Returns `None` for
132    /// unknown names so `resolve_theme` can warn + fall back.
133    #[must_use]
134    pub fn lookup(&self, name: &str) -> Option<&Theme> {
135        self.themes
136            .iter()
137            .find(|r| r.theme.name() == name)
138            .map(|r| &r.theme)
139    }
140
141    /// Enumerate registered themes in registration order: built-ins
142    /// first (in compile-time order), then user themes sorted by
143    /// filename.
144    pub fn iter(&self) -> impl Iterator<Item = &RegisteredTheme> {
145        self.themes.iter()
146    }
147}
148
149fn describe(source: &ThemeSource) -> String {
150    match source {
151        ThemeSource::BuiltIn => "built-in".to_string(),
152        ThemeSource::UserFile(p) => p.display().to_string(),
153    }
154}
155
156// --- TOML parsing ---
157
158/// Failure modes when converting a theme TOML file into a `Theme`.
159/// Carries enough detail that the user can find the typo without
160/// rerunning with a debugger.
161#[derive(Debug)]
162#[non_exhaustive]
163pub enum ThemeParseError {
164    Io(std::io::Error),
165    Toml(toml::de::Error),
166    InvalidColor { role: &'static str, value: String },
167}
168
169impl std::fmt::Display for ThemeParseError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::Io(e) => write!(f, "{e}"),
173            Self::Toml(e) => write!(f, "{e}"),
174            Self::InvalidColor { role, value } => {
175                write!(f, "invalid color for '{role}': {value:?}")
176            }
177        }
178    }
179}
180
181impl std::error::Error for ThemeParseError {}
182
183fn load_theme_file(path: &Path, warn: &mut impl FnMut(&str)) -> Result<Theme, ThemeParseError> {
184    let raw = std::fs::read_to_string(path).map_err(ThemeParseError::Io)?;
185    let value: toml::Value = toml::from_str(&raw).map_err(ThemeParseError::Toml)?;
186    validate_theme_file_keys(&value, path, warn);
187    let file: ThemeFile = value.try_into().map_err(ThemeParseError::Toml)?;
188    theme_file_to_theme(file)
189}
190
191/// Top-level keys recognized in user theme files. `author` and
192/// `license` are metadata (spec-documented); `separators` is parsed
193/// but unused until the powerline renderer lands.
194const KNOWN_FILE_TOP_LEVEL: &[&str] = &["name", "author", "license", "roles", "separators"];
195const KNOWN_ROLES: &[&str] = &[
196    "foreground",
197    "background",
198    "muted",
199    "primary",
200    "accent",
201    "success",
202    "warning",
203    "error",
204    "info",
205    "extended",
206];
207const KNOWN_ROLES_EXTENDED: &[&str] = &[
208    "success_dim",
209    "warning_dim",
210    "error_dim",
211    "primary_dim",
212    "accent_dim",
213    "surface",
214    "border",
215];
216const KNOWN_SEPARATORS: &[&str] = &["default", "powerline", "ellipsis"];
217
218/// Walk the raw TOML tree and emit one warning per key outside the
219/// allow-list. Scope matches the spec's theme-file format: top-level,
220/// `[roles]`, `[roles.extended]`, and `[separators]`. Typos like
221/// `primray = "#ff00ff"` surface here instead of silently parsing.
222fn validate_theme_file_keys(raw: &toml::Value, path: &Path, warn: &mut impl FnMut(&str)) {
223    let Some(top) = raw.as_table() else { return };
224    for (key, value) in top {
225        if !KNOWN_FILE_TOP_LEVEL.contains(&key.as_str()) {
226            warn(&format!(
227                "theme {}: unknown top-level key '{key}'; ignoring",
228                path.display()
229            ));
230            continue;
231        }
232        match key.as_str() {
233            "roles" => validate_roles_table(value, path, warn),
234            "separators" => {
235                validate_flat_theme_table(value, path, "separators", KNOWN_SEPARATORS, warn)
236            }
237            _ => {}
238        }
239    }
240}
241
242fn validate_roles_table(value: &toml::Value, path: &Path, warn: &mut impl FnMut(&str)) {
243    let Some(table) = value.as_table() else {
244        return;
245    };
246    for (key, sub) in table {
247        if !KNOWN_ROLES.contains(&key.as_str()) {
248            warn(&format!(
249                "theme {}: unknown key '{key}' in [roles]; ignoring",
250                path.display()
251            ));
252            continue;
253        }
254        if key == "extended" {
255            validate_flat_theme_table(sub, path, "roles.extended", KNOWN_ROLES_EXTENDED, warn);
256        }
257    }
258}
259
260fn validate_flat_theme_table(
261    value: &toml::Value,
262    path: &Path,
263    label: &str,
264    allowed: &[&str],
265    warn: &mut impl FnMut(&str),
266) {
267    let Some(table) = value.as_table() else {
268        return;
269    };
270    for key in table.keys() {
271        if !allowed.contains(&key.as_str()) {
272            warn(&format!(
273                "theme {}: unknown key '{key}' in [{label}]; ignoring",
274                path.display()
275            ));
276        }
277    }
278}
279
280#[derive(Debug, Deserialize)]
281struct ThemeFile {
282    name: String,
283    // `author` and `license` are metadata per the spec; accepted
284    // silently so theme authors can document provenance.
285    #[serde(default)]
286    #[allow(dead_code)]
287    author: Option<String>,
288    #[serde(default)]
289    #[allow(dead_code)]
290    license: Option<String>,
291    roles: RolesSection,
292    // `separators` is parsed but unused until the powerline renderer
293    // lands; accepting the key now keeps user files forward-compatible.
294    #[serde(default)]
295    #[allow(dead_code)]
296    separators: Option<toml::Table>,
297}
298
299#[derive(Debug, Deserialize)]
300struct RolesSection {
301    foreground: String,
302    background: String,
303    muted: String,
304    primary: String,
305    accent: String,
306    success: String,
307    warning: String,
308    error: String,
309    info: String,
310    #[serde(default)]
311    extended: Option<ExtendedRoles>,
312}
313
314#[derive(Debug, Default, Deserialize)]
315#[serde(default)]
316struct ExtendedRoles {
317    success_dim: Option<String>,
318    warning_dim: Option<String>,
319    error_dim: Option<String>,
320    primary_dim: Option<String>,
321    accent_dim: Option<String>,
322    surface: Option<String>,
323    border: Option<String>,
324}
325
326fn theme_file_to_theme(file: ThemeFile) -> Result<Theme, ThemeParseError> {
327    let mut colors = [None; Role::COUNT];
328    let name = Box::leak(file.name.into_boxed_str()) as &'static str;
329
330    set_role(
331        &mut colors,
332        Role::Foreground,
333        "foreground",
334        &file.roles.foreground,
335    )?;
336    set_role(
337        &mut colors,
338        Role::Background,
339        "background",
340        &file.roles.background,
341    )?;
342    set_role(&mut colors, Role::Muted, "muted", &file.roles.muted)?;
343    set_role(&mut colors, Role::Primary, "primary", &file.roles.primary)?;
344    set_role(&mut colors, Role::Accent, "accent", &file.roles.accent)?;
345    set_role(&mut colors, Role::Success, "success", &file.roles.success)?;
346    set_role(&mut colors, Role::Warning, "warning", &file.roles.warning)?;
347    set_role(&mut colors, Role::Error, "error", &file.roles.error)?;
348    set_role(&mut colors, Role::Info, "info", &file.roles.info)?;
349
350    if let Some(ext) = file.roles.extended {
351        set_opt(
352            &mut colors,
353            Role::SuccessDim,
354            "success_dim",
355            ext.success_dim.as_deref(),
356        )?;
357        set_opt(
358            &mut colors,
359            Role::WarningDim,
360            "warning_dim",
361            ext.warning_dim.as_deref(),
362        )?;
363        set_opt(
364            &mut colors,
365            Role::ErrorDim,
366            "error_dim",
367            ext.error_dim.as_deref(),
368        )?;
369        set_opt(
370            &mut colors,
371            Role::PrimaryDim,
372            "primary_dim",
373            ext.primary_dim.as_deref(),
374        )?;
375        set_opt(
376            &mut colors,
377            Role::AccentDim,
378            "accent_dim",
379            ext.accent_dim.as_deref(),
380        )?;
381        set_opt(
382            &mut colors,
383            Role::Surface,
384            "surface",
385            ext.surface.as_deref(),
386        )?;
387        set_opt(&mut colors, Role::Border, "border", ext.border.as_deref())?;
388    }
389
390    Ok(Theme::from_user_parts(name, colors))
391}
392
393fn set_role(
394    colors: &mut [Option<Color>; Role::COUNT],
395    role: Role,
396    label: &'static str,
397    raw: &str,
398) -> Result<(), ThemeParseError> {
399    let c = parse_color(raw).map_err(|_| ThemeParseError::InvalidColor {
400        role: label,
401        value: raw.to_string(),
402    })?;
403    colors[role as usize] = Some(c);
404    Ok(())
405}
406
407fn set_opt(
408    colors: &mut [Option<Color>; Role::COUNT],
409    role: Role,
410    label: &'static str,
411    raw: Option<&str>,
412) -> Result<(), ThemeParseError> {
413    let Some(raw) = raw else {
414        return Ok(());
415    };
416    set_role(colors, role, label, raw)
417}
418
419// --- color parsing ---
420
421/// Parse a color spec from a user theme file. Accepts:
422/// - `#rgb` or `#rrggbb` hex (normalized to `Color::TrueColor`)
423/// - named 16-color (`"red"`, `"bright_black"`, ...) → `Color::Palette16`
424/// - `rgb(r, g, b)` with 0..=255 channels → `Color::TrueColor`
425///
426/// Whitespace around the whole spec is trimmed. Named colors are
427/// case-insensitive. `none` / empty string → `Color::NoColor`.
428pub(super) fn parse_color(s: &str) -> Result<Color, ()> {
429    let s = s.trim();
430    if s.is_empty() || s.eq_ignore_ascii_case("none") {
431        return Ok(Color::NoColor);
432    }
433    if let Some(rest) = s.strip_prefix('#') {
434        return parse_hex(rest);
435    }
436    if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
437        return parse_rgb_args(inner);
438    }
439    parse_named(s)
440}
441
442fn parse_hex(rest: &str) -> Result<Color, ()> {
443    let bytes = match rest.len() {
444        3 => {
445            let r = double_nibble(rest.as_bytes()[0])?;
446            let g = double_nibble(rest.as_bytes()[1])?;
447            let b = double_nibble(rest.as_bytes()[2])?;
448            [r, g, b]
449        }
450        6 => {
451            let r = hex_pair(rest.as_bytes()[0], rest.as_bytes()[1])?;
452            let g = hex_pair(rest.as_bytes()[2], rest.as_bytes()[3])?;
453            let b = hex_pair(rest.as_bytes()[4], rest.as_bytes()[5])?;
454            [r, g, b]
455        }
456        _ => return Err(()),
457    };
458    Ok(Color::TrueColor {
459        r: bytes[0],
460        g: bytes[1],
461        b: bytes[2],
462    })
463}
464
465fn double_nibble(b: u8) -> Result<u8, ()> {
466    let n = nibble(b)?;
467    Ok((n << 4) | n)
468}
469
470fn hex_pair(hi: u8, lo: u8) -> Result<u8, ()> {
471    Ok((nibble(hi)? << 4) | nibble(lo)?)
472}
473
474fn nibble(b: u8) -> Result<u8, ()> {
475    match b {
476        b'0'..=b'9' => Ok(b - b'0'),
477        b'a'..=b'f' => Ok(b - b'a' + 10),
478        b'A'..=b'F' => Ok(b - b'A' + 10),
479        _ => Err(()),
480    }
481}
482
483fn parse_rgb_args(inner: &str) -> Result<Color, ()> {
484    let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
485    if parts.len() != 3 {
486        return Err(());
487    }
488    let r: u8 = parts[0].parse().map_err(|_| ())?;
489    let g: u8 = parts[1].parse().map_err(|_| ())?;
490    let b: u8 = parts[2].parse().map_err(|_| ())?;
491    Ok(Color::TrueColor { r, g, b })
492}
493
494fn parse_named(s: &str) -> Result<Color, ()> {
495    let ansi = match s.to_ascii_lowercase().as_str() {
496        "black" => AnsiColor::Black,
497        "red" => AnsiColor::Red,
498        "green" => AnsiColor::Green,
499        "yellow" => AnsiColor::Yellow,
500        "blue" => AnsiColor::Blue,
501        "magenta" => AnsiColor::Magenta,
502        "cyan" => AnsiColor::Cyan,
503        "white" => AnsiColor::White,
504        "bright_black" | "bright-black" => AnsiColor::BrightBlack,
505        "bright_red" | "bright-red" => AnsiColor::BrightRed,
506        "bright_green" | "bright-green" => AnsiColor::BrightGreen,
507        "bright_yellow" | "bright-yellow" => AnsiColor::BrightYellow,
508        "bright_blue" | "bright-blue" => AnsiColor::BrightBlue,
509        "bright_magenta" | "bright-magenta" => AnsiColor::BrightMagenta,
510        "bright_cyan" | "bright-cyan" => AnsiColor::BrightCyan,
511        "bright_white" | "bright-white" => AnsiColor::BrightWhite,
512        _ => return Err(()),
513    };
514    Ok(Color::Palette16(ansi))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::theme::{built_in, Capability};
521
522    // --- parse_color ---
523
524    #[test]
525    fn parse_hex_6_digit() {
526        assert_eq!(
527            parse_color("#cba6f7"),
528            Ok(Color::TrueColor {
529                r: 203,
530                g: 166,
531                b: 247,
532            })
533        );
534    }
535
536    #[test]
537    fn parse_hex_3_digit_expands() {
538        // #abc → #aabbcc
539        assert_eq!(
540            parse_color("#abc"),
541            Ok(Color::TrueColor {
542                r: 0xaa,
543                g: 0xbb,
544                b: 0xcc,
545            })
546        );
547    }
548
549    #[test]
550    fn parse_hex_case_insensitive() {
551        assert_eq!(parse_color("#ABCDEF"), parse_color("#abcdef"));
552    }
553
554    #[test]
555    fn parse_hex_rejects_bad_length() {
556        assert!(parse_color("#12").is_err());
557        assert!(parse_color("#12345").is_err());
558    }
559
560    #[test]
561    fn parse_hex_rejects_non_hex_chars() {
562        assert!(parse_color("#xyzxyz").is_err());
563    }
564
565    #[test]
566    fn parse_named_canonical_forms() {
567        assert_eq!(parse_color("red"), Ok(Color::Palette16(AnsiColor::Red)));
568        assert_eq!(parse_color("BLUE"), Ok(Color::Palette16(AnsiColor::Blue)));
569        assert_eq!(
570            parse_color("bright_cyan"),
571            Ok(Color::Palette16(AnsiColor::BrightCyan))
572        );
573        assert_eq!(
574            parse_color("bright-magenta"),
575            Ok(Color::Palette16(AnsiColor::BrightMagenta))
576        );
577    }
578
579    #[test]
580    fn parse_rgb_function_form() {
581        assert_eq!(
582            parse_color("rgb(203, 166, 247)"),
583            Ok(Color::TrueColor {
584                r: 203,
585                g: 166,
586                b: 247,
587            })
588        );
589        assert_eq!(
590            parse_color("rgb(0,0,0)"),
591            Ok(Color::TrueColor { r: 0, g: 0, b: 0 })
592        );
593    }
594
595    #[test]
596    fn parse_rgb_rejects_out_of_range_channels() {
597        assert!(parse_color("rgb(256, 0, 0)").is_err());
598        assert!(parse_color("rgb(1, 2)").is_err());
599        assert!(parse_color("rgb(1, 2, 3, 4)").is_err());
600    }
601
602    #[test]
603    fn parse_none_and_empty_yield_no_color() {
604        assert_eq!(parse_color(""), Ok(Color::NoColor));
605        assert_eq!(parse_color("  "), Ok(Color::NoColor));
606        assert_eq!(parse_color("none"), Ok(Color::NoColor));
607        assert_eq!(parse_color("NONE"), Ok(Color::NoColor));
608    }
609
610    #[test]
611    fn parse_unknown_named_color_errors() {
612        assert!(parse_color("mauve").is_err());
613    }
614
615    // --- theme_file_to_theme ---
616
617    const MIN_THEME_TOML: &str = r##"
618        name = "mytheme"
619
620        [roles]
621        foreground  = "#ffffff"
622        background  = "#000000"
623        muted       = "#888888"
624        primary     = "#ff00ff"
625        accent      = "#00ffff"
626        success     = "#00ff00"
627        warning     = "#ffff00"
628        error       = "#ff0000"
629        info        = "#0080ff"
630    "##;
631
632    #[test]
633    fn theme_file_parses_minimum_required_sections() {
634        let file: ThemeFile = toml::from_str(MIN_THEME_TOML).expect("parse");
635        let theme = theme_file_to_theme(file).expect("convert");
636        assert_eq!(theme.name(), "mytheme");
637        assert_eq!(
638            theme.color(Role::Primary),
639            Color::TrueColor {
640                r: 255,
641                g: 0,
642                b: 255,
643            }
644        );
645    }
646
647    #[test]
648    fn theme_file_missing_required_role_fails_to_deserialize() {
649        // Drop `info` from the minimum; TOML deserialization rejects.
650        let src = MIN_THEME_TOML.replace("info        = \"#0080ff\"\n", "");
651        let err = toml::from_str::<ThemeFile>(&src).unwrap_err();
652        assert!(err.to_string().contains("info"));
653    }
654
655    #[test]
656    fn theme_file_invalid_color_value_surfaces_role_label() {
657        let src = MIN_THEME_TOML.replace("#ff00ff", "not-a-color");
658        let file: ThemeFile = toml::from_str(&src).expect("still parses as TOML");
659        let err = theme_file_to_theme(file).unwrap_err();
660        match err {
661            ThemeParseError::InvalidColor { role, value } => {
662                assert_eq!(role, "primary");
663                assert_eq!(value, "not-a-color");
664            }
665            other => panic!("expected InvalidColor, got {other:?}"),
666        }
667    }
668
669    #[test]
670    fn theme_file_extended_roles_populate_when_present() {
671        let src = format!(
672            "{MIN_THEME_TOML}\n[roles.extended]\nsurface = \"#202020\"\nborder = \"#303030\"\n"
673        );
674        let file: ThemeFile = toml::from_str(&src).expect("parse");
675        let theme = theme_file_to_theme(file).expect("convert");
676        assert_eq!(
677            theme.color(Role::Surface),
678            Color::TrueColor {
679                r: 32,
680                g: 32,
681                b: 32,
682            }
683        );
684    }
685
686    // --- ThemeRegistry ---
687
688    #[test]
689    fn registry_with_built_ins_contains_every_compiled_theme() {
690        let r = ThemeRegistry::with_built_ins();
691        for name in super::super::builtin_names() {
692            assert!(r.lookup(name).is_some(), "{name} missing from registry");
693        }
694    }
695
696    #[test]
697    fn registry_user_theme_overrides_built_in_with_warning() {
698        let dir = tempdir();
699        std::fs::write(
700            dir.path().join("override.toml"),
701            r##"
702                name = "default"
703                [roles]
704                foreground = "#ff0000"
705                background = "#000000"
706                muted = "#888888"
707                primary = "#ff00ff"
708                accent = "#00ffff"
709                success = "#00ff00"
710                warning = "#ffff00"
711                error = "#ff0000"
712                info = "#0080ff"
713            "##,
714        )
715        .unwrap();
716        let mut warnings = Vec::new();
717        let r = ThemeRegistry::with_built_ins()
718            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
719        let t = r.lookup("default").expect("default present");
720        assert_eq!(
721            t.color(Role::Foreground),
722            Color::TrueColor { r: 255, g: 0, b: 0 }
723        );
724        assert!(
725            warnings.iter().any(|w| w.contains("overrides")),
726            "no override warning: {warnings:?}",
727        );
728    }
729
730    #[test]
731    fn registry_user_vs_user_collision_first_file_wins() {
732        // Per docs/specs/theming.md §Edge cases: when two user files
733        // declare the same `name`, the first one loaded wins. Files
734        // are processed in filename-sorted order, so `a_theme.toml`
735        // wins over `b_theme.toml` and the later one is warned + skipped.
736        let dir = tempdir();
737        std::fs::write(
738            dir.path().join("a_theme.toml"),
739            r##"
740                name = "clash"
741                [roles]
742                foreground = "#111111"
743                background = "#000000"
744                muted = "#888888"
745                primary = "#ff00ff"
746                accent = "#00ffff"
747                success = "#00ff00"
748                warning = "#ffff00"
749                error = "#ff0000"
750                info = "#0080ff"
751            "##,
752        )
753        .unwrap();
754        std::fs::write(
755            dir.path().join("b_theme.toml"),
756            r##"
757                name = "clash"
758                [roles]
759                foreground = "#222222"
760                background = "#000000"
761                muted = "#888888"
762                primary = "#ff00ff"
763                accent = "#00ffff"
764                success = "#00ff00"
765                warning = "#ffff00"
766                error = "#ff0000"
767                info = "#0080ff"
768            "##,
769        )
770        .unwrap();
771        let mut warnings = Vec::new();
772        let r = ThemeRegistry::with_built_ins()
773            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
774        let t = r.lookup("clash").expect("clash present");
775        assert_eq!(
776            t.color(Role::Foreground),
777            Color::TrueColor {
778                r: 0x11,
779                g: 0x11,
780                b: 0x11,
781            },
782            "first file (a_theme.toml) should win"
783        );
784        assert!(
785            warnings.iter().any(|w| w.contains("a_theme.toml")
786                && w.contains("b_theme.toml")
787                && w.contains("skipped")),
788            "skip warning missing path or 'skipped' keyword: {warnings:?}",
789        );
790    }
791
792    #[test]
793    fn unknown_role_in_theme_file_warns_with_path() {
794        // Spec: "Theme file references a role not in vocabulary ->
795        // Parse succeeds; unknown role ignored with warning."
796        let dir = tempdir();
797        let file = dir.path().join("typo.toml");
798        std::fs::write(
799            &file,
800            r##"
801                name = "typo"
802                [roles]
803                foreground = "#ffffff"
804                background = "#000000"
805                muted = "#888888"
806                primray = "#ff00ff"
807                primary = "#cba6f7"
808                accent = "#00ffff"
809                success = "#00ff00"
810                warning = "#ffff00"
811                error = "#ff0000"
812                info = "#0080ff"
813            "##,
814        )
815        .unwrap();
816        let mut warnings = Vec::new();
817        let r = ThemeRegistry::with_built_ins()
818            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
819        // Theme still loads (`primary` is present), but the typo is
820        // surfaced.
821        assert!(r.lookup("typo").is_some(), "theme should still load");
822        assert!(
823            warnings
824                .iter()
825                .any(|w| w.contains("primray") && w.contains("[roles]")),
826            "typo not warned: {warnings:?}",
827        );
828    }
829
830    #[test]
831    fn unknown_top_level_key_in_theme_file_warns() {
832        let dir = tempdir();
833        std::fs::write(
834            dir.path().join("stray.toml"),
835            format!(
836                r##"
837                    bogus = "value"
838                    {}
839                "##,
840                MIN_THEME_TOML.trim()
841            ),
842        )
843        .unwrap();
844        let mut warnings = Vec::new();
845        let _ = ThemeRegistry::with_built_ins()
846            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
847        assert!(
848            warnings
849                .iter()
850                .any(|w| w.contains("bogus") && w.contains("top-level")),
851            "unknown top-level key not warned: {warnings:?}",
852        );
853    }
854
855    #[test]
856    fn unknown_key_in_roles_extended_warns() {
857        let dir = tempdir();
858        std::fs::write(
859            dir.path().join("ext.toml"),
860            format!(
861                "{}\n[roles.extended]\nsurfaec = \"#202020\"\n",
862                MIN_THEME_TOML.trim()
863            ),
864        )
865        .unwrap();
866        let mut warnings = Vec::new();
867        let _ = ThemeRegistry::with_built_ins()
868            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
869        assert!(
870            warnings
871                .iter()
872                .any(|w| w.contains("surfaec") && w.contains("[roles.extended]")),
873            "typo in extended not warned: {warnings:?}",
874        );
875    }
876
877    #[test]
878    fn spec_metadata_keys_parse_silently() {
879        // `author` and `license` are spec-documented metadata fields.
880        // Including them must not produce unknown-key warnings.
881        let dir = tempdir();
882        std::fs::write(
883            dir.path().join("meta.toml"),
884            r##"
885                name = "metaed"
886                author = "Someone <a@b>"
887                license = "MIT"
888                [roles]
889                foreground = "#ffffff"
890                background = "#000000"
891                muted = "#888888"
892                primary = "#ff00ff"
893                accent = "#00ffff"
894                success = "#00ff00"
895                warning = "#ffff00"
896                error = "#ff0000"
897                info = "#0080ff"
898            "##,
899        )
900        .unwrap();
901        let mut warnings = Vec::new();
902        let r = ThemeRegistry::with_built_ins()
903            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
904        assert!(r.lookup("metaed").is_some());
905        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
906    }
907
908    #[test]
909    fn registry_missing_user_dir_is_silent() {
910        let dir = tempdir();
911        let missing = dir.path().join("does_not_exist");
912        let mut warnings = Vec::new();
913        let r = ThemeRegistry::with_built_ins()
914            .with_user_themes(&missing, |m| warnings.push(m.to_string()));
915        assert!(warnings.is_empty());
916        assert!(r.lookup("default").is_some());
917    }
918
919    #[test]
920    fn registry_skips_bad_files_with_diagnostic() {
921        let dir = tempdir();
922        std::fs::write(dir.path().join("broken.toml"), "not valid toml [[").unwrap();
923        std::fs::write(dir.path().join("good.toml"), MIN_THEME_TOML).unwrap();
924        let mut warnings = Vec::new();
925        let r = ThemeRegistry::with_built_ins()
926            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
927        assert!(
928            r.lookup("mytheme").is_some(),
929            "good theme should still load"
930        );
931        assert!(
932            warnings.iter().any(|w| w.contains("broken.toml")),
933            "bad file didn't warn: {warnings:?}",
934        );
935    }
936
937    #[test]
938    fn registry_ignores_non_toml_files() {
939        let dir = tempdir();
940        std::fs::write(dir.path().join("readme.md"), "not a theme").unwrap();
941        std::fs::write(dir.path().join("theme.toml"), MIN_THEME_TOML).unwrap();
942        let mut warnings = Vec::new();
943        let r = ThemeRegistry::with_built_ins()
944            .with_user_themes(dir.path(), |m| warnings.push(m.to_string()));
945        assert!(r.lookup("mytheme").is_some());
946        assert!(warnings.is_empty());
947    }
948
949    #[test]
950    fn registry_iter_yields_built_ins_first_then_user() {
951        let dir = tempdir();
952        std::fs::write(dir.path().join("mine.toml"), MIN_THEME_TOML).unwrap();
953        let r = ThemeRegistry::with_built_ins().with_user_themes(dir.path(), |_| {});
954        let names: Vec<&str> = r.iter().map(|rt| rt.theme.name()).collect();
955        // Built-ins come before the user theme.
956        let user_idx = names
957            .iter()
958            .position(|n| *n == "mytheme")
959            .expect("user theme");
960        let default_idx = names.iter().position(|n| *n == "default").expect("default");
961        assert!(default_idx < user_idx);
962    }
963
964    #[test]
965    fn user_theme_renders_through_downgrade_without_panicking() {
966        // Catches a regression in downgrade arithmetic for arbitrary
967        // user-supplied TrueColor values (above + beyond the built-in
968        // Catppuccin coverage).
969        let file: ThemeFile = toml::from_str(MIN_THEME_TOML).unwrap();
970        let theme = theme_file_to_theme(file).unwrap();
971        for role in [Role::Primary, Role::Success, Role::Warning] {
972            let _ = theme.color(role).downgrade(Capability::Palette256);
973            let _ = theme.color(role).downgrade(Capability::Palette16);
974        }
975        // Reference the built_in registry to keep the import live.
976        assert!(built_in("default").is_some());
977    }
978
979    // --- tempdir helper (separate from driver/config helpers) ---
980
981    struct TempDir(std::path::PathBuf);
982
983    impl TempDir {
984        fn path(&self) -> &std::path::Path {
985            &self.0
986        }
987    }
988
989    impl Drop for TempDir {
990        fn drop(&mut self) {
991            let _ = std::fs::remove_dir_all(&self.0);
992        }
993    }
994
995    fn tempdir() -> TempDir {
996        use std::sync::atomic::{AtomicU64, Ordering};
997        static COUNTER: AtomicU64 = AtomicU64::new(0);
998        let base = std::env::temp_dir().join(format!(
999            "linesmith-user-theme-test-{}-{}",
1000            std::time::SystemTime::now()
1001                .duration_since(std::time::UNIX_EPOCH)
1002                .expect("clock")
1003                .as_nanos(),
1004            COUNTER.fetch_add(1, Ordering::Relaxed),
1005        ));
1006        std::fs::create_dir_all(&base).expect("mkdir");
1007        TempDir(base)
1008    }
1009}