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}