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