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    #[config(nested)]
48    pub secret: SecretSection,
49}
50
51/// Pack-level settings.
52#[derive(Config, Debug, Clone, Serialize, Deserialize)]
53pub struct PackSection {
54    /// Glob patterns for files and directories to ignore during pack
55    /// discovery and file scanning.
56    #[config(default = [
57        ".git", ".svn", ".hg", "node_modules", ".DS_Store",
58        "*.swp", "*~", "#*#", ".env*", ".terraform"
59    ])]
60    pub ignore: Vec<String>,
61}
62
63/// Symlink handler settings.
64#[derive(Config, Debug, Clone, Serialize, Deserialize)]
65pub struct SymlinkSection {
66    /// Files/directories that must deploy to `$HOME` instead of
67    /// `$XDG_CONFIG_HOME`. Matched against the first path segment
68    /// (without leading dot).
69    #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
70    pub force_home: Vec<String>,
71
72    /// Whether `_app/` and `app_aliases` route through the macOS
73    /// `~/Library/Application Support` root. Defaults to `true` on
74    /// macOS, ignored on other platforms (where `app_support_dir`
75    /// always collapses to `xdg_config_home`).
76    ///
77    /// Setting this to `false` on macOS opts the user into Linux-style
78    /// `~/.config` placement for *every* `_app/` and `app_aliases`
79    /// entry. `_lib/` is unaffected — it explicitly targets
80    /// `~/Library/`.
81    ///
82    /// See `docs/proposals/macos-paths.lex` §11.2.
83    #[config(default = true)]
84    pub app_uses_library: bool,
85
86    /// Curated list of GUI-app folder names whose first path segment
87    /// routes to `<app_support_dir>/<seg>/<rest>` without requiring a
88    /// `_app/` prefix in the pack tree. Capped at 100 entries; see
89    /// `docs/proposals/macos-paths.lex` §3.4.
90    ///
91    /// Matching is case-sensitive (Library folder names are case-sensitive
92    /// on macOS) and on the first path segment only.
93    #[config(default = ["Code", "Cursor", "Zed", "Emacs"])]
94    pub force_app: Vec<String>,
95
96    /// Pack-name → GUI-app folder name rewrites. When the pack name
97    /// matches a key here, the resolver's default rule reroutes to
98    /// `<app_support_dir>/<value>/<rel_path>`. See
99    /// `docs/proposals/macos-paths.lex` §3.3.
100    #[config(default = {})]
101    pub app_aliases: std::collections::HashMap<String, String>,
102
103    /// Paths that must not be symlinked for security reasons.
104    #[config(default = [
105        ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
106        ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
107        ".password-store", ".config/gh/hosts.yml",
108        ".kube/config", ".docker/config.json"
109    ])]
110    pub protected_paths: Vec<String>,
111
112    /// Custom per-file symlink target overrides.
113    /// Maps relative pack filename to absolute or relative target path.
114    /// Absolute paths are used as-is; relative paths are resolved from
115    /// `$XDG_CONFIG_HOME`.
116    #[config(default = {})]
117    pub targets: std::collections::HashMap<String, String>,
118
119    /// Filename suffixes (without leading dot) that should be detected
120    /// as plists for `dodot git-install-filters` adopt hints and the
121    /// `.gitattributes` line. Defaults to `["plist"]`. Some apps store
122    /// plists with non-standard suffixes (`.binplist`, `.savedState`,
123    /// etc.); register additional extensions here to flow them through
124    /// the same clean/smudge pipeline.
125    ///
126    /// Comparison is case-insensitive, matching the existing detection
127    /// behavior. Honors the standard root → pack inheritance.
128    /// See `docs/proposals/plists.lex` §8.1.
129    #[config(default = ["plist"])]
130    pub plist_extensions: Vec<String>,
131}
132
133/// PATH handler settings.
134#[derive(Config, Debug, Clone, Serialize, Deserialize)]
135pub struct PathSection {
136    /// Automatically add execute permissions (`+x`) to files inside
137    /// `bin/` directories staged by the path handler.
138    ///
139    /// # Rationale
140    ///
141    /// Files placed in a `bin/` directory are there because the pack
142    /// author intends them as executables — the directory's purpose is
143    /// to expose commands via `$PATH`. However, execute bits can be
144    /// lost in common workflows:
145    ///
146    /// - **Git on macOS** defaults to `core.fileMode = false`, so
147    ///   cloned repos may have `0o644` on scripts.
148    /// - **Manual file creation** often forgets `chmod +x`.
149    ///
150    /// Without `+x` the shell finds the file via PATH lookup but fails
151    /// with "permission denied" — a confusing error when the file is
152    /// clearly in the right place.
153    ///
154    /// With this option enabled (the default), `dodot up` ensures every
155    /// file in a path-handler directory is executable, matching the
156    /// user's intent. Files that are already executable are left
157    /// untouched. Failures are reported as warnings, not hard errors.
158    ///
159    /// Set to `false` if you have `bin/` files that intentionally
160    /// should not be executable (e.g. data files or library scripts
161    /// sourced by other scripts).
162    #[config(default = true)]
163    pub auto_chmod_exec: bool,
164}
165
166/// Preprocessing pipeline settings.
167#[derive(Config, Debug, Clone, Serialize, Deserialize)]
168pub struct PreprocessorSection {
169    /// Global kill switch for all preprocessing.
170    #[config(default = true)]
171    pub enabled: bool,
172
173    #[config(nested)]
174    pub template: PreprocessorTemplateSection,
175
176    #[config(nested)]
177    pub age: PreprocessorAgeSection,
178
179    #[config(nested)]
180    pub gpg: PreprocessorGpgSection,
181}
182
183/// Template preprocessor settings.
184#[derive(Config, Debug, Clone, Serialize, Deserialize)]
185pub struct PreprocessorTemplateSection {
186    /// File extensions that trigger template rendering. Each extension
187    /// is matched as a suffix (e.g. `"tmpl"` matches `config.toml.tmpl`).
188    #[config(default = ["tmpl", "template"])]
189    pub extensions: Vec<String>,
190
191    /// User-defined variables, accessible as bare names in templates
192    /// (e.g. `name = "Alice"` makes `{{ name }}` render as `Alice`).
193    ///
194    /// Reserved: `dodot` and `env` are built-in namespaces; using them
195    /// as var names raises an error at load time.
196    #[config(default = {})]
197    pub vars: std::collections::HashMap<String, String>,
198
199    /// Glob patterns for source files whose reverse-merge should be
200    /// skipped. Templates matching are still rendered on `dodot up` and
201    /// tracked in the divergence cache, but `dodot transform check` and
202    /// the clean filter both bypass the burgertocow reverse-merge step
203    /// (echo stdin / report-only). Useful for templates that are mostly
204    /// dynamic — the heuristic degrades there and produces more conflict
205    /// markers than usable diffs.
206    ///
207    /// Patterns are matched against the source path's filename component
208    /// (e.g. `"complex-config.toml.tmpl"`, `"*.gen.tmpl"`).
209    #[config(default = [])]
210    pub no_reverse: Vec<String>,
211}
212
213/// `age` whole-file decryption preprocessor settings
214/// (`docs/proposals/secrets.lex` §4).
215///
216/// Default-disabled so a fresh dodot install never shells out to
217/// `age` against random files; users opt in by flipping `enabled =
218/// true` in their root `.dodot.toml`. The identity path defaults to
219/// `~/.config/age/identity.txt` (the conventional `age-keygen`
220/// destination); set explicitly when storing keys elsewhere or
221/// rotating identities per-pack.
222#[derive(Config, Debug, Clone, Serialize, Deserialize)]
223pub struct PreprocessorAgeSection {
224    /// Whether `*.age` files are matched and decrypted on `dodot
225    /// up`. Default false — opt-in posture mirrors the
226    /// `[secret.providers.*]` blocks.
227    #[config(default = false)]
228    pub enabled: bool,
229
230    /// File extensions that trigger age decryption. Same shape as
231    /// `template.extensions`; multi-extension config is mostly
232    /// useful for users whose conventions diverge (e.g. `.age.txt`).
233    #[config(default = ["age"])]
234    pub extensions: Vec<String>,
235
236    /// Path to the age identity file. Empty (the default) defers to
237    /// the runtime: `$AGE_IDENTITY` env var, then
238    /// `~/.config/age/identity.txt`.
239    #[config(default = "")]
240    pub identity: String,
241}
242
243/// `gpg` whole-file decryption preprocessor settings
244/// (`docs/proposals/secrets.lex` §4).
245///
246/// Same opt-in posture as `age`. gpg picks up its identity from
247/// gpg-agent so there's no `identity` field — auth is the user's
248/// existing gpg setup, not dodot's job to configure.
249#[derive(Config, Debug, Clone, Serialize, Deserialize)]
250pub struct PreprocessorGpgSection {
251    /// Whether `*.gpg` files are matched and decrypted on
252    /// `dodot up`. Default false — opt-in.
253    #[config(default = false)]
254    pub enabled: bool,
255
256    /// File extensions that trigger gpg decryption. Default
257    /// `["gpg"]` only. **Do not include `asc` here unless your
258    /// dotfiles repo only stores ASCII-armored *encrypted*
259    /// payloads under that suffix.** `.asc` is conventionally used
260    /// for armored *public keys* and *detached signatures* (release
261    /// signatures, package-manager keys), neither of which gpg
262    /// will decrypt; routing them through `gpg --decrypt` produces
263    /// confusing failures. Users storing armored encrypted
264    /// payloads as `.asc` opt in by setting
265    /// `extensions = ["gpg", "asc"]` explicitly.
266    #[config(default = ["gpg"])]
267    pub extensions: Vec<String>,
268}
269
270/// Shell-init profiling settings. Root-only — per-pack overrides are
271/// meaningless (the init script is one thing; you can't half-profile it).
272///
273/// See `docs/proposals/profiling.lex` for the full design.
274#[derive(Config, Debug, Clone, Serialize, Deserialize)]
275pub struct ProfilingSection {
276    /// Whether the generated `dodot-init.sh` carries the timing wrapper
277    /// around each `source` and PATH line. When false, the init script
278    /// is byte-identical to the pre-Phase-2 form. When true, bash 5+ /
279    /// zsh sessions emit one TSV per shell startup under
280    /// `<data_dir>/probes/shell-init/`; older shells fall through to
281    /// the no-op path even with the wrapper present.
282    #[config(default = true)]
283    pub enabled: bool,
284
285    /// Maximum number of `<data_dir>/probes/shell-init/profile-*.tsv`
286    /// files to retain. Older files are pruned at the end of every
287    /// `dodot up`. At ~4 KB per run, the default budget is roughly
288    /// 400 KB on disk.
289    #[config(default = 100)]
290    pub keep_last_runs: usize,
291}
292
293/// Secret-handling settings (`docs/proposals/secrets.lex`).
294///
295/// Top-level kill switch + per-provider blocks. Disabling the
296/// section globally (`[secret] enabled = false`) is equivalent to
297/// disabling every provider; templates that call `secret(...)` then
298/// surface a "no providers configured" render error.
299///
300/// **This section is root-only.** Unlike most config sections, the
301/// `[secret]` block is always read from the root `.dodot.toml`;
302/// per-pack overrides are ignored. Secret tooling
303/// (`$PASSWORD_STORE_DIR`, `OP_SERVICE_ACCOUNT_TOKEN`, the binaries
304/// themselves) is a property of the user's environment, not of any
305/// individual pack — a pack-level override would invalidate the
306/// once-per-run preflight contract (`secrets.lex` §5.4) and would
307/// surface as confusing "secret X probed under config A but
308/// resolved under config B" failures. Treat the root section as the
309/// single source of truth.
310#[derive(Config, Debug, Clone, Serialize, Deserialize)]
311pub struct SecretSection {
312    /// Master switch. Default true; flip to false to disable all
313    /// secret resolution without removing the per-provider blocks.
314    #[config(default = true)]
315    pub enabled: bool,
316
317    #[config(nested)]
318    pub providers: SecretProvidersSection,
319}
320
321/// Per-provider configuration. Each block has an `enabled` flag plus
322/// any provider-specific knobs (e.g. `pass.store_dir`). Providers
323/// disabled here are not registered in the runtime
324/// `SecretRegistry`; references to their schemes raise
325/// "no provider for scheme" at resolution time.
326#[derive(Config, Debug, Clone, Serialize, Deserialize)]
327pub struct SecretProvidersSection {
328    #[config(nested)]
329    pub pass: SecretProviderPass,
330
331    #[config(nested)]
332    pub op: SecretProviderOp,
333
334    #[config(nested)]
335    pub bw: SecretProviderBw,
336
337    #[config(nested)]
338    pub sops: SecretProviderSops,
339
340    #[config(nested)]
341    pub keychain: SecretProviderKeychain,
342
343    /// Note the TOML key here is `secret_tool` (underscore), even
344    /// though the scheme prefix in `secret(...)` references is
345    /// `secret-tool:` (hyphen, matching the binary name). The
346    /// reason: confique's `Config` derive (re-exported from
347    /// clapfig as `Config`) maps each TOML key 1:1 to a Rust
348    /// struct field name, and Rust identifiers can't contain
349    /// hyphens — `pub secret-tool: ...` won't compile. TOML
350    /// itself accepts bare hyphenated keys; it's the Rust-side
351    /// field-name constraint that forces the underscore form.
352    /// User-facing error messages translate via
353    /// [`crate::secret::registry::scheme_to_config_key`] so a
354    /// "no provider for scheme `secret-tool`" hint suggests the
355    /// correct `[secret.providers.secret_tool]` block.
356    #[config(nested)]
357    pub secret_tool: SecretProviderSecretTool,
358}
359
360/// `pass` (password-store) provider config.
361#[derive(Config, Debug, Clone, Serialize, Deserialize)]
362pub struct SecretProviderPass {
363    /// Whether the `pass:` scheme is registered. Default false —
364    /// users opt in explicitly so a freshly-installed dodot doesn't
365    /// shell out to `pass` on every render.
366    #[config(default = false)]
367    pub enabled: bool,
368
369    /// Override `$PASSWORD_STORE_DIR`. Empty (the default) leaves
370    /// dodot reading the env var, which falls back to
371    /// `$HOME/.password-store`.
372    #[config(default = "")]
373    pub store_dir: String,
374}
375
376/// `op` (1Password CLI) provider config.
377#[derive(Config, Debug, Clone, Serialize, Deserialize)]
378pub struct SecretProviderOp {
379    /// Whether the `op://` scheme is registered. Default false —
380    /// same opt-in posture as `pass`.
381    #[config(default = false)]
382    pub enabled: bool,
383}
384
385/// `bw` (Bitwarden CLI) provider config.
386#[derive(Config, Debug, Clone, Serialize, Deserialize)]
387pub struct SecretProviderBw {
388    /// Whether the `bw:` scheme is registered. Default false —
389    /// same opt-in posture as `pass` and `op`.
390    #[config(default = false)]
391    pub enabled: bool,
392}
393
394/// `sops` (Mozilla SOPS) provider config.
395#[derive(Config, Debug, Clone, Serialize, Deserialize)]
396pub struct SecretProviderSops {
397    /// Whether the `sops:` scheme is registered. Default false —
398    /// same opt-in posture as the other providers.
399    #[config(default = false)]
400    pub enabled: bool,
401}
402
403/// `keychain` (macOS Keychain via `security`) provider config.
404///
405/// macOS-only; on other platforms the provider's `probe()`
406/// surfaces `NotInstalled` with a "use secret-tool instead"
407/// pointer. Default `enabled = false` matches the rest of the
408/// secret providers — opt-in posture.
409#[derive(Config, Debug, Clone, Serialize, Deserialize)]
410pub struct SecretProviderKeychain {
411    #[config(default = false)]
412    pub enabled: bool,
413}
414
415/// `secret-tool` (freedesktop Secret Service via `secret-tool`)
416/// provider config.
417///
418/// Linux-first; on macOS the provider's `probe()` redirects users
419/// to the `keychain` provider. Default `enabled = false`. The
420/// scheme prefix in references is `secret-tool:` (hyphen) — see
421/// the comment on `SecretProvidersSection::secret_tool` for the
422/// reason the config field uses the underscore form instead.
423#[derive(Config, Debug, Clone, Serialize, Deserialize)]
424pub struct SecretProviderSecretTool {
425    #[config(default = false)]
426    pub enabled: bool,
427}
428
429/// File-to-handler mapping patterns.
430#[derive(Config, Debug, Clone, Serialize, Deserialize)]
431pub struct MappingsSection {
432    /// Directory name pattern for PATH handler.
433    #[config(default = "bin")]
434    pub path: String,
435
436    /// Filename patterns for install scripts.
437    ///
438    /// The extension selects the interpreter used to run the script
439    /// (`.sh`/`.bash` → `bash`, `.zsh` → `zsh`); see the install handler
440    /// for the exact mapping.
441    #[config(default = ["install.sh", "install.bash", "install.zsh"])]
442    pub install: Vec<String>,
443
444    /// Filename patterns for shell scripts to source at login.
445    ///
446    /// Sourced files run *in the user's shell* (whichever shell reads
447    /// `dodot-init.sh`), so `.zsh` files will only parse cleanly in zsh
448    /// sessions and `.bash` files in bash sessions. `.sh` is the
449    /// portable bucket for snippets that work in either.
450    #[config(default = [
451        "aliases.sh", "aliases.bash", "aliases.zsh",
452        "profile.sh", "profile.bash", "profile.zsh",
453        "login.sh", "login.bash", "login.zsh",
454        "env.sh", "env.bash", "env.zsh",
455    ])]
456    pub shell: Vec<String>,
457
458    /// Filename pattern for Homebrew Brewfile.
459    #[config(default = "Brewfile")]
460    pub homebrew: String,
461
462    /// Additional filename patterns to exclude from handler processing
463    /// within a pack. Distinct from [pack] ignore which controls discovery.
464    #[config(default = [])]
465    pub skip: Vec<String>,
466}
467
468// ── Conversions ─────────────────────────────────────────────────
469
470impl DodotConfig {
471    /// Convert to the handler-relevant config subset.
472    pub fn to_handler_config(&self) -> HandlerConfig {
473        HandlerConfig {
474            force_home: self.symlink.force_home.clone(),
475            // `force_app` and `app_aliases` always pass through to the
476            // resolver. On non-macOS (and on macOS with
477            // `app_uses_library = false`) the `app_support_dir` accessor
478            // already collapses to `xdg_config_home`, so the routing is
479            // mechanically correct without an extra branch here.
480            force_app: self.symlink.force_app.clone(),
481            app_aliases: self.symlink.app_aliases.clone(),
482            protected_paths: self.symlink.protected_paths.clone(),
483            targets: self.symlink.targets.clone(),
484            auto_chmod_exec: self.path.auto_chmod_exec,
485            pack_ignore: self.pack.ignore.clone(),
486        }
487    }
488}
489
490/// Generate rules from the mappings section.
491///
492/// This produces the default rule set that maps filename patterns to
493/// handlers, matching the Go implementation's `GenerateRulesFromMapping`.
494pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
495    use std::collections::HashMap;
496
497    let mut rules = Vec::new();
498
499    // Path handler (directory pattern with trailing slash convention)
500    if !mappings.path.is_empty() {
501        let pattern = if mappings.path.ends_with('/') {
502            mappings.path.clone()
503        } else {
504            format!("{}/", mappings.path)
505        };
506        rules.push(Rule {
507            pattern,
508            handler: "path".into(),
509            priority: 10,
510            options: HashMap::new(),
511        });
512    }
513
514    // Install handler
515    for pattern in &mappings.install {
516        if !pattern.is_empty() {
517            rules.push(Rule {
518                pattern: pattern.clone(),
519                handler: "install".into(),
520                priority: 10,
521                options: HashMap::new(),
522            });
523        }
524    }
525
526    // Shell handler
527    for pattern in &mappings.shell {
528        if !pattern.is_empty() {
529            rules.push(Rule {
530                pattern: pattern.clone(),
531                handler: "shell".into(),
532                priority: 10,
533                options: HashMap::new(),
534            });
535        }
536    }
537
538    // Homebrew handler
539    if !mappings.homebrew.is_empty() {
540        rules.push(Rule {
541            pattern: mappings.homebrew.clone(),
542            handler: "homebrew".into(),
543            priority: 10,
544            options: HashMap::new(),
545        });
546    }
547
548    // Skip patterns (exclusion rules)
549    for pattern in &mappings.skip {
550        if !pattern.is_empty() {
551            rules.push(Rule {
552                pattern: format!("!{pattern}"),
553                handler: "exclude".into(),
554                priority: 100, // exclusions checked first
555                options: HashMap::new(),
556            });
557        }
558    }
559
560    // Catchall: everything else goes to symlink (lowest priority)
561    rules.push(Rule {
562        pattern: "*".into(),
563        handler: "symlink".into(),
564        priority: 0,
565        options: HashMap::new(),
566    });
567
568    rules
569}
570
571// ── ConfigManager ───────────────────────────────────────────────
572
573/// Manages configuration loading and per-pack resolution.
574///
575/// Wraps clapfig's `Resolver` to provide cached, merged config
576/// resolution. Call [`config_for_pack`](ConfigManager::config_for_pack)
577/// for each pack — the root `.dodot.toml` is read once and cached.
578pub struct ConfigManager {
579    resolver: clapfig::Resolver<DodotConfig>,
580    dotfiles_root: PathBuf,
581}
582
583impl ConfigManager {
584    /// Create a new config manager for the given dotfiles root.
585    ///
586    /// Builds a clapfig Resolver that searches for `.dodot.toml` files
587    /// using ancestor-walk from the resolve point up to (and including)
588    /// the dotfiles root, identified by its `.git` directory. This
589    /// prevents stray `.dodot.toml` files above the repo from leaking in.
590    pub fn new(dotfiles_root: &Path) -> Result<Self> {
591        let resolver = Clapfig::builder::<DodotConfig>()
592            .app_name("dodot")
593            .file_name(".dodot.toml")
594            .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
595            .search_mode(SearchMode::Merge)
596            .no_env()
597            .build_resolver()
598            .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
599
600        Ok(Self {
601            resolver,
602            dotfiles_root: dotfiles_root.to_path_buf(),
603        })
604    }
605
606    /// Load the root-level configuration (no pack override).
607    pub fn root_config(&self) -> Result<DodotConfig> {
608        self.resolver
609            .resolve_at(&self.dotfiles_root)
610            .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
611    }
612
613    /// Load merged configuration for a specific pack.
614    ///
615    /// Resolves by walking from `pack_path` up through ancestors,
616    /// merging any `.dodot.toml` files found along the way (including
617    /// the root config). Results are cached by absolute path.
618    pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
619        self.resolver
620            .resolve_at(pack_path)
621            .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
622    }
623
624    pub fn dotfiles_root(&self) -> &Path {
625        &self.dotfiles_root
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::fs::Fs;
633    use crate::testing::TempEnvironment;
634
635    #[test]
636    fn default_config_has_expected_values() {
637        // Load with no files — should use compiled defaults
638        let env = TempEnvironment::builder().build();
639        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
640        let cfg = mgr.root_config().unwrap();
641
642        // ── pack.ignore defaults ────────────────────────────────
643        let expected_ignore: Vec<String> = vec![
644            ".git",
645            ".svn",
646            ".hg",
647            "node_modules",
648            ".DS_Store",
649            "*.swp",
650            "*~",
651            "#*#",
652            ".env*",
653            ".terraform",
654        ]
655        .into_iter()
656        .map(Into::into)
657        .collect();
658        assert_eq!(cfg.pack.ignore, expected_ignore);
659
660        // ── symlink.force_home defaults ─────────────────────────
661        let expected_force_home: Vec<String> = vec![
662            "ssh",
663            "aws",
664            "kube",
665            "bashrc",
666            "zshrc",
667            "profile",
668            "bash_profile",
669            "bash_login",
670            "bash_logout",
671            "inputrc",
672        ]
673        .into_iter()
674        .map(Into::into)
675        .collect();
676        assert_eq!(cfg.symlink.force_home, expected_force_home);
677
678        // ── symlink.protected_paths defaults ────────────────────
679        let expected_protected: Vec<String> = vec![
680            ".ssh/id_rsa",
681            ".ssh/id_ed25519",
682            ".ssh/id_dsa",
683            ".ssh/id_ecdsa",
684            ".ssh/authorized_keys",
685            ".gnupg",
686            ".aws/credentials",
687            ".password-store",
688            ".config/gh/hosts.yml",
689            ".kube/config",
690            ".docker/config.json",
691        ]
692        .into_iter()
693        .map(Into::into)
694        .collect();
695        assert_eq!(cfg.symlink.protected_paths, expected_protected);
696
697        // ── symlink.targets defaults ────────────────────────────
698        assert!(cfg.symlink.targets.is_empty());
699
700        // ── path defaults ──────────────────────────────────────
701        assert!(cfg.path.auto_chmod_exec);
702
703        // ── mappings defaults ───────────────────────────────────
704        assert_eq!(cfg.mappings.path, "bin");
705        assert_eq!(
706            cfg.mappings.install,
707            vec!["install.sh", "install.bash", "install.zsh"]
708        );
709        assert_eq!(cfg.mappings.homebrew, "Brewfile");
710        assert_eq!(
711            cfg.mappings.shell,
712            vec![
713                "aliases.sh",
714                "aliases.bash",
715                "aliases.zsh",
716                "profile.sh",
717                "profile.bash",
718                "profile.zsh",
719                "login.sh",
720                "login.bash",
721                "login.zsh",
722                "env.sh",
723                "env.bash",
724                "env.zsh",
725            ]
726        );
727        assert!(cfg.mappings.skip.is_empty());
728
729        // ── profiling defaults ──────────────────────────────────
730        assert!(cfg.profiling.enabled);
731        assert_eq!(cfg.profiling.keep_last_runs, 100);
732    }
733
734    #[test]
735    fn profiling_section_overridable() {
736        let env = TempEnvironment::builder().build();
737        env.fs
738            .write_file(
739                &env.dotfiles_root.join(".dodot.toml"),
740                b"[profiling]\nenabled = false\nkeep_last_runs = 25\n",
741            )
742            .unwrap();
743
744        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
745        let cfg = mgr.root_config().unwrap();
746        assert!(!cfg.profiling.enabled);
747        assert_eq!(cfg.profiling.keep_last_runs, 25);
748    }
749
750    #[test]
751    fn root_config_overrides_defaults() {
752        let env = TempEnvironment::builder().build();
753
754        // Write a root .dodot.toml
755        env.fs
756            .write_file(
757                &env.dotfiles_root.join(".dodot.toml"),
758                br#"
759[mappings]
760install = ["setup.sh"]
761homebrew = "MyBrewfile"
762"#,
763            )
764            .unwrap();
765
766        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
767        let cfg = mgr.root_config().unwrap();
768
769        assert_eq!(cfg.mappings.install, vec!["setup.sh"]);
770        assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
771        // Unset fields keep defaults
772        assert_eq!(cfg.mappings.path, "bin");
773    }
774
775    #[test]
776    fn pack_config_overrides_root() {
777        let env = TempEnvironment::builder()
778            .pack("vim")
779            .file("vimrc", "x")
780            .config(
781                r#"
782[pack]
783ignore = ["*.bak"]
784
785[mappings]
786install = ["vim-setup.sh"]
787"#,
788            )
789            .done()
790            .build();
791
792        // Root config
793        env.fs
794            .write_file(
795                &env.dotfiles_root.join(".dodot.toml"),
796                br#"
797[mappings]
798install = ["install.sh"]
799homebrew = "RootBrewfile"
800"#,
801            )
802            .unwrap();
803
804        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
805
806        // Root config
807        let root_cfg = mgr.root_config().unwrap();
808        assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
809
810        // Pack config merges root + pack
811        let pack_path = env.dotfiles_root.join("vim");
812        let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
813        assert_eq!(pack_cfg.mappings.install, vec!["vim-setup.sh"]); // overridden
814        assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); // inherited
815        assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); // from pack
816    }
817
818    #[test]
819    fn mappings_to_rules_produces_expected_rules() {
820        let mappings = MappingsSection {
821            path: "bin".into(),
822            install: vec!["install.sh".into(), "install.zsh".into()],
823            shell: vec!["aliases.sh".into(), "profile.sh".into()],
824            homebrew: "Brewfile".into(),
825            skip: vec!["*.tmp".into()],
826        };
827
828        let rules = mappings_to_rules(&mappings);
829
830        // Should have: path, 2x install, 2x shell, homebrew, 1x exclude, catchall = 8
831        assert_eq!(rules.len(), 8, "rules: {rules:#?}");
832
833        let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
834        assert!(handler_names.contains(&"path"));
835        assert!(handler_names.contains(&"install"));
836        assert!(handler_names.contains(&"shell"));
837        assert!(handler_names.contains(&"homebrew"));
838        assert!(handler_names.contains(&"exclude"));
839        assert!(handler_names.contains(&"symlink"));
840
841        // Exclusion rule should have ! prefix
842        let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
843        assert!(exclude.pattern.starts_with('!'));
844
845        // Catchall should be lowest priority
846        let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
847        assert_eq!(catchall.priority, 0);
848    }
849
850    #[test]
851    fn to_handler_config_converts_correctly() {
852        let env = TempEnvironment::builder().build();
853        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
854        let cfg = mgr.root_config().unwrap();
855
856        let hcfg = cfg.to_handler_config();
857        assert_eq!(hcfg.force_home, cfg.symlink.force_home);
858        assert_eq!(hcfg.force_app, cfg.symlink.force_app);
859        assert_eq!(hcfg.app_aliases, cfg.symlink.app_aliases);
860        assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
861    }
862
863    /// Hard cap on the seeded `force_app` defaults — see
864    /// `docs/proposals/macos-paths.lex` §3.4.1. Adding entry 101 is
865    /// supposed to be a forcing function to drop the weakest-justified
866    /// existing entry; this test makes that forcing function *visible*
867    /// rather than relying on review discipline alone.
868    #[test]
869    fn default_force_app_under_hundred_entry_cap() {
870        let env = TempEnvironment::builder().build();
871        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
872        let cfg = mgr.root_config().unwrap();
873        assert!(
874            cfg.symlink.force_app.len() <= 100,
875            "force_app default has {} entries; cap is 100. \
876             Drop the weakest-justified entry before adding another. \
877             See docs/proposals/macos-paths.lex §3.4.1.",
878            cfg.symlink.force_app.len()
879        );
880    }
881
882    /// Compile-time sanity on the seeded force_app entries. These ship
883    /// in the default config and must stay correctly capitalized to
884    /// match the actual macOS Application Support folder names.
885    #[test]
886    fn default_force_app_seed_contains_expected_entries() {
887        let env = TempEnvironment::builder().build();
888        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
889        let cfg = mgr.root_config().unwrap();
890        for expected in ["Code", "Cursor", "Zed", "Emacs"] {
891            assert!(
892                cfg.symlink.force_app.iter().any(|e| e == expected),
893                "expected default force_app to contain `{expected}`; got {:?}",
894                cfg.symlink.force_app
895            );
896        }
897    }
898
899    #[test]
900    fn app_uses_library_default_is_true() {
901        let env = TempEnvironment::builder().build();
902        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
903        let cfg = mgr.root_config().unwrap();
904        assert!(
905            cfg.symlink.app_uses_library,
906            "app_uses_library must default to true; macOS gets the Library \
907             root, Linux already collapses app_support_dir to xdg_config_home"
908        );
909    }
910
911    #[test]
912    fn app_aliases_overridable_in_root_config() {
913        let env = TempEnvironment::builder().build();
914        env.fs
915            .write_file(
916                &env.dotfiles_root.join(".dodot.toml"),
917                br#"
918[symlink.app_aliases]
919vscode = "Code"
920warp = "dev.warp.Warp-Stable"
921"#,
922            )
923            .unwrap();
924        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
925        let cfg = mgr.root_config().unwrap();
926        assert_eq!(
927            cfg.symlink.app_aliases.get("vscode").map(String::as_str),
928            Some("Code")
929        );
930        assert_eq!(
931            cfg.symlink.app_aliases.get("warp").map(String::as_str),
932            Some("dev.warp.Warp-Stable")
933        );
934    }
935}