Skip to main content

ferro_theme/
loader.rs

1use std::path::Path;
2
3use crate::error::ThemeError;
4use crate::template::ThemeTemplates;
5
6const DEFAULT_THEME_CSS: &str = include_str!("../assets/default.css");
7
8/// A loaded theme: CSS tokens and optional intent template overrides.
9///
10/// Two construction paths:
11/// - [`Theme::default_theme()`] — always available, embedded at compile time.
12/// - [`Theme::from_path()`] — loads from a filesystem directory.
13#[derive(Debug, Clone)]
14pub struct Theme {
15    /// CSS content as plain CSS variable declarations (`:root { ... }`).
16    pub css: String,
17
18    /// Optional intent template overrides; all-`None` means built-in layouts apply.
19    pub templates: ThemeTemplates,
20}
21
22impl Theme {
23    /// Returns the embedded default theme.
24    ///
25    /// The CSS contains plain `:root { ... }` CSS variable declarations for all 30
26    /// semantic token slots (light and dark modes). Safe to inject into a `<style>` tag
27    /// without Tailwind processing.
28    /// Templates are all-`None` — built-in intent layouts apply unchanged.
29    pub fn default_theme() -> Self {
30        Self {
31            css: DEFAULT_THEME_CSS.to_string(),
32            templates: ThemeTemplates::default(),
33        }
34    }
35
36    /// Loads a theme from a directory on the filesystem.
37    ///
38    /// Expects `tokens.css` to exist in the directory.
39    /// `theme.json` is optional — if absent, templates default to empty (`ThemeTemplates::default()`).
40    ///
41    /// # Errors
42    ///
43    /// - [`ThemeError::NotFound`] — directory does not exist.
44    /// - [`ThemeError::Io`] — `tokens.css` cannot be read.
45    /// - [`ThemeError::Json`] — `theme.json` exists but cannot be parsed.
46    pub fn from_path(path: &str) -> Result<Self, ThemeError> {
47        let dir = Path::new(path);
48        if !dir.exists() {
49            return Err(ThemeError::NotFound(path.to_string()));
50        }
51
52        let css_path = dir.join("tokens.css");
53        let css = std::fs::read_to_string(css_path)?;
54
55        let templates_path = dir.join("theme.json");
56        let templates = if templates_path.exists() {
57            let json = std::fs::read_to_string(&templates_path)?;
58            serde_json::from_str(&json)?
59        } else {
60            ThemeTemplates::default()
61        };
62
63        Ok(Self { css, templates })
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn default_theme_returns_all_30_token_slots() {
73        let theme = Theme::default_theme();
74        assert!(!theme.css.is_empty());
75        // Iterate the vocabulary itself so token.rs and default.css cannot
76        // drift. The `{token}:` suffix avoids prefix false-positives
77        // (e.g. --color-text matching --color-text-muted).
78        for token in crate::token::ALL_TOKENS {
79            assert!(
80                theme.css.contains(&format!("{token}:")),
81                "default.css missing declaration for {token}"
82            );
83        }
84    }
85
86    #[test]
87    fn default_theme_returns_all_none_templates() {
88        let theme = Theme::default_theme();
89        assert!(theme.templates.browse.is_none());
90        assert!(theme.templates.focus.is_none());
91        assert!(theme.templates.collect.is_none());
92        assert!(theme.templates.process.is_none());
93        assert!(theme.templates.summarize.is_none());
94        assert!(theme.templates.analyze.is_none());
95        assert!(theme.templates.track.is_none());
96    }
97
98    #[test]
99    fn from_path_loads_tokens_css_and_theme_json() {
100        let dir = tempfile::tempdir().unwrap();
101        let css_content = "@theme { --color-primary: oklch(55% 0.2 250); }";
102        let json_content = r#"{"browse": {"display": {"slots": ["title", "fields"]}}}"#;
103        std::fs::write(dir.path().join("tokens.css"), css_content).unwrap();
104        std::fs::write(dir.path().join("theme.json"), json_content).unwrap();
105
106        let theme = Theme::from_path(dir.path().to_str().unwrap()).unwrap();
107        assert_eq!(theme.css, css_content);
108        assert!(theme.templates.browse.is_some());
109        let browse = theme.templates.browse.unwrap();
110        assert_eq!(browse.display.slots, vec!["title", "fields"]);
111    }
112
113    #[test]
114    fn from_path_works_without_theme_json() {
115        let dir = tempfile::tempdir().unwrap();
116        let css_content = "@theme { --color-primary: oklch(55% 0.2 250); }";
117        std::fs::write(dir.path().join("tokens.css"), css_content).unwrap();
118
119        let theme = Theme::from_path(dir.path().to_str().unwrap()).unwrap();
120        assert_eq!(theme.css, css_content);
121        assert!(theme.templates.browse.is_none());
122    }
123
124    #[test]
125    fn from_path_returns_not_found_for_nonexistent_directory() {
126        let result = Theme::from_path("/nonexistent/path/that/does/not/exist");
127        assert!(matches!(result, Err(ThemeError::NotFound(_))));
128    }
129
130    #[test]
131    fn from_path_returns_json_error_for_invalid_theme_json() {
132        let dir = tempfile::tempdir().unwrap();
133        std::fs::write(dir.path().join("tokens.css"), "@theme {}").unwrap();
134        std::fs::write(dir.path().join("theme.json"), "not valid json{{").unwrap();
135
136        let result = Theme::from_path(dir.path().to_str().unwrap());
137        assert!(matches!(result, Err(ThemeError::Json(_))));
138    }
139
140    #[test]
141    fn from_path_returns_io_error_when_tokens_css_missing() {
142        let dir = tempfile::tempdir().unwrap();
143        // Only create theme.json, not tokens.css
144        std::fs::write(dir.path().join("theme.json"), "{}").unwrap();
145
146        let result = Theme::from_path(dir.path().to_str().unwrap());
147        assert!(matches!(result, Err(ThemeError::Io(_))));
148    }
149}