pub mod orchestration;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::fs::Fs;
use crate::handlers::HandlerConfig;
use crate::Result;
#[derive(Debug, Clone, Serialize)]
pub struct Pack {
pub name: String,
pub path: PathBuf,
pub config: HandlerConfig,
}
pub struct DiscoveredPacks {
pub packs: Vec<Pack>,
pub ignored: Vec<String>,
}
pub fn scan_packs(
fs: &dyn Fs,
dotfiles_root: &Path,
ignore_patterns: &[String],
) -> Result<DiscoveredPacks> {
let entries = fs.read_dir(dotfiles_root)?;
let mut packs = Vec::new();
let mut ignored = Vec::new();
for entry in entries {
if !entry.is_dir {
continue;
}
let name = &entry.name;
if name.starts_with('.') && name != ".config" {
continue;
}
if is_ignored(name, ignore_patterns) {
continue;
}
if !is_valid_pack_name(name) {
continue;
}
if fs.exists(&entry.path.join(".dodotignore")) {
ignored.push(name.clone());
continue;
}
packs.push(Pack {
name: name.clone(),
path: entry.path.clone(),
config: HandlerConfig::default(),
});
}
packs.sort_by(|a, b| a.name.cmp(&b.name));
ignored.sort();
Ok(DiscoveredPacks { packs, ignored })
}
pub fn discover_packs(
fs: &dyn Fs,
dotfiles_root: &Path,
ignore_patterns: &[String],
) -> Result<Vec<Pack>> {
Ok(scan_packs(fs, dotfiles_root, ignore_patterns)?.packs)
}
fn is_ignored(name: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if let Ok(glob) = glob::Pattern::new(pattern) {
if glob.matches(name) {
return true;
}
}
if name == pattern {
return true;
}
}
false
}
fn is_valid_pack_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
#[test]
fn discover_finds_pack_directories() {
let env = TempEnvironment::builder()
.pack("git")
.file("gitconfig", "x")
.done()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("zsh")
.file("zshrc", "x")
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["git", "vim", "zsh"]);
}
#[test]
fn discover_skips_hidden_dirs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
env.fs
.mkdir_all(&env.dotfiles_root.join(".hidden-pack"))
.unwrap();
env.fs
.write_file(&env.dotfiles_root.join(".hidden-pack/file"), b"x")
.unwrap();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["vim"]);
}
#[test]
fn discover_skips_ignored_patterns() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("scratch")
.file("notes", "x")
.done()
.build();
let packs =
discover_packs(env.fs.as_ref(), &env.dotfiles_root, &["scratch".into()]).unwrap();
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["vim"]);
}
#[test]
fn discover_skips_dodotignore() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["vim"]);
}
#[test]
fn scan_partitions_active_and_ignored_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.pack("old")
.file("thing", "x")
.ignored()
.done()
.build();
let result = scan_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let names: Vec<&str> = result.packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["vim"]);
assert_eq!(
result.ignored,
vec!["disabled".to_string(), "old".to_string()]
);
}
#[test]
fn discover_sorts_alphabetically() {
let env = TempEnvironment::builder()
.pack("zsh")
.file("z", "x")
.done()
.pack("alacritty")
.file("a", "x")
.done()
.pack("git")
.file("g", "x")
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["alacritty", "git", "zsh"]);
}
#[test]
fn discover_skips_files_at_root() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
env.fs
.write_file(&env.dotfiles_root.join("README.md"), b"# my dotfiles")
.unwrap();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].name, "vim");
}
#[test]
fn valid_pack_names() {
assert!(is_valid_pack_name("vim"));
assert!(is_valid_pack_name("my-pack"));
assert!(is_valid_pack_name("pack_name"));
assert!(is_valid_pack_name("nvim.bak"));
assert!(!is_valid_pack_name(""));
assert!(!is_valid_pack_name("has space"));
assert!(!is_valid_pack_name("path/traversal"));
}
}