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
48/// Pack-level settings.
49#[derive(Config, Debug, Clone, Serialize, Deserialize)]
50pub struct PackSection {
51    /// Glob patterns for files and directories to ignore during pack
52    /// discovery and file scanning.
53    #[config(default = [
54        ".git", ".svn", ".hg", "node_modules", ".DS_Store",
55        "*.swp", "*~", "#*#", ".env*", ".terraform"
56    ])]
57    pub ignore: Vec<String>,
58}
59
60/// Symlink handler settings.
61#[derive(Config, Debug, Clone, Serialize, Deserialize)]
62pub struct SymlinkSection {
63    /// Files/directories that must deploy to `$HOME` instead of
64    /// `$XDG_CONFIG_HOME`. Matched against the first path segment
65    /// (without leading dot).
66    #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
67    pub force_home: Vec<String>,
68
69    /// Whether `_app/` and `app_aliases` route through the macOS
70    /// `~/Library/Application Support` root. Defaults to `true` on
71    /// macOS, ignored on other platforms (where `app_support_dir`
72    /// always collapses to `xdg_config_home`).
73    ///
74    /// Setting this to `false` on macOS opts the user into Linux-style
75    /// `~/.config` placement for *every* `_app/` and `app_aliases`
76    /// entry. `_lib/` is unaffected — it explicitly targets
77    /// `~/Library/`.
78    ///
79    /// See `docs/proposals/macos-paths.lex` §11.2.
80    #[config(default = true)]
81    pub app_uses_library: bool,
82
83    /// Curated list of GUI-app folder names whose first path segment
84    /// routes to `<app_support_dir>/<seg>/<rest>` without requiring a
85    /// `_app/` prefix in the pack tree. Capped at 100 entries; see
86    /// `docs/proposals/macos-paths.lex` §3.4.
87    ///
88    /// Matching is case-sensitive (Library folder names are case-sensitive
89    /// on macOS) and on the first path segment only.
90    #[config(default = ["Code", "Cursor", "Zed", "Emacs"])]
91    pub force_app: Vec<String>,
92
93    /// Pack-name → GUI-app folder name rewrites. When the pack name
94    /// matches a key here, the resolver's default rule reroutes to
95    /// `<app_support_dir>/<value>/<rel_path>`. See
96    /// `docs/proposals/macos-paths.lex` §3.3.
97    #[config(default = {})]
98    pub app_aliases: std::collections::HashMap<String, String>,
99
100    /// Paths that must not be symlinked for security reasons.
101    #[config(default = [
102        ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
103        ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
104        ".password-store", ".config/gh/hosts.yml",
105        ".kube/config", ".docker/config.json"
106    ])]
107    pub protected_paths: Vec<String>,
108
109    /// Custom per-file symlink target overrides.
110    /// Maps relative pack filename to absolute or relative target path.
111    /// Absolute paths are used as-is; relative paths are resolved from
112    /// `$XDG_CONFIG_HOME`.
113    #[config(default = {})]
114    pub targets: std::collections::HashMap<String, String>,
115
116    /// Filename suffixes (without leading dot) that should be detected
117    /// as plists for `dodot git-install-filters` adopt hints and the
118    /// `.gitattributes` line. Defaults to `["plist"]`. Some apps store
119    /// plists with non-standard suffixes (`.binplist`, `.savedState`,
120    /// etc.); register additional extensions here to flow them through
121    /// the same clean/smudge pipeline.
122    ///
123    /// Comparison is case-insensitive, matching the existing detection
124    /// behavior. Honors the standard root → pack inheritance.
125    /// See `docs/proposals/plists.lex` §8.1.
126    #[config(default = ["plist"])]
127    pub plist_extensions: Vec<String>,
128}
129
130/// PATH handler settings.
131#[derive(Config, Debug, Clone, Serialize, Deserialize)]
132pub struct PathSection {
133    /// Automatically add execute permissions (`+x`) to files inside
134    /// `bin/` directories staged by the path handler.
135    ///
136    /// # Rationale
137    ///
138    /// Files placed in a `bin/` directory are there because the pack
139    /// author intends them as executables — the directory's purpose is
140    /// to expose commands via `$PATH`. However, execute bits can be
141    /// lost in common workflows:
142    ///
143    /// - **Git on macOS** defaults to `core.fileMode = false`, so
144    ///   cloned repos may have `0o644` on scripts.
145    /// - **Manual file creation** often forgets `chmod +x`.
146    ///
147    /// Without `+x` the shell finds the file via PATH lookup but fails
148    /// with "permission denied" — a confusing error when the file is
149    /// clearly in the right place.
150    ///
151    /// With this option enabled (the default), `dodot up` ensures every
152    /// file in a path-handler directory is executable, matching the
153    /// user's intent. Files that are already executable are left
154    /// untouched. Failures are reported as warnings, not hard errors.
155    ///
156    /// Set to `false` if you have `bin/` files that intentionally
157    /// should not be executable (e.g. data files or library scripts
158    /// sourced by other scripts).
159    #[config(default = true)]
160    pub auto_chmod_exec: bool,
161}
162
163/// Preprocessing pipeline settings.
164#[derive(Config, Debug, Clone, Serialize, Deserialize)]
165pub struct PreprocessorSection {
166    /// Global kill switch for all preprocessing.
167    #[config(default = true)]
168    pub enabled: bool,
169
170    #[config(nested)]
171    pub template: PreprocessorTemplateSection,
172}
173
174/// Template preprocessor settings.
175#[derive(Config, Debug, Clone, Serialize, Deserialize)]
176pub struct PreprocessorTemplateSection {
177    /// File extensions that trigger template rendering. Each extension
178    /// is matched as a suffix (e.g. `"tmpl"` matches `config.toml.tmpl`).
179    #[config(default = ["tmpl", "template"])]
180    pub extensions: Vec<String>,
181
182    /// User-defined variables, accessible as bare names in templates
183    /// (e.g. `name = "Alice"` makes `{{ name }}` render as `Alice`).
184    ///
185    /// Reserved: `dodot` and `env` are built-in namespaces; using them
186    /// as var names raises an error at load time.
187    #[config(default = {})]
188    pub vars: std::collections::HashMap<String, String>,
189
190    /// Glob patterns for source files whose reverse-merge should be
191    /// skipped. Templates matching are still rendered on `dodot up` and
192    /// tracked in the divergence cache, but `dodot transform check` and
193    /// the clean filter both bypass the burgertocow reverse-merge step
194    /// (echo stdin / report-only). Useful for templates that are mostly
195    /// dynamic — the heuristic degrades there and produces more conflict
196    /// markers than usable diffs.
197    ///
198    /// Patterns are matched against the source path's filename component
199    /// (e.g. `"complex-config.toml.tmpl"`, `"*.gen.tmpl"`).
200    #[config(default = [])]
201    pub no_reverse: Vec<String>,
202}
203
204/// Shell-init profiling settings. Root-only — per-pack overrides are
205/// meaningless (the init script is one thing; you can't half-profile it).
206///
207/// See `docs/proposals/profiling.lex` for the full design.
208#[derive(Config, Debug, Clone, Serialize, Deserialize)]
209pub struct ProfilingSection {
210    /// Whether the generated `dodot-init.sh` carries the timing wrapper
211    /// around each `source` and PATH line. When false, the init script
212    /// is byte-identical to the pre-Phase-2 form. When true, bash 5+ /
213    /// zsh sessions emit one TSV per shell startup under
214    /// `<data_dir>/probes/shell-init/`; older shells fall through to
215    /// the no-op path even with the wrapper present.
216    #[config(default = true)]
217    pub enabled: bool,
218
219    /// Maximum number of `<data_dir>/probes/shell-init/profile-*.tsv`
220    /// files to retain. Older files are pruned at the end of every
221    /// `dodot up`. At ~4 KB per run, the default budget is roughly
222    /// 400 KB on disk.
223    #[config(default = 100)]
224    pub keep_last_runs: usize,
225}
226
227/// File-to-handler mapping patterns.
228#[derive(Config, Debug, Clone, Serialize, Deserialize)]
229pub struct MappingsSection {
230    /// Directory name pattern for PATH handler.
231    #[config(default = "bin")]
232    pub path: String,
233
234    /// Filename patterns for install scripts.
235    ///
236    /// The extension selects the interpreter used to run the script
237    /// (`.sh`/`.bash` → `bash`, `.zsh` → `zsh`); see the install handler
238    /// for the exact mapping.
239    #[config(default = ["install.sh", "install.bash", "install.zsh"])]
240    pub install: Vec<String>,
241
242    /// Filename patterns for shell scripts to source at login.
243    ///
244    /// Sourced files run *in the user's shell* (whichever shell reads
245    /// `dodot-init.sh`), so `.zsh` files will only parse cleanly in zsh
246    /// sessions and `.bash` files in bash sessions. `.sh` is the
247    /// portable bucket for snippets that work in either.
248    #[config(default = [
249        "aliases.sh", "aliases.bash", "aliases.zsh",
250        "profile.sh", "profile.bash", "profile.zsh",
251        "login.sh", "login.bash", "login.zsh",
252        "env.sh", "env.bash", "env.zsh",
253    ])]
254    pub shell: Vec<String>,
255
256    /// Filename pattern for Homebrew Brewfile.
257    #[config(default = "Brewfile")]
258    pub homebrew: String,
259
260    /// Additional filename patterns to exclude from handler processing
261    /// within a pack. Distinct from [pack] ignore which controls discovery.
262    #[config(default = [])]
263    pub skip: Vec<String>,
264}
265
266// ── Conversions ─────────────────────────────────────────────────
267
268impl DodotConfig {
269    /// Convert to the handler-relevant config subset.
270    pub fn to_handler_config(&self) -> HandlerConfig {
271        HandlerConfig {
272            force_home: self.symlink.force_home.clone(),
273            // `force_app` and `app_aliases` always pass through to the
274            // resolver. On non-macOS (and on macOS with
275            // `app_uses_library = false`) the `app_support_dir` accessor
276            // already collapses to `xdg_config_home`, so the routing is
277            // mechanically correct without an extra branch here.
278            force_app: self.symlink.force_app.clone(),
279            app_aliases: self.symlink.app_aliases.clone(),
280            protected_paths: self.symlink.protected_paths.clone(),
281            targets: self.symlink.targets.clone(),
282            auto_chmod_exec: self.path.auto_chmod_exec,
283            pack_ignore: self.pack.ignore.clone(),
284        }
285    }
286}
287
288/// Generate rules from the mappings section.
289///
290/// This produces the default rule set that maps filename patterns to
291/// handlers, matching the Go implementation's `GenerateRulesFromMapping`.
292pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
293    use std::collections::HashMap;
294
295    let mut rules = Vec::new();
296
297    // Path handler (directory pattern with trailing slash convention)
298    if !mappings.path.is_empty() {
299        let pattern = if mappings.path.ends_with('/') {
300            mappings.path.clone()
301        } else {
302            format!("{}/", mappings.path)
303        };
304        rules.push(Rule {
305            pattern,
306            handler: "path".into(),
307            priority: 10,
308            options: HashMap::new(),
309        });
310    }
311
312    // Install handler
313    for pattern in &mappings.install {
314        if !pattern.is_empty() {
315            rules.push(Rule {
316                pattern: pattern.clone(),
317                handler: "install".into(),
318                priority: 10,
319                options: HashMap::new(),
320            });
321        }
322    }
323
324    // Shell handler
325    for pattern in &mappings.shell {
326        if !pattern.is_empty() {
327            rules.push(Rule {
328                pattern: pattern.clone(),
329                handler: "shell".into(),
330                priority: 10,
331                options: HashMap::new(),
332            });
333        }
334    }
335
336    // Homebrew handler
337    if !mappings.homebrew.is_empty() {
338        rules.push(Rule {
339            pattern: mappings.homebrew.clone(),
340            handler: "homebrew".into(),
341            priority: 10,
342            options: HashMap::new(),
343        });
344    }
345
346    // Skip patterns (exclusion rules)
347    for pattern in &mappings.skip {
348        if !pattern.is_empty() {
349            rules.push(Rule {
350                pattern: format!("!{pattern}"),
351                handler: "exclude".into(),
352                priority: 100, // exclusions checked first
353                options: HashMap::new(),
354            });
355        }
356    }
357
358    // Catchall: everything else goes to symlink (lowest priority)
359    rules.push(Rule {
360        pattern: "*".into(),
361        handler: "symlink".into(),
362        priority: 0,
363        options: HashMap::new(),
364    });
365
366    rules
367}
368
369// ── ConfigManager ───────────────────────────────────────────────
370
371/// Manages configuration loading and per-pack resolution.
372///
373/// Wraps clapfig's `Resolver` to provide cached, merged config
374/// resolution. Call [`config_for_pack`](ConfigManager::config_for_pack)
375/// for each pack — the root `.dodot.toml` is read once and cached.
376pub struct ConfigManager {
377    resolver: clapfig::Resolver<DodotConfig>,
378    dotfiles_root: PathBuf,
379}
380
381impl ConfigManager {
382    /// Create a new config manager for the given dotfiles root.
383    ///
384    /// Builds a clapfig Resolver that searches for `.dodot.toml` files
385    /// using ancestor-walk from the resolve point up to (and including)
386    /// the dotfiles root, identified by its `.git` directory. This
387    /// prevents stray `.dodot.toml` files above the repo from leaking in.
388    pub fn new(dotfiles_root: &Path) -> Result<Self> {
389        let resolver = Clapfig::builder::<DodotConfig>()
390            .app_name("dodot")
391            .file_name(".dodot.toml")
392            .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
393            .search_mode(SearchMode::Merge)
394            .no_env()
395            .build_resolver()
396            .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
397
398        Ok(Self {
399            resolver,
400            dotfiles_root: dotfiles_root.to_path_buf(),
401        })
402    }
403
404    /// Load the root-level configuration (no pack override).
405    pub fn root_config(&self) -> Result<DodotConfig> {
406        self.resolver
407            .resolve_at(&self.dotfiles_root)
408            .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
409    }
410
411    /// Load merged configuration for a specific pack.
412    ///
413    /// Resolves by walking from `pack_path` up through ancestors,
414    /// merging any `.dodot.toml` files found along the way (including
415    /// the root config). Results are cached by absolute path.
416    pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
417        self.resolver
418            .resolve_at(pack_path)
419            .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
420    }
421
422    pub fn dotfiles_root(&self) -> &Path {
423        &self.dotfiles_root
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::fs::Fs;
431    use crate::testing::TempEnvironment;
432
433    #[test]
434    fn default_config_has_expected_values() {
435        // Load with no files — should use compiled defaults
436        let env = TempEnvironment::builder().build();
437        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
438        let cfg = mgr.root_config().unwrap();
439
440        // ── pack.ignore defaults ────────────────────────────────
441        let expected_ignore: Vec<String> = vec![
442            ".git",
443            ".svn",
444            ".hg",
445            "node_modules",
446            ".DS_Store",
447            "*.swp",
448            "*~",
449            "#*#",
450            ".env*",
451            ".terraform",
452        ]
453        .into_iter()
454        .map(Into::into)
455        .collect();
456        assert_eq!(cfg.pack.ignore, expected_ignore);
457
458        // ── symlink.force_home defaults ─────────────────────────
459        let expected_force_home: Vec<String> = vec![
460            "ssh",
461            "aws",
462            "kube",
463            "bashrc",
464            "zshrc",
465            "profile",
466            "bash_profile",
467            "bash_login",
468            "bash_logout",
469            "inputrc",
470        ]
471        .into_iter()
472        .map(Into::into)
473        .collect();
474        assert_eq!(cfg.symlink.force_home, expected_force_home);
475
476        // ── symlink.protected_paths defaults ────────────────────
477        let expected_protected: Vec<String> = vec![
478            ".ssh/id_rsa",
479            ".ssh/id_ed25519",
480            ".ssh/id_dsa",
481            ".ssh/id_ecdsa",
482            ".ssh/authorized_keys",
483            ".gnupg",
484            ".aws/credentials",
485            ".password-store",
486            ".config/gh/hosts.yml",
487            ".kube/config",
488            ".docker/config.json",
489        ]
490        .into_iter()
491        .map(Into::into)
492        .collect();
493        assert_eq!(cfg.symlink.protected_paths, expected_protected);
494
495        // ── symlink.targets defaults ────────────────────────────
496        assert!(cfg.symlink.targets.is_empty());
497
498        // ── path defaults ──────────────────────────────────────
499        assert!(cfg.path.auto_chmod_exec);
500
501        // ── mappings defaults ───────────────────────────────────
502        assert_eq!(cfg.mappings.path, "bin");
503        assert_eq!(
504            cfg.mappings.install,
505            vec!["install.sh", "install.bash", "install.zsh"]
506        );
507        assert_eq!(cfg.mappings.homebrew, "Brewfile");
508        assert_eq!(
509            cfg.mappings.shell,
510            vec![
511                "aliases.sh",
512                "aliases.bash",
513                "aliases.zsh",
514                "profile.sh",
515                "profile.bash",
516                "profile.zsh",
517                "login.sh",
518                "login.bash",
519                "login.zsh",
520                "env.sh",
521                "env.bash",
522                "env.zsh",
523            ]
524        );
525        assert!(cfg.mappings.skip.is_empty());
526
527        // ── profiling defaults ──────────────────────────────────
528        assert!(cfg.profiling.enabled);
529        assert_eq!(cfg.profiling.keep_last_runs, 100);
530    }
531
532    #[test]
533    fn profiling_section_overridable() {
534        let env = TempEnvironment::builder().build();
535        env.fs
536            .write_file(
537                &env.dotfiles_root.join(".dodot.toml"),
538                b"[profiling]\nenabled = false\nkeep_last_runs = 25\n",
539            )
540            .unwrap();
541
542        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
543        let cfg = mgr.root_config().unwrap();
544        assert!(!cfg.profiling.enabled);
545        assert_eq!(cfg.profiling.keep_last_runs, 25);
546    }
547
548    #[test]
549    fn root_config_overrides_defaults() {
550        let env = TempEnvironment::builder().build();
551
552        // Write a root .dodot.toml
553        env.fs
554            .write_file(
555                &env.dotfiles_root.join(".dodot.toml"),
556                br#"
557[mappings]
558install = ["setup.sh"]
559homebrew = "MyBrewfile"
560"#,
561            )
562            .unwrap();
563
564        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
565        let cfg = mgr.root_config().unwrap();
566
567        assert_eq!(cfg.mappings.install, vec!["setup.sh"]);
568        assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
569        // Unset fields keep defaults
570        assert_eq!(cfg.mappings.path, "bin");
571    }
572
573    #[test]
574    fn pack_config_overrides_root() {
575        let env = TempEnvironment::builder()
576            .pack("vim")
577            .file("vimrc", "x")
578            .config(
579                r#"
580[pack]
581ignore = ["*.bak"]
582
583[mappings]
584install = ["vim-setup.sh"]
585"#,
586            )
587            .done()
588            .build();
589
590        // Root config
591        env.fs
592            .write_file(
593                &env.dotfiles_root.join(".dodot.toml"),
594                br#"
595[mappings]
596install = ["install.sh"]
597homebrew = "RootBrewfile"
598"#,
599            )
600            .unwrap();
601
602        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
603
604        // Root config
605        let root_cfg = mgr.root_config().unwrap();
606        assert_eq!(root_cfg.mappings.install, vec!["install.sh"]);
607
608        // Pack config merges root + pack
609        let pack_path = env.dotfiles_root.join("vim");
610        let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
611        assert_eq!(pack_cfg.mappings.install, vec!["vim-setup.sh"]); // overridden
612        assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); // inherited
613        assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); // from pack
614    }
615
616    #[test]
617    fn mappings_to_rules_produces_expected_rules() {
618        let mappings = MappingsSection {
619            path: "bin".into(),
620            install: vec!["install.sh".into(), "install.zsh".into()],
621            shell: vec!["aliases.sh".into(), "profile.sh".into()],
622            homebrew: "Brewfile".into(),
623            skip: vec!["*.tmp".into()],
624        };
625
626        let rules = mappings_to_rules(&mappings);
627
628        // Should have: path, 2x install, 2x shell, homebrew, 1x exclude, catchall = 8
629        assert_eq!(rules.len(), 8, "rules: {rules:#?}");
630
631        let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
632        assert!(handler_names.contains(&"path"));
633        assert!(handler_names.contains(&"install"));
634        assert!(handler_names.contains(&"shell"));
635        assert!(handler_names.contains(&"homebrew"));
636        assert!(handler_names.contains(&"exclude"));
637        assert!(handler_names.contains(&"symlink"));
638
639        // Exclusion rule should have ! prefix
640        let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
641        assert!(exclude.pattern.starts_with('!'));
642
643        // Catchall should be lowest priority
644        let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
645        assert_eq!(catchall.priority, 0);
646    }
647
648    #[test]
649    fn to_handler_config_converts_correctly() {
650        let env = TempEnvironment::builder().build();
651        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
652        let cfg = mgr.root_config().unwrap();
653
654        let hcfg = cfg.to_handler_config();
655        assert_eq!(hcfg.force_home, cfg.symlink.force_home);
656        assert_eq!(hcfg.force_app, cfg.symlink.force_app);
657        assert_eq!(hcfg.app_aliases, cfg.symlink.app_aliases);
658        assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
659    }
660
661    /// Hard cap on the seeded `force_app` defaults — see
662    /// `docs/proposals/macos-paths.lex` §3.4.1. Adding entry 101 is
663    /// supposed to be a forcing function to drop the weakest-justified
664    /// existing entry; this test makes that forcing function *visible*
665    /// rather than relying on review discipline alone.
666    #[test]
667    fn default_force_app_under_hundred_entry_cap() {
668        let env = TempEnvironment::builder().build();
669        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
670        let cfg = mgr.root_config().unwrap();
671        assert!(
672            cfg.symlink.force_app.len() <= 100,
673            "force_app default has {} entries; cap is 100. \
674             Drop the weakest-justified entry before adding another. \
675             See docs/proposals/macos-paths.lex §3.4.1.",
676            cfg.symlink.force_app.len()
677        );
678    }
679
680    /// Compile-time sanity on the seeded force_app entries. These ship
681    /// in the default config and must stay correctly capitalized to
682    /// match the actual macOS Application Support folder names.
683    #[test]
684    fn default_force_app_seed_contains_expected_entries() {
685        let env = TempEnvironment::builder().build();
686        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
687        let cfg = mgr.root_config().unwrap();
688        for expected in ["Code", "Cursor", "Zed", "Emacs"] {
689            assert!(
690                cfg.symlink.force_app.iter().any(|e| e == expected),
691                "expected default force_app to contain `{expected}`; got {:?}",
692                cfg.symlink.force_app
693            );
694        }
695    }
696
697    #[test]
698    fn app_uses_library_default_is_true() {
699        let env = TempEnvironment::builder().build();
700        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
701        let cfg = mgr.root_config().unwrap();
702        assert!(
703            cfg.symlink.app_uses_library,
704            "app_uses_library must default to true; macOS gets the Library \
705             root, Linux already collapses app_support_dir to xdg_config_home"
706        );
707    }
708
709    #[test]
710    fn app_aliases_overridable_in_root_config() {
711        let env = TempEnvironment::builder().build();
712        env.fs
713            .write_file(
714                &env.dotfiles_root.join(".dodot.toml"),
715                br#"
716[symlink.app_aliases]
717vscode = "Code"
718warp = "dev.warp.Warp-Stable"
719"#,
720            )
721            .unwrap();
722        let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
723        let cfg = mgr.root_config().unwrap();
724        assert_eq!(
725            cfg.symlink.app_aliases.get("vscode").map(String::as_str),
726            Some("Code")
727        );
728        assert_eq!(
729            cfg.symlink.app_aliases.get("warp").map(String::as_str),
730            Some("dev.warp.Warp-Stable")
731        );
732    }
733}