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 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}