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}