//! Shared theme loading logic for TOML-based theme files. //! //! Used by GoingsOn, Balanced Breakfast (Tauri apps), and audiofiles (egui). //! audiofiles embeds themes at compile time but uses `ThemeMeta`, `parse_meta`, //! and `extract_colors` from this crate. //! //! Theme files are TOML with this structure: //! ```text //! [meta] //! name = "Theme Name" //! variant = "dark" # or "light" //! //! [background] //! primary = "#1e1e2e" //! //! [foreground] //! primary = "#cdd6f4" //! //! [accent] //! primary = "#89b4fa" //! //! [border] //! primary = "#45475a" //! ``` use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; /// Theme metadata parsed from the `[meta]` section. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeMeta { pub id: String, pub name: String, pub variant: String, pub is_custom: bool, } /// A fully loaded theme: metadata plus flattened color map. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub meta: ThemeMeta, pub colors: HashMap, } /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores). pub fn validate_theme_id(id: &str) -> Result<(), String> { if !id .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return Err(format!("Invalid theme ID: {}", id)); } Ok(()) } /// Parse the `[meta]` section from a TOML table into `ThemeMeta`. /// /// Falls back to the file ID as the name and `"dark"` as the variant. pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta { let meta = table.get("meta").and_then(|m| m.as_table()); let name = meta .and_then(|m| m.get("name")) .and_then(|v| v.as_str()) .unwrap_or(id) .to_string(); let variant = meta .and_then(|m| m.get("variant")) .and_then(|v| v.as_str()) .unwrap_or("dark") .to_string(); ThemeMeta { id: id.to_string(), name, variant, is_custom, } } /// Extract color sections (background, foreground, accent, border) from a TOML /// table into a flat `HashMap` with keys like `"background.primary"`. pub fn extract_colors(table: &toml::Table) -> HashMap { let mut colors = HashMap::new(); for section in &["background", "foreground", "accent", "border"] { if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) { for (key, val) in sect { if let Some(color) = val.as_str() { colors.insert(format!("{}.{}", section, key), color.to_string()); } } } } colors } /// Scan directories for `.toml` theme files and return metadata for each. /// /// Directories are checked in order; later entries override earlier ones by ID. /// Each entry in `dirs` is `(path, is_custom)`. pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec { let mut seen: HashMap = HashMap::new(); for (dir, is_custom) in dirs { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; } let id = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); let content = match std::fs::read_to_string(&path) { Ok(c) => c, Err(_) => continue, }; let table: toml::Table = match content.parse() { Ok(t) => t, Err(_) => continue, }; seen.insert(id.clone(), parse_meta(&id, &table, *is_custom)); } } let mut themes: Vec = seen.into_values().collect(); themes.sort_by(|a, b| a.name.cmp(&b.name)); themes } /// Find a theme file by ID in the given directories. /// /// Checks directories in reverse order so the highest-priority directory wins. /// Returns `(path, is_custom)` or `None` if not found. pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> { let filename = format!("{}.toml", id); for (dir, is_custom) in dirs.iter().rev() { let path = dir.join(&filename); if path.is_file() { return Some((path, *is_custom)); } } None } /// Load a complete theme (metadata + colors) by ID from the given directories. pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result { validate_theme_id(id)?; let (path, is_custom) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?; let content = std::fs::read_to_string(&path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let table: toml::Table = content .parse() .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?; let meta = parse_meta(id, &table, is_custom); let colors = extract_colors(&table); Ok(ThemeColors { meta, colors }) } /// Import a theme TOML file into the custom themes directory. /// /// Validates that the file is parseable TOML with the expected color sections, /// then copies it to `custom_dir/{id}.toml` where `id` is the file stem. /// Creates `custom_dir` if it doesn't exist. Returns the theme metadata. pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result { let content = std::fs::read_to_string(source_path) .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?; let table: toml::Table = content .parse() .map_err(|e| format!("Invalid TOML: {}", e))?; // Verify it has at least one color section let has_colors = ["background", "foreground", "accent", "border"] .iter() .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some()); if !has_colors { return Err("Theme file must have at least one color section (background, foreground, accent, or border)".to_string()); } let id = source_path .file_stem() .and_then(|s| s.to_str()) .ok_or("Invalid file name")? .to_string(); validate_theme_id(&id)?; std::fs::create_dir_all(custom_dir) .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?; let dest = custom_dir.join(format!("{}.toml", id)); std::fs::copy(source_path, &dest) .map_err(|e| format!("Failed to copy theme: {}", e))?; Ok(parse_meta(&id, &table, true)) } /// Delete a custom theme by ID. /// /// Only operates on `custom_dir` — bundled themes are not deletable through /// this entry point. Returns `Err` if the ID is invalid, the file does not /// exist, or the underlying remove fails. pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> { validate_theme_id(id)?; let path = custom_dir.join(format!("{}.toml", id)); if !path.is_file() { return Err(format!("Custom theme '{}' not found", id)); } std::fs::remove_file(&path) .map_err(|e| format!("Failed to delete {}: {}", path.display(), e)) } /// A four-color palette for preview chips, swatches, etc. /// /// Smaller than `ThemeColors`: only the `primary` value from each of the four /// canonical sections, so callers rendering a list of theme thumbnails don't /// need to allocate a full `HashMap` per row. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemePreview { pub meta: ThemeMeta, pub background: Option, pub foreground: Option, pub accent: Option, pub border: Option, } fn primary_color(table: &toml::Table, section: &str) -> Option { table .get(section) .and_then(|s| s.as_table()) .and_then(|s| s.get("primary")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) } /// Load just the primary colors for a theme — for UI previews / thumbnails. pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result { validate_theme_id(id)?; let (path, is_custom) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?; let content = std::fs::read_to_string(&path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let table: toml::Table = content .parse() .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?; Ok(ThemePreview { meta: parse_meta(id, &table, is_custom), background: primary_color(&table, "background"), foreground: primary_color(&table, "foreground"), accent: primary_color(&table, "accent"), border: primary_color(&table, "border"), }) } /// Export a theme to a user-chosen path. /// /// Finds the theme by ID in the given directories and copies the TOML file /// to `dest_path`. pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> { validate_theme_id(id)?; let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?; std::fs::copy(&source, dest_path) .map_err(|e| format!("Failed to export theme: {}", e))?; Ok(()) } /// Construct a dev fallback theme directory path. /// /// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and /// appends `"themes"`. Returns the path if the directory exists. pub fn dev_themes_dir(manifest_dir: &Path, levels: usize) -> Option { let mut path = manifest_dir.to_path_buf(); for _ in 0..levels { path = path.parent()?.to_path_buf(); } let themes = path.join("MNW").join("shared").join("themes"); if themes.is_dir() { Some(themes) } else { None } } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn validate_theme_id_alphanumeric() { assert!(validate_theme_id("darkmode").is_ok()); assert!(validate_theme_id("Theme123").is_ok()); } #[test] fn validate_theme_id_hyphens_underscores() { assert!(validate_theme_id("dark-mode").is_ok()); assert!(validate_theme_id("my_theme_v2").is_ok()); assert!(validate_theme_id("a-b_c-d").is_ok()); } #[test] fn validate_theme_id_rejects_spaces() { assert!(validate_theme_id("has space").is_err()); } #[test] fn validate_theme_id_rejects_path_traversal() { assert!(validate_theme_id("../etc/passwd").is_err()); assert!(validate_theme_id("foo/bar").is_err()); } #[test] fn validate_theme_id_rejects_special_chars() { assert!(validate_theme_id("evil