Skip to main content

max / makenotwork

23.7 KB · 755 lines History Blame Raw
1 //! Shared theme loading logic for TOML-based theme files.
2 //!
3 //! Used by GoingsOn, Balanced Breakfast (Tauri apps), and audiofiles (egui).
4 //! audiofiles embeds themes at compile time but uses `ThemeMeta`, `parse_meta`,
5 //! and `extract_colors` from this crate.
6 //!
7 //! Theme files are TOML with this structure:
8 //! ```text
9 //! [meta]
10 //! name = "Theme Name"
11 //! variant = "dark" # or "light"
12 //!
13 //! [background]
14 //! primary = "#1e1e2e"
15 //!
16 //! [foreground]
17 //! primary = "#cdd6f4"
18 //!
19 //! [accent]
20 //! primary = "#89b4fa"
21 //!
22 //! [border]
23 //! primary = "#45475a"
24 //! ```
25
26 use serde::Serialize;
27 use std::collections::HashMap;
28 use std::path::{Path, PathBuf};
29
30 /// Theme metadata parsed from the `[meta]` section.
31 #[derive(Debug, Clone, Serialize)]
32 #[serde(rename_all = "camelCase")]
33 pub struct ThemeMeta {
34 pub id: String,
35 pub name: String,
36 pub variant: String,
37 pub is_custom: bool,
38 }
39
40 /// A fully loaded theme: metadata plus flattened color map.
41 #[derive(Debug, Serialize)]
42 #[serde(rename_all = "camelCase")]
43 pub struct ThemeColors {
44 pub meta: ThemeMeta,
45 pub colors: HashMap<String, String>,
46 }
47
48 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
49 pub fn validate_theme_id(id: &str) -> Result<(), String> {
50 if !id
51 .chars()
52 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
53 {
54 return Err(format!("Invalid theme ID: {}", id));
55 }
56 Ok(())
57 }
58
59 /// Parse the `[meta]` section from a TOML table into `ThemeMeta`.
60 ///
61 /// Falls back to the file ID as the name and `"dark"` as the variant.
62 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
63 let meta = table.get("meta").and_then(|m| m.as_table());
64 let name = meta
65 .and_then(|m| m.get("name"))
66 .and_then(|v| v.as_str())
67 .unwrap_or(id)
68 .to_string();
69 let variant = meta
70 .and_then(|m| m.get("variant"))
71 .and_then(|v| v.as_str())
72 .unwrap_or("dark")
73 .to_string();
74
75 ThemeMeta {
76 id: id.to_string(),
77 name,
78 variant,
79 is_custom,
80 }
81 }
82
83 /// Extract color sections (background, foreground, accent, border) from a TOML
84 /// table into a flat `HashMap` with keys like `"background.primary"`.
85 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
86 let mut colors = HashMap::new();
87 for section in &["background", "foreground", "accent", "border"] {
88 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
89 for (key, val) in sect {
90 if let Some(color) = val.as_str() {
91 colors.insert(format!("{}.{}", section, key), color.to_string());
92 }
93 }
94 }
95 }
96 colors
97 }
98
99 /// Scan directories for `.toml` theme files and return metadata for each.
100 ///
101 /// Directories are checked in order; later entries override earlier ones by ID.
102 /// Each entry in `dirs` is `(path, is_custom)`.
103 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
104 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
105
106 for (dir, is_custom) in dirs {
107 let entries = match std::fs::read_dir(dir) {
108 Ok(e) => e,
109 Err(_) => continue,
110 };
111
112 for entry in entries {
113 let entry = match entry {
114 Ok(e) => e,
115 Err(_) => continue,
116 };
117 let path = entry.path();
118 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
119 continue;
120 }
121
122 let id = path
123 .file_stem()
124 .and_then(|s| s.to_str())
125 .unwrap_or_default()
126 .to_string();
127
128 let content = match std::fs::read_to_string(&path) {
129 Ok(c) => c,
130 Err(_) => continue,
131 };
132 let table: toml::Table = match content.parse() {
133 Ok(t) => t,
134 Err(_) => continue,
135 };
136
137 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
138 }
139 }
140
141 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
142 themes.sort_by(|a, b| a.name.cmp(&b.name));
143 themes
144 }
145
146 /// Find a theme file by ID in the given directories.
147 ///
148 /// Checks directories in reverse order so the highest-priority directory wins.
149 /// Returns `(path, is_custom)` or `None` if not found.
150 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
151 let filename = format!("{}.toml", id);
152
153 for (dir, is_custom) in dirs.iter().rev() {
154 let path = dir.join(&filename);
155 if path.is_file() {
156 return Some((path, *is_custom));
157 }
158 }
159
160 None
161 }
162
163 /// Load a complete theme (metadata + colors) by ID from the given directories.
164 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
165 validate_theme_id(id)?;
166
167 let (path, is_custom) =
168 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
169
170 let content = std::fs::read_to_string(&path)
171 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
172
173 let table: toml::Table = content
174 .parse()
175 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
176
177 let meta = parse_meta(id, &table, is_custom);
178 let colors = extract_colors(&table);
179
180 Ok(ThemeColors { meta, colors })
181 }
182
183 /// Import a theme TOML file into the custom themes directory.
184 ///
185 /// Validates that the file is parseable TOML with the expected color sections,
186 /// then copies it to `custom_dir/{id}.toml` where `id` is the file stem.
187 /// Creates `custom_dir` if it doesn't exist. Returns the theme metadata.
188 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
189 let content = std::fs::read_to_string(source_path)
190 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
191
192 let table: toml::Table = content
193 .parse()
194 .map_err(|e| format!("Invalid TOML: {}", e))?;
195
196 // Verify it has at least one color section
197 let has_colors = ["background", "foreground", "accent", "border"]
198 .iter()
199 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
200 if !has_colors {
201 return Err("Theme file must have at least one color section (background, foreground, accent, or border)".to_string());
202 }
203
204 let id = source_path
205 .file_stem()
206 .and_then(|s| s.to_str())
207 .ok_or("Invalid file name")?
208 .to_string();
209 validate_theme_id(&id)?;
210
211 std::fs::create_dir_all(custom_dir)
212 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
213
214 let dest = custom_dir.join(format!("{}.toml", id));
215 std::fs::copy(source_path, &dest)
216 .map_err(|e| format!("Failed to copy theme: {}", e))?;
217
218 Ok(parse_meta(&id, &table, true))
219 }
220
221 /// Delete a custom theme by ID.
222 ///
223 /// Only operates on `custom_dir` — bundled themes are not deletable through
224 /// this entry point. Returns `Err` if the ID is invalid, the file does not
225 /// exist, or the underlying remove fails.
226 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
227 validate_theme_id(id)?;
228
229 let path = custom_dir.join(format!("{}.toml", id));
230 if !path.is_file() {
231 return Err(format!("Custom theme '{}' not found", id));
232 }
233
234 std::fs::remove_file(&path)
235 .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
236 }
237
238 /// A four-color palette for preview chips, swatches, etc.
239 ///
240 /// Smaller than `ThemeColors`: only the `primary` value from each of the four
241 /// canonical sections, so callers rendering a list of theme thumbnails don't
242 /// need to allocate a full `HashMap` per row.
243 #[derive(Debug, Clone, Serialize)]
244 #[serde(rename_all = "camelCase")]
245 pub struct ThemePreview {
246 pub meta: ThemeMeta,
247 pub background: Option<String>,
248 pub foreground: Option<String>,
249 pub accent: Option<String>,
250 pub border: Option<String>,
251 }
252
253 fn primary_color(table: &toml::Table, section: &str) -> Option<String> {
254 table
255 .get(section)
256 .and_then(|s| s.as_table())
257 .and_then(|s| s.get("primary"))
258 .and_then(|v| v.as_str())
259 .map(|s| s.to_string())
260 }
261
262 /// Load just the primary colors for a theme — for UI previews / thumbnails.
263 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
264 validate_theme_id(id)?;
265
266 let (path, is_custom) =
267 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
268
269 let content = std::fs::read_to_string(&path)
270 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
271
272 let table: toml::Table = content
273 .parse()
274 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
275
276 Ok(ThemePreview {
277 meta: parse_meta(id, &table, is_custom),
278 background: primary_color(&table, "background"),
279 foreground: primary_color(&table, "foreground"),
280 accent: primary_color(&table, "accent"),
281 border: primary_color(&table, "border"),
282 })
283 }
284
285 /// Export a theme to a user-chosen path.
286 ///
287 /// Finds the theme by ID in the given directories and copies the TOML file
288 /// to `dest_path`.
289 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
290 validate_theme_id(id)?;
291
292 let (source, _) =
293 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
294
295 std::fs::copy(&source, dest_path)
296 .map_err(|e| format!("Failed to export theme: {}", e))?;
297
298 Ok(())
299 }
300
301 /// Construct a dev fallback theme directory path.
302 ///
303 /// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and
304 /// appends `"themes"`. Returns the path if the directory exists.
305 pub fn dev_themes_dir(manifest_dir: &Path, levels: usize) -> Option<PathBuf> {
306 let mut path = manifest_dir.to_path_buf();
307 for _ in 0..levels {
308 path = path.parent()?.to_path_buf();
309 }
310 let themes = path.join("MNW").join("shared").join("themes");
311 if themes.is_dir() {
312 Some(themes)
313 } else {
314 None
315 }
316 }
317
318 #[cfg(test)]
319 mod tests {
320 use super::*;
321 use std::fs;
322
323 #[test]
324 fn validate_theme_id_alphanumeric() {
325 assert!(validate_theme_id("darkmode").is_ok());
326 assert!(validate_theme_id("Theme123").is_ok());
327 }
328
329 #[test]
330 fn validate_theme_id_hyphens_underscores() {
331 assert!(validate_theme_id("dark-mode").is_ok());
332 assert!(validate_theme_id("my_theme_v2").is_ok());
333 assert!(validate_theme_id("a-b_c-d").is_ok());
334 }
335
336 #[test]
337 fn validate_theme_id_rejects_spaces() {
338 assert!(validate_theme_id("has space").is_err());
339 }
340
341 #[test]
342 fn validate_theme_id_rejects_path_traversal() {
343 assert!(validate_theme_id("../etc/passwd").is_err());
344 assert!(validate_theme_id("foo/bar").is_err());
345 }
346
347 #[test]
348 fn validate_theme_id_rejects_special_chars() {
349 assert!(validate_theme_id("evil<script>").is_err());
350 assert!(validate_theme_id("theme;drop").is_err());
351 assert!(validate_theme_id("theme.toml").is_err());
352 }
353
354 #[test]
355 fn validate_theme_id_empty_is_valid() {
356 assert!(
357 validate_theme_id("").is_ok(),
358 "empty string has no invalid chars"
359 );
360 }
361
362 #[test]
363 fn parse_meta_with_name_and_variant() {
364 let toml_str = r#"
365 [meta]
366 name = "Solarized Dark"
367 variant = "light"
368 "#;
369 let table: toml::Table = toml_str.parse().unwrap();
370 let meta = parse_meta("solarized", &table, false);
371 assert_eq!(meta.id, "solarized");
372 assert_eq!(meta.name, "Solarized Dark");
373 assert_eq!(meta.variant, "light");
374 assert!(!meta.is_custom);
375 }
376
377 #[test]
378 fn parse_meta_defaults_to_id_and_dark() {
379 let table: toml::Table = "".parse().unwrap();
380 let meta = parse_meta("fallback", &table, true);
381 assert_eq!(meta.name, "fallback");
382 assert_eq!(meta.variant, "dark");
383 assert!(meta.is_custom);
384 }
385
386 #[test]
387 fn parse_meta_missing_variant_defaults_dark() {
388 let toml_str = r#"
389 [meta]
390 name = "Minimal"
391 "#;
392 let table: toml::Table = toml_str.parse().unwrap();
393 let meta = parse_meta("minimal", &table, false);
394 assert_eq!(meta.name, "Minimal");
395 assert_eq!(meta.variant, "dark");
396 }
397
398 #[test]
399 fn extract_colors_from_toml() {
400 let toml_str = r##"
401 [background]
402 primary = "#1e1e2e"
403 secondary = "#313244"
404
405 [foreground]
406 primary = "#cdd6f4"
407
408 [accent]
409 primary = "#89b4fa"
410
411 [border]
412 primary = "#45475a"
413 "##;
414 let table: toml::Table = toml_str.parse().unwrap();
415 let colors = extract_colors(&table);
416 assert_eq!(colors.get("background.primary").unwrap(), "#1e1e2e");
417 assert_eq!(colors.get("background.secondary").unwrap(), "#313244");
418 assert_eq!(colors.get("foreground.primary").unwrap(), "#cdd6f4");
419 assert_eq!(colors.get("accent.primary").unwrap(), "#89b4fa");
420 assert_eq!(colors.get("border.primary").unwrap(), "#45475a");
421 assert_eq!(colors.len(), 5);
422 }
423
424 #[test]
425 fn extract_colors_empty_table() {
426 let table: toml::Table = "".parse().unwrap();
427 let colors = extract_colors(&table);
428 assert!(colors.is_empty());
429 }
430
431 #[test]
432 fn list_themes_from_dirs_finds_toml_files() {
433 let dir = tempfile::tempdir().unwrap();
434 let theme_content = r#"
435 [meta]
436 name = "Test Theme"
437 variant = "dark"
438 "#;
439 fs::write(dir.path().join("test-theme.toml"), theme_content).unwrap();
440 fs::write(dir.path().join("not-a-theme.txt"), "ignored").unwrap();
441
442 let dirs = vec![(dir.path().to_path_buf(), false)];
443 let themes = list_themes_from_dirs(&dirs);
444 assert_eq!(themes.len(), 1);
445 assert_eq!(themes[0].id, "test-theme");
446 assert_eq!(themes[0].name, "Test Theme");
447 }
448
449 #[test]
450 fn list_themes_later_dir_overrides() {
451 let dir1 = tempfile::tempdir().unwrap();
452 let dir2 = tempfile::tempdir().unwrap();
453
454 let theme1 = "[meta]\nname = \"Original\"\nvariant = \"dark\"\n";
455 let theme2 = "[meta]\nname = \"Override\"\nvariant = \"light\"\n";
456
457 fs::write(dir1.path().join("same-id.toml"), theme1).unwrap();
458 fs::write(dir2.path().join("same-id.toml"), theme2).unwrap();
459
460 let dirs = vec![
461 (dir1.path().to_path_buf(), false),
462 (dir2.path().to_path_buf(), true),
463 ];
464 let themes = list_themes_from_dirs(&dirs);
465 assert_eq!(themes.len(), 1);
466 assert_eq!(themes[0].name, "Override");
467 assert!(themes[0].is_custom);
468 }
469
470 #[test]
471 fn find_theme_path_checks_reverse_priority() {
472 let dir1 = tempfile::tempdir().unwrap();
473 let dir2 = tempfile::tempdir().unwrap();
474
475 fs::write(dir1.path().join("shared.toml"), "[meta]\n").unwrap();
476 fs::write(dir2.path().join("shared.toml"), "[meta]\n").unwrap();
477
478 let dirs = vec![
479 (dir1.path().to_path_buf(), false),
480 (dir2.path().to_path_buf(), true),
481 ];
482 let (path, is_custom) = find_theme_path(&dirs, "shared").unwrap();
483 assert!(is_custom);
484 assert_eq!(path, dir2.path().join("shared.toml"));
485 }
486
487 #[test]
488 fn find_theme_path_returns_none_when_missing() {
489 let dir = tempfile::tempdir().unwrap();
490 let dirs = vec![(dir.path().to_path_buf(), false)];
491 assert!(find_theme_path(&dirs, "nonexistent").is_none());
492 }
493
494 #[test]
495 fn load_theme_full_round_trip() {
496 let dir = tempfile::tempdir().unwrap();
497 let theme_content = r##"
498 [meta]
499 name = "Full Theme"
500 variant = "light"
501
502 [background]
503 primary = "#ffffff"
504
505 [foreground]
506 primary = "#000000"
507
508 [accent]
509 primary = "#ff0000"
510 "##;
511 fs::write(dir.path().join("full.toml"), theme_content).unwrap();
512
513 let dirs = vec![(dir.path().to_path_buf(), false)];
514 let theme = load_theme(&dirs, "full").unwrap();
515 assert_eq!(theme.meta.name, "Full Theme");
516 assert_eq!(theme.meta.variant, "light");
517 assert_eq!(theme.colors.get("background.primary").unwrap(), "#ffffff");
518 assert_eq!(theme.colors.get("foreground.primary").unwrap(), "#000000");
519 assert_eq!(theme.colors.get("accent.primary").unwrap(), "#ff0000");
520 }
521
522 #[test]
523 fn load_theme_rejects_invalid_id() {
524 let dirs = vec![];
525 assert!(load_theme(&dirs, "../evil").is_err());
526 }
527
528 #[test]
529 fn load_theme_not_found() {
530 let dir = tempfile::tempdir().unwrap();
531 let dirs = vec![(dir.path().to_path_buf(), false)];
532 assert!(load_theme(&dirs, "missing").is_err());
533 }
534
535 #[test]
536 fn dev_themes_dir_walks_parents() {
537 // Use a real path that exists
538 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
539 // Walking up from theme-common/ to Code/ (3 levels), MNW/shared/themes/ should exist
540 let result = dev_themes_dir(&manifest, 3);
541 // This test is environment-dependent, just verify it returns something valid
542 if let Some(path) = result {
543 assert!(path.ends_with("themes"));
544 }
545 }
546
547 #[test]
548 fn dev_themes_dir_returns_some_when_structure_exists() {
549 // Construct a fake tree: <tmp>/leaf where <tmp>/MNW/shared/themes exists.
550 // Walking up 1 level from `leaf` should locate the themes dir.
551 // Catches `dev_themes_dir -> Option<PathBuf> with None` (always-None mutant).
552 let root = tempfile::tempdir().unwrap();
553 let leaf = root.path().join("leaf");
554 std::fs::create_dir_all(&leaf).unwrap();
555 let themes = root.path().join("MNW").join("shared").join("themes");
556 std::fs::create_dir_all(&themes).unwrap();
557
558 let result = dev_themes_dir(&leaf, 1);
559 // Compare canonical forms — tempfile may return paths with /private prefix on macOS.
560 let result_canon = result.expect("expected Some").canonicalize().unwrap();
561 let themes_canon = themes.canonicalize().unwrap();
562 assert_eq!(result_canon, themes_canon);
563 }
564
565 #[test]
566 fn dev_themes_dir_returns_none_when_structure_missing() {
567 // No MNW/shared/themes under the temp root → None.
568 let root = tempfile::tempdir().unwrap();
569 let leaf = root.path().join("leaf");
570 std::fs::create_dir_all(&leaf).unwrap();
571 // No themes dir created.
572 let result = dev_themes_dir(&leaf, 1);
573 assert!(result.is_none());
574 }
575
576 #[test]
577 fn import_theme_valid() {
578 let src_dir = tempfile::tempdir().unwrap();
579 let custom_dir = tempfile::tempdir().unwrap();
580
581 let content = r##"
582 [meta]
583 name = "Imported Theme"
584 variant = "dark"
585
586 [background]
587 primary = "#1a1b26"
588
589 [foreground]
590 primary = "#c0caf5"
591 "##;
592 let src = src_dir.path().join("my-theme.toml");
593 fs::write(&src, content).unwrap();
594
595 let meta = import_theme(&src, custom_dir.path()).unwrap();
596 assert_eq!(meta.id, "my-theme");
597 assert_eq!(meta.name, "Imported Theme");
598 assert!(meta.is_custom);
599
600 // Verify file was copied
601 assert!(custom_dir.path().join("my-theme.toml").exists());
602 }
603
604 #[test]
605 fn import_theme_creates_dir() {
606 let src_dir = tempfile::tempdir().unwrap();
607 let base = tempfile::tempdir().unwrap();
608 let custom_dir = base.path().join("nested").join("themes");
609
610 let content = "[background]\nprimary = \"#000000\"\n";
611 let src = src_dir.path().join("test.toml");
612 fs::write(&src, content).unwrap();
613
614 let meta = import_theme(&src, &custom_dir).unwrap();
615 assert_eq!(meta.id, "test");
616 assert!(custom_dir.join("test.toml").exists());
617 }
618
619 #[test]
620 fn import_theme_rejects_no_color_sections() {
621 let src_dir = tempfile::tempdir().unwrap();
622 let custom_dir = tempfile::tempdir().unwrap();
623
624 let content = "[meta]\nname = \"Empty\"\nvariant = \"dark\"\n";
625 let src = src_dir.path().join("empty.toml");
626 fs::write(&src, content).unwrap();
627
628 assert!(import_theme(&src, custom_dir.path()).is_err());
629 }
630
631 #[test]
632 fn import_theme_rejects_invalid_toml() {
633 let src_dir = tempfile::tempdir().unwrap();
634 let custom_dir = tempfile::tempdir().unwrap();
635
636 let src = src_dir.path().join("bad.toml");
637 fs::write(&src, "this is not [valid toml [[[").unwrap();
638
639 assert!(import_theme(&src, custom_dir.path()).is_err());
640 }
641
642 #[test]
643 fn import_theme_rejects_invalid_id() {
644 let src_dir = tempfile::tempdir().unwrap();
645 let custom_dir = tempfile::tempdir().unwrap();
646
647 let content = "[background]\nprimary = \"#000000\"\n";
648 let src = src_dir.path().join("has space.toml");
649 fs::write(&src, content).unwrap();
650
651 assert!(import_theme(&src, custom_dir.path()).is_err());
652 }
653
654 #[test]
655 fn export_theme_copies_file() {
656 let src_dir = tempfile::tempdir().unwrap();
657 let dest_dir = tempfile::tempdir().unwrap();
658
659 let content = "[meta]\nname = \"Export Me\"\nvariant = \"light\"\n[background]\nprimary = \"#ffffff\"\n";
660 fs::write(src_dir.path().join("exportable.toml"), content).unwrap();
661
662 let dirs = vec![(src_dir.path().to_path_buf(), false)];
663 let dest = dest_dir.path().join("exported.toml");
664
665 export_theme(&dirs, "exportable", &dest).unwrap();
666 assert!(dest.exists());
667 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
668 }
669
670 #[test]
671 fn delete_theme_removes_file() {
672 let custom = tempfile::tempdir().unwrap();
673 let path = custom.path().join("doomed.toml");
674 fs::write(&path, "[background]\nprimary = \"#000\"\n").unwrap();
675 assert!(path.exists());
676
677 delete_theme(custom.path(), "doomed").unwrap();
678 assert!(!path.exists());
679 }
680
681 #[test]
682 fn delete_theme_rejects_invalid_id() {
683 let custom = tempfile::tempdir().unwrap();
684 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
685 }
686
687 #[test]
688 fn delete_theme_not_found() {
689 let custom = tempfile::tempdir().unwrap();
690 assert!(delete_theme(custom.path(), "ghost").is_err());
691 }
692
693 #[test]
694 fn load_theme_preview_returns_primaries() {
695 let dir = tempfile::tempdir().unwrap();
696 let content = r##"
697 [meta]
698 name = "Preview Me"
699 variant = "dark"
700
701 [background]
702 primary = "#111"
703 secondary = "#222"
704
705 [foreground]
706 primary = "#eee"
707
708 [accent]
709 primary = "#f0a"
710
711 [border]
712 primary = "#444"
713 "##;
714 fs::write(dir.path().join("preview.toml"), content).unwrap();
715
716 let dirs = vec![(dir.path().to_path_buf(), false)];
717 let p = load_theme_preview(&dirs, "preview").unwrap();
718 assert_eq!(p.meta.name, "Preview Me");
719 assert_eq!(p.background.as_deref(), Some("#111"));
720 assert_eq!(p.foreground.as_deref(), Some("#eee"));
721 assert_eq!(p.accent.as_deref(), Some("#f0a"));
722 assert_eq!(p.border.as_deref(), Some("#444"));
723 }
724
725 #[test]
726 fn load_theme_preview_missing_sections_are_none() {
727 let dir = tempfile::tempdir().unwrap();
728 let content = "[background]\nprimary = \"#000\"\n";
729 fs::write(dir.path().join("sparse.toml"), content).unwrap();
730
731 let dirs = vec![(dir.path().to_path_buf(), false)];
732 let p = load_theme_preview(&dirs, "sparse").unwrap();
733 assert_eq!(p.background.as_deref(), Some("#000"));
734 assert!(p.foreground.is_none());
735 assert!(p.accent.is_none());
736 assert!(p.border.is_none());
737 }
738
739 #[test]
740 fn load_theme_preview_not_found() {
741 let dir = tempfile::tempdir().unwrap();
742 let dirs = vec![(dir.path().to_path_buf(), false)];
743 assert!(load_theme_preview(&dirs, "missing").is_err());
744 }
745
746 #[test]
747 fn export_theme_not_found() {
748 let dir = tempfile::tempdir().unwrap();
749 let dirs = vec![(dir.path().to_path_buf(), false)];
750 let dest = dir.path().join("out.toml");
751
752 assert!(export_theme(&dirs, "nonexistent", &dest).is_err());
753 }
754 }
755