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 path: PathSection,
37
38 #[config(nested)]
39 pub mappings: MappingsSection,
40
41 #[config(nested)]
42 pub preprocessor: PreprocessorSection,
43
44 #[config(nested)]
45 pub profiling: ProfilingSection,
46}
47
48#[derive(Config, Debug, Clone, Serialize, Deserialize)]
50pub struct PackSection {
51 #[config(default = [
54 ".git", ".svn", ".hg", "node_modules", ".DS_Store",
55 "*.swp", "*~", "#*#", ".env*", ".terraform"
56 ])]
57 pub ignore: Vec<String>,
58}
59
60#[derive(Config, Debug, Clone, Serialize, Deserialize)]
62pub struct SymlinkSection {
63 #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
67 pub force_home: Vec<String>,
68
69 #[config(default = [
71 ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
72 ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
73 ".password-store", ".config/gh/hosts.yml",
74 ".kube/config", ".docker/config.json"
75 ])]
76 pub protected_paths: Vec<String>,
77
78 #[config(default = {})]
83 pub targets: std::collections::HashMap<String, String>,
84}
85
86#[derive(Config, Debug, Clone, Serialize, Deserialize)]
88pub struct PathSection {
89 #[config(default = true)]
116 pub auto_chmod_exec: bool,
117}
118
119#[derive(Config, Debug, Clone, Serialize, Deserialize)]
121pub struct PreprocessorSection {
122 #[config(default = true)]
124 pub enabled: bool,
125
126 #[config(nested)]
127 pub template: PreprocessorTemplateSection,
128}
129
130#[derive(Config, Debug, Clone, Serialize, Deserialize)]
132pub struct PreprocessorTemplateSection {
133 #[config(default = ["tmpl", "template"])]
136 pub extensions: Vec<String>,
137
138 #[config(default = {})]
144 pub vars: std::collections::HashMap<String, String>,
145}
146
147#[derive(Config, Debug, Clone, Serialize, Deserialize)]
152pub struct ProfilingSection {
153 #[config(default = true)]
160 pub enabled: bool,
161
162 #[config(default = 100)]
167 pub keep_last_runs: usize,
168}
169
170#[derive(Config, Debug, Clone, Serialize, Deserialize)]
172pub struct MappingsSection {
173 #[config(default = "bin")]
175 pub path: String,
176
177 #[config(default = ["install.sh", "install.bash", "install.zsh"])]
183 pub install: Vec<String>,
184
185 #[config(default = [
192 "aliases.sh", "aliases.bash", "aliases.zsh",
193 "profile.sh", "profile.bash", "profile.zsh",
194 "login.sh", "login.bash", "login.zsh",
195 "env.sh", "env.bash", "env.zsh",
196 ])]
197 pub shell: Vec<String>,
198
199 #[config(default = "Brewfile")]
201 pub homebrew: String,
202
203 #[config(default = [])]
206 pub skip: Vec<String>,
207}
208
209impl DodotConfig {
212 pub fn to_handler_config(&self) -> HandlerConfig {
214 HandlerConfig {
215 force_home: self.symlink.force_home.clone(),
216 protected_paths: self.symlink.protected_paths.clone(),
217 targets: self.symlink.targets.clone(),
218 auto_chmod_exec: self.path.auto_chmod_exec,
219 pack_ignore: self.pack.ignore.clone(),
220 }
221 }
222}
223
224pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
229 use std::collections::HashMap;
230
231 let mut rules = Vec::new();
232
233 if !mappings.path.is_empty() {
235 let pattern = if mappings.path.ends_with('/') {
236 mappings.path.clone()
237 } else {
238 format!("{}/", mappings.path)
239 };
240 rules.push(Rule {
241 pattern,
242 handler: "path".into(),
243 priority: 10,
244 options: HashMap::new(),
245 });
246 }
247
248 for pattern in &mappings.install {
250 if !pattern.is_empty() {
251 rules.push(Rule {
252 pattern: pattern.clone(),
253 handler: "install".into(),
254 priority: 10,
255 options: HashMap::new(),
256 });
257 }
258 }
259
260 for pattern in &mappings.shell {
262 if !pattern.is_empty() {
263 rules.push(Rule {
264 pattern: pattern.clone(),
265 handler: "shell".into(),
266 priority: 10,
267 options: HashMap::new(),
268 });
269 }
270 }
271
272 if !mappings.homebrew.is_empty() {
274 rules.push(Rule {
275 pattern: mappings.homebrew.clone(),
276 handler: "homebrew".into(),
277 priority: 10,
278 options: HashMap::new(),
279 });
280 }
281
282 for pattern in &mappings.skip {
284 if !pattern.is_empty() {
285 rules.push(Rule {
286 pattern: format!("!{pattern}"),
287 handler: "exclude".into(),
288 priority: 100, options: HashMap::new(),
290 });
291 }
292 }
293
294 rules.push(Rule {
296 pattern: "*".into(),
297 handler: "symlink".into(),
298 priority: 0,
299 options: HashMap::new(),
300 });
301
302 rules
303}
304
305pub struct ConfigManager {
313 resolver: clapfig::Resolver<DodotConfig>,
314 dotfiles_root: PathBuf,
315}
316
317impl ConfigManager {
318 pub fn new(dotfiles_root: &Path) -> Result<Self> {
325 let resolver = Clapfig::builder::<DodotConfig>()
326 .app_name("dodot")
327 .file_name(".dodot.toml")
328 .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
329 .search_mode(SearchMode::Merge)
330 .no_env()
331 .build_resolver()
332 .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
333
334 Ok(Self {
335 resolver,
336 dotfiles_root: dotfiles_root.to_path_buf(),
337 })
338 }
339
340 pub fn root_config(&self) -> Result<DodotConfig> {
342 self.resolver
343 .resolve_at(&self.dotfiles_root)
344 .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
345 }
346
347 pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
353 self.resolver
354 .resolve_at(pack_path)
355 .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
356 }
357
358 pub fn dotfiles_root(&self) -> &Path {
359 &self.dotfiles_root
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::fs::Fs;
367 use crate::testing::TempEnvironment;
368
369 #[test]
370 fn default_config_has_expected_values() {
371 let env = TempEnvironment::builder().build();
373 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
374 let cfg = mgr.root_config().unwrap();
375
376 let expected_ignore: Vec<String> = vec![
378 ".git",
379 ".svn",
380 ".hg",
381 "node_modules",
382 ".DS_Store",
383 "*.swp",
384 "*~",
385 "#*#",
386 ".env*",
387 ".terraform",
388 ]
389 .into_iter()
390 .map(Into::into)
391 .collect();
392 assert_eq!(cfg.pack.ignore, expected_ignore);
393
394 let expected_force_home: Vec<String> = vec![
396 "ssh",
397 "aws",
398 "kube",
399 "bashrc",
400 "zshrc",
401 "profile",
402 "bash_profile",
403 "bash_login",
404 "bash_logout",
405 "inputrc",
406 ]
407 .into_iter()
408 .map(Into::into)
409 .collect();
410 assert_eq!(cfg.symlink.force_home, expected_force_home);
411
412 let expected_protected: Vec<String> = vec![
414 ".ssh/id_rsa",
415 ".ssh/id_ed25519",
416 ".ssh/id_dsa",
417 ".ssh/id_ecdsa",
418 ".ssh/authorized_keys",
419 ".gnupg",
420 ".aws/credentials",
421 ".password-store",
422 ".config/gh/hosts.yml",
423 ".kube/config",
424 ".docker/config.json",
425 ]
426 .into_iter()
427 .map(Into::into)
428 .collect();
429 assert_eq!(cfg.symlink.protected_paths, expected_protected);
430
431 assert!(cfg.symlink.targets.is_empty());
433
434 assert!(cfg.path.auto_chmod_exec);
436
437 assert_eq!(cfg.mappings.path, "bin");
439 assert_eq!(
440 cfg.mappings.install,
441 vec!["install.sh", "install.bash", "install.zsh"]
442 );
443 assert_eq!(cfg.mappings.homebrew, "Brewfile");
444 assert_eq!(
445 cfg.mappings.shell,
446 vec![
447 "aliases.sh",
448 "aliases.bash",
449 "aliases.zsh",
450 "profile.sh",
451 "profile.bash",
452 "profile.zsh",
453 "login.sh",
454 "login.bash",
455 "login.zsh",
456 "env.sh",
457 "env.bash",
458 "env.zsh",
459 ]
460 );
461 assert!(cfg.mappings.skip.is_empty());
462
463 assert!(cfg.profiling.enabled);
465 assert_eq!(cfg.profiling.keep_last_runs, 100);
466 }
467
468 #[test]
469 fn profiling_section_overridable() {
470 let env = TempEnvironment::builder().build();
471 env.fs
472 .write_file(
473 &env.dotfiles_root.join(".dodot.toml"),
474 b"[profiling]\nenabled = false\nkeep_last_runs = 25\n",
475 )
476 .unwrap();
477
478 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
479 let cfg = mgr.root_config().unwrap();
480 assert!(!cfg.profiling.enabled);
481 assert_eq!(cfg.profiling.keep_last_runs, 25);
482 }
483
484 #[test]
485 fn root_config_overrides_defaults() {
486 let env = TempEnvironment::builder().build();
487
488 env.fs
490 .write_file(
491 &env.dotfiles_root.join(".dodot.toml"),
492 br#"
493[mappings]
494install = ["setup.sh"]
495homebrew = "MyBrewfile"
496"#,
497 )
498 .unwrap();
499
500 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
501 let cfg = mgr.root_config().unwrap();
502
503 assert_eq!(cfg.mappings.install, vec!["setup.sh"]);
504 assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
505 assert_eq!(cfg.mappings.path, "bin");
507 }
508
509 #[test]
510 fn pack_config_overrides_root() {
511 let env = TempEnvironment::builder()
512 .pack("vim")
513 .file("vimrc", "x")
514 .config(
515 r#"
516[pack]
517ignore = ["*.bak"]
518
519[mappings]
520install = ["vim-setup.sh"]
521"#,
522 )
523 .done()
524 .build();
525
526 env.fs
528 .write_file(
529 &env.dotfiles_root.join(".dodot.toml"),
530 br#"
531[mappings]
532install = ["install.sh"]
533homebrew = "RootBrewfile"
534"#,
535 )
536 .unwrap();
537
538 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
539
540 let root_cfg = mgr.root_config().unwrap();
542 assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
543
544 let pack_path = env.dotfiles_root.join("vim");
546 let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
547 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"]); }
551
552 #[test]
553 fn mappings_to_rules_produces_expected_rules() {
554 let mappings = MappingsSection {
555 path: "bin".into(),
556 install: vec!["install.sh".into(), "install.zsh".into()],
557 shell: vec!["aliases.sh".into(), "profile.sh".into()],
558 homebrew: "Brewfile".into(),
559 skip: vec!["*.tmp".into()],
560 };
561
562 let rules = mappings_to_rules(&mappings);
563
564 assert_eq!(rules.len(), 8, "rules: {rules:#?}");
566
567 let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
568 assert!(handler_names.contains(&"path"));
569 assert!(handler_names.contains(&"install"));
570 assert!(handler_names.contains(&"shell"));
571 assert!(handler_names.contains(&"homebrew"));
572 assert!(handler_names.contains(&"exclude"));
573 assert!(handler_names.contains(&"symlink"));
574
575 let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
577 assert!(exclude.pattern.starts_with('!'));
578
579 let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
581 assert_eq!(catchall.priority, 0);
582 }
583
584 #[test]
585 fn to_handler_config_converts_correctly() {
586 let env = TempEnvironment::builder().build();
587 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
588 let cfg = mgr.root_config().unwrap();
589
590 let hcfg = cfg.to_handler_config();
591 assert_eq!(hcfg.force_home, cfg.symlink.force_home);
592 assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
593 }
594}