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