use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::fs::Fs;
use crate::packs::Pack;
use crate::Result;
#[derive(Debug, Clone, Serialize)]
pub struct Rule {
pub pattern: String,
pub handler: String,
pub priority: i32,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub options: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct PackEntry {
pub relative_path: PathBuf,
pub absolute_path: PathBuf,
pub is_dir: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RuleMatch {
pub relative_path: PathBuf,
pub absolute_path: PathBuf,
pub pack: String,
pub handler: String,
pub is_dir: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub options: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preprocessor_source: Option<PathBuf>,
}
pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
for m in matches {
groups.entry(m.handler.clone()).or_default().push(m.clone());
}
groups
}
pub fn handler_execution_order(
groups: &HashMap<String, Vec<RuleMatch>>,
registry: &HashMap<String, Box<dyn crate::handlers::Handler + '_>>,
) -> Vec<String> {
let mut names: Vec<String> = groups.keys().cloned().collect();
names.sort_by(|a, b| {
let pa = registry.get(a).map(|h| h.phase());
let pb = registry.get(b).map(|h| h.phase());
match (pa, pb) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.cmp(b),
}
});
names
}
#[derive(Debug)]
enum CompiledPattern {
Exact(String),
Glob(glob::Pattern),
Directory(String),
}
#[derive(Debug)]
struct CompiledRule {
pattern: CompiledPattern,
is_exclusion: bool,
handler: String,
priority: i32,
options: HashMap<String, String>,
}
fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
rules
.iter()
.map(|rule| {
let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
(rest.to_string(), true)
} else {
(rule.pattern.clone(), false)
};
let pattern = if raw_pattern.ends_with('/') {
let dir_name = raw_pattern.trim_end_matches('/').to_string();
CompiledPattern::Directory(dir_name)
} else if raw_pattern.contains('*')
|| raw_pattern.contains('?')
|| raw_pattern.contains('[')
{
match glob::Pattern::new(&raw_pattern) {
Ok(p) => CompiledPattern::Glob(p),
Err(_) => CompiledPattern::Exact(raw_pattern),
}
} else {
CompiledPattern::Exact(raw_pattern)
};
CompiledRule {
pattern,
is_exclusion,
handler: rule.handler.clone(),
priority: rule.priority,
options: rule.options.clone(),
}
})
.collect()
}
fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
match pattern {
CompiledPattern::Exact(name) => filename == name,
CompiledPattern::Glob(glob) => glob.matches(filename),
CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
}
}
pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
}
pub struct Scanner<'a> {
fs: &'a dyn Fs,
}
impl<'a> Scanner<'a> {
pub fn new(fs: &'a dyn Fs) -> Self {
Self { fs }
}
pub fn scan_pack(
&self,
pack: &Pack,
rules: &[Rule],
pack_ignore: &[String],
) -> Result<Vec<RuleMatch>> {
let entries = self.walk_pack(&pack.path, pack_ignore)?;
Ok(self.match_entries(&entries, rules, &pack.name))
}
pub fn walk_pack(
&self,
pack_path: &Path,
ignore_patterns: &[String],
) -> Result<Vec<PackEntry>> {
let mut results = Vec::new();
self.list_top_level(pack_path, ignore_patterns, &mut results)?;
Ok(results)
}
pub fn walk_pack_recursive(
&self,
pack_path: &Path,
ignore_patterns: &[String],
) -> Result<Vec<PackEntry>> {
let mut results = Vec::new();
self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
Ok(results)
}
pub fn match_entries(
&self,
entries: &[PackEntry],
rules: &[Rule],
pack_name: &str,
) -> Vec<RuleMatch> {
let compiled = compile_rules(rules);
let mut matches = Vec::new();
for entry in entries {
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(rule_match) = match_file(
&compiled,
&filename,
entry.is_dir,
&entry.relative_path,
&entry.absolute_path,
pack_name,
) {
matches.push(rule_match);
}
}
matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
matches
}
fn list_top_level(
&self,
pack_path: &Path,
ignore_patterns: &[String],
results: &mut Vec<PackEntry>,
) -> Result<()> {
let entries = self.fs.read_dir(pack_path)?;
for entry in entries {
let name = &entry.name;
if name.starts_with('.') && name != ".config" {
continue;
}
if SPECIAL_FILES.contains(&name.as_str()) {
continue;
}
if is_ignored(name, ignore_patterns) {
continue;
}
let rel_path = entry
.path
.strip_prefix(pack_path)
.unwrap_or(&entry.path)
.to_path_buf();
results.push(PackEntry {
relative_path: rel_path,
absolute_path: entry.path.clone(),
is_dir: entry.is_dir,
});
}
Ok(())
}
fn walk_dir(
&self,
base: &Path,
dir: &Path,
ignore_patterns: &[String],
results: &mut Vec<PackEntry>,
) -> Result<()> {
let entries = self.fs.read_dir(dir)?;
for entry in entries {
let name = &entry.name;
if name.starts_with('.') && name != ".config" {
continue;
}
if SPECIAL_FILES.contains(&name.as_str()) {
continue;
}
if is_ignored(name, ignore_patterns) {
continue;
}
let rel_path = entry
.path
.strip_prefix(base)
.unwrap_or(&entry.path)
.to_path_buf();
if entry.is_dir {
results.push(PackEntry {
relative_path: rel_path.clone(),
absolute_path: entry.path.clone(),
is_dir: true,
});
self.walk_dir(base, &entry.path, ignore_patterns, results)?;
} else {
results.push(PackEntry {
relative_path: rel_path,
absolute_path: entry.path.clone(),
is_dir: false,
});
}
}
Ok(())
}
}
fn match_file(
compiled: &[CompiledRule],
filename: &str,
is_dir: bool,
rel_path: &Path,
abs_path: &Path,
pack: &str,
) -> Option<RuleMatch> {
for rule in compiled {
if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
return None;
}
}
let mut inclusion_rules: Vec<&CompiledRule> =
compiled.iter().filter(|r| !r.is_exclusion).collect();
inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
for rule in inclusion_rules {
if matches_entry(&rule.pattern, filename, is_dir) {
return Some(RuleMatch {
relative_path: rel_path.to_path_buf(),
absolute_path: abs_path.to_path_buf(),
pack: pack.to_string(),
handler: rule.handler.clone(),
is_dir,
options: rule.options.clone(),
preprocessor_source: None,
});
}
}
None
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::HandlerConfig;
use crate::testing::TempEnvironment;
fn make_pack(name: &str, path: PathBuf) -> Pack {
Pack {
name: name.into(),
path,
config: HandlerConfig::default(),
}
}
fn default_rules() -> Vec<Rule> {
vec![
Rule {
pattern: "bin/".into(),
handler: "path".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "install.sh".into(),
handler: "install".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "aliases.sh".into(),
handler: "shell".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "profile.sh".into(),
handler: "shell".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "Brewfile".into(),
handler: "homebrew".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
options: HashMap::new(),
},
]
}
#[test]
fn exact_match() {
let compiled = compile_rules(&[Rule {
pattern: "install.sh".into(),
handler: "install".into(),
priority: 0,
options: HashMap::new(),
}]);
assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
}
#[test]
fn glob_match() {
let compiled = compile_rules(&[Rule {
pattern: "*.sh".into(),
handler: "shell".into(),
priority: 0,
options: HashMap::new(),
}]);
assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
}
#[test]
fn directory_match() {
let compiled = compile_rules(&[Rule {
pattern: "bin/".into(),
handler: "path".into(),
priority: 0,
options: HashMap::new(),
}]);
assert!(matches_entry(&compiled[0].pattern, "bin", true));
assert!(!matches_entry(&compiled[0].pattern, "bin", false));
assert!(!matches_entry(&compiled[0].pattern, "lib", true));
}
#[test]
fn exclusion_prefix() {
let compiled = compile_rules(&[Rule {
pattern: "!*.tmp".into(),
handler: "exclude".into(),
priority: 100,
options: HashMap::new(),
}]);
assert!(compiled[0].is_exclusion);
assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
}
#[test]
fn catchall_matches_everything() {
let compiled = compile_rules(&[Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
options: HashMap::new(),
}]);
assert!(matches_entry(&compiled[0].pattern, "anything", false));
assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
}
#[test]
fn scan_pack_basic() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.file("aliases.sh", "alias vi=vim")
.file("install.sh", "#!/bin/sh\necho setup")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("vim", env.dotfiles_root.join("vim"));
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let handler_map: HashMap<String, Vec<String>> = {
let mut m: HashMap<String, Vec<String>> = HashMap::new();
for rm in &matches {
m.entry(rm.handler.clone())
.or_default()
.push(rm.relative_path.to_string_lossy().to_string());
}
m
};
assert_eq!(handler_map["install"], vec!["install.sh"]);
assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
}
#[test]
fn scan_pack_skips_hidden_files() {
let env = TempEnvironment::builder()
.pack("test")
.file("visible", "yes")
.file(".hidden", "no")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", env.dotfiles_root.join("test"));
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let names: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"visible".to_string()));
assert!(!names.contains(&".hidden".to_string()));
}
#[test]
fn scan_pack_skips_special_files() {
let env = TempEnvironment::builder()
.pack("test")
.file("normal", "yes")
.config("[pack]\nignore = []")
.done()
.build();
let pack_dir = env.dotfiles_root.join("test");
env.fs
.write_file(&pack_dir.join(".dodotignore"), b"")
.unwrap();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", pack_dir);
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let names: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"normal".to_string()));
assert!(!names.contains(&".dodot.toml".to_string()));
assert!(!names.contains(&".dodotignore".to_string()));
}
#[test]
fn scan_pack_with_ignore_patterns() {
let env = TempEnvironment::builder()
.pack("test")
.file("keep.txt", "yes")
.file("skip.bak", "no")
.file("other.bak", "no")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", env.dotfiles_root.join("test"));
let rules = default_rules();
let matches = scanner
.scan_pack(&pack, &rules, &["*.bak".to_string()])
.unwrap();
let names: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"keep.txt".to_string()));
assert!(!names.contains(&"skip.bak".to_string()));
assert!(!names.contains(&"other.bak".to_string()));
}
#[test]
fn scan_pack_exclusion_rules_override_catchall() {
let env = TempEnvironment::builder()
.pack("test")
.file("good.txt", "yes")
.file("bad.tmp", "no")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", env.dotfiles_root.join("test"));
let rules = vec![
Rule {
pattern: "!*.tmp".into(),
handler: "exclude".into(),
priority: 100,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
options: HashMap::new(),
},
];
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let names: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"good.txt".to_string()));
assert!(!names.contains(&"bad.tmp".to_string()));
}
#[test]
fn scan_pack_priority_ordering() {
let env = TempEnvironment::builder()
.pack("test")
.file("aliases.sh", "# shell")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", env.dotfiles_root.join("test"));
let rules = vec![
Rule {
pattern: "*.sh".into(),
handler: "generic-shell".into(),
priority: 5,
options: HashMap::new(),
},
Rule {
pattern: "aliases.sh".into(),
handler: "specific-shell".into(),
priority: 10,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
options: HashMap::new(),
},
];
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "specific-shell");
}
#[test]
fn scan_pack_directory_entry() {
let env = TempEnvironment::builder()
.pack("test")
.file("bin/my-script", "#!/bin/sh")
.file("normal", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("test", env.dotfiles_root.join("test"));
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let bin_match = matches
.iter()
.find(|m| m.relative_path.to_string_lossy() == "bin");
assert!(bin_match.is_some(), "bin directory should match");
assert_eq!(bin_match.unwrap().handler, "path");
assert!(bin_match.unwrap().is_dir);
}
#[test]
fn nested_install_sh_is_not_matched_by_install_rule() {
let env = TempEnvironment::builder()
.pack("sneaky")
.file("config/install.sh", "echo boom")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
assert!(
!matches.iter().any(|m| m.handler == "install"),
"nested install.sh should not route to install handler: {matches:?}"
);
}
#[test]
fn scan_pack_returns_only_top_level_entries() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("nvim/init.lua", "require('config')")
.file("nvim/lua/plugins.lua", "return {}")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
let rules = default_rules();
let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
let relpaths: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(
relpaths.iter().any(|p| p == "nvim"),
"top-level nvim dir should match: {relpaths:?}"
);
assert!(
!relpaths.iter().any(|p| p.contains('/')),
"no nested paths expected: {relpaths:?}"
);
}
#[test]
fn group_by_handler_groups_correctly() {
let matches = vec![
RuleMatch {
relative_path: "vimrc".into(),
absolute_path: "/d/vim/vimrc".into(),
pack: "vim".into(),
handler: "symlink".into(),
is_dir: false,
options: HashMap::new(),
preprocessor_source: None,
},
RuleMatch {
relative_path: "aliases.sh".into(),
absolute_path: "/d/vim/aliases.sh".into(),
pack: "vim".into(),
handler: "shell".into(),
is_dir: false,
options: HashMap::new(),
preprocessor_source: None,
},
RuleMatch {
relative_path: "gvimrc".into(),
absolute_path: "/d/vim/gvimrc".into(),
pack: "vim".into(),
handler: "symlink".into(),
is_dir: false,
options: HashMap::new(),
preprocessor_source: None,
},
];
let groups = group_by_handler(&matches);
assert_eq!(groups.len(), 2);
assert_eq!(groups["symlink"].len(), 2);
assert_eq!(groups["shell"].len(), 1);
}
#[test]
fn handler_execution_order_follows_phase_declaration() {
let mut groups = HashMap::new();
groups.insert("symlink".into(), vec![]);
groups.insert("install".into(), vec![]);
groups.insert("shell".into(), vec![]);
groups.insert("homebrew".into(), vec![]);
groups.insert("path".into(), vec![]);
let fs = crate::fs::OsFs::new();
let registry = crate::handlers::create_registry(&fs);
let order = handler_execution_order(&groups, ®istry);
assert_eq!(
order,
vec!["homebrew", "install", "path", "shell", "symlink"]
);
}
#[test]
fn handler_execution_order_places_unknown_handlers_last() {
let mut groups = HashMap::new();
groups.insert("symlink".into(), vec![]);
groups.insert("zzz-unknown".into(), vec![]);
groups.insert("homebrew".into(), vec![]);
let fs = crate::fs::OsFs::new();
let registry = crate::handlers::create_registry(&fs);
let order = handler_execution_order(&groups, ®istry);
assert_eq!(order, vec!["homebrew", "symlink", "zzz-unknown"]);
}
#[test]
fn rule_match_serializes() {
let m = RuleMatch {
relative_path: "vimrc".into(),
absolute_path: "/dots/vim/vimrc".into(),
pack: "vim".into(),
handler: "symlink".into(),
is_dir: false,
options: HashMap::new(),
preprocessor_source: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("vimrc"));
assert!(json.contains("symlink"));
assert!(!json.contains("options"));
}
}