Skip to main content

pi/
theme.rs

1//! JSON theme file format and loader.
2//!
3//! This module defines a Pi-specific theme schema and discovery rules:
4//! - Global themes: `~/.pi/agent/themes/*.json`
5//! - Project themes: `<cwd>/.pi/themes/*.json`
6
7use 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/// Explicit roots for theme discovery.
68#[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    /// Resolve the active theme for the given config/cwd.
86    ///
87    /// - If `config.theme` is unset/empty, defaults to [`Theme::dark`].
88    /// - If set to `dark`, `light`, or `solarized`, uses built-in defaults.
89    /// - Otherwise, attempts to resolve a theme spec:
90    ///   - discovered theme name (from user/project theme dirs)
91    ///   - theme JSON file path (absolute or cwd-relative, supports `~/...`)
92    ///
93    /// Falls back to dark on error.
94    #[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    /// Resolve a theme spec into a theme.
114    ///
115    /// Supported specs:
116    /// - Built-ins: `dark`, `light`, `solarized`
117    /// - Theme name: resolves via [`Self::load_by_name`]
118    /// - File path: resolves via [`Self::load`] (absolute or cwd-relative, supports `~/...`)
119    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        // Relative luminance (sRGB) without gamma correction is sufficient here.
154        // Treat anything above mid-gray as light.
155        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        // Headings use accent color
209        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        // Links
219        config.link.color.clone_from(&accent);
220        config.link_text.color = accent;
221
222        // Emphasis (bold/italic) uses foreground
223        config.strong.color = Some(self.colors.foreground.clone());
224        config.emph.color = Some(self.colors.foreground.clone());
225
226        // Basic code styling (syntax-highlighting is controlled by glamour feature flags).
227        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        // Blockquotes use muted color
232        config.block_quote.style.color = Some(self.colors.muted.clone());
233
234        // Horizontal rules use muted color
235        config.horizontal_rule.color = Some(self.colors.muted.clone());
236
237        // Lists use foreground
238        config.item.color = Some(self.colors.foreground.clone());
239        config.enumeration.color = Some(self.colors.foreground.clone());
240
241        config
242    }
243
244    /// Discover available theme JSON files.
245    #[must_use]
246    pub fn discover_themes(cwd: &Path) -> Vec<PathBuf> {
247        Self::discover_themes_with_roots(&ThemeRoots::from_cwd(cwd))
248    }
249
250    /// Discover available theme JSON files using explicit roots.
251    #[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    /// Load a theme from a JSON file.
261    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    /// Load a theme by name, searching global and project theme directories.
269    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    /// Load a theme by name using explicit roots.
274    ///
275    /// Project themes take precedence over global themes with the same name,
276    /// consistent with the project-over-global convention used elsewhere
277    /// (config, extension policies, resources).
278    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        // Search project themes first so they override global themes.
285        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    /// Default dark theme.
322    #[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    /// Default light theme.
352    #[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    /// Default solarized dark theme.
382    #[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/// Returns true if the theme spec looks like a file path rather than a theme name.
481/// Path-like specs: start with ~, have .json extension, or contain / or \.
482#[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    // ── resolve with empty/None config ───────────────────────────────
780
781    #[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    // ── resolve_spec case insensitivity ──────────────────────────────
815
816    #[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    // ── validate_color edge cases ────────────────────────────────────
834
835    #[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    // ── validate ─────────────────────────────────────────────────────
858
859    #[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    // ── is_light ─────────────────────────────────────────────────────
874
875    #[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    // ── parse_hex_color ──────────────────────────────────────────────
886
887    #[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    // ── glob_json ────────────────────────────────────────────────────
899
900    #[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    // ── discover_themes_with_roots ──────────────────────────────────
919
920    #[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    // ── Theme serialization roundtrip ────────────────────────────────
932
933    #[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    // ── load_by_name_with_roots ─────────────────────────────────────
943
944    #[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        // Global theme with accent "#111111"
995        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        // Project theme with same name but accent "#222222"
1002        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    // ── tui_styles and glamour_style_config smoke tests ─────────────
1050
1051    #[test]
1052    fn tui_styles_returns_valid_struct() {
1053        let styles = Theme::dark().tui_styles();
1054        // Just verify all fields are accessible without panic
1055        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        // Verify the configs are created without panic
1066        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            /// `parse_hex_color` never panics.
1076            #[test]
1077            fn parse_hex_never_panics(s in ".{0,20}") {
1078                let _ = parse_hex_color(&s);
1079            }
1080
1081            /// Valid 6-digit hex colors parse successfully.
1082            #[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            /// Uppercase hex also parses.
1090            #[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            /// Missing `#` prefix returns None.
1098            #[test]
1099            fn parse_hex_missing_hash(hex in "[0-9a-f]{6}") {
1100                assert!(parse_hex_color(&hex).is_none());
1101            }
1102
1103            /// Wrong-length hex (not 6 digits) returns None.
1104            #[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            /// Whitespace-padded hex parses correctly.
1112            #[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            /// `looks_like_theme_path` returns true for tilde paths.
1119            #[test]
1120            fn theme_path_tilde(suffix in "[a-z/]{0,20}") {
1121                assert!(looks_like_theme_path(&format!("~{suffix}")));
1122            }
1123
1124            /// `looks_like_theme_path` returns true for .json extension.
1125            #[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            /// `looks_like_theme_path` returns true for paths with slashes.
1131            #[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            /// `looks_like_theme_path` returns false for plain names.
1137            #[test]
1138            fn theme_path_plain_name(name in "[a-z]{1,10}") {
1139                assert!(!looks_like_theme_path(&name));
1140            }
1141
1142            /// `is_light` — black is dark, white is light.
1143            #[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            /// `is_light` — luminance threshold at ~128.
1154            #[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            /// `is_light` returns false for invalid background color.
1164            #[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            /// `Theme::dark()` serde roundtrip.
1172            #[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            /// `Theme::light()` serde roundtrip.
1182            #[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            /// `resolve_theme_path` — absolute paths are returned as-is.
1192            #[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            /// `resolve_theme_path` — relative paths are joined with cwd.
1200            #[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            /// `Theme::validate` accepts arbitrary valid 6-digit hex palettes.
1208            #[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            /// `Theme::validate` fails closed when any color field is invalid.
1246            #[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}