1use crate::config::Config;
8use crate::error::{Error, Result};
9use glamour::{Style as GlamourStyle, StyleConfig as GlamourStyleConfig};
10use lipgloss::Style as LipglossStyle;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone)]
16pub struct TuiStyles {
17 pub title: LipglossStyle,
18 pub muted: LipglossStyle,
19 pub muted_bold: LipglossStyle,
20 pub muted_italic: LipglossStyle,
21 pub accent: LipglossStyle,
22 pub accent_bold: LipglossStyle,
23 pub success_bold: LipglossStyle,
24 pub warning: LipglossStyle,
25 pub warning_bold: LipglossStyle,
26 pub error_bold: LipglossStyle,
27 pub border: LipglossStyle,
28 pub selection: LipglossStyle,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct Theme {
33 pub name: String,
34 pub version: String,
35 pub colors: ThemeColors,
36 pub syntax: SyntaxColors,
37 pub ui: UiColors,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct ThemeColors {
42 pub foreground: String,
43 pub background: String,
44 pub accent: String,
45 pub success: String,
46 pub warning: String,
47 pub error: String,
48 pub muted: String,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct SyntaxColors {
53 pub keyword: String,
54 pub string: String,
55 pub number: String,
56 pub comment: String,
57 pub function: String,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct UiColors {
62 pub border: String,
63 pub selection: String,
64 pub cursor: String,
65}
66
67#[derive(Debug, Clone)]
69pub struct ThemeRoots {
70 pub global_dir: PathBuf,
71 pub project_dir: PathBuf,
72}
73
74impl ThemeRoots {
75 #[must_use]
76 pub fn from_cwd(cwd: &Path) -> Self {
77 Self {
78 global_dir: Config::global_dir(),
79 project_dir: cwd.join(Config::project_dir()),
80 }
81 }
82}
83
84impl Theme {
85 #[must_use]
95 pub fn resolve(config: &Config, cwd: &Path) -> Self {
96 let Some(spec) = config.theme.as_deref() else {
97 return Self::dark();
98 };
99 let spec = spec.trim();
100 if spec.is_empty() {
101 return Self::dark();
102 }
103
104 match Self::resolve_spec(spec, cwd) {
105 Ok(theme) => theme,
106 Err(err) => {
107 tracing::warn!("Failed to load theme '{spec}': {err}");
108 Self::dark()
109 }
110 }
111 }
112
113 pub fn resolve_spec(spec: &str, cwd: &Path) -> Result<Self> {
120 let spec = spec.trim();
121 if spec.is_empty() {
122 return Err(Error::validation("Theme spec is empty"));
123 }
124 if spec.eq_ignore_ascii_case("dark") {
125 return Ok(Self::dark());
126 }
127 if spec.eq_ignore_ascii_case("light") {
128 return Ok(Self::light());
129 }
130 if spec.eq_ignore_ascii_case("solarized") {
131 return Ok(Self::solarized());
132 }
133
134 if looks_like_theme_path(spec) {
135 let path = resolve_theme_path(spec, cwd);
136 if !path.exists() {
137 return Err(Error::config(format!(
138 "Theme file not found: {}",
139 path.display()
140 )));
141 }
142 return Self::load(&path);
143 }
144
145 Self::load_by_name(spec, cwd)
146 }
147
148 #[must_use]
149 pub fn is_light(&self) -> bool {
150 let Some((r, g, b)) = parse_hex_color(&self.colors.background) else {
151 return false;
152 };
153 let r = f64::from(r);
156 let g = f64::from(g);
157 let b = f64::from(b);
158 let luma = 0.0722_f64.mul_add(b, 0.2126_f64.mul_add(r, 0.7152 * g));
159 luma >= 128.0
160 }
161
162 #[must_use]
163 pub fn tui_styles(&self) -> TuiStyles {
164 let title = LipglossStyle::new()
165 .bold()
166 .foreground(self.colors.accent.as_str());
167 let muted = LipglossStyle::new().foreground(self.colors.muted.as_str());
168 let muted_bold = muted.clone().bold();
169 let muted_italic = muted.clone().italic();
170
171 TuiStyles {
172 title,
173 muted,
174 muted_bold,
175 muted_italic,
176 accent: LipglossStyle::new().foreground(self.colors.accent.as_str()),
177 accent_bold: LipglossStyle::new()
178 .foreground(self.colors.accent.as_str())
179 .bold(),
180 success_bold: LipglossStyle::new()
181 .foreground(self.colors.success.as_str())
182 .bold(),
183 warning: LipglossStyle::new().foreground(self.colors.warning.as_str()),
184 warning_bold: LipglossStyle::new()
185 .foreground(self.colors.warning.as_str())
186 .bold(),
187 error_bold: LipglossStyle::new()
188 .foreground(self.colors.error.as_str())
189 .bold(),
190 border: LipglossStyle::new().foreground(self.ui.border.as_str()),
191 selection: LipglossStyle::new()
192 .foreground(self.colors.foreground.as_str())
193 .background(self.ui.selection.as_str())
194 .bold(),
195 }
196 }
197
198 #[must_use]
199 pub fn glamour_style_config(&self) -> GlamourStyleConfig {
200 let mut config = if self.is_light() {
201 GlamourStyle::Light.config()
202 } else {
203 GlamourStyle::Dark.config()
204 };
205
206 config.document.style.color = Some(self.colors.foreground.clone());
207
208 let accent = Some(self.colors.accent.clone());
210 config.heading.style.color.clone_from(&accent);
211 config.h1.style.color.clone_from(&accent);
212 config.h2.style.color.clone_from(&accent);
213 config.h3.style.color.clone_from(&accent);
214 config.h4.style.color.clone_from(&accent);
215 config.h5.style.color.clone_from(&accent);
216 config.h6.style.color.clone_from(&accent);
217
218 config.link.color.clone_from(&accent);
220 config.link_text.color = accent;
221
222 config.strong.color = Some(self.colors.foreground.clone());
224 config.emph.color = Some(self.colors.foreground.clone());
225
226 let code_color = Some(self.syntax.string.clone());
228 config.code.style.color.clone_from(&code_color);
229 config.code_block.block.style.color = code_color;
230
231 config.block_quote.style.color = Some(self.colors.muted.clone());
233
234 config.horizontal_rule.color = Some(self.colors.muted.clone());
236
237 config.item.color = Some(self.colors.foreground.clone());
239 config.enumeration.color = Some(self.colors.foreground.clone());
240
241 config
242 }
243
244 #[must_use]
246 pub fn discover_themes(cwd: &Path) -> Vec<PathBuf> {
247 Self::discover_themes_with_roots(&ThemeRoots::from_cwd(cwd))
248 }
249
250 #[must_use]
252 pub fn discover_themes_with_roots(roots: &ThemeRoots) -> Vec<PathBuf> {
253 let mut paths = Vec::new();
254 paths.extend(glob_json(&roots.global_dir.join("themes")));
255 paths.extend(glob_json(&roots.project_dir.join("themes")));
256 paths.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
257 paths
258 }
259
260 pub fn load(path: &Path) -> Result<Self> {
262 let content = fs::read_to_string(path)?;
263 let theme: Self = serde_json::from_str(&content)?;
264 theme.validate()?;
265 Ok(theme)
266 }
267
268 pub fn load_by_name(name: &str, cwd: &Path) -> Result<Self> {
270 Self::load_by_name_with_roots(name, &ThemeRoots::from_cwd(cwd))
271 }
272
273 pub fn load_by_name_with_roots(name: &str, roots: &ThemeRoots) -> Result<Self> {
279 let name = name.trim();
280 if name.is_empty() {
281 return Err(Error::validation("Theme name is empty"));
282 }
283
284 if let Some(theme) =
286 Self::load_named_theme_from_dir(&roots.project_dir.join("themes"), name)?
287 {
288 return Ok(theme);
289 }
290
291 if let Some(theme) =
292 Self::load_named_theme_from_dir(&roots.global_dir.join("themes"), name)?
293 {
294 return Ok(theme);
295 }
296
297 Err(Error::config(format!("Theme not found: {name}")))
298 }
299
300 fn load_named_theme_from_dir(dir: &Path, name: &str) -> Result<Option<Self>> {
301 for path in glob_json(dir) {
302 match Self::load(&path) {
303 Ok(theme) => {
304 if theme.name.eq_ignore_ascii_case(name) {
305 return Ok(Some(theme));
306 }
307 }
308 Err(err) if theme_path_stem_matches(&path, name) => {
309 return Err(Error::config(format!(
310 "Failed to load theme '{name}' from {}: {err}",
311 path.display()
312 )));
313 }
314 Err(_) => {}
315 }
316 }
317
318 Ok(None)
319 }
320
321 #[must_use]
323 pub fn dark() -> Self {
324 Self {
325 name: "dark".to_string(),
326 version: "1.0".to_string(),
327 colors: ThemeColors {
328 foreground: "#d4d4d4".to_string(),
329 background: "#1e1e1e".to_string(),
330 accent: "#007acc".to_string(),
331 success: "#4ec9b0".to_string(),
332 warning: "#ce9178".to_string(),
333 error: "#f44747".to_string(),
334 muted: "#6a6a6a".to_string(),
335 },
336 syntax: SyntaxColors {
337 keyword: "#569cd6".to_string(),
338 string: "#ce9178".to_string(),
339 number: "#b5cea8".to_string(),
340 comment: "#6a9955".to_string(),
341 function: "#dcdcaa".to_string(),
342 },
343 ui: UiColors {
344 border: "#3c3c3c".to_string(),
345 selection: "#264f78".to_string(),
346 cursor: "#aeafad".to_string(),
347 },
348 }
349 }
350
351 #[must_use]
353 pub fn light() -> Self {
354 Self {
355 name: "light".to_string(),
356 version: "1.0".to_string(),
357 colors: ThemeColors {
358 foreground: "#2d2d2d".to_string(),
359 background: "#ffffff".to_string(),
360 accent: "#0066bf".to_string(),
361 success: "#2e8b57".to_string(),
362 warning: "#b36200".to_string(),
363 error: "#c62828".to_string(),
364 muted: "#7a7a7a".to_string(),
365 },
366 syntax: SyntaxColors {
367 keyword: "#0000ff".to_string(),
368 string: "#a31515".to_string(),
369 number: "#098658".to_string(),
370 comment: "#008000".to_string(),
371 function: "#795e26".to_string(),
372 },
373 ui: UiColors {
374 border: "#c8c8c8".to_string(),
375 selection: "#cce7ff".to_string(),
376 cursor: "#000000".to_string(),
377 },
378 }
379 }
380
381 #[must_use]
383 pub fn solarized() -> Self {
384 Self {
385 name: "solarized".to_string(),
386 version: "1.0".to_string(),
387 colors: ThemeColors {
388 foreground: "#839496".to_string(),
389 background: "#002b36".to_string(),
390 accent: "#268bd2".to_string(),
391 success: "#859900".to_string(),
392 warning: "#b58900".to_string(),
393 error: "#dc322f".to_string(),
394 muted: "#586e75".to_string(),
395 },
396 syntax: SyntaxColors {
397 keyword: "#268bd2".to_string(),
398 string: "#2aa198".to_string(),
399 number: "#d33682".to_string(),
400 comment: "#586e75".to_string(),
401 function: "#b58900".to_string(),
402 },
403 ui: UiColors {
404 border: "#073642".to_string(),
405 selection: "#073642".to_string(),
406 cursor: "#93a1a1".to_string(),
407 },
408 }
409 }
410
411 fn validate(&self) -> Result<()> {
412 if self.name.trim().is_empty() {
413 return Err(Error::validation("Theme name is empty"));
414 }
415 if self.version.trim().is_empty() {
416 return Err(Error::validation("Theme version is empty"));
417 }
418
419 Self::validate_color("colors.foreground", &self.colors.foreground)?;
420 Self::validate_color("colors.background", &self.colors.background)?;
421 Self::validate_color("colors.accent", &self.colors.accent)?;
422 Self::validate_color("colors.success", &self.colors.success)?;
423 Self::validate_color("colors.warning", &self.colors.warning)?;
424 Self::validate_color("colors.error", &self.colors.error)?;
425 Self::validate_color("colors.muted", &self.colors.muted)?;
426
427 Self::validate_color("syntax.keyword", &self.syntax.keyword)?;
428 Self::validate_color("syntax.string", &self.syntax.string)?;
429 Self::validate_color("syntax.number", &self.syntax.number)?;
430 Self::validate_color("syntax.comment", &self.syntax.comment)?;
431 Self::validate_color("syntax.function", &self.syntax.function)?;
432
433 Self::validate_color("ui.border", &self.ui.border)?;
434 Self::validate_color("ui.selection", &self.ui.selection)?;
435 Self::validate_color("ui.cursor", &self.ui.cursor)?;
436
437 Ok(())
438 }
439
440 fn validate_color(field: &str, value: &str) -> Result<()> {
441 let value = value.trim();
442 if !value.starts_with('#') || value.len() != 7 {
443 return Err(Error::validation(format!(
444 "Invalid color for {field}: {value}"
445 )));
446 }
447 if !value[1..].chars().all(|c| c.is_ascii_hexdigit()) {
448 return Err(Error::validation(format!(
449 "Invalid color for {field}: {value}"
450 )));
451 }
452 Ok(())
453 }
454}
455
456fn glob_json(dir: &Path) -> Vec<PathBuf> {
457 if !dir.exists() {
458 return Vec::new();
459 }
460 let Ok(entries) = fs::read_dir(dir) else {
461 return Vec::new();
462 };
463 let mut out = Vec::new();
464 for entry in entries.flatten() {
465 let path = entry.path();
466 if !path.is_file() {
467 continue;
468 }
469 if path
470 .extension()
471 .and_then(|ext| ext.to_str())
472 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
473 {
474 out.push(path);
475 }
476 }
477 out
478}
479
480#[must_use]
483pub fn looks_like_theme_path(spec: &str) -> bool {
484 let spec = spec.trim();
485 if spec.starts_with('~') {
486 return true;
487 }
488 if Path::new(spec)
489 .extension()
490 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
491 {
492 return true;
493 }
494 spec.contains('/') || spec.contains('\\')
495}
496
497fn theme_path_stem_matches(path: &Path, name: &str) -> bool {
498 path.file_stem()
499 .and_then(|stem| stem.to_str())
500 .is_some_and(|stem| stem.eq_ignore_ascii_case(name))
501}
502
503fn resolve_theme_path(spec: &str, cwd: &Path) -> PathBuf {
504 let trimmed = spec.trim();
505
506 if trimmed == "~" {
507 return dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
508 }
509 if let Some(rest) = trimmed.strip_prefix("~/") {
510 return dirs::home_dir()
511 .unwrap_or_else(|| cwd.to_path_buf())
512 .join(rest);
513 }
514 if let Some(rest) = trimmed.strip_prefix('~') {
515 return dirs::home_dir()
516 .unwrap_or_else(|| cwd.to_path_buf())
517 .join(rest);
518 }
519
520 let path = PathBuf::from(trimmed);
521 if path.is_absolute() {
522 path
523 } else {
524 cwd.join(path)
525 }
526}
527
528fn parse_hex_color(value: &str) -> Option<(u8, u8, u8)> {
529 let value = value.trim();
530 let hex = value.strip_prefix('#')?;
531 if hex.len() != 6 || !hex.is_ascii() {
532 return None;
533 }
534
535 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
536 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
537 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
538 Some((r, g, b))
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn load_valid_theme_json() {
547 let dir = tempfile::tempdir().expect("tempdir");
548 let path = dir.path().join("dark.json");
549 let json = serde_json::json!({
550 "name": "test-dark",
551 "version": "1.0",
552 "colors": {
553 "foreground": "#ffffff",
554 "background": "#000000",
555 "accent": "#123456",
556 "success": "#00ff00",
557 "warning": "#ffcc00",
558 "error": "#ff0000",
559 "muted": "#888888"
560 },
561 "syntax": {
562 "keyword": "#111111",
563 "string": "#222222",
564 "number": "#333333",
565 "comment": "#444444",
566 "function": "#555555"
567 },
568 "ui": {
569 "border": "#666666",
570 "selection": "#777777",
571 "cursor": "#888888"
572 }
573 });
574 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
575
576 let theme = Theme::load(&path).expect("load theme");
577 assert_eq!(theme.name, "test-dark");
578 assert_eq!(theme.version, "1.0");
579 }
580
581 #[test]
582 fn rejects_invalid_json() {
583 let dir = tempfile::tempdir().expect("tempdir");
584 let path = dir.path().join("broken.json");
585 fs::write(&path, "{this is not json").unwrap();
586 let err = Theme::load(&path).unwrap_err();
587 assert!(
588 matches!(&err, Error::Json(_)),
589 "expected json error, got {err:?}"
590 );
591 }
592
593 #[test]
594 fn rejects_invalid_colors() {
595 let dir = tempfile::tempdir().expect("tempdir");
596 let path = dir.path().join("bad.json");
597 let json = serde_json::json!({
598 "name": "bad",
599 "version": "1.0",
600 "colors": {
601 "foreground": "red",
602 "background": "#000000",
603 "accent": "#123456",
604 "success": "#00ff00",
605 "warning": "#ffcc00",
606 "error": "#ff0000",
607 "muted": "#888888"
608 },
609 "syntax": {
610 "keyword": "#111111",
611 "string": "#222222",
612 "number": "#333333",
613 "comment": "#444444",
614 "function": "#555555"
615 },
616 "ui": {
617 "border": "#666666",
618 "selection": "#777777",
619 "cursor": "#888888"
620 }
621 });
622 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
623
624 let err = Theme::load(&path).unwrap_err();
625 assert!(
626 matches!(&err, Error::Validation(_)),
627 "expected validation error, got {err:?}"
628 );
629 }
630
631 #[test]
632 fn discover_themes_from_roots() {
633 let dir = tempfile::tempdir().expect("tempdir");
634 let global = dir.path().join("global");
635 let project = dir.path().join("project");
636 let global_theme_dir = global.join("themes");
637 let project_theme_dir = project.join("themes");
638 fs::create_dir_all(&global_theme_dir).unwrap();
639 fs::create_dir_all(&project_theme_dir).unwrap();
640 fs::write(global_theme_dir.join("g.json"), "{}").unwrap();
641 fs::write(project_theme_dir.join("p.json"), "{}").unwrap();
642
643 let roots = ThemeRoots {
644 global_dir: global,
645 project_dir: project,
646 };
647 let themes = Theme::discover_themes_with_roots(&roots);
648 assert_eq!(themes.len(), 2);
649 }
650
651 #[test]
652 fn default_themes_validate() {
653 Theme::dark().validate().expect("dark theme valid");
654 Theme::light().validate().expect("light theme valid");
655 Theme::solarized()
656 .validate()
657 .expect("solarized theme valid");
658 }
659
660 #[test]
661 fn resolve_spec_supports_builtins() {
662 let cwd = Path::new(".");
663 assert_eq!(Theme::resolve_spec("dark", cwd).unwrap().name, "dark");
664 assert_eq!(Theme::resolve_spec("light", cwd).unwrap().name, "light");
665 assert_eq!(
666 Theme::resolve_spec("solarized", cwd).unwrap().name,
667 "solarized"
668 );
669 }
670
671 #[test]
672 fn resolve_spec_loads_from_path() {
673 let dir = tempfile::tempdir().expect("tempdir");
674 let path = dir.path().join("custom.json");
675 let json = serde_json::json!({
676 "name": "custom",
677 "version": "1.0",
678 "colors": {
679 "foreground": "#ffffff",
680 "background": "#000000",
681 "accent": "#123456",
682 "success": "#00ff00",
683 "warning": "#ffcc00",
684 "error": "#ff0000",
685 "muted": "#888888"
686 },
687 "syntax": {
688 "keyword": "#111111",
689 "string": "#222222",
690 "number": "#333333",
691 "comment": "#444444",
692 "function": "#555555"
693 },
694 "ui": {
695 "border": "#666666",
696 "selection": "#777777",
697 "cursor": "#888888"
698 }
699 });
700 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
701
702 let theme = Theme::resolve_spec(path.to_str().unwrap(), dir.path()).expect("resolve spec");
703 assert_eq!(theme.name, "custom");
704 }
705
706 #[test]
707 fn resolve_spec_errors_on_missing_path() {
708 let cwd = tempfile::tempdir().expect("tempdir");
709 let err = Theme::resolve_spec("does-not-exist.json", cwd.path()).unwrap_err();
710 assert!(
711 matches!(err, Error::Config(_)),
712 "expected config error, got {err:?}"
713 );
714 }
715
716 #[test]
717 fn looks_like_theme_path_detects_names_and_paths() {
718 assert!(!looks_like_theme_path("dark"));
719 assert!(!looks_like_theme_path("custom-theme"));
720 assert!(looks_like_theme_path("dark.json"));
721 assert!(looks_like_theme_path("themes/dark"));
722 assert!(looks_like_theme_path(r"themes\dark"));
723 assert!(looks_like_theme_path("~/themes/dark.json"));
724 }
725
726 #[test]
727 fn resolve_theme_path_handles_home_relative_and_absolute() {
728 let cwd = Path::new("/work/cwd");
729 let home = dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
730
731 assert_eq!(
732 resolve_theme_path("themes/dark.json", cwd),
733 cwd.join("themes/dark.json")
734 );
735 assert_eq!(
736 resolve_theme_path("/tmp/theme.json", cwd),
737 PathBuf::from("/tmp/theme.json")
738 );
739 assert_eq!(resolve_theme_path("~", cwd), home);
740 assert_eq!(
741 resolve_theme_path("~/themes/dark.json", cwd),
742 home.join("themes/dark.json")
743 );
744 assert_eq!(resolve_theme_path("~custom", cwd), home.join("custom"));
745 }
746
747 #[test]
748 fn parse_hex_color_trims_and_rejects_invalid_inputs() {
749 assert_eq!(parse_hex_color(" #A0b1C2 "), Some((160, 177, 194)));
750 assert_eq!(parse_hex_color("A0b1C2"), None);
751 assert_eq!(parse_hex_color("#123"), None);
752 assert_eq!(parse_hex_color("#12345G"), None);
753 }
754
755 #[test]
756 fn is_light_uses_background_luminance_threshold() {
757 let mut theme = Theme::dark();
758 theme.colors.background = "#808080".to_string();
759 assert!(theme.is_light(), "mid-gray should be treated as light");
760
761 theme.colors.background = "#7f7f7f".to_string();
762 assert!(!theme.is_light(), "just below threshold should be dark");
763
764 theme.colors.background = "not-a-color".to_string();
765 assert!(!theme.is_light(), "invalid colors should default to dark");
766 }
767
768 #[test]
769 fn resolve_falls_back_to_dark_for_invalid_spec() {
770 let cfg = Config {
771 theme: Some("does-not-exist".to_string()),
772 ..Default::default()
773 };
774 let cwd = tempfile::tempdir().expect("tempdir");
775 let resolved = Theme::resolve(&cfg, cwd.path());
776 assert_eq!(resolved.name, "dark");
777 }
778
779 #[test]
782 fn resolve_defaults_to_dark_when_no_theme_set() {
783 let cfg = Config {
784 theme: None,
785 ..Default::default()
786 };
787 let cwd = tempfile::tempdir().expect("tempdir");
788 let resolved = Theme::resolve(&cfg, cwd.path());
789 assert_eq!(resolved.name, "dark");
790 }
791
792 #[test]
793 fn resolve_defaults_to_dark_when_theme_is_empty() {
794 let cfg = Config {
795 theme: Some(String::new()),
796 ..Default::default()
797 };
798 let cwd = tempfile::tempdir().expect("tempdir");
799 let resolved = Theme::resolve(&cfg, cwd.path());
800 assert_eq!(resolved.name, "dark");
801 }
802
803 #[test]
804 fn resolve_defaults_to_dark_when_theme_is_whitespace() {
805 let cfg = Config {
806 theme: Some(" ".to_string()),
807 ..Default::default()
808 };
809 let cwd = tempfile::tempdir().expect("tempdir");
810 let resolved = Theme::resolve(&cfg, cwd.path());
811 assert_eq!(resolved.name, "dark");
812 }
813
814 #[test]
817 fn resolve_spec_case_insensitive() {
818 let cwd = Path::new(".");
819 assert_eq!(Theme::resolve_spec("DARK", cwd).unwrap().name, "dark");
820 assert_eq!(Theme::resolve_spec("Light", cwd).unwrap().name, "light");
821 assert_eq!(
822 Theme::resolve_spec("SOLARIZED", cwd).unwrap().name,
823 "solarized"
824 );
825 }
826
827 #[test]
828 fn resolve_spec_empty_returns_error() {
829 let err = Theme::resolve_spec("", Path::new(".")).unwrap_err();
830 assert!(matches!(err, Error::Validation(_)));
831 }
832
833 #[test]
836 fn validate_color_valid() {
837 assert!(Theme::validate_color("test", "#000000").is_ok());
838 assert!(Theme::validate_color("test", "#ffffff").is_ok());
839 assert!(Theme::validate_color("test", "#AbCdEf").is_ok());
840 }
841
842 #[test]
843 fn validate_color_invalid_no_hash() {
844 assert!(Theme::validate_color("test", "000000").is_err());
845 }
846
847 #[test]
848 fn validate_color_invalid_too_short() {
849 assert!(Theme::validate_color("test", "#123").is_err());
850 }
851
852 #[test]
853 fn validate_color_invalid_chars() {
854 assert!(Theme::validate_color("test", "#ZZZZZZ").is_err());
855 }
856
857 #[test]
860 fn validate_rejects_empty_name() {
861 let mut theme = Theme::dark();
862 theme.name = String::new();
863 assert!(theme.validate().is_err());
864 }
865
866 #[test]
867 fn validate_rejects_empty_version() {
868 let mut theme = Theme::dark();
869 theme.version = " ".to_string();
870 assert!(theme.validate().is_err());
871 }
872
873 #[test]
876 fn dark_theme_is_not_light() {
877 assert!(!Theme::dark().is_light());
878 }
879
880 #[test]
881 fn light_theme_is_light() {
882 assert!(Theme::light().is_light());
883 }
884
885 #[test]
888 fn parse_hex_color_black_and_white() {
889 assert_eq!(parse_hex_color("#000000"), Some((0, 0, 0)));
890 assert_eq!(parse_hex_color("#ffffff"), Some((255, 255, 255)));
891 }
892
893 #[test]
894 fn parse_hex_color_empty_returns_none() {
895 assert_eq!(parse_hex_color(""), None);
896 }
897
898 #[test]
901 fn glob_json_nonexistent_dir() {
902 let result = glob_json(Path::new("/nonexistent/dir"));
903 assert!(result.is_empty());
904 }
905
906 #[test]
907 fn glob_json_dir_with_non_json_files() {
908 let dir = tempfile::tempdir().expect("tempdir");
909 fs::write(dir.path().join("readme.txt"), "hi").unwrap();
910 fs::write(dir.path().join("theme.json"), "{}").unwrap();
911 fs::write(dir.path().join("other.toml"), "").unwrap();
912
913 let result = glob_json(dir.path());
914 assert_eq!(result.len(), 1);
915 assert!(result[0].to_string_lossy().ends_with("theme.json"));
916 }
917
918 #[test]
921 fn discover_themes_empty_dirs() {
922 let dir = tempfile::tempdir().expect("tempdir");
923 let roots = ThemeRoots {
924 global_dir: dir.path().join("global"),
925 project_dir: dir.path().join("project"),
926 };
927 let themes = Theme::discover_themes_with_roots(&roots);
928 assert!(themes.is_empty());
929 }
930
931 #[test]
934 fn theme_serde_roundtrip() {
935 let theme = Theme::dark();
936 let json = serde_json::to_string(&theme).unwrap();
937 let theme2: Theme = serde_json::from_str(&json).unwrap();
938 assert_eq!(theme.name, theme2.name);
939 assert_eq!(theme.colors.foreground, theme2.colors.foreground);
940 }
941
942 #[test]
945 fn load_by_name_empty_name_returns_error() {
946 let dir = tempfile::tempdir().expect("tempdir");
947 let roots = ThemeRoots {
948 global_dir: dir.path().join("global"),
949 project_dir: dir.path().join("project"),
950 };
951 let err = Theme::load_by_name_with_roots("", &roots).unwrap_err();
952 assert!(matches!(err, Error::Validation(_)));
953 }
954
955 #[test]
956 fn load_by_name_not_found_returns_error() {
957 let dir = tempfile::tempdir().expect("tempdir");
958 let roots = ThemeRoots {
959 global_dir: dir.path().join("global"),
960 project_dir: dir.path().join("project"),
961 };
962 let err = Theme::load_by_name_with_roots("nonexistent", &roots).unwrap_err();
963 assert!(matches!(err, Error::Config(_)));
964 }
965
966 #[test]
967 fn load_by_name_finds_theme_in_global_dir() {
968 let dir = tempfile::tempdir().expect("tempdir");
969 let global_themes = dir.path().join("global/themes");
970 fs::create_dir_all(&global_themes).unwrap();
971
972 let theme = Theme::dark();
973 let mut custom = theme;
974 custom.name = "mycustom".to_string();
975 let json = serde_json::to_string_pretty(&custom).unwrap();
976 fs::write(global_themes.join("mycustom.json"), json).unwrap();
977
978 let roots = ThemeRoots {
979 global_dir: dir.path().join("global"),
980 project_dir: dir.path().join("project"),
981 };
982 let loaded = Theme::load_by_name_with_roots("mycustom", &roots).unwrap();
983 assert_eq!(loaded.name, "mycustom");
984 }
985
986 #[test]
987 fn load_by_name_project_overrides_global() {
988 let dir = tempfile::tempdir().expect("tempdir");
989 let global_themes = dir.path().join("global/themes");
990 let project_themes = dir.path().join("project/themes");
991 fs::create_dir_all(&global_themes).unwrap();
992 fs::create_dir_all(&project_themes).unwrap();
993
994 let mut global_theme = Theme::dark();
996 global_theme.name = "shared".to_string();
997 global_theme.colors.accent = "#111111".to_string();
998 let json = serde_json::to_string_pretty(&global_theme).unwrap();
999 fs::write(global_themes.join("shared.json"), json).unwrap();
1000
1001 let mut project_theme = Theme::dark();
1003 project_theme.name = "shared".to_string();
1004 project_theme.colors.accent = "#222222".to_string();
1005 let json = serde_json::to_string_pretty(&project_theme).unwrap();
1006 fs::write(project_themes.join("shared.json"), json).unwrap();
1007
1008 let roots = ThemeRoots {
1009 global_dir: dir.path().join("global"),
1010 project_dir: dir.path().join("project"),
1011 };
1012 let loaded = Theme::load_by_name_with_roots("shared", &roots).unwrap();
1013 assert_eq!(
1014 loaded.colors.accent, "#222222",
1015 "project theme should override global theme with the same name"
1016 );
1017 }
1018
1019 #[test]
1020 fn load_by_name_invalid_project_override_does_not_fall_back_to_global() {
1021 let dir = tempfile::tempdir().expect("tempdir");
1022 let global_themes = dir.path().join("global/themes");
1023 let project_themes = dir.path().join("project/themes");
1024 fs::create_dir_all(&global_themes).unwrap();
1025 fs::create_dir_all(&project_themes).unwrap();
1026
1027 let mut global_theme = Theme::dark();
1028 global_theme.name = "shared".to_string();
1029 let json = serde_json::to_string_pretty(&global_theme).unwrap();
1030 fs::write(global_themes.join("shared.json"), json).unwrap();
1031 fs::write(project_themes.join("shared.json"), "{ not valid json").unwrap();
1032
1033 let roots = ThemeRoots {
1034 global_dir: dir.path().join("global"),
1035 project_dir: dir.path().join("project"),
1036 };
1037 let err = Theme::load_by_name_with_roots("shared", &roots).unwrap_err();
1038 let message = err.to_string();
1039 assert!(
1040 message.contains("Failed to load theme 'shared'"),
1041 "unexpected error: {message}"
1042 );
1043 assert!(
1044 message.contains("project/themes/shared.json"),
1045 "unexpected error: {message}"
1046 );
1047 }
1048
1049 #[test]
1052 fn tui_styles_returns_valid_struct() {
1053 let styles = Theme::dark().tui_styles();
1054 let _ = format!("{:?}", styles.title);
1056 let _ = format!("{:?}", styles.muted);
1057 let _ = format!("{:?}", styles.accent);
1058 let _ = format!("{:?}", styles.error_bold);
1059 }
1060
1061 #[test]
1062 fn glamour_style_config_smoke() {
1063 let dark_config = Theme::dark().glamour_style_config();
1064 let light_config = Theme::light().glamour_style_config();
1065 assert!(dark_config.document.style.color.is_some());
1067 assert!(light_config.document.style.color.is_some());
1068 }
1069
1070 mod proptest_theme {
1071 use super::*;
1072 use proptest::prelude::*;
1073
1074 proptest! {
1075 #[test]
1077 fn parse_hex_never_panics(s in ".{0,20}") {
1078 let _ = parse_hex_color(&s);
1079 }
1080
1081 #[test]
1083 fn parse_hex_valid(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
1084 let hex = format!("#{r:02x}{g:02x}{b:02x}");
1085 let parsed = parse_hex_color(&hex);
1086 assert_eq!(parsed, Some((r, g, b)));
1087 }
1088
1089 #[test]
1091 fn parse_hex_case_insensitive(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
1092 let upper = format!("#{r:02X}{g:02X}{b:02X}");
1093 let lower = format!("#{r:02x}{g:02x}{b:02x}");
1094 assert_eq!(parse_hex_color(&upper), parse_hex_color(&lower));
1095 }
1096
1097 #[test]
1099 fn parse_hex_missing_hash(hex in "[0-9a-f]{6}") {
1100 assert!(parse_hex_color(&hex).is_none());
1101 }
1102
1103 #[test]
1105 fn parse_hex_wrong_length(n in 1..10usize) {
1106 if n == 6 { return Ok(()); }
1107 let hex = format!("#{}", "a".repeat(n));
1108 assert!(parse_hex_color(&hex).is_none());
1109 }
1110
1111 #[test]
1113 fn parse_hex_trims(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255, ws in "[ \\t]{0,3}") {
1114 let hex = format!("{ws}#{r:02x}{g:02x}{b:02x}{ws}");
1115 assert_eq!(parse_hex_color(&hex), Some((r, g, b)));
1116 }
1117
1118 #[test]
1120 fn theme_path_tilde(suffix in "[a-z/]{0,20}") {
1121 assert!(looks_like_theme_path(&format!("~{suffix}")));
1122 }
1123
1124 #[test]
1126 fn theme_path_json_ext(name in "[a-z]{1,10}") {
1127 assert!(looks_like_theme_path(&format!("{name}.json")));
1128 }
1129
1130 #[test]
1132 fn theme_path_with_slash(a in "[a-z]{1,10}", b in "[a-z]{1,10}") {
1133 assert!(looks_like_theme_path(&format!("{a}/{b}")));
1134 }
1135
1136 #[test]
1138 fn theme_path_plain_name(name in "[a-z]{1,10}") {
1139 assert!(!looks_like_theme_path(&name));
1140 }
1141
1142 #[test]
1144 fn is_light_boundary(_dummy in 0..1u8) {
1145 let mut dark = Theme::dark();
1146 dark.colors.background = "#000000".to_string();
1147 assert!(!dark.is_light());
1148
1149 dark.colors.background = "#ffffff".to_string();
1150 assert!(dark.is_light());
1151 }
1152
1153 #[test]
1155 fn is_light_luminance(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
1156 let mut theme = Theme::dark();
1157 theme.colors.background = format!("#{r:02x}{g:02x}{b:02x}");
1158 let luma =
1159 0.0722_f64.mul_add(f64::from(b), 0.2126_f64.mul_add(f64::from(r), 0.7152 * f64::from(g)));
1160 assert_eq!(theme.is_light(), luma >= 128.0);
1161 }
1162
1163 #[test]
1165 fn is_light_invalid_color(s in "[a-z]{3,10}") {
1166 let mut theme = Theme::dark();
1167 theme.colors.background = s;
1168 assert!(!theme.is_light());
1169 }
1170
1171 #[test]
1173 fn theme_dark_serde_roundtrip(_dummy in 0..1u8) {
1174 let theme = Theme::dark();
1175 let json = serde_json::to_string(&theme).unwrap();
1176 let back: Theme = serde_json::from_str(&json).unwrap();
1177 assert_eq!(back.name, theme.name);
1178 assert_eq!(back.colors.background, theme.colors.background);
1179 }
1180
1181 #[test]
1183 fn theme_light_serde_roundtrip(_dummy in 0..1u8) {
1184 let theme = Theme::light();
1185 let json = serde_json::to_string(&theme).unwrap();
1186 let back: Theme = serde_json::from_str(&json).unwrap();
1187 assert_eq!(back.name, theme.name);
1188 assert_eq!(back.colors.background, theme.colors.background);
1189 }
1190
1191 #[test]
1193 fn resolve_absolute_path(suffix in "[a-z]{1,20}") {
1194 let abs = format!("/tmp/{suffix}.json");
1195 let resolved = resolve_theme_path(&abs, Path::new("/cwd"));
1196 assert_eq!(resolved, PathBuf::from(&abs));
1197 }
1198
1199 #[test]
1201 fn resolve_relative_path(name in "[a-z]{1,10}") {
1202 let cwd = Path::new("/some/dir");
1203 let resolved = resolve_theme_path(&name, cwd);
1204 assert_eq!(resolved, cwd.join(&name));
1205 }
1206
1207 #[test]
1209 fn theme_validate_accepts_generated_valid_palette(
1210 name in "[a-z][a-z0-9_-]{0,15}",
1211 version in "[0-9]{1,2}\\.[0-9]{1,2}",
1212 palette in proptest::collection::vec((0u8..=255, 0u8..=255, 0u8..=255), 15)
1213 ) {
1214 let mut colors = palette.into_iter();
1215 let next_hex = |colors: &mut std::vec::IntoIter<(u8, u8, u8)>| -> String {
1216 let (r, g, b) = colors.next().expect("palette length is fixed to 15");
1217 format!("#{r:02x}{g:02x}{b:02x}")
1218 };
1219
1220 let mut theme = Theme::dark();
1221 theme.name = name;
1222 theme.version = version;
1223
1224 theme.colors.foreground = next_hex(&mut colors);
1225 theme.colors.background = next_hex(&mut colors);
1226 theme.colors.accent = next_hex(&mut colors);
1227 theme.colors.success = next_hex(&mut colors);
1228 theme.colors.warning = next_hex(&mut colors);
1229 theme.colors.error = next_hex(&mut colors);
1230 theme.colors.muted = next_hex(&mut colors);
1231
1232 theme.syntax.keyword = next_hex(&mut colors);
1233 theme.syntax.string = next_hex(&mut colors);
1234 theme.syntax.number = next_hex(&mut colors);
1235 theme.syntax.comment = next_hex(&mut colors);
1236 theme.syntax.function = next_hex(&mut colors);
1237
1238 theme.ui.border = next_hex(&mut colors);
1239 theme.ui.selection = next_hex(&mut colors);
1240 theme.ui.cursor = next_hex(&mut colors);
1241
1242 assert!(theme.validate().is_ok());
1243 }
1244
1245 #[test]
1247 fn theme_validate_rejects_invalid_color_fields(field_idx in 0usize..15usize) {
1248 let mut theme = Theme::dark();
1249 let invalid = "not-a-color".to_string();
1250
1251 match field_idx {
1252 0 => theme.colors.foreground = invalid,
1253 1 => theme.colors.background = invalid,
1254 2 => theme.colors.accent = invalid,
1255 3 => theme.colors.success = invalid,
1256 4 => theme.colors.warning = invalid,
1257 5 => theme.colors.error = invalid,
1258 6 => theme.colors.muted = invalid,
1259 7 => theme.syntax.keyword = invalid,
1260 8 => theme.syntax.string = invalid,
1261 9 => theme.syntax.number = invalid,
1262 10 => theme.syntax.comment = invalid,
1263 11 => theme.syntax.function = invalid,
1264 12 => theme.ui.border = invalid,
1265 13 => theme.ui.selection = invalid,
1266 14 => theme.ui.cursor = invalid,
1267 _ => unreachable!("field_idx range is 0..15"),
1268 }
1269
1270 assert!(theme.validate().is_err());
1271 }
1272 }
1273 }
1274}