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> {
275 let name = name.trim();
276 if name.is_empty() {
277 return Err(Error::validation("Theme name is empty"));
278 }
279
280 for path in Self::discover_themes_with_roots(roots) {
281 if let Ok(theme) = Self::load(&path) {
282 if theme.name.eq_ignore_ascii_case(name) {
283 return Ok(theme);
284 }
285 }
286 }
287
288 Err(Error::config(format!("Theme not found: {name}")))
289 }
290
291 #[must_use]
293 pub fn dark() -> Self {
294 Self {
295 name: "dark".to_string(),
296 version: "1.0".to_string(),
297 colors: ThemeColors {
298 foreground: "#d4d4d4".to_string(),
299 background: "#1e1e1e".to_string(),
300 accent: "#007acc".to_string(),
301 success: "#4ec9b0".to_string(),
302 warning: "#ce9178".to_string(),
303 error: "#f44747".to_string(),
304 muted: "#6a6a6a".to_string(),
305 },
306 syntax: SyntaxColors {
307 keyword: "#569cd6".to_string(),
308 string: "#ce9178".to_string(),
309 number: "#b5cea8".to_string(),
310 comment: "#6a9955".to_string(),
311 function: "#dcdcaa".to_string(),
312 },
313 ui: UiColors {
314 border: "#3c3c3c".to_string(),
315 selection: "#264f78".to_string(),
316 cursor: "#aeafad".to_string(),
317 },
318 }
319 }
320
321 #[must_use]
323 pub fn light() -> Self {
324 Self {
325 name: "light".to_string(),
326 version: "1.0".to_string(),
327 colors: ThemeColors {
328 foreground: "#2d2d2d".to_string(),
329 background: "#ffffff".to_string(),
330 accent: "#0066bf".to_string(),
331 success: "#2e8b57".to_string(),
332 warning: "#b36200".to_string(),
333 error: "#c62828".to_string(),
334 muted: "#7a7a7a".to_string(),
335 },
336 syntax: SyntaxColors {
337 keyword: "#0000ff".to_string(),
338 string: "#a31515".to_string(),
339 number: "#098658".to_string(),
340 comment: "#008000".to_string(),
341 function: "#795e26".to_string(),
342 },
343 ui: UiColors {
344 border: "#c8c8c8".to_string(),
345 selection: "#cce7ff".to_string(),
346 cursor: "#000000".to_string(),
347 },
348 }
349 }
350
351 #[must_use]
353 pub fn solarized() -> Self {
354 Self {
355 name: "solarized".to_string(),
356 version: "1.0".to_string(),
357 colors: ThemeColors {
358 foreground: "#839496".to_string(),
359 background: "#002b36".to_string(),
360 accent: "#268bd2".to_string(),
361 success: "#859900".to_string(),
362 warning: "#b58900".to_string(),
363 error: "#dc322f".to_string(),
364 muted: "#586e75".to_string(),
365 },
366 syntax: SyntaxColors {
367 keyword: "#268bd2".to_string(),
368 string: "#2aa198".to_string(),
369 number: "#d33682".to_string(),
370 comment: "#586e75".to_string(),
371 function: "#b58900".to_string(),
372 },
373 ui: UiColors {
374 border: "#073642".to_string(),
375 selection: "#073642".to_string(),
376 cursor: "#93a1a1".to_string(),
377 },
378 }
379 }
380
381 fn validate(&self) -> Result<()> {
382 if self.name.trim().is_empty() {
383 return Err(Error::validation("Theme name is empty"));
384 }
385 if self.version.trim().is_empty() {
386 return Err(Error::validation("Theme version is empty"));
387 }
388
389 Self::validate_color("colors.foreground", &self.colors.foreground)?;
390 Self::validate_color("colors.background", &self.colors.background)?;
391 Self::validate_color("colors.accent", &self.colors.accent)?;
392 Self::validate_color("colors.success", &self.colors.success)?;
393 Self::validate_color("colors.warning", &self.colors.warning)?;
394 Self::validate_color("colors.error", &self.colors.error)?;
395 Self::validate_color("colors.muted", &self.colors.muted)?;
396
397 Self::validate_color("syntax.keyword", &self.syntax.keyword)?;
398 Self::validate_color("syntax.string", &self.syntax.string)?;
399 Self::validate_color("syntax.number", &self.syntax.number)?;
400 Self::validate_color("syntax.comment", &self.syntax.comment)?;
401 Self::validate_color("syntax.function", &self.syntax.function)?;
402
403 Self::validate_color("ui.border", &self.ui.border)?;
404 Self::validate_color("ui.selection", &self.ui.selection)?;
405 Self::validate_color("ui.cursor", &self.ui.cursor)?;
406
407 Ok(())
408 }
409
410 fn validate_color(field: &str, value: &str) -> Result<()> {
411 let value = value.trim();
412 if !value.starts_with('#') || value.len() != 7 {
413 return Err(Error::validation(format!(
414 "Invalid color for {field}: {value}"
415 )));
416 }
417 if !value[1..].chars().all(|c| c.is_ascii_hexdigit()) {
418 return Err(Error::validation(format!(
419 "Invalid color for {field}: {value}"
420 )));
421 }
422 Ok(())
423 }
424}
425
426fn glob_json(dir: &Path) -> Vec<PathBuf> {
427 if !dir.exists() {
428 return Vec::new();
429 }
430 let Ok(entries) = fs::read_dir(dir) else {
431 return Vec::new();
432 };
433 let mut out = Vec::new();
434 for entry in entries.flatten() {
435 let path = entry.path();
436 if !path.is_file() {
437 continue;
438 }
439 if path
440 .extension()
441 .and_then(|ext| ext.to_str())
442 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
443 {
444 out.push(path);
445 }
446 }
447 out
448}
449
450#[must_use]
453pub fn looks_like_theme_path(spec: &str) -> bool {
454 let spec = spec.trim();
455 if spec.starts_with('~') {
456 return true;
457 }
458 if Path::new(spec)
459 .extension()
460 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
461 {
462 return true;
463 }
464 spec.contains('/') || spec.contains('\\')
465}
466
467fn resolve_theme_path(spec: &str, cwd: &Path) -> PathBuf {
468 let trimmed = spec.trim();
469
470 if trimmed == "~" {
471 return dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
472 }
473 if let Some(rest) = trimmed.strip_prefix("~/") {
474 return dirs::home_dir()
475 .unwrap_or_else(|| cwd.to_path_buf())
476 .join(rest);
477 }
478 if let Some(rest) = trimmed.strip_prefix('~') {
479 return dirs::home_dir()
480 .unwrap_or_else(|| cwd.to_path_buf())
481 .join(rest);
482 }
483
484 let path = PathBuf::from(trimmed);
485 if path.is_absolute() {
486 path
487 } else {
488 cwd.join(path)
489 }
490}
491
492fn parse_hex_color(value: &str) -> Option<(u8, u8, u8)> {
493 let value = value.trim();
494 let hex = value.strip_prefix('#')?;
495 if hex.len() != 6 {
496 return None;
497 }
498
499 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
500 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
501 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
502 Some((r, g, b))
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn load_valid_theme_json() {
511 let dir = tempfile::tempdir().expect("tempdir");
512 let path = dir.path().join("dark.json");
513 let json = serde_json::json!({
514 "name": "test-dark",
515 "version": "1.0",
516 "colors": {
517 "foreground": "#ffffff",
518 "background": "#000000",
519 "accent": "#123456",
520 "success": "#00ff00",
521 "warning": "#ffcc00",
522 "error": "#ff0000",
523 "muted": "#888888"
524 },
525 "syntax": {
526 "keyword": "#111111",
527 "string": "#222222",
528 "number": "#333333",
529 "comment": "#444444",
530 "function": "#555555"
531 },
532 "ui": {
533 "border": "#666666",
534 "selection": "#777777",
535 "cursor": "#888888"
536 }
537 });
538 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
539
540 let theme = Theme::load(&path).expect("load theme");
541 assert_eq!(theme.name, "test-dark");
542 assert_eq!(theme.version, "1.0");
543 }
544
545 #[test]
546 fn rejects_invalid_json() {
547 let dir = tempfile::tempdir().expect("tempdir");
548 let path = dir.path().join("broken.json");
549 fs::write(&path, "{this is not json").unwrap();
550 let err = Theme::load(&path).unwrap_err();
551 assert!(
552 matches!(&err, Error::Json(_)),
553 "expected json error, got {err:?}"
554 );
555 }
556
557 #[test]
558 fn rejects_invalid_colors() {
559 let dir = tempfile::tempdir().expect("tempdir");
560 let path = dir.path().join("bad.json");
561 let json = serde_json::json!({
562 "name": "bad",
563 "version": "1.0",
564 "colors": {
565 "foreground": "red",
566 "background": "#000000",
567 "accent": "#123456",
568 "success": "#00ff00",
569 "warning": "#ffcc00",
570 "error": "#ff0000",
571 "muted": "#888888"
572 },
573 "syntax": {
574 "keyword": "#111111",
575 "string": "#222222",
576 "number": "#333333",
577 "comment": "#444444",
578 "function": "#555555"
579 },
580 "ui": {
581 "border": "#666666",
582 "selection": "#777777",
583 "cursor": "#888888"
584 }
585 });
586 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
587
588 let err = Theme::load(&path).unwrap_err();
589 assert!(
590 matches!(&err, Error::Validation(_)),
591 "expected validation error, got {err:?}"
592 );
593 }
594
595 #[test]
596 fn discover_themes_from_roots() {
597 let dir = tempfile::tempdir().expect("tempdir");
598 let global = dir.path().join("global");
599 let project = dir.path().join("project");
600 let global_theme_dir = global.join("themes");
601 let project_theme_dir = project.join("themes");
602 fs::create_dir_all(&global_theme_dir).unwrap();
603 fs::create_dir_all(&project_theme_dir).unwrap();
604 fs::write(global_theme_dir.join("g.json"), "{}").unwrap();
605 fs::write(project_theme_dir.join("p.json"), "{}").unwrap();
606
607 let roots = ThemeRoots {
608 global_dir: global,
609 project_dir: project,
610 };
611 let themes = Theme::discover_themes_with_roots(&roots);
612 assert_eq!(themes.len(), 2);
613 }
614
615 #[test]
616 fn default_themes_validate() {
617 Theme::dark().validate().expect("dark theme valid");
618 Theme::light().validate().expect("light theme valid");
619 Theme::solarized()
620 .validate()
621 .expect("solarized theme valid");
622 }
623
624 #[test]
625 fn resolve_spec_supports_builtins() {
626 let cwd = Path::new(".");
627 assert_eq!(Theme::resolve_spec("dark", cwd).unwrap().name, "dark");
628 assert_eq!(Theme::resolve_spec("light", cwd).unwrap().name, "light");
629 assert_eq!(
630 Theme::resolve_spec("solarized", cwd).unwrap().name,
631 "solarized"
632 );
633 }
634
635 #[test]
636 fn resolve_spec_loads_from_path() {
637 let dir = tempfile::tempdir().expect("tempdir");
638 let path = dir.path().join("custom.json");
639 let json = serde_json::json!({
640 "name": "custom",
641 "version": "1.0",
642 "colors": {
643 "foreground": "#ffffff",
644 "background": "#000000",
645 "accent": "#123456",
646 "success": "#00ff00",
647 "warning": "#ffcc00",
648 "error": "#ff0000",
649 "muted": "#888888"
650 },
651 "syntax": {
652 "keyword": "#111111",
653 "string": "#222222",
654 "number": "#333333",
655 "comment": "#444444",
656 "function": "#555555"
657 },
658 "ui": {
659 "border": "#666666",
660 "selection": "#777777",
661 "cursor": "#888888"
662 }
663 });
664 fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
665
666 let theme = Theme::resolve_spec(path.to_str().unwrap(), dir.path()).expect("resolve spec");
667 assert_eq!(theme.name, "custom");
668 }
669
670 #[test]
671 fn resolve_spec_errors_on_missing_path() {
672 let cwd = tempfile::tempdir().expect("tempdir");
673 let err = Theme::resolve_spec("does-not-exist.json", cwd.path()).unwrap_err();
674 assert!(
675 matches!(err, Error::Config(_)),
676 "expected config error, got {err:?}"
677 );
678 }
679
680 #[test]
681 fn looks_like_theme_path_detects_names_and_paths() {
682 assert!(!looks_like_theme_path("dark"));
683 assert!(!looks_like_theme_path("custom-theme"));
684 assert!(looks_like_theme_path("dark.json"));
685 assert!(looks_like_theme_path("themes/dark"));
686 assert!(looks_like_theme_path(r"themes\dark"));
687 assert!(looks_like_theme_path("~/themes/dark.json"));
688 }
689
690 #[test]
691 fn resolve_theme_path_handles_home_relative_and_absolute() {
692 let cwd = Path::new("/work/cwd");
693 let home = dirs::home_dir().unwrap_or_else(|| cwd.to_path_buf());
694
695 assert_eq!(
696 resolve_theme_path("themes/dark.json", cwd),
697 cwd.join("themes/dark.json")
698 );
699 assert_eq!(
700 resolve_theme_path("/tmp/theme.json", cwd),
701 PathBuf::from("/tmp/theme.json")
702 );
703 assert_eq!(resolve_theme_path("~", cwd), home);
704 assert_eq!(
705 resolve_theme_path("~/themes/dark.json", cwd),
706 home.join("themes/dark.json")
707 );
708 assert_eq!(resolve_theme_path("~custom", cwd), home.join("custom"));
709 }
710
711 #[test]
712 fn parse_hex_color_trims_and_rejects_invalid_inputs() {
713 assert_eq!(parse_hex_color(" #A0b1C2 "), Some((160, 177, 194)));
714 assert_eq!(parse_hex_color("A0b1C2"), None);
715 assert_eq!(parse_hex_color("#123"), None);
716 assert_eq!(parse_hex_color("#12345G"), None);
717 }
718
719 #[test]
720 fn is_light_uses_background_luminance_threshold() {
721 let mut theme = Theme::dark();
722 theme.colors.background = "#808080".to_string();
723 assert!(theme.is_light(), "mid-gray should be treated as light");
724
725 theme.colors.background = "#7f7f7f".to_string();
726 assert!(!theme.is_light(), "just below threshold should be dark");
727
728 theme.colors.background = "not-a-color".to_string();
729 assert!(!theme.is_light(), "invalid colors should default to dark");
730 }
731
732 #[test]
733 fn resolve_falls_back_to_dark_for_invalid_spec() {
734 let cfg = Config {
735 theme: Some("does-not-exist".to_string()),
736 ..Default::default()
737 };
738 let cwd = tempfile::tempdir().expect("tempdir");
739 let resolved = Theme::resolve(&cfg, cwd.path());
740 assert_eq!(resolved.name, "dark");
741 }
742
743 #[test]
746 fn resolve_defaults_to_dark_when_no_theme_set() {
747 let cfg = Config {
748 theme: None,
749 ..Default::default()
750 };
751 let cwd = tempfile::tempdir().expect("tempdir");
752 let resolved = Theme::resolve(&cfg, cwd.path());
753 assert_eq!(resolved.name, "dark");
754 }
755
756 #[test]
757 fn resolve_defaults_to_dark_when_theme_is_empty() {
758 let cfg = Config {
759 theme: Some(String::new()),
760 ..Default::default()
761 };
762 let cwd = tempfile::tempdir().expect("tempdir");
763 let resolved = Theme::resolve(&cfg, cwd.path());
764 assert_eq!(resolved.name, "dark");
765 }
766
767 #[test]
768 fn resolve_defaults_to_dark_when_theme_is_whitespace() {
769 let cfg = Config {
770 theme: Some(" ".to_string()),
771 ..Default::default()
772 };
773 let cwd = tempfile::tempdir().expect("tempdir");
774 let resolved = Theme::resolve(&cfg, cwd.path());
775 assert_eq!(resolved.name, "dark");
776 }
777
778 #[test]
781 fn resolve_spec_case_insensitive() {
782 let cwd = Path::new(".");
783 assert_eq!(Theme::resolve_spec("DARK", cwd).unwrap().name, "dark");
784 assert_eq!(Theme::resolve_spec("Light", cwd).unwrap().name, "light");
785 assert_eq!(
786 Theme::resolve_spec("SOLARIZED", cwd).unwrap().name,
787 "solarized"
788 );
789 }
790
791 #[test]
792 fn resolve_spec_empty_returns_error() {
793 let err = Theme::resolve_spec("", Path::new(".")).unwrap_err();
794 assert!(matches!(err, Error::Validation(_)));
795 }
796
797 #[test]
800 fn validate_color_valid() {
801 assert!(Theme::validate_color("test", "#000000").is_ok());
802 assert!(Theme::validate_color("test", "#ffffff").is_ok());
803 assert!(Theme::validate_color("test", "#AbCdEf").is_ok());
804 }
805
806 #[test]
807 fn validate_color_invalid_no_hash() {
808 assert!(Theme::validate_color("test", "000000").is_err());
809 }
810
811 #[test]
812 fn validate_color_invalid_too_short() {
813 assert!(Theme::validate_color("test", "#123").is_err());
814 }
815
816 #[test]
817 fn validate_color_invalid_chars() {
818 assert!(Theme::validate_color("test", "#ZZZZZZ").is_err());
819 }
820
821 #[test]
824 fn validate_rejects_empty_name() {
825 let mut theme = Theme::dark();
826 theme.name = String::new();
827 assert!(theme.validate().is_err());
828 }
829
830 #[test]
831 fn validate_rejects_empty_version() {
832 let mut theme = Theme::dark();
833 theme.version = " ".to_string();
834 assert!(theme.validate().is_err());
835 }
836
837 #[test]
840 fn dark_theme_is_not_light() {
841 assert!(!Theme::dark().is_light());
842 }
843
844 #[test]
845 fn light_theme_is_light() {
846 assert!(Theme::light().is_light());
847 }
848
849 #[test]
852 fn parse_hex_color_black_and_white() {
853 assert_eq!(parse_hex_color("#000000"), Some((0, 0, 0)));
854 assert_eq!(parse_hex_color("#ffffff"), Some((255, 255, 255)));
855 }
856
857 #[test]
858 fn parse_hex_color_empty_returns_none() {
859 assert_eq!(parse_hex_color(""), None);
860 }
861
862 #[test]
865 fn glob_json_nonexistent_dir() {
866 let result = glob_json(Path::new("/nonexistent/dir"));
867 assert!(result.is_empty());
868 }
869
870 #[test]
871 fn glob_json_dir_with_non_json_files() {
872 let dir = tempfile::tempdir().expect("tempdir");
873 fs::write(dir.path().join("readme.txt"), "hi").unwrap();
874 fs::write(dir.path().join("theme.json"), "{}").unwrap();
875 fs::write(dir.path().join("other.toml"), "").unwrap();
876
877 let result = glob_json(dir.path());
878 assert_eq!(result.len(), 1);
879 assert!(result[0].to_string_lossy().ends_with("theme.json"));
880 }
881
882 #[test]
885 fn discover_themes_empty_dirs() {
886 let dir = tempfile::tempdir().expect("tempdir");
887 let roots = ThemeRoots {
888 global_dir: dir.path().join("global"),
889 project_dir: dir.path().join("project"),
890 };
891 let themes = Theme::discover_themes_with_roots(&roots);
892 assert!(themes.is_empty());
893 }
894
895 #[test]
898 fn theme_serde_roundtrip() {
899 let theme = Theme::dark();
900 let json = serde_json::to_string(&theme).unwrap();
901 let theme2: Theme = serde_json::from_str(&json).unwrap();
902 assert_eq!(theme.name, theme2.name);
903 assert_eq!(theme.colors.foreground, theme2.colors.foreground);
904 }
905
906 #[test]
909 fn load_by_name_empty_name_returns_error() {
910 let dir = tempfile::tempdir().expect("tempdir");
911 let roots = ThemeRoots {
912 global_dir: dir.path().join("global"),
913 project_dir: dir.path().join("project"),
914 };
915 let err = Theme::load_by_name_with_roots("", &roots).unwrap_err();
916 assert!(matches!(err, Error::Validation(_)));
917 }
918
919 #[test]
920 fn load_by_name_not_found_returns_error() {
921 let dir = tempfile::tempdir().expect("tempdir");
922 let roots = ThemeRoots {
923 global_dir: dir.path().join("global"),
924 project_dir: dir.path().join("project"),
925 };
926 let err = Theme::load_by_name_with_roots("nonexistent", &roots).unwrap_err();
927 assert!(matches!(err, Error::Config(_)));
928 }
929
930 #[test]
931 fn load_by_name_finds_theme_in_global_dir() {
932 let dir = tempfile::tempdir().expect("tempdir");
933 let global_themes = dir.path().join("global/themes");
934 fs::create_dir_all(&global_themes).unwrap();
935
936 let theme = Theme::dark();
937 let mut custom = theme;
938 custom.name = "mycustom".to_string();
939 let json = serde_json::to_string_pretty(&custom).unwrap();
940 fs::write(global_themes.join("mycustom.json"), json).unwrap();
941
942 let roots = ThemeRoots {
943 global_dir: dir.path().join("global"),
944 project_dir: dir.path().join("project"),
945 };
946 let loaded = Theme::load_by_name_with_roots("mycustom", &roots).unwrap();
947 assert_eq!(loaded.name, "mycustom");
948 }
949
950 #[test]
953 fn tui_styles_returns_valid_struct() {
954 let styles = Theme::dark().tui_styles();
955 let _ = format!("{:?}", styles.title);
957 let _ = format!("{:?}", styles.muted);
958 let _ = format!("{:?}", styles.accent);
959 let _ = format!("{:?}", styles.error_bold);
960 }
961
962 #[test]
963 fn glamour_style_config_smoke() {
964 let dark_config = Theme::dark().glamour_style_config();
965 let light_config = Theme::light().glamour_style_config();
966 assert!(dark_config.document.style.color.is_some());
968 assert!(light_config.document.style.color.is_some());
969 }
970
971 mod proptest_theme {
972 use super::*;
973 use proptest::prelude::*;
974
975 proptest! {
976 #[test]
978 fn parse_hex_never_panics(s in ".{0,20}") {
979 let _ = parse_hex_color(&s);
980 }
981
982 #[test]
984 fn parse_hex_valid(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
985 let hex = format!("#{r:02x}{g:02x}{b:02x}");
986 let parsed = parse_hex_color(&hex);
987 assert_eq!(parsed, Some((r, g, b)));
988 }
989
990 #[test]
992 fn parse_hex_case_insensitive(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
993 let upper = format!("#{r:02X}{g:02X}{b:02X}");
994 let lower = format!("#{r:02x}{g:02x}{b:02x}");
995 assert_eq!(parse_hex_color(&upper), parse_hex_color(&lower));
996 }
997
998 #[test]
1000 fn parse_hex_missing_hash(hex in "[0-9a-f]{6}") {
1001 assert!(parse_hex_color(&hex).is_none());
1002 }
1003
1004 #[test]
1006 fn parse_hex_wrong_length(n in 1..10usize) {
1007 if n == 6 { return Ok(()); }
1008 let hex = format!("#{}", "a".repeat(n));
1009 assert!(parse_hex_color(&hex).is_none());
1010 }
1011
1012 #[test]
1014 fn parse_hex_trims(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255, ws in "[ \\t]{0,3}") {
1015 let hex = format!("{ws}#{r:02x}{g:02x}{b:02x}{ws}");
1016 assert_eq!(parse_hex_color(&hex), Some((r, g, b)));
1017 }
1018
1019 #[test]
1021 fn theme_path_tilde(suffix in "[a-z/]{0,20}") {
1022 assert!(looks_like_theme_path(&format!("~{suffix}")));
1023 }
1024
1025 #[test]
1027 fn theme_path_json_ext(name in "[a-z]{1,10}") {
1028 assert!(looks_like_theme_path(&format!("{name}.json")));
1029 }
1030
1031 #[test]
1033 fn theme_path_with_slash(a in "[a-z]{1,10}", b in "[a-z]{1,10}") {
1034 assert!(looks_like_theme_path(&format!("{a}/{b}")));
1035 }
1036
1037 #[test]
1039 fn theme_path_plain_name(name in "[a-z]{1,10}") {
1040 assert!(!looks_like_theme_path(&name));
1041 }
1042
1043 #[test]
1045 fn is_light_boundary(_dummy in 0..1u8) {
1046 let mut dark = Theme::dark();
1047 dark.colors.background = "#000000".to_string();
1048 assert!(!dark.is_light());
1049
1050 dark.colors.background = "#ffffff".to_string();
1051 assert!(dark.is_light());
1052 }
1053
1054 #[test]
1056 fn is_light_luminance(r in 0u8..=255, g in 0u8..=255, b in 0u8..=255) {
1057 let mut theme = Theme::dark();
1058 theme.colors.background = format!("#{r:02x}{g:02x}{b:02x}");
1059 let luma =
1060 0.0722_f64.mul_add(f64::from(b), 0.2126_f64.mul_add(f64::from(r), 0.7152 * f64::from(g)));
1061 assert_eq!(theme.is_light(), luma >= 128.0);
1062 }
1063
1064 #[test]
1066 fn is_light_invalid_color(s in "[a-z]{3,10}") {
1067 let mut theme = Theme::dark();
1068 theme.colors.background = s;
1069 assert!(!theme.is_light());
1070 }
1071
1072 #[test]
1074 fn theme_dark_serde_roundtrip(_dummy in 0..1u8) {
1075 let theme = Theme::dark();
1076 let json = serde_json::to_string(&theme).unwrap();
1077 let back: Theme = serde_json::from_str(&json).unwrap();
1078 assert_eq!(back.name, theme.name);
1079 assert_eq!(back.colors.background, theme.colors.background);
1080 }
1081
1082 #[test]
1084 fn theme_light_serde_roundtrip(_dummy in 0..1u8) {
1085 let theme = Theme::light();
1086 let json = serde_json::to_string(&theme).unwrap();
1087 let back: Theme = serde_json::from_str(&json).unwrap();
1088 assert_eq!(back.name, theme.name);
1089 assert_eq!(back.colors.background, theme.colors.background);
1090 }
1091
1092 #[test]
1094 fn resolve_absolute_path(suffix in "[a-z]{1,20}") {
1095 let abs = format!("/tmp/{suffix}.json");
1096 let resolved = resolve_theme_path(&abs, Path::new("/cwd"));
1097 assert_eq!(resolved, PathBuf::from(&abs));
1098 }
1099
1100 #[test]
1102 fn resolve_relative_path(name in "[a-z]{1,10}") {
1103 let cwd = Path::new("/some/dir");
1104 let resolved = resolve_theme_path(&name, cwd);
1105 assert_eq!(resolved, cwd.join(&name));
1106 }
1107
1108 #[test]
1110 fn theme_validate_accepts_generated_valid_palette(
1111 name in "[a-z][a-z0-9_-]{0,15}",
1112 version in "[0-9]{1,2}\\.[0-9]{1,2}",
1113 palette in proptest::collection::vec((0u8..=255, 0u8..=255, 0u8..=255), 15)
1114 ) {
1115 let mut colors = palette.into_iter();
1116 let next_hex = |colors: &mut std::vec::IntoIter<(u8, u8, u8)>| -> String {
1117 let (r, g, b) = colors.next().expect("palette length is fixed to 15");
1118 format!("#{r:02x}{g:02x}{b:02x}")
1119 };
1120
1121 let mut theme = Theme::dark();
1122 theme.name = name;
1123 theme.version = version;
1124
1125 theme.colors.foreground = next_hex(&mut colors);
1126 theme.colors.background = next_hex(&mut colors);
1127 theme.colors.accent = next_hex(&mut colors);
1128 theme.colors.success = next_hex(&mut colors);
1129 theme.colors.warning = next_hex(&mut colors);
1130 theme.colors.error = next_hex(&mut colors);
1131 theme.colors.muted = next_hex(&mut colors);
1132
1133 theme.syntax.keyword = next_hex(&mut colors);
1134 theme.syntax.string = next_hex(&mut colors);
1135 theme.syntax.number = next_hex(&mut colors);
1136 theme.syntax.comment = next_hex(&mut colors);
1137 theme.syntax.function = next_hex(&mut colors);
1138
1139 theme.ui.border = next_hex(&mut colors);
1140 theme.ui.selection = next_hex(&mut colors);
1141 theme.ui.cursor = next_hex(&mut colors);
1142
1143 assert!(theme.validate().is_ok());
1144 }
1145
1146 #[test]
1148 fn theme_validate_rejects_invalid_color_fields(field_idx in 0usize..15usize) {
1149 let mut theme = Theme::dark();
1150 let invalid = "not-a-color".to_string();
1151
1152 match field_idx {
1153 0 => theme.colors.foreground = invalid,
1154 1 => theme.colors.background = invalid,
1155 2 => theme.colors.accent = invalid,
1156 3 => theme.colors.success = invalid,
1157 4 => theme.colors.warning = invalid,
1158 5 => theme.colors.error = invalid,
1159 6 => theme.colors.muted = invalid,
1160 7 => theme.syntax.keyword = invalid,
1161 8 => theme.syntax.string = invalid,
1162 9 => theme.syntax.number = invalid,
1163 10 => theme.syntax.comment = invalid,
1164 11 => theme.syntax.function = invalid,
1165 12 => theme.ui.border = invalid,
1166 13 => theme.ui.selection = invalid,
1167 14 => theme.ui.cursor = invalid,
1168 _ => unreachable!("field_idx range is 0..15"),
1169 }
1170
1171 assert!(theme.validate().is_err());
1172 }
1173 }
1174 }
1175}