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/// Discover all packs in the dotfiles root.
31///
32/// Scans for directories, skipping:
33/// - Hidden directories (except `.config`)
34/// - Directories matching ignore patterns
35/// - Directories containing a `.dodotignore` file
36///
37/// Packs are returned sorted alphabetically by name.
38pub fn discover_packs(
39    fs: &dyn Fs,
40    dotfiles_root: &Path,
41    ignore_patterns: &[String],
42) -> Result<Vec<Pack>> {
43    let entries = fs.read_dir(dotfiles_root)?;
44    let mut packs = Vec::new();
45
46    for entry in entries {
47        if !entry.is_dir {
48            continue;
49        }
50
51        let name = &entry.name;
52
53        // Skip hidden directories (except .config)
54        if name.starts_with('.') && name != ".config" {
55            continue;
56        }
57
58        // Skip ignored patterns
59        if is_ignored(name, ignore_patterns) {
60            continue;
61        }
62
63        // Skip packs with .dodotignore
64        if fs.exists(&entry.path.join(".dodotignore")) {
65            continue;
66        }
67
68        // Validate pack name (alphanumeric, underscore, dash)
69        if !is_valid_pack_name(name) {
70            continue;
71        }
72
73        packs.push(Pack {
74            name: name.clone(),
75            path: entry.path.clone(),
76            config: HandlerConfig::default(),
77        });
78    }
79
80    // Already sorted by read_dir (OsFs sorts), but ensure it
81    packs.sort_by(|a, b| a.name.cmp(&b.name));
82    Ok(packs)
83}
84
85/// Check if a name matches any ignore pattern.
86fn is_ignored(name: &str, patterns: &[String]) -> bool {
87    for pattern in patterns {
88        if let Ok(glob) = glob::Pattern::new(pattern) {
89            if glob.matches(name) {
90                return true;
91            }
92        }
93        if name == pattern {
94            return true;
95        }
96    }
97    false
98}
99
100/// Validate that a pack name contains only safe characters.
101fn is_valid_pack_name(name: &str) -> bool {
102    !name.is_empty()
103        && name
104            .chars()
105            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::testing::TempEnvironment;
112
113    #[test]
114    fn discover_finds_pack_directories() {
115        let env = TempEnvironment::builder()
116            .pack("git")
117            .file("gitconfig", "x")
118            .done()
119            .pack("vim")
120            .file("vimrc", "x")
121            .done()
122            .pack("zsh")
123            .file("zshrc", "x")
124            .done()
125            .build();
126
127        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
128        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
129        assert_eq!(names, vec!["git", "vim", "zsh"]);
130    }
131
132    #[test]
133    fn discover_skips_hidden_dirs() {
134        let env = TempEnvironment::builder()
135            .pack("vim")
136            .file("vimrc", "x")
137            .done()
138            .build();
139
140        // Manually create a hidden dir
141        env.fs
142            .mkdir_all(&env.dotfiles_root.join(".hidden-pack"))
143            .unwrap();
144        env.fs
145            .write_file(&env.dotfiles_root.join(".hidden-pack/file"), b"x")
146            .unwrap();
147
148        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
149        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
150        assert_eq!(names, vec!["vim"]);
151    }
152
153    #[test]
154    fn discover_skips_ignored_patterns() {
155        let env = TempEnvironment::builder()
156            .pack("vim")
157            .file("vimrc", "x")
158            .done()
159            .pack("scratch")
160            .file("notes", "x")
161            .done()
162            .build();
163
164        let packs =
165            discover_packs(env.fs.as_ref(), &env.dotfiles_root, &["scratch".into()]).unwrap();
166        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
167        assert_eq!(names, vec!["vim"]);
168    }
169
170    #[test]
171    fn discover_skips_dodotignore() {
172        let env = TempEnvironment::builder()
173            .pack("vim")
174            .file("vimrc", "x")
175            .done()
176            .pack("disabled")
177            .file("stuff", "x")
178            .ignored()
179            .done()
180            .build();
181
182        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
183        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
184        assert_eq!(names, vec!["vim"]);
185    }
186
187    #[test]
188    fn discover_sorts_alphabetically() {
189        let env = TempEnvironment::builder()
190            .pack("zsh")
191            .file("z", "x")
192            .done()
193            .pack("alacritty")
194            .file("a", "x")
195            .done()
196            .pack("git")
197            .file("g", "x")
198            .done()
199            .build();
200
201        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
202        let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
203        assert_eq!(names, vec!["alacritty", "git", "zsh"]);
204    }
205
206    #[test]
207    fn discover_skips_files_at_root() {
208        let env = TempEnvironment::builder()
209            .pack("vim")
210            .file("vimrc", "x")
211            .done()
212            .build();
213
214        // Create a file at dotfiles root (not a pack)
215        env.fs
216            .write_file(&env.dotfiles_root.join("README.md"), b"# my dotfiles")
217            .unwrap();
218
219        let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
220        assert_eq!(packs.len(), 1);
221        assert_eq!(packs[0].name, "vim");
222    }
223
224    #[test]
225    fn valid_pack_names() {
226        assert!(is_valid_pack_name("vim"));
227        assert!(is_valid_pack_name("my-pack"));
228        assert!(is_valid_pack_name("pack_name"));
229        assert!(is_valid_pack_name("nvim.bak"));
230        assert!(!is_valid_pack_name(""));
231        assert!(!is_valid_pack_name("has space"));
232        assert!(!is_valid_pack_name("path/traversal"));
233    }
234}