Skip to main content

dodot_lib/packs/
mod.rs

1//! Pack types, discovery, and orchestration.
2//!
3//! A pack is a directory of related dotfiles (e.g. `vim/`, `git/`, `zsh/`).
4//! It is the unit of organisation, deployment, and removal.
5//!
6//! # Pack ordering
7//!
8//! Packs are processed in lexicographic order of their on-disk directory
9//! names. That order determines every cross-pack effect: shell init
10//! source order, `$PATH` entry order, install/homebrew execution order.
11//! See [`docs/reference/handlers.lex`](../../../../docs/reference/handlers.lex)
12//! "Cross-Pack Ordering" for the user-facing contract.
13//!
14//! For the small minority of packs where ordering actually matters,
15//! dodot recognises a numeric prefix on the directory name as ordering
16//! metadata: `010-brew`, `020_zsh`, `100-starship`. The grammar is
17//! `^(\d+)[-_](.+)$`. The portion after the separator is the pack's
18//! *display name* — what every user-facing surface shows
19//! (`dodot status`, `dodot list`, error messages, shell-init comments,
20//! log lines). The full directory name is the pack's *sort key* and
21//! the identity used for every internal surface (datastore subtree,
22//! sentinel keys, paths).
23//!
24//! Three classes of collision are rejected at scan time, with both
25//! offending paths reported:
26//!
27//! - **Logical-name collision** — `nvim` and `010-nvim` both exist;
28//!   the display name `nvim` is ambiguous.
29//! - **Multi-prefix collision** — `010-nvim` and `020-nvim` both exist;
30//!   the display name `nvim` resolves to two packs.
31//! - **Empty stem** — `010-` or `010_` with no name after the separator.
32//!
33//! The non-collision case where two packs share a prefix but differ
34//! on the stem (`010-brew` and `010-zsh`) is permitted; lex order on
35//! the stem decides between them. The 10/20/30 gap convention is
36//! documented but not enforced.
37//!
38//! ## Why no formal dependency graph?
39//!
40//! A `priority = 30` field, a `requires:` / `after:` declaration, or a
41//! phase-bucket directory layout were all considered and explicitly
42//! rejected for this iteration (see `docs/proposals/pack-ordering.lex`
43//! §6). The systemd lesson — that conflating ordering with dependency
44//! is the documented failure mode of every system that has tried — is
45//! the reason. The prefix is purely an ordering primitive; it says
46//! "A applies before B", not "A is required for B to make sense".
47//! A pack with a missing dependency is the user's problem, not the
48//! framework's.
49
50pub mod orchestration;
51
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54
55use serde::Serialize;
56
57use crate::fs::Fs;
58use crate::handlers::HandlerConfig;
59use crate::{DodotError, Result};
60
61/// A dotfile pack — a directory of related configuration files.
62#[derive(Debug, Clone, Serialize)]
63pub struct Pack {
64    /// On-disk directory name (e.g. `"vim"`, `"010-nvim"`). This is the
65    /// sort key that drives cross-pack ordering, and the identity used
66    /// by every internal surface (datastore subtree, sentinel keys,
67    /// path resolution).
68    pub name: String,
69
70    /// User-facing pack name. For unprefixed packs this equals
71    /// [`name`](Self::name); for packs whose directory matches the
72    /// `^(\d+)[-_](.+)$` ordering grammar, this is the portion after
73    /// the separator (e.g. `010-nvim` → `nvim`). Used by every
74    /// user-facing surface: `dodot status`, `dodot list`, error
75    /// messages, generated shell-init comments, log lines, CLI
76    /// argument resolution.
77    pub display_name: String,
78
79    /// Absolute path to the pack directory.
80    pub path: PathBuf,
81
82    /// Handler-relevant configuration for this pack (merged from
83    /// app defaults + root config + pack config).
84    pub config: HandlerConfig,
85}
86
87impl Pack {
88    /// Construct a `Pack` from its on-disk directory name. Derives
89    /// [`display_name`](Self::display_name) by stripping a recognised
90    /// numeric prefix (`010-foo` → `foo`); for names without a prefix,
91    /// `display_name == name`.
92    ///
93    /// Pack name validation (alphanumerics, `_`, `-`, `.`) is the
94    /// caller's responsibility — typically [`scan_packs`].
95    pub fn new(name: String, path: PathBuf, config: HandlerConfig) -> Self {
96        let display_name = match parse_prefix(&name) {
97            Ok(Some(stem)) => stem.to_string(),
98            // Empty-stem (`010-`) is rejected upstream by scan_packs;
99            // if a caller bypasses that, fall back to the raw name
100            // rather than silently producing an empty display name.
101            Ok(None) | Err(_) => name.clone(),
102        };
103        Pack {
104            name,
105            display_name,
106            path,
107            config,
108        }
109    }
110}
111
112/// Compute the user-facing pack name for an on-disk directory name.
113/// Strips a recognised numeric prefix (`010-foo` → `foo`); for
114/// directories without a prefix, returns the full name.
115///
116/// For empty-stem inputs (`010-`, `010_`), returns the full name —
117/// scan-time validation rejects those before they reach this function,
118/// but the fallback keeps the helper total.
119pub fn display_name_for(dir_name: &str) -> &str {
120    match parse_prefix(dir_name) {
121        Ok(Some(stem)) => stem,
122        _ => dir_name,
123    }
124}
125
126/// Outcome of parsing a pack directory name against the ordering
127/// prefix grammar `^(\d+)[-_](.+)$`.
128///
129/// - `Ok(Some(stem))` — the name has a recognised prefix; the returned
130///   `&str` is the user-facing portion (e.g. `"010-nvim"` → `"nvim"`).
131/// - `Ok(None)` — the name does not match the grammar; treat it as
132///   unprefixed (display name equals the directory name).
133/// - `Err(())` — the name looks like a prefix (`010-` / `010_`) but
134///   nothing follows the separator. A scan-time error: a pack must
135///   have a name.
136fn parse_prefix(name: &str) -> std::result::Result<Option<&str>, ()> {
137    let bytes = name.as_bytes();
138    let digits_len = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
139    if digits_len == 0 {
140        return Ok(None);
141    }
142    match bytes.get(digits_len) {
143        Some(b'-') | Some(b'_') => {}
144        _ => return Ok(None),
145    }
146    let stem = &name[digits_len + 1..];
147    if stem.is_empty() {
148        return Err(());
149    }
150    Ok(Some(stem))
151}
152
153/// Detect collisions between recognised display names. Reports the
154/// three classes covered in the module docs; returns the first
155/// collision encountered (deterministic, since `packs` is sorted by
156/// directory name on entry).
157fn detect_display_collisions(packs: &[Pack]) -> Result<()> {
158    let mut by_display: HashMap<&str, Vec<&Pack>> = HashMap::new();
159    for pack in packs {
160        by_display.entry(&pack.display_name).or_default().push(pack);
161    }
162
163    // Walk in sort order so the error is deterministic across runs.
164    for pack in packs {
165        if let Some(group) = by_display.get(pack.display_name.as_str()) {
166            if group.len() > 1 {
167                let paths: Vec<PathBuf> = group.iter().map(|p| p.path.clone()).collect();
168                return Err(DodotError::PackOrderingCollision {
169                    display_name: pack.display_name.clone(),
170                    paths,
171                });
172            }
173        }
174    }
175    Ok(())
176}
177
178/// Result of scanning the dotfiles root: active packs + names of
179/// pack-shaped directories skipped via `.dodotignore`.
180pub struct DiscoveredPacks {
181    pub packs: Vec<Pack>,
182    pub ignored: Vec<String>,
183}
184
185/// Scan the dotfiles root once, partitioning pack-shaped directories into
186/// active packs and those skipped via `.dodotignore`.
187///
188/// Directories filtered out entirely (hidden, matching `ignore_patterns`,
189/// invalid names) appear in neither list — they aren't pack-shaped.
190///
191/// Both lists are returned sorted lexicographically by on-disk
192/// directory name. That sort order is the contract that drives every
193/// cross-pack effect (shell init source order, `$PATH` order,
194/// install/homebrew execution order); see the module docs.
195///
196/// Errors:
197///
198/// - [`DodotError::PackInvalid`] for an empty-stem prefix
199///   (e.g. `010-` or `010_`) — a pack must have a name.
200/// - [`DodotError::PackOrderingCollision`] when two or more packs
201///   resolve to the same display name (`nvim` + `010-nvim`, or
202///   `010-nvim` + `020-nvim`).
203pub fn scan_packs(
204    fs: &dyn Fs,
205    dotfiles_root: &Path,
206    ignore_patterns: &[String],
207) -> Result<DiscoveredPacks> {
208    let entries = fs.read_dir(dotfiles_root)?;
209    let mut packs = Vec::new();
210    let mut ignored = Vec::new();
211
212    for entry in entries {
213        if !entry.is_dir {
214            continue;
215        }
216
217        let name = &entry.name;
218
219        if name.starts_with('.') && name != ".config" {
220            continue;
221        }
222
223        if is_ignored(name, ignore_patterns) {
224            continue;
225        }
226
227        if !is_valid_pack_name(name) {
228            continue;
229        }
230
231        // Reject pack directories that look like an ordering prefix
232        // but have no name after the separator (e.g. `010-`, `010_`).
233        // Done here so the error carries the offending path.
234        if parse_prefix(name).is_err() {
235            return Err(DodotError::PackInvalid {
236                name: name.clone(),
237                reason:
238                    "directory looks like an ordering prefix but has no name after the separator"
239                        .into(),
240            });
241        }
242
243        if fs.exists(&entry.path.join(".dodotignore")) {
244            ignored.push(name.clone());
245            continue;
246        }
247
248        packs.push(Pack::new(
249            name.clone(),
250            entry.path.clone(),
251            HandlerConfig::default(),
252        ));
253    }
254
255    packs.sort_by(|a, b| a.name.cmp(&b.name));
256    ignored.sort();
257
258    detect_display_collisions(&packs)?;
259
260    Ok(DiscoveredPacks { packs, ignored })
261}
262
263/// Discover all active packs in the dotfiles root.
264///
265/// Skips hidden directories (except `.config`), directories matching
266/// ignore patterns, directories carrying a `.dodotignore` file, and
267/// directories with invalid names. Returns sorted alphabetically.
268///
269/// Prefer [`scan_packs`] when you also need the ignored list —
270/// this is a convenience wrapper over the same single-pass scan.
271pub fn discover_packs(
272    fs: &dyn Fs,
273    dotfiles_root: &Path,
274    ignore_patterns: &[String],
275) -> Result<Vec<Pack>> {
276    Ok(scan_packs(fs, dotfiles_root, ignore_patterns)?.packs)
277}
278
279/// Check if a name matches any ignore pattern.
280fn is_ignored(name: &str, patterns: &[String]) -> bool {
281    for pattern in patterns {
282        if let Ok(glob) = glob::Pattern::new(pattern) {
283            if glob.matches(name) {
284                return true;
285            }
286        }
287        if name == pattern {
288            return true;
289        }
290    }
291    false
292}
293
294/// Validate that a pack name contains only safe characters.
295fn is_valid_pack_name(name: &str) -> bool {
296    !name.is_empty()
297        && name
298            .chars()
299            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::testing::TempEnvironment;
306
307    #[test]
308    fn discover_finds_pack_directories() {
309        let env = TempEnvironment::builder()
310            .pack("git")
311            .file("gitconfig", "x")
312            .done()
313            .pack("vim")
314            .file("vimrc", "x")
315            .done()
316            .pack("zsh")
317            .file("zshrc", "x")
318            .done()
319            .build();
320
321        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
322        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
323        assert_eq!(names, vec!["git", "vim", "zsh"]);
324    }
325
326    #[test]
327    fn discover_skips_hidden_dirs() {
328        let env = TempEnvironment::builder()
329            .pack("vim")
330            .file("vimrc", "x")
331            .done()
332            .build();
333
334        // Manually create a hidden dir
335        env.fs
336            .mkdir_all(&env.dotfiles_root.join(".hidden-pack"))
337            .unwrap();
338        env.fs
339            .write_file(&env.dotfiles_root.join(".hidden-pack/file"), b"x")
340            .unwrap();
341
342        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
343        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
344        assert_eq!(names, vec!["vim"]);
345    }
346
347    #[test]
348    fn discover_skips_ignored_patterns() {
349        let env = TempEnvironment::builder()
350            .pack("vim")
351            .file("vimrc", "x")
352            .done()
353            .pack("scratch")
354            .file("notes", "x")
355            .done()
356            .build();
357
358        let packs =
359            discover_packs(env.fs.as_ref(), &env.dotfiles_root, &["scratch".into()]).unwrap();
360        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
361        assert_eq!(names, vec!["vim"]);
362    }
363
364    #[test]
365    fn discover_skips_dodotignore() {
366        let env = TempEnvironment::builder()
367            .pack("vim")
368            .file("vimrc", "x")
369            .done()
370            .pack("disabled")
371            .file("stuff", "x")
372            .ignored()
373            .done()
374            .build();
375
376        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
377        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
378        assert_eq!(names, vec!["vim"]);
379    }
380
381    #[test]
382    fn scan_partitions_active_and_ignored_packs() {
383        let env = TempEnvironment::builder()
384            .pack("vim")
385            .file("vimrc", "x")
386            .done()
387            .pack("disabled")
388            .file("stuff", "x")
389            .ignored()
390            .done()
391            .pack("old")
392            .file("thing", "x")
393            .ignored()
394            .done()
395            .build();
396
397        let result = scan_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
398        let names: Vec<&str> = result.packs.iter().map(|p| p.name.as_str()).collect();
399        assert_eq!(names, vec!["vim"]);
400        assert_eq!(
401            result.ignored,
402            vec!["disabled".to_string(), "old".to_string()]
403        );
404    }
405
406    #[test]
407    fn discover_sorts_alphabetically() {
408        let env = TempEnvironment::builder()
409            .pack("zsh")
410            .file("z", "x")
411            .done()
412            .pack("alacritty")
413            .file("a", "x")
414            .done()
415            .pack("git")
416            .file("g", "x")
417            .done()
418            .build();
419
420        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
421        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
422        assert_eq!(names, vec!["alacritty", "git", "zsh"]);
423    }
424
425    #[test]
426    fn discover_skips_files_at_root() {
427        let env = TempEnvironment::builder()
428            .pack("vim")
429            .file("vimrc", "x")
430            .done()
431            .build();
432
433        // Create a file at dotfiles root (not a pack)
434        env.fs
435            .write_file(&env.dotfiles_root.join("README.md"), b"# my dotfiles")
436            .unwrap();
437
438        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
439        assert_eq!(packs.len(), 1);
440        assert_eq!(packs[0].name, "vim");
441    }
442
443    #[test]
444    fn valid_pack_names() {
445        assert!(is_valid_pack_name("vim"));
446        assert!(is_valid_pack_name("my-pack"));
447        assert!(is_valid_pack_name("pack_name"));
448        assert!(is_valid_pack_name("nvim.bak"));
449        assert!(!is_valid_pack_name(""));
450        assert!(!is_valid_pack_name("has space"));
451        assert!(!is_valid_pack_name("path/traversal"));
452    }
453
454    // ── Pack ordering: prefix grammar ──────────────────────────────
455
456    #[test]
457    fn parse_prefix_recognises_dash_separator() {
458        assert_eq!(parse_prefix("010-nvim"), Ok(Some("nvim")));
459        assert_eq!(parse_prefix("1-a"), Ok(Some("a")));
460        assert_eq!(parse_prefix("100-fzf-tab"), Ok(Some("fzf-tab")));
461    }
462
463    #[test]
464    fn parse_prefix_recognises_underscore_separator() {
465        assert_eq!(parse_prefix("020_zsh"), Ok(Some("zsh")));
466        assert_eq!(parse_prefix("99_late"), Ok(Some("late")));
467    }
468
469    #[test]
470    fn parse_prefix_passes_through_unprefixed_names() {
471        assert_eq!(parse_prefix("vim"), Ok(None));
472        assert_eq!(parse_prefix("my-pack"), Ok(None));
473        // Digits without a separator → not a prefix.
474        assert_eq!(parse_prefix("vim2"), Ok(None));
475        // Non-digit prefix → not a prefix.
476        assert_eq!(parse_prefix("a01-foo"), Ok(None));
477        // Separator at position 0 (no digits) → not a prefix.
478        assert_eq!(parse_prefix("-foo"), Ok(None));
479        assert_eq!(parse_prefix("_foo"), Ok(None));
480    }
481
482    #[test]
483    fn parse_prefix_rejects_empty_stem() {
484        assert_eq!(parse_prefix("010-"), Err(()));
485        assert_eq!(parse_prefix("010_"), Err(()));
486        assert_eq!(parse_prefix("1-"), Err(()));
487    }
488
489    #[test]
490    fn pack_new_strips_prefix_for_display_name() {
491        let p = Pack::new(
492            "010-nvim".into(),
493            PathBuf::from("/x/010-nvim"),
494            HandlerConfig::default(),
495        );
496        assert_eq!(p.name, "010-nvim");
497        assert_eq!(p.display_name, "nvim");
498    }
499
500    #[test]
501    fn pack_new_keeps_unprefixed_name_for_display_name() {
502        let p = Pack::new(
503            "vim".into(),
504            PathBuf::from("/x/vim"),
505            HandlerConfig::default(),
506        );
507        assert_eq!(p.name, "vim");
508        assert_eq!(p.display_name, "vim");
509    }
510
511    #[test]
512    fn display_name_for_helper_handles_both_forms() {
513        assert_eq!(display_name_for("010-nvim"), "nvim");
514        assert_eq!(display_name_for("020_zsh"), "zsh");
515        assert_eq!(display_name_for("vim"), "vim");
516        // Empty-stem inputs fall back to the raw name (callers should
517        // have rejected them at scan time).
518        assert_eq!(display_name_for("010-"), "010-");
519    }
520
521    #[test]
522    fn scan_sorts_prefixed_packs_numerically_via_lex_when_zero_padded() {
523        let env = TempEnvironment::builder()
524            .pack("100-zsh")
525            .file("zshrc", "x")
526            .done()
527            .pack("010-brew")
528            .file("Brewfile", "x")
529            .done()
530            .pack("020-git")
531            .file("gitconfig", "x")
532            .done()
533            .build();
534
535        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
536        let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
537        assert_eq!(dirs, vec!["010-brew", "020-git", "100-zsh"]);
538        let displays: Vec<&str> = packs.iter().map(|p| p.display_name.as_str()).collect();
539        assert_eq!(displays, vec!["brew", "git", "zsh"]);
540    }
541
542    #[test]
543    fn scan_interleaves_prefixed_and_unprefixed_via_lex() {
544        // `010-brew` < `020-zsh` < `nvim` < `starship`.
545        let env = TempEnvironment::builder()
546            .pack("nvim")
547            .file("init.lua", "x")
548            .done()
549            .pack("starship")
550            .file("starship.toml", "x")
551            .done()
552            .pack("010-brew")
553            .file("Brewfile", "x")
554            .done()
555            .pack("020-zsh")
556            .file("zshrc", "x")
557            .done()
558            .build();
559
560        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
561        let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
562        assert_eq!(dirs, vec!["010-brew", "020-zsh", "nvim", "starship"]);
563    }
564
565    #[test]
566    fn scan_rejects_logical_name_collision_between_prefixed_and_unprefixed() {
567        let env = TempEnvironment::builder()
568            .pack("nvim")
569            .file("init.lua", "x")
570            .done()
571            .pack("010-nvim")
572            .file("init.lua", "x")
573            .done()
574            .build();
575
576        let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
577        match err {
578            DodotError::PackOrderingCollision {
579                display_name,
580                paths,
581            } => {
582                assert_eq!(display_name, "nvim");
583                assert_eq!(paths.len(), 2);
584                let path_strs: Vec<String> =
585                    paths.iter().map(|p| p.display().to_string()).collect();
586                assert!(path_strs.iter().any(|s| s.ends_with("nvim")));
587                assert!(path_strs.iter().any(|s| s.ends_with("010-nvim")));
588            }
589            other => panic!("expected PackOrderingCollision, got: {other:?}"),
590        }
591    }
592
593    #[test]
594    fn scan_rejects_multi_prefix_collision() {
595        let env = TempEnvironment::builder()
596            .pack("010-nvim")
597            .file("init.lua", "x")
598            .done()
599            .pack("020-nvim")
600            .file("init.lua", "x")
601            .done()
602            .build();
603
604        let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
605        assert!(matches!(
606            err,
607            DodotError::PackOrderingCollision { ref display_name, .. } if display_name == "nvim"
608        ));
609    }
610
611    #[test]
612    fn scan_allows_same_prefix_with_different_stems() {
613        // `010-brew` and `010-zsh` both legal — display names differ
614        // (`brew`, `zsh`), and lex order on the stem decides between
615        // them.
616        let env = TempEnvironment::builder()
617            .pack("010-brew")
618            .file("Brewfile", "x")
619            .done()
620            .pack("010-zsh")
621            .file("zshrc", "x")
622            .done()
623            .build();
624
625        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
626        let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
627        assert_eq!(dirs, vec!["010-brew", "010-zsh"]);
628        let displays: Vec<&str> = packs.iter().map(|p| p.display_name.as_str()).collect();
629        assert_eq!(displays, vec!["brew", "zsh"]);
630    }
631
632    #[test]
633    fn scan_rejects_empty_stem_directory() {
634        let env = TempEnvironment::builder()
635            .pack("010-")
636            .file("placeholder", "x")
637            .done()
638            .build();
639
640        let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
641        match err {
642            DodotError::PackInvalid { name, reason } => {
643                assert_eq!(name, "010-");
644                assert!(reason.contains("ordering prefix"));
645            }
646            other => panic!("expected PackInvalid, got: {other:?}"),
647        }
648    }
649}