1pub 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#[derive(Debug, Clone, Serialize)]
63pub struct Pack {
64 pub name: String,
69
70 pub display_name: String,
78
79 pub path: PathBuf,
81
82 pub config: HandlerConfig,
85}
86
87impl Pack {
88 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 Ok(None) | Err(_) => name.clone(),
102 };
103 Pack {
104 name,
105 display_name,
106 path,
107 config,
108 }
109 }
110}
111
112pub 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
126fn 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
153fn 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 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
178pub struct DiscoveredPacks {
181 pub packs: Vec<Pack>,
182 pub ignored: Vec<String>,
183}
184
185pub 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 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
263pub 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
279fn 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
294fn 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 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 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 #[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 assert_eq!(parse_prefix("vim2"), Ok(None));
475 assert_eq!(parse_prefix("a01-foo"), Ok(None));
477 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 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 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 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}