1pub 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#[derive(Debug, Clone, Serialize)]
18pub struct Pack {
19 pub name: String,
21
22 pub path: PathBuf,
24
25 pub config: HandlerConfig,
28}
29
30pub struct DiscoveredPacks {
33 pub packs: Vec<Pack>,
34 pub ignored: Vec<String>,
35}
36
37pub 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
89pub 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
105fn 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
120fn 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 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 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}