max / makeover
1 file changed,
+140 insertions,
-0 deletions
| @@ -218,6 +218,70 @@ pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, | |||
| 218 | 218 | Ok(parse_meta(&id, &table, true)) | |
| 219 | 219 | } | |
| 220 | 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 | + | ||
| 221 | 285 | /// Export a theme to a user-chosen path. | |
| 222 | 286 | /// | |
| 223 | 287 | /// Finds the theme by ID in the given directories and copies the TOML file | |
| @@ -604,6 +668,82 @@ primary = "#c0caf5" | |||
| 604 | 668 | } | |
| 605 | 669 | ||
| 606 | 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] | |
| 607 | 747 | fn export_theme_not_found() { | |
| 608 | 748 | let dir = tempfile::tempdir().unwrap(); | |
| 609 | 749 | let dirs = vec![(dir.path().to_path_buf(), false)]; |