1use super::{AnsiColor, Color, Role, Theme};
8use serde::Deserialize;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
14pub struct RegisteredTheme {
15 pub theme: Theme,
16 pub source: ThemeSource,
17}
18
19#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum ThemeSource {
24 BuiltIn,
25 UserFile(PathBuf),
26}
27
28#[derive(Debug, Clone, Default)]
35pub struct ThemeRegistry {
36 themes: Vec<RegisteredTheme>,
37}
38
39impl ThemeRegistry {
40 #[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 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 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(); 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 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 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 #[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 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#[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
191const 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
218fn 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 #[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 #[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
419pub(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 #[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 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 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 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 #[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 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 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 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 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 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 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 assert!(built_in("default").is_some());
977 }
978
979 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}