Skip to main content

osp_cli/ui/
theme_catalog.rs

1use serde::Deserialize;
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::config::{ConfigSource, ResolvedConfig};
7use crate::ui::style::is_valid_style_spec;
8use crate::ui::theme::{
9    ThemeDefinition, ThemeOverrides, ThemePalette, builtin_themes, display_name_from_id,
10    find_builtin_theme, normalize_theme_name,
11};
12
13#[derive(Debug, Clone)]
14pub(crate) struct ThemeLoadIssue {
15    pub(crate) path: PathBuf,
16    pub(crate) message: String,
17}
18
19#[derive(Debug, Clone, Default)]
20pub(crate) struct ThemeCatalog {
21    pub(crate) entries: BTreeMap<String, ThemeEntry>,
22    pub(crate) issues: Vec<ThemeLoadIssue>,
23}
24
25impl ThemeCatalog {
26    pub(crate) fn ids(&self) -> Vec<String> {
27        self.entries.keys().cloned().collect()
28    }
29
30    pub(crate) fn resolve(&self, input: &str) -> Option<&ThemeEntry> {
31        let normalized = normalize_theme_name(input);
32        if normalized.is_empty() {
33            return None;
34        }
35        self.entries.get(&normalized)
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum ThemeSource {
41    Builtin,
42    Custom,
43}
44
45#[derive(Debug, Clone)]
46pub(crate) struct ThemeEntry {
47    pub(crate) theme: ThemeDefinition,
48    pub(crate) source: ThemeSource,
49    pub(crate) origin: Option<PathBuf>,
50}
51
52#[derive(Debug, Clone, Default)]
53struct CustomThemeLoad {
54    themes: Vec<ThemeDefinition>,
55    origins: BTreeMap<String, PathBuf>,
56    issues: Vec<ThemeLoadIssue>,
57}
58
59#[derive(Debug, Clone)]
60struct ThemeSpec {
61    id: String,
62    name: String,
63    base: Option<String>,
64    palette: ThemePaletteFile,
65    overrides: ThemeOverrides,
66}
67
68struct ThemePathSelection {
69    paths: Vec<PathBuf>,
70    explicit: bool,
71}
72
73pub(crate) fn load_theme_catalog(config: &ResolvedConfig) -> ThemeCatalog {
74    let custom = load_custom_themes(config);
75    let mut entries: BTreeMap<String, ThemeEntry> = BTreeMap::new();
76    for theme in builtin_themes() {
77        entries.insert(
78            theme.id.clone(),
79            ThemeEntry {
80                theme,
81                source: ThemeSource::Builtin,
82                origin: None,
83            },
84        );
85    }
86
87    let mut issues = custom.issues;
88    for theme in custom.themes {
89        let origin = custom.origins.get(&theme.id).cloned();
90        if let Some(path) = origin.clone()
91            && entries.contains_key(&theme.id)
92        {
93            issues.push(ThemeLoadIssue {
94                path,
95                message: format!("custom theme overrides builtin: {}", theme.id),
96            });
97        }
98        entries.insert(
99            theme.id.clone(),
100            ThemeEntry {
101                theme,
102                source: ThemeSource::Custom,
103                origin,
104            },
105        );
106    }
107
108    ThemeCatalog { entries, issues }
109}
110
111fn load_custom_themes(config: &ResolvedConfig) -> CustomThemeLoad {
112    let mut issues = Vec::new();
113    let mut specs: BTreeMap<String, ThemeSpec> = BTreeMap::new();
114    let mut origins: BTreeMap<String, PathBuf> = BTreeMap::new();
115
116    let selection = resolve_theme_paths(config);
117    for dir in selection.paths {
118        if !dir.is_dir() {
119            if selection.explicit {
120                issues.push(ThemeLoadIssue {
121                    path: dir,
122                    message: "theme path is not a directory".to_string(),
123                });
124            }
125            continue;
126        }
127
128        let mut entries = match fs::read_dir(&dir) {
129            Ok(entries) => entries
130                .filter_map(|entry| entry.ok())
131                .map(|entry| entry.path())
132                .collect::<Vec<_>>(),
133            Err(err) => {
134                if selection.explicit {
135                    issues.push(ThemeLoadIssue {
136                        path: dir,
137                        message: format!("failed to read theme directory: {err}"),
138                    });
139                }
140                continue;
141            }
142        };
143        entries.sort();
144
145        for path in entries {
146            if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
147                continue;
148            }
149
150            match parse_theme_spec(&path) {
151                Ok(spec) => {
152                    let theme = match resolve_theme_spec(
153                        &spec.id,
154                        &specs,
155                        &mut BTreeMap::new(),
156                        &mut Vec::new(),
157                    ) {
158                        Ok(theme) => apply_theme_overrides(theme, &spec),
159                        Err(_) => {
160                            // The full recursive resolve runs after all specs are known.
161                            // Validate only the direct patch values at parse time here.
162                            apply_theme_overrides(
163                                empty_theme(&spec.id, &spec.name, spec.base.clone()),
164                                &spec,
165                            )
166                        }
167                    };
168                    for message in validate_theme_specs(&theme) {
169                        issues.push(ThemeLoadIssue {
170                            path: path.clone(),
171                            message,
172                        });
173                    }
174                    if let Some(existing) = origins.get(&spec.id) {
175                        issues.push(ThemeLoadIssue {
176                            path: path.clone(),
177                            message: format!(
178                                "theme id collision: {} overridden (previous: {})",
179                                spec.id,
180                                existing.display()
181                            ),
182                        });
183                    }
184                    origins.insert(spec.id.clone(), path.clone());
185                    specs.insert(spec.id.clone(), spec);
186                }
187                Err(err) => {
188                    issues.push(ThemeLoadIssue { path, message: err });
189                }
190            }
191        }
192    }
193
194    let mut resolved = BTreeMap::new();
195    for id in specs.keys().cloned().collect::<Vec<_>>() {
196        let mut stack = Vec::new();
197        match resolve_theme_spec(&id, &specs, &mut resolved, &mut stack) {
198            Ok(_) => {}
199            Err(message) => {
200                if let Some(path) = origins.get(&id).cloned() {
201                    issues.push(ThemeLoadIssue { path, message });
202                }
203            }
204        }
205    }
206
207    CustomThemeLoad {
208        themes: resolved.into_values().collect(),
209        origins,
210        issues,
211    }
212}
213
214pub(crate) fn log_theme_issues(issues: &[ThemeLoadIssue]) {
215    for issue in issues {
216        tracing::warn!(path = %issue.path.display(), "{message}", message = issue.message);
217    }
218}
219
220fn resolve_theme_paths(config: &ResolvedConfig) -> ThemePathSelection {
221    if let Some(paths) = config.get_string_list("theme.path") {
222        let explicit = config
223            .get_value_entry("theme.path")
224            .map(|entry| {
225                !matches!(
226                    entry.source,
227                    ConfigSource::BuiltinDefaults | ConfigSource::Derived
228                )
229            })
230            .unwrap_or(false);
231        return ThemePathSelection {
232            paths: normalize_theme_paths(paths),
233            explicit,
234        };
235    }
236    ThemePathSelection {
237        paths: default_theme_paths(),
238        explicit: false,
239    }
240}
241
242fn normalize_theme_paths(paths: Vec<String>) -> Vec<PathBuf> {
243    paths
244        .into_iter()
245        .filter_map(|raw| expand_theme_path(&raw))
246        .collect()
247}
248
249fn expand_theme_path(raw: &str) -> Option<PathBuf> {
250    let trimmed = raw.trim();
251    if trimmed.is_empty() {
252        return None;
253    }
254
255    if trimmed == "~" {
256        return crate::config::default_home_dir();
257    }
258
259    if let Some(home) = crate::config::default_home_dir()
260        && let Some(stripped) = trimmed
261            .strip_prefix("~/")
262            .or_else(|| trimmed.strip_prefix("~\\"))
263    {
264        return Some(home.join(stripped));
265    }
266
267    Some(PathBuf::from(trimmed))
268}
269
270fn default_theme_paths() -> Vec<PathBuf> {
271    crate::config::default_config_root_dir()
272        .map(|mut root| {
273            root.push("themes");
274            root
275        })
276        .into_iter()
277        .collect()
278}
279
280#[derive(Debug, Deserialize)]
281struct ThemeFile {
282    base: Option<String>,
283    id: Option<String>,
284    name: Option<String>,
285    palette: Option<ThemePaletteFile>,
286    #[serde(default)]
287    overrides: ThemeOverridesFile,
288}
289
290#[derive(Debug, Clone, Deserialize, Default)]
291struct ThemePaletteFile {
292    text: Option<String>,
293    muted: Option<String>,
294    accent: Option<String>,
295    info: Option<String>,
296    warning: Option<String>,
297    success: Option<String>,
298    error: Option<String>,
299    border: Option<String>,
300    title: Option<String>,
301    selection: Option<String>,
302    link: Option<String>,
303    bg: Option<String>,
304    bg_alt: Option<String>,
305}
306
307#[derive(Debug, Deserialize, Default)]
308struct ThemeOverridesFile {
309    value_number: Option<String>,
310    repl_completion_text: Option<String>,
311    repl_completion_background: Option<String>,
312    repl_completion_highlight: Option<String>,
313}
314
315fn parse_theme_spec(path: &Path) -> Result<ThemeSpec, String> {
316    let raw =
317        fs::read_to_string(path).map_err(|err| format!("failed to read theme file: {err}"))?;
318    let parsed: ThemeFile =
319        toml::from_str(&raw).map_err(|err| format!("failed to parse toml: {err}"))?;
320
321    let stem = path
322        .file_stem()
323        .and_then(|value| value.to_str())
324        .unwrap_or_default();
325    let mut id = parsed
326        .id
327        .as_deref()
328        .map(normalize_theme_name)
329        .unwrap_or_default();
330    if id.is_empty() {
331        id = normalize_theme_name(stem);
332    }
333    if id.is_empty() {
334        return Err("theme id is empty".to_string());
335    }
336
337    let name = parsed
338        .name
339        .as_deref()
340        .map(str::trim)
341        .filter(|value| !value.is_empty())
342        .map(str::to_string)
343        .unwrap_or_else(|| display_name_from_id(&id));
344
345    let base = parsed
346        .base
347        .as_deref()
348        .map(normalize_theme_name)
349        .filter(|value| !value.is_empty())
350        .filter(|value| value != "none");
351
352    let overrides = ThemeOverrides {
353        value_number: parsed.overrides.value_number,
354        repl_completion_text: parsed.overrides.repl_completion_text,
355        repl_completion_background: parsed.overrides.repl_completion_background,
356        repl_completion_highlight: parsed.overrides.repl_completion_highlight,
357    };
358
359    Ok(ThemeSpec {
360        id,
361        name,
362        base,
363        palette: parsed.palette.unwrap_or_default(),
364        overrides,
365    })
366}
367
368fn resolve_theme_spec(
369    id: &str,
370    specs: &BTreeMap<String, ThemeSpec>,
371    resolved: &mut BTreeMap<String, ThemeDefinition>,
372    stack: &mut Vec<String>,
373) -> Result<ThemeDefinition, String> {
374    if let Some(theme) = resolved.get(id) {
375        return Ok(theme.clone());
376    }
377    if stack.iter().any(|entry| entry == id) {
378        stack.push(id.to_string());
379        return Err(format!("theme base cycle detected: {}", stack.join(" -> ")));
380    }
381
382    let spec = specs
383        .get(id)
384        .ok_or_else(|| format!("theme missing during resolution: {id}"))?;
385    stack.push(id.to_string());
386
387    let base_theme = match spec.base.as_deref() {
388        Some(base) if specs.contains_key(base) => {
389            Some(resolve_theme_spec(base, specs, resolved, stack)?)
390        }
391        Some(base) => find_builtin_theme(base)
392            .ok_or_else(|| format!("unknown base theme: {base}"))
393            .map(Some)?,
394        None => None,
395    };
396
397    let theme = apply_theme_overrides(
398        base_theme.unwrap_or_else(|| empty_theme(&spec.id, &spec.name, spec.base.clone())),
399        spec,
400    );
401    stack.pop();
402    resolved.insert(id.to_string(), theme.clone());
403    Ok(theme)
404}
405
406fn empty_theme(id: &str, name: &str, base: Option<String>) -> ThemeDefinition {
407    ThemeDefinition::new(id, name, base, empty_palette(), ThemeOverrides::default())
408}
409
410fn apply_theme_overrides(theme: ThemeDefinition, spec: &ThemeSpec) -> ThemeDefinition {
411    let mut palette = theme.palette.clone();
412    if let Some(value) = spec.palette.text.as_ref() {
413        palette.text = value.clone();
414    }
415    if let Some(value) = spec.palette.muted.as_ref() {
416        palette.muted = value.clone();
417    }
418    if let Some(value) = spec.palette.accent.as_ref() {
419        palette.accent = value.clone();
420    }
421    if let Some(value) = spec.palette.info.as_ref() {
422        palette.info = value.clone();
423    }
424    if let Some(value) = spec.palette.warning.as_ref() {
425        palette.warning = value.clone();
426    }
427    if let Some(value) = spec.palette.success.as_ref() {
428        palette.success = value.clone();
429    }
430    if let Some(value) = spec.palette.error.as_ref() {
431        palette.error = value.clone();
432    }
433    if let Some(value) = spec.palette.border.as_ref() {
434        palette.border = value.clone();
435    }
436    if let Some(value) = spec.palette.title.as_ref() {
437        palette.title = value.clone();
438    }
439    if let Some(value) = spec.palette.selection.as_ref() {
440        palette.selection = value.clone();
441    }
442    if let Some(value) = spec.palette.link.as_ref() {
443        palette.link = value.clone();
444    }
445    if let Some(value) = spec.palette.bg.as_ref() {
446        palette.bg = Some(value.clone());
447    }
448    if let Some(value) = spec.palette.bg_alt.as_ref() {
449        palette.bg_alt = Some(value.clone());
450    }
451
452    ThemeDefinition::new(
453        spec.id.clone(),
454        spec.name.clone(),
455        spec.base.clone(),
456        palette,
457        spec.overrides.clone(),
458    )
459}
460
461fn validate_theme_specs(theme: &ThemeDefinition) -> Vec<String> {
462    let mut issues = Vec::new();
463
464    check_spec(&mut issues, "palette.text", &theme.palette.text);
465    check_spec(&mut issues, "palette.muted", &theme.palette.muted);
466    check_spec(&mut issues, "palette.accent", &theme.palette.accent);
467    check_spec(&mut issues, "palette.info", &theme.palette.info);
468    check_spec(&mut issues, "palette.warning", &theme.palette.warning);
469    check_spec(&mut issues, "palette.success", &theme.palette.success);
470    check_spec(&mut issues, "palette.error", &theme.palette.error);
471    check_spec(&mut issues, "palette.border", &theme.palette.border);
472    check_spec(&mut issues, "palette.title", &theme.palette.title);
473    check_spec(&mut issues, "palette.selection", &theme.palette.selection);
474    check_spec(&mut issues, "palette.link", &theme.palette.link);
475    if let Some(value) = &theme.palette.bg {
476        check_spec(&mut issues, "palette.bg", value);
477    }
478    if let Some(value) = &theme.palette.bg_alt {
479        check_spec(&mut issues, "palette.bg_alt", value);
480    }
481    if let Some(value) = &theme.overrides.value_number {
482        check_spec(&mut issues, "overrides.value_number", value);
483    }
484    if let Some(value) = &theme.overrides.repl_completion_text {
485        check_spec(&mut issues, "overrides.repl_completion_text", value);
486    }
487    if let Some(value) = &theme.overrides.repl_completion_background {
488        check_spec(&mut issues, "overrides.repl_completion_background", value);
489    }
490    if let Some(value) = &theme.overrides.repl_completion_highlight {
491        check_spec(&mut issues, "overrides.repl_completion_highlight", value);
492    }
493
494    issues
495}
496
497fn check_spec(issues: &mut Vec<String>, key: &str, value: &str) {
498    if is_valid_color_spec(value) {
499        return;
500    }
501    issues.push(format!("invalid color spec for {key}: {value}"));
502}
503
504fn is_valid_color_spec(value: &str) -> bool {
505    is_valid_style_spec(value)
506}
507
508fn empty_palette() -> ThemePalette {
509    ThemePalette {
510        text: String::new(),
511        muted: String::new(),
512        accent: String::new(),
513        info: String::new(),
514        warning: String::new(),
515        success: String::new(),
516        error: String::new(),
517        border: String::new(),
518        title: String::new(),
519        selection: String::new(),
520        link: String::new(),
521        bg: None,
522        bg_alt: None,
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::{
529        ThemeCatalog, ThemePaletteFile, ThemeSource, ThemeSpec, apply_theme_overrides,
530        default_theme_paths, empty_theme, expand_theme_path, is_valid_color_spec,
531        load_theme_catalog, log_theme_issues, normalize_theme_paths, parse_theme_spec,
532        resolve_theme_spec,
533    };
534    use crate::config::{ConfigLayer, ConfigResolver, ResolveOptions};
535    use std::collections::BTreeMap;
536    use std::fs;
537    use std::path::Path;
538    use std::sync::Mutex;
539
540    fn env_lock() -> &'static Mutex<()> {
541        crate::tests::env_lock()
542    }
543
544    fn unique_temp_dir(prefix: &str) -> crate::tests::TestTempDir {
545        crate::tests::make_temp_dir(prefix)
546    }
547
548    fn resolved_config_with_theme_paths(paths: Vec<String>) -> crate::config::ResolvedConfig {
549        let mut defaults = ConfigLayer::default();
550        defaults.set("profile.default", "default");
551        let mut file = ConfigLayer::default();
552        file.set("theme.path", paths);
553
554        let mut resolver = ConfigResolver::default();
555        resolver.set_defaults(defaults);
556        resolver.set_file(file);
557        resolver
558            .resolve(ResolveOptions::default().with_terminal("cli"))
559            .expect("theme test config should resolve")
560    }
561
562    #[test]
563    fn theme_file_defaults_id_and_name_from_file_stem() {
564        let dir = unique_temp_dir("osp-theme-loader-test");
565        let path = dir.join("solarized-dark.toml");
566        fs::write(
567            &path,
568            r##"
569[palette]
570text = "#eee8d5"
571muted = "#93a1a1"
572accent = "#268bd2"
573info = "#2aa198"
574warning = "#b58900"
575success = "#859900"
576error = "bold #dc322f"
577border = "#586e75"
578title = "#586e75"
579"##,
580        )
581        .expect("theme file should be written");
582
583        let spec = parse_theme_spec(&path).expect("theme should parse");
584        let theme =
585            apply_theme_overrides(empty_theme(&spec.id, &spec.name, spec.base.clone()), &spec);
586        assert_eq!(theme.id, "solarized-dark");
587        assert_eq!(theme.name, "Solarized Dark");
588    }
589
590    #[test]
591    fn theme_file_inherits_from_base() {
592        let dir = unique_temp_dir("osp-theme-loader-test-base");
593        let path = dir.join("custom.toml");
594        fs::write(
595            &path,
596            r##"
597base = "dracula"
598
599[palette]
600accent = "#123456"
601"##,
602        )
603        .expect("theme file should be written");
604
605        let spec = parse_theme_spec(&path).expect("theme should parse");
606        let mut specs = BTreeMap::new();
607        specs.insert(spec.id.clone(), spec);
608        let theme = resolve_theme_spec("custom", &specs, &mut BTreeMap::new(), &mut Vec::new())
609            .expect("theme should resolve");
610        assert_eq!(theme.palette.accent, "#123456");
611        assert_eq!(theme.palette.text, "#f8f8f2");
612    }
613
614    #[test]
615    fn custom_theme_can_inherit_from_custom_base() {
616        let mut specs = BTreeMap::new();
617        specs.insert(
618            "brand-base".to_string(),
619            ThemeSpec {
620                id: "brand-base".to_string(),
621                name: "Brand Base".to_string(),
622                base: Some("nord".to_string()),
623                palette: ThemePaletteFile {
624                    accent: Some("#123456".to_string()),
625                    ..ThemePaletteFile::default()
626                },
627                overrides: Default::default(),
628            },
629        );
630        specs.insert(
631            "brand-child".to_string(),
632            ThemeSpec {
633                id: "brand-child".to_string(),
634                name: "Brand Child".to_string(),
635                base: Some("brand-base".to_string()),
636                palette: ThemePaletteFile {
637                    warning: Some("#abcdef".to_string()),
638                    ..ThemePaletteFile::default()
639                },
640                overrides: Default::default(),
641            },
642        );
643
644        let theme =
645            resolve_theme_spec("brand-child", &specs, &mut BTreeMap::new(), &mut Vec::new())
646                .expect("custom base chain should resolve");
647
648        assert_eq!(theme.palette.accent, "#123456");
649        assert_eq!(theme.palette.warning, "#abcdef");
650        assert_eq!(theme.palette.text, "#d8dee9");
651    }
652
653    #[test]
654    fn color_spec_validation_accepts_known_tokens() {
655        assert!(is_valid_color_spec(""));
656        assert!(is_valid_color_spec("bold #ff00ff"));
657        assert!(is_valid_color_spec("bright-blue"));
658    }
659
660    #[test]
661    fn color_spec_validation_rejects_unknown_tokens() {
662        assert!(!is_valid_color_spec("nope"));
663        assert!(!is_valid_color_spec("#12345"));
664    }
665
666    #[test]
667    fn theme_catalog_resolve_normalizes_input_and_rejects_blank_unit() {
668        let mut catalog = ThemeCatalog::default();
669        catalog.entries.insert(
670            "rose-pine".to_string(),
671            super::ThemeEntry {
672                theme: empty_theme("rose-pine", "Rose Pine", None),
673                source: ThemeSource::Builtin,
674                origin: None,
675            },
676        );
677
678        assert!(catalog.resolve("  ").is_none());
679        assert!(catalog.resolve("Rose Pine").is_some());
680        assert_eq!(catalog.ids(), vec!["rose-pine".to_string()]);
681    }
682
683    #[test]
684    fn theme_path_helpers_expand_home_and_drop_blank_entries_unit() {
685        let _guard = env_lock().lock().expect("env lock should not be poisoned");
686        let original = std::env::var("HOME").ok();
687        unsafe { std::env::set_var("HOME", "/tmp/theme-home") };
688
689        assert_eq!(expand_theme_path("   "), None);
690        assert_eq!(
691            expand_theme_path("~"),
692            Some(std::path::PathBuf::from("/tmp/theme-home"))
693        );
694        assert_eq!(
695            expand_theme_path("~/themes"),
696            Some(std::path::PathBuf::from("/tmp/theme-home/themes"))
697        );
698        assert_eq!(
699            expand_theme_path("~\\themes"),
700            Some(std::path::PathBuf::from("/tmp/theme-home/themes"))
701        );
702        assert_eq!(
703            normalize_theme_paths(vec![" ".to_string(), "~/themes".to_string()]),
704            vec![std::path::PathBuf::from("/tmp/theme-home/themes")]
705        );
706
707        match original {
708            Some(value) => unsafe { std::env::set_var("HOME", value) },
709            None => unsafe { std::env::remove_var("HOME") },
710        }
711    }
712
713    #[test]
714    fn theme_catalog_load_reports_invalid_specs_and_preserves_custom_origins_unit() {
715        let root = unique_temp_dir("osp-theme-loader-catalog");
716        let themes_dir = root.join("themes");
717        let missing_dir = root.join("missing");
718        let dracula_path = themes_dir.join("dracula.toml");
719        let broken_path = themes_dir.join("broken.toml");
720        let cycle_a_path = themes_dir.join("cycle-a.toml");
721        let cycle_b_path = themes_dir.join("cycle-b.toml");
722        let dupe_a_path = themes_dir.join("dupe-a.toml");
723        let dupe_b_path = themes_dir.join("dupe-b.toml");
724        fs::create_dir_all(&themes_dir).expect("themes dir should be created");
725
726        fs::write(
727            &dracula_path,
728            r##"
729[palette]
730accent = "#123456"
731"##,
732        )
733        .expect("override theme should be written");
734        fs::write(&broken_path, "not = [valid").expect("broken theme writes");
735        fs::write(
736            &cycle_a_path,
737            r##"
738id = "cycle-a"
739base = "cycle-b"
740"##,
741        )
742        .expect("cycle a writes");
743        fs::write(
744            &cycle_b_path,
745            r##"
746id = "cycle-b"
747base = "cycle-a"
748"##,
749        )
750        .expect("cycle b writes");
751        fs::write(
752            &dupe_a_path,
753            r##"
754id = "dupe"
755[palette]
756text = "bogus"
757selection = "#111111"
758link = "#222222"
759bg = "#000000"
760bg_alt = "#010101"
761
762[overrides]
763value_number = "broken"
764repl_completion_text = "#eeeeee"
765repl_completion_background = "#111111"
766repl_completion_highlight = "bad"
767"##,
768        )
769        .expect("dupe a writes");
770        fs::write(
771            &dupe_b_path,
772            r##"
773id = "dupe"
774name = "Dupe Final"
775base = "none"
776[palette]
777text = "#ffffff"
778"##,
779        )
780        .expect("dupe b writes");
781
782        let config = resolved_config_with_theme_paths(vec![
783            missing_dir.display().to_string(),
784            themes_dir.display().to_string(),
785        ]);
786        let catalog = load_theme_catalog(&config);
787
788        let dracula = catalog
789            .resolve("dracula")
790            .expect("custom builtin override should resolve");
791        assert_eq!(dracula.source, ThemeSource::Custom);
792        assert_eq!(dracula.theme.palette.accent, "#123456");
793        assert_eq!(dracula.origin.as_deref(), Some(dracula_path.as_path()));
794
795        let dupe = catalog
796            .resolve("dupe")
797            .expect("latest duplicate should win");
798        assert_eq!(dupe.theme.name, "Dupe Final");
799        assert_eq!(dupe.origin.as_deref(), Some(dupe_b_path.as_path()));
800
801        let messages = catalog
802            .issues
803            .iter()
804            .map(|issue| issue.message.clone())
805            .collect::<Vec<_>>();
806        assert!(
807            messages
808                .iter()
809                .any(|message| message.contains("theme path is not a directory"))
810        );
811        assert!(
812            messages
813                .iter()
814                .any(|message| message.contains("custom theme overrides builtin: dracula"))
815        );
816        assert!(
817            messages
818                .iter()
819                .any(|message| message.contains("failed to parse toml"))
820        );
821        assert!(
822            messages
823                .iter()
824                .any(|message| message.contains("theme id collision: dupe overridden"))
825        );
826        assert!(
827            messages
828                .iter()
829                .any(|message| message.contains("theme base cycle detected"))
830        );
831        assert!(
832            messages
833                .iter()
834                .any(|message| message.contains("invalid color spec for palette.text"))
835        );
836        assert!(
837            messages
838                .iter()
839                .any(|message| message.contains("invalid color spec for overrides.value_number"))
840        );
841
842        log_theme_issues(&catalog.issues);
843    }
844
845    #[test]
846    fn default_theme_paths_tracks_home_config_root_unit() {
847        let _guard = env_lock().lock().expect("env lock should not be poisoned");
848        let original_home = std::env::var("HOME").ok();
849        let original_xdg_config_home = std::env::var("XDG_CONFIG_HOME").ok();
850        unsafe { std::env::set_var("HOME", "/tmp/osp-theme-loader-home") };
851        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
852
853        assert_eq!(
854            default_theme_paths(),
855            vec![Path::new("/tmp/osp-theme-loader-home/.config/osp/themes").to_path_buf()]
856        );
857
858        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/osp-theme-loader-xdg") };
859        assert_eq!(
860            default_theme_paths(),
861            vec![Path::new("/tmp/osp-theme-loader-xdg/osp/themes").to_path_buf()]
862        );
863
864        match original_home {
865            Some(value) => unsafe { std::env::set_var("HOME", value) },
866            None => unsafe { std::env::remove_var("HOME") },
867        }
868        match original_xdg_config_home {
869            Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) },
870            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
871        }
872    }
873}