Skip to main content

dodot_lib/config/
mod.rs

1//! Configuration system for dodot, powered by clapfig.
2//!
3//! [`DodotConfig`] is the authoritative schema for all dodot settings.
4//! Configuration is loaded from a 3-layer hierarchy:
5//!
6//! 1. **Compiled defaults** — `#[config(default = ...)]` on struct fields
7//! 2. **Root config** — `$DOTFILES_ROOT/.dodot.toml`
8//! 3. **Pack config** — `$DOTFILES_ROOT/<pack>/.dodot.toml`
9//!
10//! [`ConfigManager`] wraps clapfig's `Resolver` to provide per-pack
11//! config resolution with automatic caching and merging.
12
13use 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/// The complete dodot configuration schema.
24///
25/// All fields have compiled defaults via `#[config(default = ...)]`.
26/// Root and pack `.dodot.toml` files can override any subset.
27#[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/// Pack-level settings.
49#[derive(Config, Debug, Clone, Serialize, Deserialize)]
50pub struct PackSection {
51    /// Glob patterns for files and directories to ignore during pack
52    /// discovery and file scanning.
53    #[config(default = [
54        ".git", ".svn", ".hg", "node_modules", ".DS_Store",
55        "*.swp", "*~", "#*#", ".env*", ".terraform"
56    ])]
57    pub ignore: Vec<String>,
58}
59
60/// Symlink handler settings.
61#[derive(Config, Debug, Clone, Serialize, Deserialize)]
62pub struct SymlinkSection {
63    /// Files/directories that must deploy to `$HOME` instead of
64    /// `$XDG_CONFIG_HOME`. Matched against the first path segment
65    /// (without leading dot).
66    #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
67    pub force_home: Vec<String>,
68
69    /// Paths that must not be symlinked for security reasons.
70    #[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    /// Custom per-file symlink target overrides.
79    /// Maps relative pack filename to absolute or relative target path.
80    /// Absolute paths are used as-is; relative paths are resolved from
81    /// `$XDG_CONFIG_HOME`.
82    #[config(default = {})]
83    pub targets: std::collections::HashMap<String, String>,
84}
85
86/// PATH handler settings.
87#[derive(Config, Debug, Clone, Serialize, Deserialize)]
88pub struct PathSection {
89    /// Automatically add execute permissions (`+x`) to files inside
90    /// `bin/` directories staged by the path handler.
91    ///
92    /// # Rationale
93    ///
94    /// Files placed in a `bin/` directory are there because the pack
95    /// author intends them as executables — the directory's purpose is
96    /// to expose commands via `$PATH`. However, execute bits can be
97    /// lost in common workflows:
98    ///
99    /// - **Git on macOS** defaults to `core.fileMode = false`, so
100    ///   cloned repos may have `0o644` on scripts.
101    /// - **Manual file creation** often forgets `chmod +x`.
102    ///
103    /// Without `+x` the shell finds the file via PATH lookup but fails
104    /// with "permission denied" — a confusing error when the file is
105    /// clearly in the right place.
106    ///
107    /// With this option enabled (the default), `dodot up` ensures every
108    /// file in a path-handler directory is executable, matching the
109    /// user's intent. Files that are already executable are left
110    /// untouched. Failures are reported as warnings, not hard errors.
111    ///
112    /// Set to `false` if you have `bin/` files that intentionally
113    /// should not be executable (e.g. data files or library scripts
114    /// sourced by other scripts).
115    #[config(default = true)]
116    pub auto_chmod_exec: bool,
117}
118
119/// Preprocessing pipeline settings.
120#[derive(Config, Debug, Clone, Serialize, Deserialize)]
121pub struct PreprocessorSection {
122    /// Global kill switch for all preprocessing.
123    #[config(default = true)]
124    pub enabled: bool,
125
126    #[config(nested)]
127    pub template: PreprocessorTemplateSection,
128}
129
130/// Template preprocessor settings.
131#[derive(Config, Debug, Clone, Serialize, Deserialize)]
132pub struct PreprocessorTemplateSection {
133    /// File extensions that trigger template rendering. Each extension
134    /// is matched as a suffix (e.g. `"tmpl"` matches `config.toml.tmpl`).
135    #[config(default = ["tmpl", "template"])]
136    pub extensions: Vec<String>,
137
138    /// User-defined variables, accessible as bare names in templates
139    /// (e.g. `name = "Alice"` makes `{{ name }}` render as `Alice`).
140    ///
141    /// Reserved: `dodot` and `env` are built-in namespaces; using them
142    /// as var names raises an error at load time.
143    #[config(default = {})]
144    pub vars: std::collections::HashMap<String, String>,
145}
146
147/// Shell-init profiling settings. Root-only — per-pack overrides are
148/// meaningless (the init script is one thing; you can't half-profile it).
149///
150/// See `docs/proposals/profiling.lex` for the full design.
151#[derive(Config, Debug, Clone, Serialize, Deserialize)]
152pub struct ProfilingSection {
153    /// Whether the generated `dodot-init.sh` carries the timing wrapper
154    /// around each `source` and PATH line. When false, the init script
155    /// is byte-identical to the pre-Phase-2 form. When true, bash 5+ /
156    /// zsh sessions emit one TSV per shell startup under
157    /// `<data_dir>/probes/shell-init/`; older shells fall through to
158    /// the no-op path even with the wrapper present.
159    #[config(default = true)]
160    pub enabled: bool,
161
162    /// Maximum number of `<data_dir>/probes/shell-init/profile-*.tsv`
163    /// files to retain. Older files are pruned at the end of every
164    /// `dodot up`. At ~4 KB per run, the default budget is roughly
165    /// 400 KB on disk.
166    #[config(default = 100)]
167    pub keep_last_runs: usize,
168}
169
170/// File-to-handler mapping patterns.
171#[derive(Config, Debug, Clone, Serialize, Deserialize)]
172pub struct MappingsSection {
173    /// Directory name pattern for PATH handler.
174    #[config(default = "bin")]
175    pub path: String,
176
177    /// Filename patterns for install scripts.
178    ///
179    /// The extension selects the interpreter used to run the script
180    /// (`.sh`/`.bash` → `bash`, `.zsh` → `zsh`); see the install handler
181    /// for the exact mapping.
182    #[config(default = ["install.sh", "install.bash", "install.zsh"])]
183    pub install: Vec<String>,
184
185    /// Filename patterns for shell scripts to source at login.
186    ///
187    /// Sourced files run *in the user's shell* (whichever shell reads
188    /// `dodot-init.sh`), so `.zsh` files will only parse cleanly in zsh
189    /// sessions and `.bash` files in bash sessions. `.sh` is the
190    /// portable bucket for snippets that work in either.
191    #[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    /// Filename pattern for Homebrew Brewfile.
200    #[config(default = "Brewfile")]
201    pub homebrew: String,
202
203    /// Additional filename patterns to exclude from handler processing
204    /// within a pack. Distinct from [pack] ignore which controls discovery.
205    #[config(default = [])]
206    pub skip: Vec<String>,
207}
208
209// ── Conversions ─────────────────────────────────────────────────
210
211impl DodotConfig {
212    /// Convert to the handler-relevant config subset.
213    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
224/// Generate rules from the mappings section.
225///
226/// This produces the default rule set that maps filename patterns to
227/// handlers, matching the Go implementation's `GenerateRulesFromMapping`.
228pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
229    use std::collections::HashMap;
230
231    let mut rules = Vec::new();
232
233    // Path handler (directory pattern with trailing slash convention)
234    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    // Install handler
249    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    // Shell handler
261    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    // Homebrew handler
273    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    // Skip patterns (exclusion rules)
283    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, // exclusions checked first
289                options: HashMap::new(),
290            });
291        }
292    }
293
294    // Catchall: everything else goes to symlink (lowest priority)
295    rules.push(Rule {
296        pattern: "*".into(),
297        handler: "symlink".into(),
298        priority: 0,
299        options: HashMap::new(),
300    });
301
302    rules
303}
304
305// ── ConfigManager ───────────────────────────────────────────────
306
307/// Manages configuration loading and per-pack resolution.
308///
309/// Wraps clapfig's `Resolver` to provide cached, merged config
310/// resolution. Call [`config_for_pack`](ConfigManager::config_for_pack)
311/// for each pack — the root `.dodot.toml` is read once and cached.
312pub struct ConfigManager {
313    resolver: clapfig::Resolver<DodotConfig>,
314    dotfiles_root: PathBuf,
315}
316
317impl ConfigManager {
318    /// Create a new config manager for the given dotfiles root.
319    ///
320    /// Builds a clapfig Resolver that searches for `.dodot.toml` files
321    /// using ancestor-walk from the resolve point up to (and including)
322    /// the dotfiles root, identified by its `.git` directory. This
323    /// prevents stray `.dodot.toml` files above the repo from leaking in.
324    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    /// Load the root-level configuration (no pack override).
341    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    /// Load merged configuration for a specific pack.
348    ///
349    /// Resolves by walking from `pack_path` up through ancestors,
350    /// merging any `.dodot.toml` files found along the way (including
351    /// the root config). Results are cached by absolute path.
352    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        // Load with no files — should use compiled defaults
372        let env = TempEnvironment::builder().build();
373        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
374        let cfg = mgr.root_config().unwrap();
375
376        // ── pack.ignore defaults ────────────────────────────────
377        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        // ── symlink.force_home defaults ─────────────────────────
395        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        // ── symlink.protected_paths defaults ────────────────────
413        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        // ── symlink.targets defaults ────────────────────────────
432        assert!(cfg.symlink.targets.is_empty());
433
434        // ── path defaults ──────────────────────────────────────
435        assert!(cfg.path.auto_chmod_exec);
436
437        // ── mappings defaults ───────────────────────────────────
438        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        // ── profiling defaults ──────────────────────────────────
464        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        // Write a root .dodot.toml
489        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        // Unset fields keep defaults
506        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        // Root config
527        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        // Root config
541        let root_cfg = mgr.root_config().unwrap();
542        assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
543
544        // Pack config merges root + pack
545        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"]); // overridden
548        assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); // inherited
549        assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); // from pack
550    }
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        // Should have: path, 2x install, 2x shell, homebrew, 1x exclude, catchall = 8
565        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        // Exclusion rule should have ! prefix
576        let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
577        assert!(exclude.pattern.starts_with('!'));
578
579        // Catchall should be lowest priority
580        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}