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 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 if name.starts_with('.') && name != ".config" {
55 continue;
56 }
57
58 if is_ignored(name, ignore_patterns) {
60 continue;
61 }
62
63 if fs.exists(&entry.path.join(".dodotignore")) {
65 continue;
66 }
67
68 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 packs.sort_by(|a, b| a.name.cmp(&b.name));
82 Ok(packs)
83}
84
85fn 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
100fn 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 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 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}