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    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    /// Default dark theme.
292    #[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    /// Default light theme.
322    #[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    /// Default solarized dark theme.
352    #[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/// Returns true if the theme spec looks like a file path rather than a theme name.
451/// Path-like specs: start with ~, have .json extension, or contain / or \.
452#[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    // ── resolve with empty/None config ───────────────────────────────
744
745    #[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    // ── resolve_spec case insensitivity ──────────────────────────────
779
780    #[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    // ── validate_color edge cases ────────────────────────────────────
798
799    #[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    // ── validate ─────────────────────────────────────────────────────
822
823    #[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    // ── is_light ─────────────────────────────────────────────────────
838
839    #[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    // ── parse_hex_color ──────────────────────────────────────────────
850
851    #[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    // ── glob_json ────────────────────────────────────────────────────
863
864    #[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    // ── discover_themes_with_roots ──────────────────────────────────
883
884    #[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    // ── Theme serialization roundtrip ────────────────────────────────
896
897    #[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    // ── load_by_name_with_roots ─────────────────────────────────────
907
908    #[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    // ── tui_styles and glamour_style_config smoke tests ─────────────
951
952    #[test]
953    fn tui_styles_returns_valid_struct() {
954        let styles = Theme::dark().tui_styles();
955        // Just verify all fields are accessible without panic
956        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        // Verify the configs are created without panic
967        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            /// `parse_hex_color` never panics.
977            #[test]
978            fn parse_hex_never_panics(s in ".{0,20}") {
979                let _ = parse_hex_color(&s);
980            }
981
982            /// Valid 6-digit hex colors parse successfully.
983            #[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            /// Uppercase hex also parses.
991            #[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            /// Missing `#` prefix returns None.
999            #[test]
1000            fn parse_hex_missing_hash(hex in "[0-9a-f]{6}") {
1001                assert!(parse_hex_color(&hex).is_none());
1002            }
1003
1004            /// Wrong-length hex (not 6 digits) returns None.
1005            #[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            /// Whitespace-padded hex parses correctly.
1013            #[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            /// `looks_like_theme_path` returns true for tilde paths.
1020            #[test]
1021            fn theme_path_tilde(suffix in "[a-z/]{0,20}") {
1022                assert!(looks_like_theme_path(&format!("~{suffix}")));
1023            }
1024
1025            /// `looks_like_theme_path` returns true for .json extension.
1026            #[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            /// `looks_like_theme_path` returns true for paths with slashes.
1032            #[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            /// `looks_like_theme_path` returns false for plain names.
1038            #[test]
1039            fn theme_path_plain_name(name in "[a-z]{1,10}") {
1040                assert!(!looks_like_theme_path(&name));
1041            }
1042
1043            /// `is_light` — black is dark, white is light.
1044            #[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            /// `is_light` — luminance threshold at ~128.
1055            #[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            /// `is_light` returns false for invalid background color.
1065            #[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            /// `Theme::dark()` serde roundtrip.
1073            #[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            /// `Theme::light()` serde roundtrip.
1083            #[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            /// `resolve_theme_path` — absolute paths are returned as-is.
1093            #[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            /// `resolve_theme_path` — relative paths are joined with cwd.
1101            #[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            /// `Theme::validate` accepts arbitrary valid 6-digit hex palettes.
1109            #[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            /// `Theme::validate` fails closed when any color field is invalid.
1147            #[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}