1use std::path::{Path, PathBuf};
14
15use clapfig::{Boundary, Clapfig, SearchMode, SearchPath};
16use confique::Config;
17use serde::{Deserialize, Serialize};
18
19use crate::handlers::HandlerConfig;
20use crate::rules::Rule;
21use crate::{DodotError, Result};
22
23#[derive(Config, Debug, Clone, Serialize, Deserialize)]
28pub struct DodotConfig {
29 #[config(nested)]
30 pub pack: PackSection,
31
32 #[config(nested)]
33 pub symlink: SymlinkSection,
34
35 #[config(nested)]
36 pub mappings: MappingsSection,
37}
38
39#[derive(Config, Debug, Clone, Serialize, Deserialize)]
41pub struct PackSection {
42 #[config(default = [
45 ".git", ".svn", ".hg", "node_modules", ".DS_Store",
46 "*.swp", "*~", "#*#", ".env*", ".terraform"
47 ])]
48 pub ignore: Vec<String>,
49}
50
51#[derive(Config, Debug, Clone, Serialize, Deserialize)]
53pub struct SymlinkSection {
54 #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
58 pub force_home: Vec<String>,
59
60 #[config(default = [
62 ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
63 ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
64 ".password-store", ".config/gh/hosts.yml",
65 ".kube/config", ".docker/config.json"
66 ])]
67 pub protected_paths: Vec<String>,
68
69 #[config(default = {})]
74 pub targets: std::collections::HashMap<String, String>,
75}
76
77#[derive(Config, Debug, Clone, Serialize, Deserialize)]
79pub struct MappingsSection {
80 #[config(default = "bin")]
82 pub path: String,
83
84 #[config(default = "install.sh")]
86 pub install: String,
87
88 #[config(default = ["aliases.sh", "profile.sh", "login.sh"])]
90 pub shell: Vec<String>,
91
92 #[config(default = "Brewfile")]
94 pub homebrew: String,
95
96 #[config(default = [])]
99 pub skip: Vec<String>,
100}
101
102impl DodotConfig {
105 pub fn to_handler_config(&self) -> HandlerConfig {
107 HandlerConfig {
108 force_home: self.symlink.force_home.clone(),
109 protected_paths: self.symlink.protected_paths.clone(),
110 targets: self.symlink.targets.clone(),
111 }
112 }
113}
114
115pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
120 use std::collections::HashMap;
121
122 let mut rules = Vec::new();
123
124 if !mappings.path.is_empty() {
126 let pattern = if mappings.path.ends_with('/') {
127 mappings.path.clone()
128 } else {
129 format!("{}/", mappings.path)
130 };
131 rules.push(Rule {
132 pattern,
133 handler: "path".into(),
134 priority: 10,
135 options: HashMap::new(),
136 });
137 }
138
139 if !mappings.install.is_empty() {
141 rules.push(Rule {
142 pattern: mappings.install.clone(),
143 handler: "install".into(),
144 priority: 10,
145 options: HashMap::new(),
146 });
147 }
148
149 for pattern in &mappings.shell {
151 if !pattern.is_empty() {
152 rules.push(Rule {
153 pattern: pattern.clone(),
154 handler: "shell".into(),
155 priority: 10,
156 options: HashMap::new(),
157 });
158 }
159 }
160
161 if !mappings.homebrew.is_empty() {
163 rules.push(Rule {
164 pattern: mappings.homebrew.clone(),
165 handler: "homebrew".into(),
166 priority: 10,
167 options: HashMap::new(),
168 });
169 }
170
171 for pattern in &mappings.skip {
173 if !pattern.is_empty() {
174 rules.push(Rule {
175 pattern: format!("!{pattern}"),
176 handler: "exclude".into(),
177 priority: 100, options: HashMap::new(),
179 });
180 }
181 }
182
183 rules.push(Rule {
185 pattern: "*".into(),
186 handler: "symlink".into(),
187 priority: 0,
188 options: HashMap::new(),
189 });
190
191 rules
192}
193
194pub struct ConfigManager {
202 resolver: clapfig::Resolver<DodotConfig>,
203 dotfiles_root: PathBuf,
204}
205
206impl ConfigManager {
207 pub fn new(dotfiles_root: &Path) -> Result<Self> {
214 let resolver = Clapfig::builder::<DodotConfig>()
215 .app_name("dodot")
216 .file_name(".dodot.toml")
217 .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
218 .search_mode(SearchMode::Merge)
219 .no_env()
220 .build_resolver()
221 .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
222
223 Ok(Self {
224 resolver,
225 dotfiles_root: dotfiles_root.to_path_buf(),
226 })
227 }
228
229 pub fn root_config(&self) -> Result<DodotConfig> {
231 self.resolver
232 .resolve_at(&self.dotfiles_root)
233 .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
234 }
235
236 pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
242 self.resolver
243 .resolve_at(pack_path)
244 .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
245 }
246
247 pub fn dotfiles_root(&self) -> &Path {
248 &self.dotfiles_root
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::fs::Fs;
256 use crate::testing::TempEnvironment;
257
258 #[test]
259 fn default_config_has_expected_values() {
260 let env = TempEnvironment::builder().build();
262 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
263 let cfg = mgr.root_config().unwrap();
264
265 let expected_ignore: Vec<String> = vec![
267 ".git",
268 ".svn",
269 ".hg",
270 "node_modules",
271 ".DS_Store",
272 "*.swp",
273 "*~",
274 "#*#",
275 ".env*",
276 ".terraform",
277 ]
278 .into_iter()
279 .map(Into::into)
280 .collect();
281 assert_eq!(cfg.pack.ignore, expected_ignore);
282
283 let expected_force_home: Vec<String> = vec![
285 "ssh",
286 "aws",
287 "kube",
288 "bashrc",
289 "zshrc",
290 "profile",
291 "bash_profile",
292 "bash_login",
293 "bash_logout",
294 "inputrc",
295 ]
296 .into_iter()
297 .map(Into::into)
298 .collect();
299 assert_eq!(cfg.symlink.force_home, expected_force_home);
300
301 let expected_protected: Vec<String> = vec![
303 ".ssh/id_rsa",
304 ".ssh/id_ed25519",
305 ".ssh/id_dsa",
306 ".ssh/id_ecdsa",
307 ".ssh/authorized_keys",
308 ".gnupg",
309 ".aws/credentials",
310 ".password-store",
311 ".config/gh/hosts.yml",
312 ".kube/config",
313 ".docker/config.json",
314 ]
315 .into_iter()
316 .map(Into::into)
317 .collect();
318 assert_eq!(cfg.symlink.protected_paths, expected_protected);
319
320 assert!(cfg.symlink.targets.is_empty());
322
323 assert_eq!(cfg.mappings.path, "bin");
325 assert_eq!(cfg.mappings.install, "install.sh");
326 assert_eq!(cfg.mappings.homebrew, "Brewfile");
327 assert_eq!(
328 cfg.mappings.shell,
329 vec!["aliases.sh", "profile.sh", "login.sh"]
330 );
331 assert!(cfg.mappings.skip.is_empty());
332 }
333
334 #[test]
335 fn root_config_overrides_defaults() {
336 let env = TempEnvironment::builder().build();
337
338 env.fs
340 .write_file(
341 &env.dotfiles_root.join(".dodot.toml"),
342 br#"
343[mappings]
344install = "setup.sh"
345homebrew = "MyBrewfile"
346"#,
347 )
348 .unwrap();
349
350 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
351 let cfg = mgr.root_config().unwrap();
352
353 assert_eq!(cfg.mappings.install, "setup.sh");
354 assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
355 assert_eq!(cfg.mappings.path, "bin");
357 }
358
359 #[test]
360 fn pack_config_overrides_root() {
361 let env = TempEnvironment::builder()
362 .pack("vim")
363 .file("vimrc", "x")
364 .config(
365 r#"
366[pack]
367ignore = ["*.bak"]
368
369[mappings]
370install = "vim-setup.sh"
371"#,
372 )
373 .done()
374 .build();
375
376 env.fs
378 .write_file(
379 &env.dotfiles_root.join(".dodot.toml"),
380 br#"
381[mappings]
382install = "install.sh"
383homebrew = "RootBrewfile"
384"#,
385 )
386 .unwrap();
387
388 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
389
390 let root_cfg = mgr.root_config().unwrap();
392 assert_eq!(root_cfg.mappings.install, "install.sh");
393
394 let pack_path = env.dotfiles_root.join("vim");
396 let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
397 assert_eq!(pack_cfg.mappings.install, "vim-setup.sh"); assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); }
401
402 #[test]
403 fn mappings_to_rules_produces_expected_rules() {
404 let mappings = MappingsSection {
405 path: "bin".into(),
406 install: "install.sh".into(),
407 shell: vec!["aliases.sh".into(), "profile.sh".into()],
408 homebrew: "Brewfile".into(),
409 skip: vec!["*.tmp".into()],
410 };
411
412 let rules = mappings_to_rules(&mappings);
413
414 assert_eq!(rules.len(), 7, "rules: {rules:#?}");
416
417 let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
418 assert!(handler_names.contains(&"path"));
419 assert!(handler_names.contains(&"install"));
420 assert!(handler_names.contains(&"shell"));
421 assert!(handler_names.contains(&"homebrew"));
422 assert!(handler_names.contains(&"exclude"));
423 assert!(handler_names.contains(&"symlink"));
424
425 let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
427 assert!(exclude.pattern.starts_with('!'));
428
429 let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
431 assert_eq!(catchall.priority, 0);
432 }
433
434 #[test]
435 fn to_handler_config_converts_correctly() {
436 let env = TempEnvironment::builder().build();
437 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
438 let cfg = mgr.root_config().unwrap();
439
440 let hcfg = cfg.to_handler_config();
441 assert_eq!(hcfg.force_home, cfg.symlink.force_home);
442 assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
443 }
444}