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,
}
#[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>,
}
#[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 = [
".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>,
}
#[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,
}
#[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>,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct MappingsSection {
#[config(default = "bin")]
pub path: String,
#[config(default = "install.sh")]
pub install: String,
#[config(default = ["aliases.sh", "profile.sh", "login.sh", "env.sh"])]
pub shell: Vec<String>,
#[config(default = "Brewfile")]
pub homebrew: String,
#[config(default = [])]
pub skip: Vec<String>,
}
impl DodotConfig {
pub fn to_handler_config(&self) -> HandlerConfig {
HandlerConfig {
force_home: self.symlink.force_home.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,
options: HashMap::new(),
});
}
if !mappings.install.is_empty() {
rules.push(Rule {
pattern: mappings.install.clone(),
handler: "install".into(),
priority: 10,
options: HashMap::new(),
});
}
for pattern in &mappings.shell {
if !pattern.is_empty() {
rules.push(Rule {
pattern: pattern.clone(),
handler: "shell".into(),
priority: 10,
options: HashMap::new(),
});
}
}
if !mappings.homebrew.is_empty() {
rules.push(Rule {
pattern: mappings.homebrew.clone(),
handler: "homebrew".into(),
priority: 10,
options: HashMap::new(),
});
}
for pattern in &mappings.skip {
if !pattern.is_empty() {
rules.push(Rule {
pattern: format!("!{pattern}"),
handler: "exclude".into(),
priority: 100, options: HashMap::new(),
});
}
}
rules.push(Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
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> {
self.resolver
.resolve_at(&self.dotfiles_root)
.map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
}
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, "install.sh");
assert_eq!(cfg.mappings.homebrew, "Brewfile");
assert_eq!(
cfg.mappings.shell,
vec!["aliases.sh", "profile.sh", "login.sh", "env.sh"]
);
assert!(cfg.mappings.skip.is_empty());
}
#[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, "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, "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, "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: "install.sh".into(),
shell: vec!["aliases.sh".into(), "profile.sh".into()],
homebrew: "Brewfile".into(),
skip: vec!["*.tmp".into()],
};
let rules = mappings_to_rules(&mappings);
assert_eq!(rules.len(), 7, "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(&"exclude"));
assert!(handler_names.contains(&"symlink"));
let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
assert!(exclude.pattern.starts_with('!'));
let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
assert_eq!(catchall.priority, 0);
}
#[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.protected_paths, cfg.symlink.protected_paths);
}
}