Skip to main content

ferro_cli/commands/
make_theme.rs

1//! make:theme command — scaffold theme files for the Ferro theme system
2
3use console::style;
4use std::fs;
5use std::path::Path;
6
7/// Generate theme scaffold files in `themes/{name}/`.
8///
9/// Creates:
10/// - `themes/{name}/tokens.css` — Tailwind v4 `@theme` block with all 23 semantic token slots
11/// - `themes/{name}/theme.json` — empty JSON object for partial intent template overrides
12///
13/// Returns an error if `themes/{name}/` already exists.
14pub fn make_theme(name: &str) -> Result<(), Box<dyn std::error::Error>> {
15    make_theme_in_dir(name, Path::new("."))
16}
17
18/// Core logic — generate in a given base directory (enables testability).
19pub fn make_theme_in_dir(name: &str, base: &Path) -> Result<(), Box<dyn std::error::Error>> {
20    let theme_dir = base.join("themes").join(name);
21
22    if theme_dir.exists() {
23        return Err(format!("Theme directory '{}' already exists", theme_dir.display()).into());
24    }
25
26    fs::create_dir_all(&theme_dir)?;
27
28    // Write tokens.css — Tailwind v4 @theme authoring format
29    let tokens_path = theme_dir.join("tokens.css");
30    fs::write(&tokens_path, tokens_css_template())?;
31
32    // Write theme.json — empty object for partial intent template overrides
33    let theme_json_path = theme_dir.join("theme.json");
34    fs::write(&theme_json_path, "{}\n")?;
35
36    println!(
37        "{} Created theme '{}' at {}/",
38        style("✓").green(),
39        style(name).cyan().bold(),
40        theme_dir.display()
41    );
42    println!();
43    println!("Next steps:");
44    println!(
45        "  {} Edit {}/tokens.css to customize colors, shapes, and typography",
46        style("1.").dim(),
47        theme_dir.display()
48    );
49    println!(
50        "  {} Add intent template overrides to {}/theme.json",
51        style("2.").dim(),
52        theme_dir.display()
53    );
54    println!(
55        "  {} Activate the theme via ThemeMiddleware in your application",
56        style("3.").dim()
57    );
58    println!();
59
60    Ok(())
61}
62
63/// Public entry point called from main.rs.
64pub fn run(name: &str) {
65    if let Err(e) = make_theme(name) {
66        eprintln!("{} {}", style("Error:").red().bold(), e);
67        std::process::exit(1);
68    }
69}
70
71fn tokens_css_template() -> &'static str {
72    r#"@import "tailwindcss";
73
74@theme {
75  /* Surface tokens */
76  --color-background: oklch(100% 0 0);
77  --color-surface: oklch(97% 0 0);
78  --color-card: oklch(95% 0 0);
79  --color-border: oklch(90% 0 0);
80  --color-text: oklch(15% 0 0);
81  --color-text-muted: oklch(50% 0 0);
82
83  /* Role tokens */
84  --color-primary: oklch(55% 0.2 250);
85  --color-primary-foreground: oklch(100% 0 0);
86  --color-secondary: oklch(70% 0.05 250);
87  --color-secondary-foreground: oklch(15% 0 0);
88  --color-accent: oklch(65% 0.15 200);
89  --color-destructive: oklch(55% 0.22 25);
90  --color-success: oklch(55% 0.18 145);
91  --color-warning: oklch(70% 0.18 80);
92
93  /* Shape tokens */
94  --radius-sm: 0.25rem;
95  --radius-md: 0.375rem;
96  --radius-lg: 0.5rem;
97  --radius-full: 9999px;
98
99  /* Shadow tokens */
100  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
101  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
102  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
103
104  /* Typography tokens */
105  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
106  --font-mono: ui-monospace, monospace;
107}
108
109@media (prefers-color-scheme: dark) {
110  @theme {
111    --color-background: oklch(12% 0 0);
112    --color-surface: oklch(17% 0 0);
113    --color-card: oklch(20% 0 0);
114    --color-border: oklch(30% 0 0);
115    --color-text: oklch(95% 0 0);
116    --color-text-muted: oklch(60% 0 0);
117    --color-primary: oklch(65% 0.2 250);
118    --color-primary-foreground: oklch(100% 0 0);
119    --color-secondary: oklch(60% 0.05 250);
120    --color-secondary-foreground: oklch(95% 0 0);
121    --color-accent: oklch(60% 0.15 200);
122    --color-destructive: oklch(60% 0.22 25);
123    --color-success: oklch(60% 0.18 145);
124    --color-warning: oklch(65% 0.18 80);
125  }
126}
127"#
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use tempfile::TempDir;
134
135    fn read_file(path: &Path) -> String {
136        fs::read_to_string(path).unwrap_or_else(|e| panic!("Failed to read {path:?}: {e}"))
137    }
138
139    #[test]
140    fn test_make_theme_creates_directory_structure() {
141        let tmp = TempDir::new().unwrap();
142        make_theme_in_dir("myapp", tmp.path()).unwrap();
143
144        assert!(tmp.path().join("themes/myapp").exists());
145        assert!(tmp.path().join("themes/myapp/tokens.css").exists());
146        assert!(tmp.path().join("themes/myapp/theme.json").exists());
147    }
148
149    #[test]
150    fn test_make_theme_tokens_css_has_all_23_token_slots() {
151        let tmp = TempDir::new().unwrap();
152        make_theme_in_dir("test", tmp.path()).unwrap();
153
154        let css = read_file(&tmp.path().join("themes/test/tokens.css"));
155
156        // Surface tokens (6)
157        assert!(
158            css.contains("--color-background:"),
159            "missing --color-background"
160        );
161        assert!(css.contains("--color-surface:"), "missing --color-surface");
162        assert!(css.contains("--color-card:"), "missing --color-card");
163        assert!(css.contains("--color-border:"), "missing --color-border");
164        assert!(css.contains("--color-text:"), "missing --color-text");
165        assert!(
166            css.contains("--color-text-muted:"),
167            "missing --color-text-muted"
168        );
169
170        // Role tokens (8)
171        assert!(css.contains("--color-primary:"), "missing --color-primary");
172        assert!(
173            css.contains("--color-primary-foreground:"),
174            "missing --color-primary-foreground"
175        );
176        assert!(
177            css.contains("--color-secondary:"),
178            "missing --color-secondary"
179        );
180        assert!(
181            css.contains("--color-secondary-foreground:"),
182            "missing --color-secondary-foreground"
183        );
184        assert!(css.contains("--color-accent:"), "missing --color-accent");
185        assert!(
186            css.contains("--color-destructive:"),
187            "missing --color-destructive"
188        );
189        assert!(css.contains("--color-success:"), "missing --color-success");
190        assert!(css.contains("--color-warning:"), "missing --color-warning");
191
192        // Shape tokens (4)
193        assert!(css.contains("--radius-sm:"), "missing --radius-sm");
194        assert!(css.contains("--radius-md:"), "missing --radius-md");
195        assert!(css.contains("--radius-lg:"), "missing --radius-lg");
196        assert!(css.contains("--radius-full:"), "missing --radius-full");
197
198        // Shadow tokens (3)
199        assert!(css.contains("--shadow-sm:"), "missing --shadow-sm");
200        assert!(css.contains("--shadow-md:"), "missing --shadow-md");
201        assert!(css.contains("--shadow-lg:"), "missing --shadow-lg");
202
203        // Typography tokens (2)
204        assert!(css.contains("--font-sans:"), "missing --font-sans");
205        assert!(css.contains("--font-mono:"), "missing --font-mono");
206    }
207
208    #[test]
209    fn test_make_theme_tokens_css_has_theme_block() {
210        let tmp = TempDir::new().unwrap();
211        make_theme_in_dir("test", tmp.path()).unwrap();
212
213        let css = read_file(&tmp.path().join("themes/test/tokens.css"));
214
215        assert!(css.contains("@import \"tailwindcss\";"), "missing @import");
216        assert!(css.contains("@theme {"), "missing @theme block");
217        assert!(
218            css.contains("--color-primary:"),
219            "missing --color-primary in @theme"
220        );
221    }
222
223    #[test]
224    fn test_make_theme_tokens_css_has_dark_mode_block() {
225        let tmp = TempDir::new().unwrap();
226        make_theme_in_dir("test", tmp.path()).unwrap();
227
228        let css = read_file(&tmp.path().join("themes/test/tokens.css"));
229
230        assert!(
231            css.contains("@media (prefers-color-scheme: dark)"),
232            "missing dark mode @media"
233        );
234        assert!(
235            css.contains("oklch(12%"),
236            "missing dark mode background value"
237        );
238    }
239
240    #[test]
241    fn test_make_theme_theme_json_is_empty_object() {
242        let tmp = TempDir::new().unwrap();
243        make_theme_in_dir("test", tmp.path()).unwrap();
244
245        let json_content = read_file(&tmp.path().join("themes/test/theme.json"));
246        let trimmed = json_content.trim();
247
248        // Must be valid JSON that deserializes to an empty object
249        let parsed: serde_json::Value =
250            serde_json::from_str(trimmed).expect("theme.json must be valid JSON");
251        assert_eq!(
252            parsed,
253            serde_json::json!({}),
254            "theme.json must be empty object"
255        );
256    }
257
258    #[test]
259    fn test_make_theme_fails_if_directory_exists() {
260        let tmp = TempDir::new().unwrap();
261
262        // Create the directory first
263        fs::create_dir_all(tmp.path().join("themes/duplicate")).unwrap();
264
265        let result = make_theme_in_dir("duplicate", tmp.path());
266        assert!(
267            result.is_err(),
268            "should return error for duplicate theme name"
269        );
270        let err_msg = result.unwrap_err().to_string();
271        assert!(
272            err_msg.contains("already exists"),
273            "error should mention 'already exists'"
274        );
275    }
276
277    #[test]
278    fn test_make_theme_succeeds_once_fails_on_repeat() {
279        let tmp = TempDir::new().unwrap();
280
281        // First call succeeds
282        make_theme_in_dir("myapp", tmp.path()).unwrap();
283
284        // Second call with same name fails
285        let result = make_theme_in_dir("myapp", tmp.path());
286        assert!(result.is_err());
287    }
288}