| 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 |
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)];
|