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