use std::path::{Path, PathBuf};
use clapfig::{Boundary, Clapfig, SearchMode, SearchPath};
use confique::Config;
use serde::{Deserialize, Serialize};
use crate::handlers::HandlerConfig;
use crate::rules::Rule;
use crate::{DodotError, Result};
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct DodotConfig {
#[config(nested)]
pub pack: PackSection,
#[config(nested)]
pub symlink: SymlinkSection,
#[config(nested)]
pub path: PathSection,
#[config(nested)]
pub mappings: MappingsSection,
#[config(nested)]
pub preprocessor: PreprocessorSection,
#[config(nested)]
pub profiling: ProfilingSection,
#[config(nested)]
pub secret: SecretSection,
#[config(default = {})]
pub gates: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PackSection {
#[config(default = [
".git", ".svn", ".hg", "node_modules", ".DS_Store",
"*.swp", "*~", "#*#", ".env*", ".terraform"
])]
pub ignore: Vec<String>,
#[config(default = [])]
pub os: Vec<String>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SymlinkSection {
#[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
pub force_home: Vec<String>,
#[config(default = true)]
pub app_uses_library: bool,
#[config(default = ["Code", "Cursor", "Zed", "Emacs"])]
pub force_app: Vec<String>,
#[config(default = {})]
pub app_aliases: std::collections::HashMap<String, String>,
#[config(default = [
".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
".ssh/authorized_keys", ".gnupg", ".aws/credentials",
".password-store", ".config/gh/hosts.yml",
".kube/config", ".docker/config.json"
])]
pub protected_paths: Vec<String>,
#[config(default = {})]
pub targets: std::collections::HashMap<String, String>,
#[config(default = ["plist"])]
pub plist_extensions: Vec<String>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PathSection {
#[config(default = true)]
pub auto_chmod_exec: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PreprocessorSection {
#[config(default = true)]
pub enabled: bool,
#[config(nested)]
pub template: PreprocessorTemplateSection,
#[config(nested)]
pub age: PreprocessorAgeSection,
#[config(nested)]
pub gpg: PreprocessorGpgSection,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PreprocessorTemplateSection {
#[config(default = ["tmpl", "template"])]
pub extensions: Vec<String>,
#[config(default = {})]
pub vars: std::collections::HashMap<String, String>,
#[config(default = [])]
pub no_reverse: Vec<String>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PreprocessorAgeSection {
#[config(default = false)]
pub enabled: bool,
#[config(default = ["age"])]
pub extensions: Vec<String>,
#[config(default = "")]
pub identity: String,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct PreprocessorGpgSection {
#[config(default = false)]
pub enabled: bool,
#[config(default = ["gpg"])]
pub extensions: Vec<String>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct ProfilingSection {
#[config(default = true)]
pub enabled: bool,
#[config(default = 100)]
pub keep_last_runs: usize,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretSection {
#[config(default = true)]
pub enabled: bool,
#[config(nested)]
pub providers: SecretProvidersSection,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProvidersSection {
#[config(nested)]
pub pass: SecretProviderPass,
#[config(nested)]
pub op: SecretProviderOp,
#[config(nested)]
pub bw: SecretProviderBw,
#[config(nested)]
pub sops: SecretProviderSops,
#[config(nested)]
pub keychain: SecretProviderKeychain,
#[config(nested)]
pub secret_tool: SecretProviderSecretTool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderPass {
#[config(default = false)]
pub enabled: bool,
#[config(default = "")]
pub store_dir: String,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderOp {
#[config(default = false)]
pub enabled: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderBw {
#[config(default = false)]
pub enabled: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderSops {
#[config(default = false)]
pub enabled: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderKeychain {
#[config(default = false)]
pub enabled: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SecretProviderSecretTool {
#[config(default = false)]
pub enabled: bool,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct MappingsSection {
#[config(default = "bin")]
pub path: String,
#[config(default = ["install.sh", "install.bash", "install.zsh"])]
pub install: Vec<String>,
#[config(default = ["*.sh", "*.bash", "*.zsh"])]
pub shell: Vec<String>,
#[config(default = "Brewfile")]
pub homebrew: String,
#[config(default = [])]
pub ignore: Vec<String>,
#[config(default = [
"README", "README.*",
"LICENSE", "LICENSE.*",
"CHANGELOG", "CHANGELOG.*",
"CONTRIBUTING", "CONTRIBUTING.*",
"AUTHORS", "AUTHORS.*",
"NOTICE", "NOTICE.*",
"COPYING", "COPYING.*",
])]
pub skip: Vec<String>,
#[config(default = {})]
pub gates: std::collections::HashMap<String, String>,
}
impl DodotConfig {
pub fn to_handler_config(&self) -> HandlerConfig {
HandlerConfig {
force_home: self.symlink.force_home.clone(),
force_app: self.symlink.force_app.clone(),
app_aliases: self.symlink.app_aliases.clone(),
protected_paths: self.symlink.protected_paths.clone(),
targets: self.symlink.targets.clone(),
auto_chmod_exec: self.path.auto_chmod_exec,
pack_ignore: self.pack.ignore.clone(),
}
}
}
pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
use std::collections::HashMap;
let mut rules = Vec::new();
if !mappings.path.is_empty() {
let pattern = if mappings.path.ends_with('/') {
mappings.path.clone()
} else {
format!("{}/", mappings.path)
};
rules.push(Rule {
pattern,
handler: "path".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
});
}
for pattern in &mappings.install {
if !pattern.is_empty() {
rules.push(Rule {
pattern: pattern.clone(),
handler: "install".into(),
priority: 20,
case_insensitive: false,
options: HashMap::new(),
});
}
}
for pattern in &mappings.shell {
if !pattern.is_empty() {
rules.push(Rule {
pattern: pattern.clone(),
handler: "shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
});
}
}
if !mappings.homebrew.is_empty() {
rules.push(Rule {
pattern: mappings.homebrew.clone(),
handler: "homebrew".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
});
}
for pattern in &mappings.ignore {
if !pattern.is_empty() {
rules.push(Rule {
pattern: pattern.clone(),
handler: crate::handlers::HANDLER_IGNORE.into(),
priority: 100,
case_insensitive: false,
options: HashMap::new(),
});
}
}
for pattern in &mappings.skip {
if !pattern.is_empty() {
rules.push(Rule {
pattern: pattern.clone(),
handler: crate::handlers::HANDLER_SKIP.into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
});
}
}
rules.push(Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
});
rules
}
pub struct ConfigManager {
resolver: clapfig::Resolver<DodotConfig>,
dotfiles_root: PathBuf,
}
impl ConfigManager {
pub fn new(dotfiles_root: &Path) -> Result<Self> {
let resolver = Clapfig::builder::<DodotConfig>()
.app_name("dodot")
.file_name(".dodot.toml")
.search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
.search_mode(SearchMode::Merge)
.no_env()
.build_resolver()
.map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
Ok(Self {
resolver,
dotfiles_root: dotfiles_root.to_path_buf(),
})
}
pub fn root_config(&self) -> Result<DodotConfig> {
let cfg = self
.resolver
.resolve_at(&self.dotfiles_root)
.map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))?;
if !cfg.pack.os.is_empty() {
return Err(DodotError::Config(format!(
"root-level `[pack] os` is not allowed (found `os = {:?}` in \
the root .dodot.toml). `[pack] os` is a pack-level key — \
move it into the specific pack's .dodot.toml.",
cfg.pack.os
)));
}
Ok(cfg)
}
pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
self.resolver
.resolve_at(pack_path)
.map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
}
pub fn dotfiles_root(&self) -> &Path {
&self.dotfiles_root
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::Fs;
use crate::testing::TempEnvironment;
#[test]
fn default_config_has_expected_values() {
let env = TempEnvironment::builder().build();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
let expected_ignore: Vec<String> = vec![
".git",
".svn",
".hg",
"node_modules",
".DS_Store",
"*.swp",
"*~",
"#*#",
".env*",
".terraform",
]
.into_iter()
.map(Into::into)
.collect();
assert_eq!(cfg.pack.ignore, expected_ignore);
let expected_force_home: Vec<String> = vec![
"ssh",
"aws",
"kube",
"bashrc",
"zshrc",
"profile",
"bash_profile",
"bash_login",
"bash_logout",
"inputrc",
]
.into_iter()
.map(Into::into)
.collect();
assert_eq!(cfg.symlink.force_home, expected_force_home);
let expected_protected: Vec<String> = vec![
".ssh/id_rsa",
".ssh/id_ed25519",
".ssh/id_dsa",
".ssh/id_ecdsa",
".ssh/authorized_keys",
".gnupg",
".aws/credentials",
".password-store",
".config/gh/hosts.yml",
".kube/config",
".docker/config.json",
]
.into_iter()
.map(Into::into)
.collect();
assert_eq!(cfg.symlink.protected_paths, expected_protected);
assert!(cfg.symlink.targets.is_empty());
assert!(cfg.path.auto_chmod_exec);
assert_eq!(cfg.mappings.path, "bin");
assert_eq!(
cfg.mappings.install,
vec!["install.sh", "install.bash", "install.zsh"]
);
assert_eq!(cfg.mappings.homebrew, "Brewfile");
assert_eq!(cfg.mappings.shell, vec!["*.sh", "*.bash", "*.zsh"]);
assert!(cfg.mappings.ignore.is_empty());
assert!(
cfg.mappings.skip.iter().any(|p| p == "README"),
"skip defaults should include documentation/legal patterns: {:?}",
cfg.mappings.skip
);
assert!(cfg.profiling.enabled);
assert_eq!(cfg.profiling.keep_last_runs, 100);
}
#[test]
fn profiling_section_overridable() {
let env = TempEnvironment::builder().build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[profiling]\nenabled = false\nkeep_last_runs = 25\n",
)
.unwrap();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
assert!(!cfg.profiling.enabled);
assert_eq!(cfg.profiling.keep_last_runs, 25);
}
#[test]
fn root_config_overrides_defaults() {
let env = TempEnvironment::builder().build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
br#"
[mappings]
install = ["setup.sh"]
homebrew = "MyBrewfile"
"#,
)
.unwrap();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
assert_eq!(cfg.mappings.install, vec!["setup.sh"]);
assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
assert_eq!(cfg.mappings.path, "bin");
}
#[test]
fn pack_config_overrides_root() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.config(
r#"
[pack]
ignore = ["*.bak"]
[mappings]
install = ["vim-setup.sh"]
"#,
)
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
br#"
[mappings]
install = ["install.sh"]
homebrew = "RootBrewfile"
"#,
)
.unwrap();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let root_cfg = mgr.root_config().unwrap();
assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
let pack_path = env.dotfiles_root.join("vim");
let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
assert_eq!(pack_cfg.mappings.install, vec!["vim-setup.sh"]); assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); }
#[test]
fn mappings_to_rules_produces_expected_rules() {
let mappings = MappingsSection {
path: "bin".into(),
install: vec!["install.sh".into(), "install.zsh".into()],
shell: vec!["aliases.sh".into(), "profile.sh".into()],
homebrew: "Brewfile".into(),
ignore: vec!["*.tmp".into()],
skip: vec![],
gates: std::collections::HashMap::new(),
};
let rules = mappings_to_rules(&mappings);
assert_eq!(rules.len(), 8, "rules: {rules:#?}");
let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
assert!(handler_names.contains(&"path"));
assert!(handler_names.contains(&"install"));
assert!(handler_names.contains(&"shell"));
assert!(handler_names.contains(&"homebrew"));
assert!(handler_names.contains(&"ignore"));
assert!(handler_names.contains(&"symlink"));
let ignore = rules.iter().find(|r| r.handler == "ignore").unwrap();
assert_eq!(ignore.priority, 100);
assert!(!ignore.pattern.starts_with('!'));
assert!(!ignore.case_insensitive);
let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
assert_eq!(catchall.priority, 0);
}
#[test]
fn install_rules_outrank_shell_glob() {
let mappings = MappingsSection {
path: "bin".into(),
install: vec!["install.sh".into()],
shell: vec!["*.sh".into()],
homebrew: String::new(),
ignore: vec![],
skip: vec![],
gates: std::collections::HashMap::new(),
};
let rules = mappings_to_rules(&mappings);
let install = rules.iter().find(|r| r.handler == "install").unwrap();
let shell = rules.iter().find(|r| r.handler == "shell").unwrap();
assert!(
install.priority > shell.priority,
"install priority ({}) must exceed shell priority ({}) so \
install.sh wins over the *.sh shell glob.",
install.priority,
shell.priority,
);
}
#[test]
fn mappings_skip_emits_priority_50_skip_rules() {
let mappings = MappingsSection {
path: String::new(),
install: vec![],
shell: vec![],
homebrew: String::new(),
ignore: vec![],
skip: vec!["README".into(), "README.*".into(), "LICENSE".into()],
gates: std::collections::HashMap::new(),
};
let rules = mappings_to_rules(&mappings);
let skip_rules: Vec<&Rule> = rules.iter().filter(|r| r.handler == "skip").collect();
assert_eq!(skip_rules.len(), 3);
for rule in &skip_rules {
assert_eq!(rule.priority, 50);
assert!(rule.case_insensitive);
assert!(!rule.pattern.starts_with('!'));
}
}
#[test]
fn to_handler_config_converts_correctly() {
let env = TempEnvironment::builder().build();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
let hcfg = cfg.to_handler_config();
assert_eq!(hcfg.force_home, cfg.symlink.force_home);
assert_eq!(hcfg.force_app, cfg.symlink.force_app);
assert_eq!(hcfg.app_aliases, cfg.symlink.app_aliases);
assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
}
#[test]
fn default_force_app_under_hundred_entry_cap() {
let env = TempEnvironment::builder().build();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
assert!(
cfg.symlink.force_app.len() <= 100,
"force_app default has {} entries; cap is 100. \
Drop the weakest-justified entry before adding another. \
See docs/proposals/macos-paths.lex §3.4.1.",
cfg.symlink.force_app.len()
);
}
#[test]
fn default_force_app_seed_contains_expected_entries() {
let env = TempEnvironment::builder().build();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
for expected in ["Code", "Cursor", "Zed", "Emacs"] {
assert!(
cfg.symlink.force_app.iter().any(|e| e == expected),
"expected default force_app to contain `{expected}`; got {:?}",
cfg.symlink.force_app
);
}
}
#[test]
fn app_uses_library_default_is_true() {
let env = TempEnvironment::builder().build();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
assert!(
cfg.symlink.app_uses_library,
"app_uses_library must default to true; macOS gets the Library \
root, Linux already collapses app_support_dir to xdg_config_home"
);
}
#[test]
fn app_aliases_overridable_in_root_config() {
let env = TempEnvironment::builder().build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
br#"
[symlink.app_aliases]
vscode = "Code"
warp = "dev.warp.Warp-Stable"
"#,
)
.unwrap();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let cfg = mgr.root_config().unwrap();
assert_eq!(
cfg.symlink.app_aliases.get("vscode").map(String::as_str),
Some("Code")
);
assert_eq!(
cfg.symlink.app_aliases.get("warp").map(String::as_str),
Some("dev.warp.Warp-Stable")
);
}
#[test]
fn root_config_rejects_pack_os() {
let env = TempEnvironment::builder().build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
br#"
[pack]
os = ["darwin"]
"#,
)
.unwrap();
let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
let err = mgr.root_config().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("root-level"), "missing reason: {msg}");
assert!(msg.contains("[pack] os"), "missing key: {msg}");
assert!(msg.contains("darwin"), "missing offending value: {msg}");
}
}