use std::collections::HashMap;
use std::path::Path;
use crate::fs::Fs;
use crate::gates::{parse_basename_gate, BasenameGate, GateTable, HostFacts};
use crate::handlers::HANDLER_GATE;
use crate::packs::Pack;
use crate::rules::pattern::{compile_rules, match_file, CompiledRule};
use crate::rules::{GateFailure, PackEntry, Rule, RuleMatch};
use crate::{DodotError, Result};
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)
}
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
}
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],
gates: &GateTable,
host: &HostFacts,
mappings_gates: &HashMap<String, String>,
) -> Result<Vec<RuleMatch>> {
let entries = self.walk_pack(&pack.path, pack_ignore, gates, host)?;
self.match_entries(&entries, rules, &pack.name, gates, host, mappings_gates)
}
pub fn walk_pack(
&self,
pack_path: &Path,
ignore_patterns: &[String],
gates: &GateTable,
host: &HostFacts,
) -> Result<Vec<PackEntry>> {
let mut results = Vec::new();
self.list_top_level(pack_path, ignore_patterns, gates, host, &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,
gates: &GateTable,
host: &HostFacts,
mappings_gates: &HashMap<String, String>,
) -> Result<Vec<RuleMatch>> {
let compiled = compile_rules(rules);
let has_ci_rules = compiled.iter().any(|r| r.case_insensitive);
let mut sorted: Vec<&CompiledRule> = compiled.iter().collect();
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
let compiled_mapping_gates =
crate::gates::compile_mapping_gates(mappings_gates, pack_name)?;
let mut matches = Vec::new();
for entry in entries {
if let Some(failure) = &entry.gate_failure {
let mut options = HashMap::new();
options.insert("gate_label".into(), failure.label.clone());
options.insert("gate_predicate".into(), failure.predicate.clone());
options.insert("gate_host".into(), failure.host.clone());
matches.push(RuleMatch {
relative_path: entry.relative_path.clone(),
absolute_path: entry.absolute_path.clone(),
pack: pack_name.to_string(),
handler: HANDLER_GATE.into(),
is_dir: entry.is_dir,
options,
preprocessor_source: None,
rendered_bytes: None,
});
continue;
}
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let rel_str = crate::gates::rel_path_for_glob(&entry.relative_path);
let mapping_gate_label: Option<&str> = compiled_mapping_gates
.iter()
.find(|(pat, _)| pat.matches(&rel_str))
.map(|(_, label)| *label);
let basename_gate = parse_basename_gate(&filename);
if let Some(map_label) = mapping_gate_label {
if matches!(basename_gate, BasenameGate::Found { .. }) {
return Err(DodotError::Config(format!(
"gate-routing conflict in pack `{pack_name}` for `{}`: \
file carries both a filename gate token (`._<label>`) \
and a `[mappings.gates]` entry (`{map_label}`). \
Pick one — either rename the file (drop the suffix) \
or remove the `[mappings.gates]` entry.",
entry.relative_path.display()
)));
}
let pred = gates.lookup(map_label).ok_or_else(|| {
DodotError::Config(format!(
"unknown gate label `{map_label}` referenced from \
`[mappings.gates]` in pack `{pack_name}`: label is \
not in the built-in seed and not defined in [gates]."
))
})?;
if !pred.matches(host) {
let mut options = HashMap::new();
options.insert("gate_label".into(), map_label.to_string());
options.insert("gate_predicate".into(), pred.describe());
options.insert("gate_host".into(), describe_host_for_predicate(pred, host));
matches.push(RuleMatch {
relative_path: entry.relative_path.clone(),
absolute_path: entry.absolute_path.clone(),
pack: pack_name.to_string(),
handler: HANDLER_GATE.into(),
is_dir: entry.is_dir,
options,
preprocessor_source: None,
rendered_bytes: None,
});
continue;
}
}
let (effective_filename, effective_rel_path) = match basename_gate {
BasenameGate::None => (filename.clone(), entry.relative_path.clone()),
BasenameGate::Found { label, stripped } => {
let pred = gates.lookup(label).ok_or_else(|| {
DodotError::Config(format!(
"unknown gate label `{label}` in pack `{pack_name}`, file `{}`: \
label is not in the built-in seed and not defined in [gates]. \
Built-ins: darwin, linux, macos, arm64, aarch64, x86_64.",
entry.relative_path.display()
))
})?;
if pred.matches(host) {
let stripped_rel = entry.relative_path.with_file_name(&stripped);
(stripped, stripped_rel)
} else {
let mut options = HashMap::new();
options.insert("gate_label".into(), label.to_string());
options.insert("gate_predicate".into(), pred.describe());
options.insert("gate_host".into(), describe_host_for_predicate(pred, host));
matches.push(RuleMatch {
relative_path: entry.relative_path.clone(),
absolute_path: entry.absolute_path.clone(),
pack: pack_name.to_string(),
handler: HANDLER_GATE.into(),
is_dir: entry.is_dir,
options,
preprocessor_source: None,
rendered_bytes: None,
});
continue;
}
}
};
if let Some(rule_match) = match_file(
&sorted,
has_ci_rules,
&effective_filename,
entry.is_dir,
&effective_rel_path,
&entry.absolute_path,
pack_name,
) {
matches.push(rule_match);
}
}
matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(matches)
}
fn list_top_level(
&self,
pack_path: &Path,
ignore_patterns: &[String],
gates: &GateTable,
host: &HostFacts,
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();
if entry.is_dir {
if let Some(label) = crate::gates::parse_dir_gate_label(name) {
let pred = gates.lookup(label).ok_or_else(|| {
DodotError::Config(format!(
"unknown gate label `{label}` in directory `{}`: \
label is not in the built-in seed and not defined in [gates]. \
Built-ins: darwin, linux, macos, arm64, aarch64, x86_64.",
entry.path.display()
))
})?;
if pred.matches(host) {
self.list_top_level(&entry.path, ignore_patterns, gates, host, results)?;
} else {
results.push(PackEntry {
relative_path: rel_path,
absolute_path: entry.path.clone(),
is_dir: true,
gate_failure: Some(GateFailure {
label: label.to_string(),
predicate: pred.describe(),
host: describe_host_for_predicate(pred, host),
}),
});
}
continue;
}
}
results.push(PackEntry {
relative_path: rel_path,
absolute_path: entry.path.clone(),
is_dir: entry.is_dir,
gate_failure: None,
});
}
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,
gate_failure: None,
});
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,
gate_failure: None,
});
}
}
Ok(())
}
}
fn describe_host_for_predicate(pred: &crate::gates::GatePredicate, host: &HostFacts) -> String {
let parts: Vec<String> = pred
.matchers
.iter()
.map(|(dim, _)| {
let actual = host.get(*dim).unwrap_or("<unset>");
format!("{}={}", dim.as_str(), actual)
})
.collect();
parts.join(", ")
}
#[cfg(test)]
mod tests;