From eb692b56e4b1bbb9e75a7b9807cf80da788f2afb Mon Sep 17 00:00:00 2001 From: ManOfGoldForever Date: Wed, 1 Apr 2026 13:03:39 -0400 Subject: [PATCH] Added base theme for defaults --- src/apply.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/theme.rs | 4 +++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/apply.rs b/src/apply.rs index 61432ce..7becffd 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -103,13 +103,33 @@ pub fn plan_apply( force: bool, current_state: Option<&State>, ) -> Result { - let theme_dir = themes_root()?.join(theme_name); + if theme_name == "base" { + anyhow::bail!("\"base\" is a reserved layer name and cannot be applied as a theme"); + } + + let root = themes_root()?; + let theme_dir = root.join(theme_name); if !theme_dir.is_dir() { anyhow::bail!("theme not found: {}", theme_name); } let home = effective_home()?; - let files = enumerate_theme_files(&theme_dir)?; + + // Build a merged file map: base files first, theme files overlay (and win on conflict). + // Using BTreeMap keeps the iteration order deterministic. + let mut file_map: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + let base_dir = root.join("base"); + if base_dir.is_dir() { + for (rel, src) in enumerate_theme_files(&base_dir)? { + file_map.insert(rel, src); + } + } + + for (rel, src) in enumerate_theme_files(&theme_dir)? { + file_map.insert(rel, src); + } let managed_targets: HashSet = current_state .map(|s| s.entries.iter().map(|e| PathBuf::from(&e.target)).collect()) @@ -117,7 +137,7 @@ pub fn plan_apply( let mut actions = Vec::new(); - for (relative, source) in files { + for (relative, source) in file_map { let target = home.join(&relative); match target.symlink_metadata() { @@ -724,4 +744,78 @@ mod tests { let link = fs::read_link(env.home.path().join(".zshrc")).unwrap(); assert!(link.to_string_lossy().contains("catppuccin")); } + + // ---- base layer tests ----------------------------------------------- + + fn make_base_file(env: &TestEnv, rel: &str, content: &str) -> PathBuf { + make_theme_file(env, "base", rel, content) + } + + #[test] + fn base_files_applied_alongside_theme() { + let env = TestEnv::new(); + make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds"); + make_theme_file(&env, "dracula", ".zshrc", "# dracula"); + + let plan = plan_apply("dracula", false, None).unwrap(); + assert_eq!(plan.actions.len(), 2); + + let (_, summary) = execute_apply(&plan, false).unwrap(); + assert_eq!(summary.created, 2); + + let keybinds = fs::read_link(env.home.path().join(".config/hypr/keybinds.conf")).unwrap(); + assert!(keybinds.to_string_lossy().contains("base")); + + let zshrc = fs::read_link(env.home.path().join(".zshrc")).unwrap(); + assert!(zshrc.to_string_lossy().contains("dracula")); + } + + #[test] + fn theme_overrides_base_file() { + let env = TestEnv::new(); + make_base_file(&env, ".zshrc", "# base zshrc"); + make_theme_file(&env, "dracula", ".zshrc", "# dracula zshrc"); + + let plan = plan_apply("dracula", false, None).unwrap(); + // Only one action — theme wins over base for same path. + assert_eq!(plan.actions.len(), 1); + + execute_apply(&plan, false).unwrap(); + let link = fs::read_link(env.home.path().join(".zshrc")).unwrap(); + assert!(link.to_string_lossy().contains("dracula")); + } + + #[test] + fn base_file_stays_when_theme_has_no_override() { + let env = TestEnv::new(); + make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds"); + make_theme_file(&env, "dracula", ".zshrc", "# dracula"); + make_theme_file(&env, "catppuccin", ".zshrc", "# catppuccin"); + + // Apply dracula + let plan = plan_apply("dracula", false, None).unwrap(); + let (dracula_state, _) = execute_apply(&plan, false).unwrap(); + + // Switch to catppuccin — neither theme has keybinds.conf, so base symlink stays + let plan2 = plan_apply("catppuccin", false, Some(&dracula_state)).unwrap(); + + let keybinds_action = plan2 + .actions + .iter() + .find(|a| match a { + PlannedAction::Skip { target, .. } => { + target.to_string_lossy().contains("keybinds") + } + _ => false, + }); + assert!(keybinds_action.is_some(), "keybinds.conf should be skipped (already correct)"); + } + + #[test] + fn base_is_rejected_as_theme_name() { + let env = TestEnv::new(); + make_base_file(&env, ".zshrc", "# base"); + let result = plan_apply("base", false, None); + assert!(result.is_err()); + } } diff --git a/src/theme.rs b/src/theme.rs index 5610181..adf0e08 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -31,6 +31,10 @@ pub fn discover_themes() -> Result> { 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()); } }