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 = true)]
81 pub app_uses_library: bool,
82
83 #[config(default = ["Code", "Cursor", "Zed", "Emacs"])]
91 pub force_app: Vec<String>,
92
93 #[config(default = {})]
98 pub app_aliases: std::collections::HashMap<String, String>,
99
100 #[config(default = [
102 ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
103 ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
104 ".password-store", ".config/gh/hosts.yml",
105 ".kube/config", ".docker/config.json"
106 ])]
107 pub protected_paths: Vec<String>,
108
109 #[config(default = {})]
114 pub targets: std::collections::HashMap<String, String>,
115}
116
117#[derive(Config, Debug, Clone, Serialize, Deserialize)]
119pub struct PathSection {
120 #[config(default = true)]
147 pub auto_chmod_exec: bool,
148}
149
150#[derive(Config, Debug, Clone, Serialize, Deserialize)]
152pub struct PreprocessorSection {
153 #[config(default = true)]
155 pub enabled: bool,
156
157 #[config(nested)]
158 pub template: PreprocessorTemplateSection,
159}
160
161#[derive(Config, Debug, Clone, Serialize, Deserialize)]
163pub struct PreprocessorTemplateSection {
164 #[config(default = ["tmpl", "template"])]
167 pub extensions: Vec<String>,
168
169 #[config(default = {})]
175 pub vars: std::collections::HashMap<String, String>,
176}
177
178#[derive(Config, Debug, Clone, Serialize, Deserialize)]
183pub struct ProfilingSection {
184 #[config(default = true)]
191 pub enabled: bool,
192
193 #[config(default = 100)]
198 pub keep_last_runs: usize,
199}
200
201#[derive(Config, Debug, Clone, Serialize, Deserialize)]
203pub struct MappingsSection {
204 #[config(default = "bin")]
206 pub path: String,
207
208 #[config(default = ["install.sh", "install.bash", "install.zsh"])]
214 pub install: Vec<String>,
215
216 #[config(default = [
223 "aliases.sh", "aliases.bash", "aliases.zsh",
224 "profile.sh", "profile.bash", "profile.zsh",
225 "login.sh", "login.bash", "login.zsh",
226 "env.sh", "env.bash", "env.zsh",
227 ])]
228 pub shell: Vec<String>,
229
230 #[config(default = "Brewfile")]
232 pub homebrew: String,
233
234 #[config(default = [])]
237 pub skip: Vec<String>,
238}
239
240impl DodotConfig {
243 pub fn to_handler_config(&self) -> HandlerConfig {
245 HandlerConfig {
246 force_home: self.symlink.force_home.clone(),
247 force_app: self.symlink.force_app.clone(),
253 app_aliases: self.symlink.app_aliases.clone(),
254 protected_paths: self.symlink.protected_paths.clone(),
255 targets: self.symlink.targets.clone(),
256 auto_chmod_exec: self.path.auto_chmod_exec,
257 pack_ignore: self.pack.ignore.clone(),
258 }
259 }
260}
261
262pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
267 use std::collections::HashMap;
268
269 let mut rules = Vec::new();
270
271 if !mappings.path.is_empty() {
273 let pattern = if mappings.path.ends_with('/') {
274 mappings.path.clone()
275 } else {
276 format!("{}/", mappings.path)
277 };
278 rules.push(Rule {
279 pattern,
280 handler: "path".into(),
281 priority: 10,
282 options: HashMap::new(),
283 });
284 }
285
286 for pattern in &mappings.install {
288 if !pattern.is_empty() {
289 rules.push(Rule {
290 pattern: pattern.clone(),
291 handler: "install".into(),
292 priority: 10,
293 options: HashMap::new(),
294 });
295 }
296 }
297
298 for pattern in &mappings.shell {
300 if !pattern.is_empty() {
301 rules.push(Rule {
302 pattern: pattern.clone(),
303 handler: "shell".into(),
304 priority: 10,
305 options: HashMap::new(),
306 });
307 }
308 }
309
310 if !mappings.homebrew.is_empty() {
312 rules.push(Rule {
313 pattern: mappings.homebrew.clone(),
314 handler: "homebrew".into(),
315 priority: 10,
316 options: HashMap::new(),
317 });
318 }
319
320 for pattern in &mappings.skip {
322 if !pattern.is_empty() {
323 rules.push(Rule {
324 pattern: format!("!{pattern}"),
325 handler: "exclude".into(),
326 priority: 100, options: HashMap::new(),
328 });
329 }
330 }
331
332 rules.push(Rule {
334 pattern: "*".into(),
335 handler: "symlink".into(),
336 priority: 0,
337 options: HashMap::new(),
338 });
339
340 rules
341}
342
343pub struct ConfigManager {
351 resolver: clapfig::Resolver<DodotConfig>,
352 dotfiles_root: PathBuf,
353}
354
355impl ConfigManager {
356 pub fn new(dotfiles_root: &Path) -> Result<Self> {
363 let resolver = Clapfig::builder::<DodotConfig>()
364 .app_name("dodot")
365 .file_name(".dodot.toml")
366 .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
367 .search_mode(SearchMode::Merge)
368 .no_env()
369 .build_resolver()
370 .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
371
372 Ok(Self {
373 resolver,
374 dotfiles_root: dotfiles_root.to_path_buf(),
375 })
376 }
377
378 pub fn root_config(&self) -> Result<DodotConfig> {
380 self.resolver
381 .resolve_at(&self.dotfiles_root)
382 .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
383 }
384
385 pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
391 self.resolver
392 .resolve_at(pack_path)
393 .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
394 }
395
396 pub fn dotfiles_root(&self) -> &Path {
397 &self.dotfiles_root
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::fs::Fs;
405 use crate::testing::TempEnvironment;
406
407 #[test]
408 fn default_config_has_expected_values() {
409 let env = TempEnvironment::builder().build();
411 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
412 let cfg = mgr.root_config().unwrap();
413
414 let expected_ignore: Vec<String> = vec![
416 ".git",
417 ".svn",
418 ".hg",
419 "node_modules",
420 ".DS_Store",
421 "*.swp",
422 "*~",
423 "#*#",
424 ".env*",
425 ".terraform",
426 ]
427 .into_iter()
428 .map(Into::into)
429 .collect();
430 assert_eq!(cfg.pack.ignore, expected_ignore);
431
432 let expected_force_home: Vec<String> = vec![
434 "ssh",
435 "aws",
436 "kube",
437 "bashrc",
438 "zshrc",
439 "profile",
440 "bash_profile",
441 "bash_login",
442 "bash_logout",
443 "inputrc",
444 ]
445 .into_iter()
446 .map(Into::into)
447 .collect();
448 assert_eq!(cfg.symlink.force_home, expected_force_home);
449
450 let expected_protected: Vec<String> = vec![
452 ".ssh/id_rsa",
453 ".ssh/id_ed25519",
454 ".ssh/id_dsa",
455 ".ssh/id_ecdsa",
456 ".ssh/authorized_keys",
457 ".gnupg",
458 ".aws/credentials",
459 ".password-store",
460 ".config/gh/hosts.yml",
461 ".kube/config",
462 ".docker/config.json",
463 ]
464 .into_iter()
465 .map(Into::into)
466 .collect();
467 assert_eq!(cfg.symlink.protected_paths, expected_protected);
468
469 assert!(cfg.symlink.targets.is_empty());
471
472 assert!(cfg.path.auto_chmod_exec);
474
475 assert_eq!(cfg.mappings.path, "bin");
477 assert_eq!(
478 cfg.mappings.install,
479 vec!["install.sh", "install.bash", "install.zsh"]
480 );
481 assert_eq!(cfg.mappings.homebrew, "Brewfile");
482 assert_eq!(
483 cfg.mappings.shell,
484 vec![
485 "aliases.sh",
486 "aliases.bash",
487 "aliases.zsh",
488 "profile.sh",
489 "profile.bash",
490 "profile.zsh",
491 "login.sh",
492 "login.bash",
493 "login.zsh",
494 "env.sh",
495 "env.bash",
496 "env.zsh",
497 ]
498 );
499 assert!(cfg.mappings.skip.is_empty());
500
501 assert!(cfg.profiling.enabled);
503 assert_eq!(cfg.profiling.keep_last_runs, 100);
504 }
505
506 #[test]
507 fn profiling_section_overridable() {
508 let env = TempEnvironment::builder().build();
509 env.fs
510 .write_file(
511 &env.dotfiles_root.join(".dodot.toml"),
512 b"[profiling]\nenabled = false\nkeep_last_runs = 25\n",
513 )
514 .unwrap();
515
516 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
517 let cfg = mgr.root_config().unwrap();
518 assert!(!cfg.profiling.enabled);
519 assert_eq!(cfg.profiling.keep_last_runs, 25);
520 }
521
522 #[test]
523 fn root_config_overrides_defaults() {
524 let env = TempEnvironment::builder().build();
525
526 env.fs
528 .write_file(
529 &env.dotfiles_root.join(".dodot.toml"),
530 br#"
531[mappings]
532install = ["setup.sh"]
533homebrew = "MyBrewfile"
534"#,
535 )
536 .unwrap();
537
538 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
539 let cfg = mgr.root_config().unwrap();
540
541 assert_eq!(cfg.mappings.install, vec!["setup.sh"]);
542 assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
543 assert_eq!(cfg.mappings.path, "bin");
545 }
546
547 #[test]
548 fn pack_config_overrides_root() {
549 let env = TempEnvironment::builder()
550 .pack("vim")
551 .file("vimrc", "x")
552 .config(
553 r#"
554[pack]
555ignore = ["*.bak"]
556
557[mappings]
558install = ["vim-setup.sh"]
559"#,
560 )
561 .done()
562 .build();
563
564 env.fs
566 .write_file(
567 &env.dotfiles_root.join(".dodot.toml"),
568 br#"
569[mappings]
570install = ["install.sh"]
571homebrew = "RootBrewfile"
572"#,
573 )
574 .unwrap();
575
576 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
577
578 let root_cfg = mgr.root_config().unwrap();
580 assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
581
582 let pack_path = env.dotfiles_root.join("vim");
584 let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
585 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"]); }
589
590 #[test]
591 fn mappings_to_rules_produces_expected_rules() {
592 let mappings = MappingsSection {
593 path: "bin".into(),
594 install: vec!["install.sh".into(), "install.zsh".into()],
595 shell: vec!["aliases.sh".into(), "profile.sh".into()],
596 homebrew: "Brewfile".into(),
597 skip: vec!["*.tmp".into()],
598 };
599
600 let rules = mappings_to_rules(&mappings);
601
602 assert_eq!(rules.len(), 8, "rules: {rules:#?}");
604
605 let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
606 assert!(handler_names.contains(&"path"));
607 assert!(handler_names.contains(&"install"));
608 assert!(handler_names.contains(&"shell"));
609 assert!(handler_names.contains(&"homebrew"));
610 assert!(handler_names.contains(&"exclude"));
611 assert!(handler_names.contains(&"symlink"));
612
613 let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
615 assert!(exclude.pattern.starts_with('!'));
616
617 let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
619 assert_eq!(catchall.priority, 0);
620 }
621
622 #[test]
623 fn to_handler_config_converts_correctly() {
624 let env = TempEnvironment::builder().build();
625 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
626 let cfg = mgr.root_config().unwrap();
627
628 let hcfg = cfg.to_handler_config();
629 assert_eq!(hcfg.force_home, cfg.symlink.force_home);
630 assert_eq!(hcfg.force_app, cfg.symlink.force_app);
631 assert_eq!(hcfg.app_aliases, cfg.symlink.app_aliases);
632 assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
633 }
634
635 #[test]
641 fn default_force_app_under_hundred_entry_cap() {
642 let env = TempEnvironment::builder().build();
643 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
644 let cfg = mgr.root_config().unwrap();
645 assert!(
646 cfg.symlink.force_app.len() <= 100,
647 "force_app default has {} entries; cap is 100. \
648 Drop the weakest-justified entry before adding another. \
649 See docs/proposals/macos-paths.lex §3.4.1.",
650 cfg.symlink.force_app.len()
651 );
652 }
653
654 #[test]
658 fn default_force_app_seed_contains_expected_entries() {
659 let env = TempEnvironment::builder().build();
660 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
661 let cfg = mgr.root_config().unwrap();
662 for expected in ["Code", "Cursor", "Zed", "Emacs"] {
663 assert!(
664 cfg.symlink.force_app.iter().any(|e| e == expected),
665 "expected default force_app to contain `{expected}`; got {:?}",
666 cfg.symlink.force_app
667 );
668 }
669 }
670
671 #[test]
672 fn app_uses_library_default_is_true() {
673 let env = TempEnvironment::builder().build();
674 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
675 let cfg = mgr.root_config().unwrap();
676 assert!(
677 cfg.symlink.app_uses_library,
678 "app_uses_library must default to true; macOS gets the Library \
679 root, Linux already collapses app_support_dir to xdg_config_home"
680 );
681 }
682
683 #[test]
684 fn app_aliases_overridable_in_root_config() {
685 let env = TempEnvironment::builder().build();
686 env.fs
687 .write_file(
688 &env.dotfiles_root.join(".dodot.toml"),
689 br#"
690[symlink.app_aliases]
691vscode = "Code"
692warp = "dev.warp.Warp-Stable"
693"#,
694 )
695 .unwrap();
696 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
697 let cfg = mgr.root_config().unwrap();
698 assert_eq!(
699 cfg.symlink.app_aliases.get("vscode").map(String::as_str),
700 Some("Code")
701 );
702 assert_eq!(
703 cfg.symlink.app_aliases.get("warp").map(String::as_str),
704 Some("dev.warp.Warp-Stable")
705 );
706 }
707}