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
45/// Pack-level settings.
46#[derive(Config, Debug, Clone, Serialize, Deserialize)]
47pub struct PackSection {
48    /// Glob patterns for files and directories to ignore during pack
49    /// discovery and file scanning.
50    #[config(default = [
51        ".git", ".svn", ".hg", "node_modules", ".DS_Store",
52        "*.swp", "*~", "#*#", ".env*", ".terraform"
53    ])]
54    pub ignore: Vec<String>,
55}
56
57/// Symlink handler settings.
58#[derive(Config, Debug, Clone, Serialize, Deserialize)]
59pub struct SymlinkSection {
60    /// Files/directories that must deploy to `$HOME` instead of
61    /// `$XDG_CONFIG_HOME`. Matched against the first path segment
62    /// (without leading dot).
63    #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
64    pub force_home: Vec<String>,
65
66    /// Paths that must not be symlinked for security reasons.
67    #[config(default = [
68        ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
69        ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
70        ".password-store", ".config/gh/hosts.yml",
71        ".kube/config", ".docker/config.json"
72    ])]
73    pub protected_paths: Vec<String>,
74
75    /// Custom per-file symlink target overrides.
76    /// Maps relative pack filename to absolute or relative target path.
77    /// Absolute paths are used as-is; relative paths are resolved from
78    /// `$XDG_CONFIG_HOME`.
79    #[config(default = {})]
80    pub targets: std::collections::HashMap<String, String>,
81}
82
83/// PATH handler settings.
84#[derive(Config, Debug, Clone, Serialize, Deserialize)]
85pub struct PathSection {
86    /// Automatically add execute permissions (`+x`) to files inside
87    /// `bin/` directories staged by the path handler.
88    ///
89    /// # Rationale
90    ///
91    /// Files placed in a `bin/` directory are there because the pack
92    /// author intends them as executables — the directory's purpose is
93    /// to expose commands via `$PATH`. However, execute bits can be
94    /// lost in common workflows:
95    ///
96    /// - **Git on macOS** defaults to `core.fileMode = false`, so
97    ///   cloned repos may have `0o644` on scripts.
98    /// - **Manual file creation** often forgets `chmod +x`.
99    ///
100    /// Without `+x` the shell finds the file via PATH lookup but fails
101    /// with "permission denied" — a confusing error when the file is
102    /// clearly in the right place.
103    ///
104    /// With this option enabled (the default), `dodot up` ensures every
105    /// file in a path-handler directory is executable, matching the
106    /// user's intent. Files that are already executable are left
107    /// untouched. Failures are reported as warnings, not hard errors.
108    ///
109    /// Set to `false` if you have `bin/` files that intentionally
110    /// should not be executable (e.g. data files or library scripts
111    /// sourced by other scripts).
112    #[config(default = true)]
113    pub auto_chmod_exec: bool,
114}
115
116/// Preprocessing pipeline settings.
117#[derive(Config, Debug, Clone, Serialize, Deserialize)]
118pub struct PreprocessorSection {
119    /// Global kill switch for all preprocessing.
120    #[config(default = true)]
121    pub enabled: bool,
122
123    #[config(nested)]
124    pub template: PreprocessorTemplateSection,
125}
126
127/// Template preprocessor settings.
128#[derive(Config, Debug, Clone, Serialize, Deserialize)]
129pub struct PreprocessorTemplateSection {
130    /// File extensions that trigger template rendering. Each extension
131    /// is matched as a suffix (e.g. `"tmpl"` matches `config.toml.tmpl`).
132    #[config(default = ["tmpl", "template"])]
133    pub extensions: Vec<String>,
134
135    /// User-defined variables, accessible as bare names in templates
136    /// (e.g. `name = "Alice"` makes `{{ name }}` render as `Alice`).
137    ///
138    /// Reserved: `dodot` and `env` are built-in namespaces; using them
139    /// as var names raises an error at load time.
140    #[config(default = {})]
141    pub vars: std::collections::HashMap<String, String>,
142}
143
144/// File-to-handler mapping patterns.
145#[derive(Config, Debug, Clone, Serialize, Deserialize)]
146pub struct MappingsSection {
147    /// Directory name pattern for PATH handler.
148    #[config(default = "bin")]
149    pub path: String,
150
151    /// Filename pattern for install scripts.
152    #[config(default = "install.sh")]
153    pub install: String,
154
155    /// Filename patterns for shell scripts to source.
156    #[config(default = ["aliases.sh", "profile.sh", "login.sh", "env.sh"])]
157    pub shell: Vec<String>,
158
159    /// Filename pattern for Homebrew Brewfile.
160    #[config(default = "Brewfile")]
161    pub homebrew: String,
162
163    /// Additional filename patterns to exclude from handler processing
164    /// within a pack. Distinct from [pack] ignore which controls discovery.
165    #[config(default = [])]
166    pub skip: Vec<String>,
167}
168
169// ── Conversions ─────────────────────────────────────────────────
170
171impl DodotConfig {
172    /// Convert to the handler-relevant config subset.
173    pub fn to_handler_config(&self) -> HandlerConfig {
174        HandlerConfig {
175            force_home: self.symlink.force_home.clone(),
176            protected_paths: self.symlink.protected_paths.clone(),
177            targets: self.symlink.targets.clone(),
178            auto_chmod_exec: self.path.auto_chmod_exec,
179            pack_ignore: self.pack.ignore.clone(),
180        }
181    }
182}
183
184/// Generate rules from the mappings section.
185///
186/// This produces the default rule set that maps filename patterns to
187/// handlers, matching the Go implementation's `GenerateRulesFromMapping`.
188pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
189    use std::collections::HashMap;
190
191    let mut rules = Vec::new();
192
193    // Path handler (directory pattern with trailing slash convention)
194    if !mappings.path.is_empty() {
195        let pattern = if mappings.path.ends_with('/') {
196            mappings.path.clone()
197        } else {
198            format!("{}/", mappings.path)
199        };
200        rules.push(Rule {
201            pattern,
202            handler: "path".into(),
203            priority: 10,
204            options: HashMap::new(),
205        });
206    }
207
208    // Install handler
209    if !mappings.install.is_empty() {
210        rules.push(Rule {
211            pattern: mappings.install.clone(),
212            handler: "install".into(),
213            priority: 10,
214            options: HashMap::new(),
215        });
216    }
217
218    // Shell handler
219    for pattern in &mappings.shell {
220        if !pattern.is_empty() {
221            rules.push(Rule {
222                pattern: pattern.clone(),
223                handler: "shell".into(),
224                priority: 10,
225                options: HashMap::new(),
226            });
227        }
228    }
229
230    // Homebrew handler
231    if !mappings.homebrew.is_empty() {
232        rules.push(Rule {
233            pattern: mappings.homebrew.clone(),
234            handler: "homebrew".into(),
235            priority: 10,
236            options: HashMap::new(),
237        });
238    }
239
240    // Skip patterns (exclusion rules)
241    for pattern in &mappings.skip {
242        if !pattern.is_empty() {
243            rules.push(Rule {
244                pattern: format!("!{pattern}"),
245                handler: "exclude".into(),
246                priority: 100, // exclusions checked first
247                options: HashMap::new(),
248            });
249        }
250    }
251
252    // Catchall: everything else goes to symlink (lowest priority)
253    rules.push(Rule {
254        pattern: "*".into(),
255        handler: "symlink".into(),
256        priority: 0,
257        options: HashMap::new(),
258    });
259
260    rules
261}
262
263// ── ConfigManager ───────────────────────────────────────────────
264
265/// Manages configuration loading and per-pack resolution.
266///
267/// Wraps clapfig's `Resolver` to provide cached, merged config
268/// resolution. Call [`config_for_pack`](ConfigManager::config_for_pack)
269/// for each pack — the root `.dodot.toml` is read once and cached.
270pub struct ConfigManager {
271    resolver: clapfig::Resolver<DodotConfig>,
272    dotfiles_root: PathBuf,
273}
274
275impl ConfigManager {
276    /// Create a new config manager for the given dotfiles root.
277    ///
278    /// Builds a clapfig Resolver that searches for `.dodot.toml` files
279    /// using ancestor-walk from the resolve point up to (and including)
280    /// the dotfiles root, identified by its `.git` directory. This
281    /// prevents stray `.dodot.toml` files above the repo from leaking in.
282    pub fn new(dotfiles_root: &Path) -> Result<Self> {
283        let resolver = Clapfig::builder::<DodotConfig>()
284            .app_name("dodot")
285            .file_name(".dodot.toml")
286            .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
287            .search_mode(SearchMode::Merge)
288            .no_env()
289            .build_resolver()
290            .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
291
292        Ok(Self {
293            resolver,
294            dotfiles_root: dotfiles_root.to_path_buf(),
295        })
296    }
297
298    /// Load the root-level configuration (no pack override).
299    pub fn root_config(&self) -> Result<DodotConfig> {
300        self.resolver
301            .resolve_at(&self.dotfiles_root)
302            .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
303    }
304
305    /// Load merged configuration for a specific pack.
306    ///
307    /// Resolves by walking from `pack_path` up through ancestors,
308    /// merging any `.dodot.toml` files found along the way (including
309    /// the root config). Results are cached by absolute path.
310    pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
311        self.resolver
312            .resolve_at(pack_path)
313            .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
314    }
315
316    pub fn dotfiles_root(&self) -> &Path {
317        &self.dotfiles_root
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::fs::Fs;
325    use crate::testing::TempEnvironment;
326
327    #[test]
328    fn default_config_has_expected_values() {
329        // Load with no files — should use compiled defaults
330        let env = TempEnvironment::builder().build();
331        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
332        let cfg = mgr.root_config().unwrap();
333
334        // ── pack.ignore defaults ────────────────────────────────
335        let expected_ignore: Vec<String> = vec![
336            ".git",
337            ".svn",
338            ".hg",
339            "node_modules",
340            ".DS_Store",
341            "*.swp",
342            "*~",
343            "#*#",
344            ".env*",
345            ".terraform",
346        ]
347        .into_iter()
348        .map(Into::into)
349        .collect();
350        assert_eq!(cfg.pack.ignore, expected_ignore);
351
352        // ── symlink.force_home defaults ─────────────────────────
353        let expected_force_home: Vec<String> = vec![
354            "ssh",
355            "aws",
356            "kube",
357            "bashrc",
358            "zshrc",
359            "profile",
360            "bash_profile",
361            "bash_login",
362            "bash_logout",
363            "inputrc",
364        ]
365        .into_iter()
366        .map(Into::into)
367        .collect();
368        assert_eq!(cfg.symlink.force_home, expected_force_home);
369
370        // ── symlink.protected_paths defaults ────────────────────
371        let expected_protected: Vec<String> = vec![
372            ".ssh/id_rsa",
373            ".ssh/id_ed25519",
374            ".ssh/id_dsa",
375            ".ssh/id_ecdsa",
376            ".ssh/authorized_keys",
377            ".gnupg",
378            ".aws/credentials",
379            ".password-store",
380            ".config/gh/hosts.yml",
381            ".kube/config",
382            ".docker/config.json",
383        ]
384        .into_iter()
385        .map(Into::into)
386        .collect();
387        assert_eq!(cfg.symlink.protected_paths, expected_protected);
388
389        // ── symlink.targets defaults ────────────────────────────
390        assert!(cfg.symlink.targets.is_empty());
391
392        // ── path defaults ──────────────────────────────────────
393        assert!(cfg.path.auto_chmod_exec);
394
395        // ── mappings defaults ───────────────────────────────────
396        assert_eq!(cfg.mappings.path, "bin");
397        assert_eq!(cfg.mappings.install, "install.sh");
398        assert_eq!(cfg.mappings.homebrew, "Brewfile");
399        assert_eq!(
400            cfg.mappings.shell,
401            vec!["aliases.sh", "profile.sh", "login.sh", "env.sh"]
402        );
403        assert!(cfg.mappings.skip.is_empty());
404    }
405
406    #[test]
407    fn root_config_overrides_defaults() {
408        let env = TempEnvironment::builder().build();
409
410        // Write a root .dodot.toml
411        env.fs
412            .write_file(
413                &env.dotfiles_root.join(".dodot.toml"),
414                br#"
415[mappings]
416install = "setup.sh"
417homebrew = "MyBrewfile"
418"#,
419            )
420            .unwrap();
421
422        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
423        let cfg = mgr.root_config().unwrap();
424
425        assert_eq!(cfg.mappings.install, "setup.sh");
426        assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
427        // Unset fields keep defaults
428        assert_eq!(cfg.mappings.path, "bin");
429    }
430
431    #[test]
432    fn pack_config_overrides_root() {
433        let env = TempEnvironment::builder()
434            .pack("vim")
435            .file("vimrc", "x")
436            .config(
437                r#"
438[pack]
439ignore = ["*.bak"]
440
441[mappings]
442install = "vim-setup.sh"
443"#,
444            )
445            .done()
446            .build();
447
448        // Root config
449        env.fs
450            .write_file(
451                &env.dotfiles_root.join(".dodot.toml"),
452                br#"
453[mappings]
454install = "install.sh"
455homebrew = "RootBrewfile"
456"#,
457            )
458            .unwrap();
459
460        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
461
462        // Root config
463        let root_cfg = mgr.root_config().unwrap();
464        assert_eq!(root_cfg.mappings.install, "install.sh");
465
466        // Pack config merges root + pack
467        let pack_path = env.dotfiles_root.join("vim");
468        let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
469        assert_eq!(pack_cfg.mappings.install, "vim-setup.sh"); // overridden
470        assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); // inherited
471        assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); // from pack
472    }
473
474    #[test]
475    fn mappings_to_rules_produces_expected_rules() {
476        let mappings = MappingsSection {
477            path: "bin".into(),
478            install: "install.sh".into(),
479            shell: vec!["aliases.sh".into(), "profile.sh".into()],
480            homebrew: "Brewfile".into(),
481            skip: vec!["*.tmp".into()],
482        };
483
484        let rules = mappings_to_rules(&mappings);
485
486        // Should have: path, install, 2x shell, homebrew, 1x exclude, catchall = 7
487        assert_eq!(rules.len(), 7, "rules: {rules:#?}");
488
489        let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
490        assert!(handler_names.contains(&"path"));
491        assert!(handler_names.contains(&"install"));
492        assert!(handler_names.contains(&"shell"));
493        assert!(handler_names.contains(&"homebrew"));
494        assert!(handler_names.contains(&"exclude"));
495        assert!(handler_names.contains(&"symlink"));
496
497        // Exclusion rule should have ! prefix
498        let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
499        assert!(exclude.pattern.starts_with('!'));
500
501        // Catchall should be lowest priority
502        let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
503        assert_eq!(catchall.priority, 0);
504    }
505
506    #[test]
507    fn to_handler_config_converts_correctly() {
508        let env = TempEnvironment::builder().build();
509        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
510        let cfg = mgr.root_config().unwrap();
511
512        let hcfg = cfg.to_handler_config();
513        assert_eq!(hcfg.force_home, cfg.symlink.force_home);
514        assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
515    }
516}