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
6pub mod orchestration;
7
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::fs::Fs;
13use crate::handlers::HandlerConfig;
14use crate::Result;
15
16/// A dotfile pack — a directory of related configuration files.
17#[derive(Debug, Clone, Serialize)]
18pub struct Pack {
19    /// Directory name (e.g. `"vim"`).
20    pub name: String,
21
22    /// Absolute path to the pack directory.
23    pub path: PathBuf,
24
25    /// Handler-relevant configuration for this pack (merged from
26    /// app defaults + root config + pack config).
27    pub config: HandlerConfig,
28}
29
30/// Result of scanning the dotfiles root: active packs + names of
31/// pack-shaped directories skipped via `.dodotignore`.
32pub struct DiscoveredPacks {
33    pub packs: Vec<Pack>,
34    pub ignored: Vec<String>,
35}
36
37/// Scan the dotfiles root once, partitioning pack-shaped directories into
38/// active packs and those skipped via `.dodotignore`.
39///
40/// Directories filtered out entirely (hidden, matching `ignore_patterns`,
41/// invalid names) appear in neither list — they aren't pack-shaped.
42///
43/// Both lists are returned sorted alphabetically.
44pub fn scan_packs(
45    fs: &dyn Fs,
46    dotfiles_root: &Path,
47    ignore_patterns: &[String],
48) -> Result<DiscoveredPacks> {
49    let entries = fs.read_dir(dotfiles_root)?;
50    let mut packs = Vec::new();
51    let mut ignored = Vec::new();
52
53    for entry in entries {
54        if !entry.is_dir {
55            continue;
56        }
57
58        let name = &entry.name;
59
60        if name.starts_with('.') && name != ".config" {
61            continue;
62        }
63
64        if is_ignored(name, ignore_patterns) {
65            continue;
66        }
67
68        if !is_valid_pack_name(name) {
69            continue;
70        }
71
72        if fs.exists(&entry.path.join(".dodotignore")) {
73            ignored.push(name.clone());
74            continue;
75        }
76
77        packs.push(Pack {
78            name: name.clone(),
79            path: entry.path.clone(),
80            config: HandlerConfig::default(),
81        });
82    }
83
84    packs.sort_by(|a, b| a.name.cmp(&b.name));
85    ignored.sort();
86    Ok(DiscoveredPacks { packs, ignored })
87}
88
89/// Discover all active packs in the dotfiles root.
90///
91/// Skips hidden directories (except `.config`), directories matching
92/// ignore patterns, directories carrying a `.dodotignore` file, and
93/// directories with invalid names. Returns sorted alphabetically.
94///
95/// Prefer [`scan_packs`] when you also need the ignored list —
96/// this is a convenience wrapper over the same single-pass scan.
97pub fn discover_packs(
98    fs: &dyn Fs,
99    dotfiles_root: &Path,
100    ignore_patterns: &[String],
101) -> Result<Vec<Pack>> {
102    Ok(scan_packs(fs, dotfiles_root, ignore_patterns)?.packs)
103}
104
105/// Check if a name matches any ignore pattern.
106fn is_ignored(name: &str, patterns: &[String]) -> bool {
107    for pattern in patterns {
108        if let Ok(glob) = glob::Pattern::new(pattern) {
109            if glob.matches(name) {
110                return true;
111            }
112        }
113        if name == pattern {
114            return true;
115        }
116    }
117    false
118}
119
120/// Validate that a pack name contains only safe characters.
121fn is_valid_pack_name(name: &str) -> bool {
122    !name.is_empty()
123        && name
124            .chars()
125            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::testing::TempEnvironment;
132
133    #[test]
134    fn discover_finds_pack_directories() {
135        let env = TempEnvironment::builder()
136            .pack("git")
137            .file("gitconfig", "x")
138            .done()
139            .pack("vim")
140            .file("vimrc", "x")
141            .done()
142            .pack("zsh")
143            .file("zshrc", "x")
144            .done()
145            .build();
146
147        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
148        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
149        assert_eq!(names, vec!["git", "vim", "zsh"]);
150    }
151
152    #[test]
153    fn discover_skips_hidden_dirs() {
154        let env = TempEnvironment::builder()
155            .pack("vim")
156            .file("vimrc", "x")
157            .done()
158            .build();
159
160        // Manually create a hidden dir
161        env.fs
162            .mkdir_all(&env.dotfiles_root.join(".hidden-pack"))
163            .unwrap();
164        env.fs
165            .write_file(&env.dotfiles_root.join(".hidden-pack/file"), b"x")
166            .unwrap();
167
168        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
169        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
170        assert_eq!(names, vec!["vim"]);
171    }
172
173    #[test]
174    fn discover_skips_ignored_patterns() {
175        let env = TempEnvironment::builder()
176            .pack("vim")
177            .file("vimrc", "x")
178            .done()
179            .pack("scratch")
180            .file("notes", "x")
181            .done()
182            .build();
183
184        let packs =
185            discover_packs(env.fs.as_ref(), &env.dotfiles_root, &["scratch".into()]).unwrap();
186        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
187        assert_eq!(names, vec!["vim"]);
188    }
189
190    #[test]
191    fn discover_skips_dodotignore() {
192        let env = TempEnvironment::builder()
193            .pack("vim")
194            .file("vimrc", "x")
195            .done()
196            .pack("disabled")
197            .file("stuff", "x")
198            .ignored()
199            .done()
200            .build();
201
202        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
203        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
204        assert_eq!(names, vec!["vim"]);
205    }
206
207    #[test]
208    fn scan_partitions_active_and_ignored_packs() {
209        let env = TempEnvironment::builder()
210            .pack("vim")
211            .file("vimrc", "x")
212            .done()
213            .pack("disabled")
214            .file("stuff", "x")
215            .ignored()
216            .done()
217            .pack("old")
218            .file("thing", "x")
219            .ignored()
220            .done()
221            .build();
222
223        let result = scan_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
224        let names: Vec<&str> = result.packs.iter().map(|p| p.name.as_str()).collect();
225        assert_eq!(names, vec!["vim"]);
226        assert_eq!(
227            result.ignored,
228            vec!["disabled".to_string(), "old".to_string()]
229        );
230    }
231
232    #[test]
233    fn discover_sorts_alphabetically() {
234        let env = TempEnvironment::builder()
235            .pack("zsh")
236            .file("z", "x")
237            .done()
238            .pack("alacritty")
239            .file("a", "x")
240            .done()
241            .pack("git")
242            .file("g", "x")
243            .done()
244            .build();
245
246        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
247        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
248        assert_eq!(names, vec!["alacritty", "git", "zsh"]);
249    }
250
251    #[test]
252    fn discover_skips_files_at_root() {
253        let env = TempEnvironment::builder()
254            .pack("vim")
255            .file("vimrc", "x")
256            .done()
257            .build();
258
259        // Create a file at dotfiles root (not a pack)
260        env.fs
261            .write_file(&env.dotfiles_root.join("README.md"), b"# my dotfiles")
262            .unwrap();
263
264        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
265        assert_eq!(packs.len(), 1);
266        assert_eq!(packs[0].name, "vim");
267    }
268
269    #[test]
270    fn valid_pack_names() {
271        assert!(is_valid_pack_name("vim"));
272        assert!(is_valid_pack_name("my-pack"));
273        assert!(is_valid_pack_name("pack_name"));
274        assert!(is_valid_pack_name("nvim.bak"));
275        assert!(!is_valid_pack_name(""));
276        assert!(!is_valid_pack_name("has space"));
277        assert!(!is_valid_pack_name("path/traversal"));
278    }
279}