pub mod context;
pub mod orchestration;
pub mod types;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::fs::Fs;
use crate::handlers::HandlerConfig;
use crate::{DodotError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct Pack {
pub name: String,
pub display_name: String,
pub path: PathBuf,
pub config: HandlerConfig,
}
impl Pack {
pub fn new(name: String, path: PathBuf, config: HandlerConfig) -> Self {
let display_name = match parse_prefix(&name) {
Ok(Some(stem)) => stem.to_string(),
Ok(None) | Err(_) => name.clone(),
};
Pack {
name,
display_name,
path,
config,
}
}
}
pub fn display_name_for(dir_name: &str) -> &str {
match parse_prefix(dir_name) {
Ok(Some(stem)) => stem,
_ => dir_name,
}
}
fn parse_prefix(name: &str) -> std::result::Result<Option<&str>, ()> {
let bytes = name.as_bytes();
let digits_len = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
if digits_len == 0 {
return Ok(None);
}
match bytes.get(digits_len) {
Some(b'-') | Some(b'_') => {}
_ => return Ok(None),
}
let stem = &name[digits_len + 1..];
if stem.is_empty() {
return Err(());
}
Ok(Some(stem))
}
fn detect_display_collisions(packs: &[Pack]) -> Result<()> {
let mut by_display: HashMap<&str, Vec<&Pack>> = HashMap::new();
for pack in packs {
by_display.entry(&pack.display_name).or_default().push(pack);
}
for pack in packs {
if let Some(group) = by_display.get(pack.display_name.as_str()) {
if group.len() > 1 {
let paths: Vec<PathBuf> = group.iter().map(|p| p.path.clone()).collect();
return Err(DodotError::PackOrderingCollision {
display_name: pack.display_name.clone(),
paths,
});
}
}
}
Ok(())
}
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 parse_prefix(name).is_err() {
return Err(DodotError::PackInvalid {
name: name.clone(),
reason:
"directory looks like an ordering prefix but has no name after the separator"
.into(),
});
}
if fs.exists(&entry.path.join(".dodotignore")) {
ignored.push(name.clone());
continue;
}
packs.push(Pack::new(
name.clone(),
entry.path.clone(),
HandlerConfig::default(),
));
}
packs.sort_by(|a, b| a.name.cmp(&b.name));
ignored.sort();
detect_display_collisions(&packs)?;
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"));
}
#[test]
fn parse_prefix_recognises_dash_separator() {
assert_eq!(parse_prefix("010-nvim"), Ok(Some("nvim")));
assert_eq!(parse_prefix("1-a"), Ok(Some("a")));
assert_eq!(parse_prefix("100-fzf-tab"), Ok(Some("fzf-tab")));
}
#[test]
fn parse_prefix_recognises_underscore_separator() {
assert_eq!(parse_prefix("020_zsh"), Ok(Some("zsh")));
assert_eq!(parse_prefix("99_late"), Ok(Some("late")));
}
#[test]
fn parse_prefix_passes_through_unprefixed_names() {
assert_eq!(parse_prefix("vim"), Ok(None));
assert_eq!(parse_prefix("my-pack"), Ok(None));
assert_eq!(parse_prefix("vim2"), Ok(None));
assert_eq!(parse_prefix("a01-foo"), Ok(None));
assert_eq!(parse_prefix("-foo"), Ok(None));
assert_eq!(parse_prefix("_foo"), Ok(None));
}
#[test]
fn parse_prefix_rejects_empty_stem() {
assert_eq!(parse_prefix("010-"), Err(()));
assert_eq!(parse_prefix("010_"), Err(()));
assert_eq!(parse_prefix("1-"), Err(()));
}
#[test]
fn pack_new_strips_prefix_for_display_name() {
let p = Pack::new(
"010-nvim".into(),
PathBuf::from("/x/010-nvim"),
HandlerConfig::default(),
);
assert_eq!(p.name, "010-nvim");
assert_eq!(p.display_name, "nvim");
}
#[test]
fn pack_new_keeps_unprefixed_name_for_display_name() {
let p = Pack::new(
"vim".into(),
PathBuf::from("/x/vim"),
HandlerConfig::default(),
);
assert_eq!(p.name, "vim");
assert_eq!(p.display_name, "vim");
}
#[test]
fn display_name_for_helper_handles_both_forms() {
assert_eq!(display_name_for("010-nvim"), "nvim");
assert_eq!(display_name_for("020_zsh"), "zsh");
assert_eq!(display_name_for("vim"), "vim");
assert_eq!(display_name_for("010-"), "010-");
}
#[test]
fn scan_sorts_prefixed_packs_numerically_via_lex_when_zero_padded() {
let env = TempEnvironment::builder()
.pack("100-zsh")
.file("zshrc", "x")
.done()
.pack("010-brew")
.file("Brewfile", "x")
.done()
.pack("020-git")
.file("gitconfig", "x")
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(dirs, vec!["010-brew", "020-git", "100-zsh"]);
let displays: Vec<&str> = packs.iter().map(|p| p.display_name.as_str()).collect();
assert_eq!(displays, vec!["brew", "git", "zsh"]);
}
#[test]
fn scan_interleaves_prefixed_and_unprefixed_via_lex() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("init.lua", "x")
.done()
.pack("starship")
.file("starship.toml", "x")
.done()
.pack("010-brew")
.file("Brewfile", "x")
.done()
.pack("020-zsh")
.file("zshrc", "x")
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(dirs, vec!["010-brew", "020-zsh", "nvim", "starship"]);
}
#[test]
fn scan_rejects_logical_name_collision_between_prefixed_and_unprefixed() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("init.lua", "x")
.done()
.pack("010-nvim")
.file("init.lua", "x")
.done()
.build();
let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
match err {
DodotError::PackOrderingCollision {
display_name,
paths,
} => {
assert_eq!(display_name, "nvim");
assert_eq!(paths.len(), 2);
let path_strs: Vec<String> =
paths.iter().map(|p| p.display().to_string()).collect();
assert!(path_strs.iter().any(|s| s.ends_with("nvim")));
assert!(path_strs.iter().any(|s| s.ends_with("010-nvim")));
}
other => panic!("expected PackOrderingCollision, got: {other:?}"),
}
}
#[test]
fn scan_rejects_multi_prefix_collision() {
let env = TempEnvironment::builder()
.pack("010-nvim")
.file("init.lua", "x")
.done()
.pack("020-nvim")
.file("init.lua", "x")
.done()
.build();
let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
assert!(matches!(
err,
DodotError::PackOrderingCollision { ref display_name, .. } if display_name == "nvim"
));
}
#[test]
fn scan_allows_same_prefix_with_different_stems() {
let env = TempEnvironment::builder()
.pack("010-brew")
.file("Brewfile", "x")
.done()
.pack("010-zsh")
.file("zshrc", "x")
.done()
.build();
let packs = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap();
let dirs: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(dirs, vec!["010-brew", "010-zsh"]);
let displays: Vec<&str> = packs.iter().map(|p| p.display_name.as_str()).collect();
assert_eq!(displays, vec!["brew", "zsh"]);
}
#[test]
fn scan_rejects_empty_stem_directory() {
let env = TempEnvironment::builder()
.pack("010-")
.file("placeholder", "x")
.done()
.build();
let err = discover_packs(env.fs.as_ref(), &env.dotfiles_root, &[]).unwrap_err();
match err {
DodotError::PackInvalid { name, reason } => {
assert_eq!(name, "010-");
assert!(reason.contains("ordering prefix"));
}
other => panic!("expected PackInvalid, got: {other:?}"),
}
}
}