use anyhow::{Context, Result}; use dirs::home_dir; use std::fs; use std::path::{Path, PathBuf}; pub fn themes_root() -> Result { if let Ok(root) = std::env::var("DM_THEMES_ROOT") { return Ok(PathBuf::from(root)); } let home = home_dir().context("failed to find home directory")?; Ok(home.join("dotfiles").join("themes")) } pub fn discover_themes() -> Result> { let root = themes_root()?; let entries = fs::read_dir(&root) .with_context(|| format!("failed to read themes directory: {}", root.display()))?; let mut themes = Vec::new(); for entry in entries { let entry = entry.with_context(|| { format!( "failed to read an entry from themes directory: {}", root.display() ) })?; let path = entry.path(); if path.is_dir() { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; // "base" is a reserved layer name, not a selectable theme. if name == "base" { continue; } themes.push(name.to_string()); } } themes.sort(); Ok(themes) } /// Recursively enumerate all files under `theme_dir`. /// Returns `(theme_relative_path, absolute_source_path)` pairs sorted by path. pub fn enumerate_theme_files(theme_dir: &Path) -> Result> { let mut files = Vec::new(); enumerate_recursive(theme_dir, theme_dir, &mut files)?; files.sort_by(|a, b| a.0.cmp(&b.0)); Ok(files) } fn enumerate_recursive( root: &Path, current: &Path, files: &mut Vec<(PathBuf, PathBuf)>, ) -> Result<()> { let entries = fs::read_dir(current) .with_context(|| format!("failed to read directory: {}", current.display()))?; for entry in entries { let entry = entry.with_context(|| format!("failed to read entry from: {}", current.display()))?; let path = entry.path(); // Skip symlinks inside a theme source tree if path .symlink_metadata() .map(|m| m.file_type().is_symlink()) .unwrap_or(false) { continue; } if path.is_dir() { enumerate_recursive(root, &path, files)?; } else if path.is_file() { let relative = path .strip_prefix(root) .with_context(|| format!("failed to strip prefix from: {}", path.display()))?; files.push((relative.to_path_buf(), path)); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::test_utils::ENV_MUTEX; use std::fs; use tempfile::TempDir; fn make_theme(root: &Path, theme: &str, files: &[&str]) { for rel in files { let p = root.join(theme).join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(&p, "").unwrap(); } } #[test] fn discover_themes_returns_directory_names() { let _guard = ENV_MUTEX.lock().unwrap(); let tmp = TempDir::new().unwrap(); fs::create_dir(tmp.path().join("dracula")).unwrap(); fs::create_dir(tmp.path().join("catppuccin")).unwrap(); fs::write(tmp.path().join("not-a-dir.txt"), "").unwrap(); unsafe { std::env::set_var("DM_THEMES_ROOT", tmp.path()) }; let themes = discover_themes().unwrap(); unsafe { std::env::remove_var("DM_THEMES_ROOT") }; assert_eq!(themes, vec!["catppuccin", "dracula"]); } #[test] fn enumerate_theme_files_returns_relative_paths() { let tmp = TempDir::new().unwrap(); make_theme(tmp.path(), "dracula", &[".config/nvim/init.lua", ".zshrc"]); let theme_dir = tmp.path().join("dracula"); let files = enumerate_theme_files(&theme_dir).unwrap(); let relatives: Vec = files.into_iter().map(|(r, _)| r).collect(); assert!(relatives.contains(&PathBuf::from(".config/nvim/init.lua"))); assert!(relatives.contains(&PathBuf::from(".zshrc"))); } #[test] fn enumerate_theme_files_is_sorted() { let tmp = TempDir::new().unwrap(); make_theme(tmp.path(), "t", &["b.txt", "a.txt", ".config/z.txt"]); let theme_dir = tmp.path().join("t"); let files = enumerate_theme_files(&theme_dir).unwrap(); let relatives: Vec = files.into_iter().map(|(r, _)| r).collect(); let mut sorted = relatives.clone(); sorted.sort(); assert_eq!(relatives, sorted); } }