use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Serialize;
use crate::fs::Fs;
use crate::gates::{parse_basename_gate, BasenameGate, GateTable, HostFacts};
use crate::handlers::HANDLER_GATE;
use crate::packs::Pack;
use crate::{DodotError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct Rule {
pub pattern: String,
pub handler: String,
pub priority: i32,
#[serde(default, skip_serializing_if = "is_false")]
pub case_insensitive: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub options: HashMap<String, String>,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone)]
pub struct PackEntry {
pub relative_path: PathBuf,
pub absolute_path: PathBuf,
pub is_dir: bool,
pub gate_failure: Option<GateFailure>,
}
#[derive(Debug, Clone)]
pub struct GateFailure {
pub label: String,
pub predicate: String,
pub host: String,
}
#[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>,
#[serde(skip)]
pub rendered_bytes: Option<Arc<[u8]>>,
}
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,
case_insensitive: bool,
handler: String,
priority: i32,
options: HashMap<String, String>,
}
fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
rules
.iter()
.map(|rule| {
let raw_pattern = rule.pattern.clone();
let case_insensitive = rule.case_insensitive;
let normalized = if case_insensitive {
raw_pattern.to_lowercase()
} else {
raw_pattern
};
let pattern = if normalized.ends_with('/') {
let dir_name = normalized.trim_end_matches('/').to_string();
CompiledPattern::Directory(dir_name)
} else if normalized.contains('*')
|| normalized.contains('?')
|| normalized.contains('[')
{
match glob::Pattern::new(&normalized) {
Ok(p) => CompiledPattern::Glob(p),
Err(_) => CompiledPattern::Exact(normalized),
}
} else {
CompiledPattern::Exact(normalized)
};
CompiledRule {
pattern,
case_insensitive,
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],
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 match_file<'a>(
sorted: &'a [&'a CompiledRule],
has_ci_rules: bool,
filename: &str,
is_dir: bool,
rel_path: &Path,
abs_path: &Path,
pack: &str,
) -> Option<RuleMatch> {
let lowered = if has_ci_rules {
Some(filename.to_lowercase())
} else {
None
};
let pick = |rule: &CompiledRule| -> &str {
if rule.case_insensitive {
lowered.as_deref().unwrap_or(filename)
} else {
filename
}
};
for rule in sorted {
if matches_entry(&rule.pattern, pick(rule), 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,
rendered_bytes: None,
});
}
}
None
}
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(", ")
}
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::new(name.into(), path, HandlerConfig::default())
}
fn test_gates() -> (GateTable, HostFacts) {
(
GateTable::with_builtins(),
HostFacts::for_tests("darwin", "aarch64"),
)
}
fn default_rules() -> Vec<Rule> {
vec![
Rule {
pattern: "bin/".into(),
handler: "path".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "install.sh".into(),
handler: "install".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "aliases.sh".into(),
handler: "shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "profile.sh".into(),
handler: "shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "Brewfile".into(),
handler: "homebrew".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
},
]
}
#[test]
fn exact_match() {
let compiled = compile_rules(&[Rule {
pattern: "install.sh".into(),
handler: "install".into(),
priority: 0,
case_insensitive: false,
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,
case_insensitive: false,
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,
case_insensitive: false,
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 case_insensitive_pattern_lowercases_at_compile_time() {
let compiled = compile_rules(&[Rule {
pattern: "README.*".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
}]);
assert!(compiled[0].case_insensitive);
assert!(matches_entry(&compiled[0].pattern, "readme.md", false));
}
#[test]
fn catchall_matches_everything() {
let compiled = compile_rules(&[Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(
&pack,
&rules,
&["*.bak".to_string()],
&gates,
&host,
&HashMap::new(),
)
.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_ignore_rule_outranks_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: "ignore".into(),
priority: 100,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
},
];
let (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.unwrap();
let bad = matches
.iter()
.find(|m| m.relative_path.to_string_lossy() == "bad.tmp")
.expect("bad.tmp must still appear as a match");
assert_eq!(bad.handler, "ignore");
let good = matches
.iter()
.find(|m| m.relative_path.to_string_lossy() == "good.txt")
.expect("good.txt must appear as a match");
assert_eq!(good.handler, "symlink");
}
#[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,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "aliases.sh".into(),
handler: "specific-shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
},
];
let (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "specific-shell");
}
#[test]
fn skip_handler_matches_case_insensitively() {
let env = TempEnvironment::builder()
.pack("test")
.file("README", "x")
.file("readme.md", "x")
.file("License.txt", "x")
.file("notes.md", "x")
.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: "README".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
},
Rule {
pattern: "README.*".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
},
Rule {
pattern: "LICENSE.*".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
},
];
let (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.unwrap();
let by_handler: std::collections::HashMap<&str, Vec<&str>> =
matches.iter().fold(Default::default(), |mut acc, m| {
acc.entry(m.handler.as_str())
.or_default()
.push(m.relative_path.to_str().unwrap());
acc
});
let mut skipped = by_handler.get("skip").cloned().unwrap_or_default();
skipped.sort();
assert_eq!(skipped, vec!["License.txt", "README", "readme.md"]);
let symlinked = by_handler.get("symlink").cloned().unwrap_or_default();
assert_eq!(symlinked, vec!["notes.md"]);
}
#[test]
fn skip_handler_outranks_precise_handler() {
let env = TempEnvironment::builder()
.pack("test")
.file("README.sh", "x")
.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: "README.*".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
},
Rule {
pattern: "*.sh".into(),
handler: "shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
},
];
let (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "skip");
}
#[test]
fn ignore_rule_outranks_skip() {
let env = TempEnvironment::builder()
.pack("test")
.file("README.md", "x")
.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: "README.md".into(),
handler: "ignore".into(),
priority: 100,
case_insensitive: false,
options: HashMap::new(),
},
Rule {
pattern: "README.*".into(),
handler: "skip".into(),
priority: 50,
case_insensitive: true,
options: HashMap::new(),
},
Rule {
pattern: "*".into(),
handler: "symlink".into(),
priority: 0,
case_insensitive: false,
options: HashMap::new(),
},
];
let (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "ignore");
}
#[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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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 (gates, host) = test_gates();
let matches = scanner
.scan_pack(&pack, &rules, &[], &gates, &host, &HashMap::new())
.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,
rendered_bytes: 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,
rendered_bytes: 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,
rendered_bytes: 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,
rendered_bytes: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("vimrc"));
assert!(json.contains("symlink"));
assert!(!json.contains("options"));
}
fn host_pair(os: &str, arch: &str) -> (GateTable, HostFacts) {
(GateTable::with_builtins(), HostFacts::for_tests(os, arch))
}
#[test]
fn gate_passing_strips_suffix_and_routes_to_handler() {
let env = TempEnvironment::builder()
.pack("mac")
.file("install._darwin.sh", "#!/bin/sh\necho mac-only")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("mac", env.dotfiles_root.join("mac"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.handler, "install");
assert_eq!(m.relative_path.to_string_lossy(), "install.sh");
assert!(m
.absolute_path
.to_string_lossy()
.ends_with("install._darwin.sh"));
}
#[test]
fn gate_failing_emits_gate_handler_match() {
let env = TempEnvironment::builder()
.pack("cross")
.file("install._linux.sh", "#!/bin/sh\napt-get foo")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("cross", env.dotfiles_root.join("cross"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.handler, crate::handlers::HANDLER_GATE);
assert_eq!(m.relative_path.to_string_lossy(), "install._linux.sh");
assert_eq!(m.options.get("gate_label"), Some(&"linux".to_string()));
assert_eq!(
m.options.get("gate_predicate"),
Some(&"os=linux".to_string())
);
assert_eq!(m.options.get("gate_host"), Some(&"os=darwin".to_string()));
}
#[test]
fn gate_unknown_label_is_hard_error() {
let env = TempEnvironment::builder()
.pack("typo")
.file("install._darwn.sh", "#!/bin/sh") .done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("typo", env.dotfiles_root.join("typo"));
let (gates, host) = host_pair("darwin", "aarch64");
let err = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("darwn"), "missing label: {msg}");
assert!(msg.contains("typo"), "missing pack: {msg}");
assert!(msg.contains("install._darwn.sh"), "missing file: {msg}");
}
#[test]
fn gate_compound_user_label_evaluates_and() {
let env = TempEnvironment::builder()
.pack("p")
.file("setup._arm-mac.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let mut user = HashMap::new();
let mut arm_mac = HashMap::new();
arm_mac.insert("os".into(), "darwin".into());
arm_mac.insert("arch".into(), "aarch64".into());
user.insert("arm-mac".into(), arm_mac);
let mut gates = GateTable::with_builtins();
gates.merge_user(&user).unwrap();
let host = HostFacts::for_tests("darwin", "aarch64");
let mut rules = default_rules();
rules.push(Rule {
pattern: "setup.sh".into(),
handler: "shell".into(),
priority: 10,
case_insensitive: false,
options: HashMap::new(),
});
let matches = scanner
.match_entries(
&scanner.walk_pack(&pack.path, &[], &gates, &host).unwrap(),
&rules,
&pack.name,
&gates,
&host,
&HashMap::new(),
)
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "shell");
assert_eq!(matches[0].relative_path.to_string_lossy(), "setup.sh");
let host_intel = HostFacts::for_tests("darwin", "x86_64");
let matches = scanner
.match_entries(
&scanner
.walk_pack(&pack.path, &[], &gates, &host_intel)
.unwrap(),
&rules,
&pack.name,
&gates,
&host_intel,
&HashMap::new(),
)
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, crate::handlers::HANDLER_GATE);
}
#[test]
fn gate_composes_with_template_extension() {
let env = TempEnvironment::builder()
.pack("p")
.file("aliases._darwin.sh.tmpl", "alias x=y")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "aliases.sh.tmpl");
assert_eq!(m.handler, "symlink");
}
#[test]
fn gate_composes_with_home_routing_prefix() {
let env = TempEnvironment::builder()
.pack("p")
.file("home.bashrc._darwin", "# bashrc")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "home.bashrc");
assert_eq!(m.handler, "symlink");
}
#[test]
fn gate_mixed_files_in_one_pack() {
let env = TempEnvironment::builder()
.pack("cross")
.file("install._darwin.sh", "#!/bin/sh\necho mac")
.file("install._linux.sh", "#!/bin/sh\necho linux")
.file("vimrc", "set nocompatible")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("cross", env.dotfiles_root.join("cross"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
let by_handler: HashMap<&str, Vec<String>> =
matches.iter().fold(HashMap::new(), |mut acc, m| {
acc.entry(m.handler.as_str())
.or_default()
.push(m.relative_path.to_string_lossy().to_string());
acc
});
assert_eq!(
by_handler.get("install"),
Some(&vec!["install.sh".to_string()])
);
assert_eq!(
by_handler.get(crate::handlers::HANDLER_GATE),
Some(&vec!["install._linux.sh".to_string()])
);
assert_eq!(by_handler.get("symlink"), Some(&vec!["vimrc".to_string()]));
}
#[test]
fn gate_brewfile_extensionless() {
let env = TempEnvironment::builder()
.pack("brew")
.file("Brewfile._darwin", "brew \"ripgrep\"")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("brew", env.dotfiles_root.join("brew"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "homebrew");
assert_eq!(matches[0].relative_path.to_string_lossy(), "Brewfile");
}
#[test]
fn gate_arch_label_uses_arm64_alias() {
let env = TempEnvironment::builder()
.pack("p")
.file("aliases._arm64.sh", "alias x=y")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "aliases.sh");
assert_eq!(m.handler, "shell");
}
#[test]
fn dir_gate_passing_descends_and_flattens() {
let env = TempEnvironment::builder()
.pack("cross")
.file("_darwin/macos.sh", "#!/bin/sh\necho mac")
.file("shared", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("cross", env.dotfiles_root.join("cross"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
let names: Vec<String> = matches
.iter()
.map(|m| m.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"macos.sh".to_string()), "{names:?}");
assert!(names.contains(&"shared".to_string()), "{names:?}");
assert!(!names.iter().any(|n| n.starts_with("_darwin")), "{names:?}");
}
#[test]
fn dir_gate_failing_emits_gate_match() {
let env = TempEnvironment::builder()
.pack("cross")
.file("_linux/linux.sh", "#!/bin/sh\necho linux")
.file("shared", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("cross", env.dotfiles_root.join("cross"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 2, "{matches:?}");
let gate_match = matches
.iter()
.find(|m| m.handler == crate::handlers::HANDLER_GATE)
.expect("expected gate match");
assert_eq!(gate_match.relative_path.to_string_lossy(), "_linux");
assert!(gate_match.is_dir);
assert_eq!(
gate_match.options.get("gate_label"),
Some(&"linux".to_string())
);
let shared = matches
.iter()
.find(|m| m.relative_path.to_string_lossy() == "shared")
.expect("expected shared file");
assert_eq!(shared.handler, "symlink");
}
#[test]
fn dir_gate_routing_prefix_is_not_a_gate() {
let env = TempEnvironment::builder()
.pack("p")
.file("_home/.bashrc", "# bashrc")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "_home");
assert!(m.is_dir);
assert_eq!(m.handler, "symlink");
}
#[test]
fn dir_gate_unknown_label_is_hard_error() {
let env = TempEnvironment::builder()
.pack("typo")
.file("_darwn/foo.sh", "x") .done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("typo", env.dotfiles_root.join("typo"));
let (gates, host) = host_pair("darwin", "aarch64");
let err = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("darwn"), "missing label: {msg}");
assert!(msg.contains("_darwn"), "missing dir name: {msg}");
}
#[test]
fn dir_gate_nested_inside_passing_gate_still_evaluates() {
let env = TempEnvironment::builder()
.pack("p")
.file("_darwin/_arm64/install.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "install.sh");
assert_eq!(m.handler, "install");
}
#[test]
fn dir_gate_nested_failing_inner_gate_drops_subtree() {
let env = TempEnvironment::builder()
.pack("p")
.file("_darwin/_x86_64/install.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
let gate = matches
.iter()
.find(|m| m.handler == crate::handlers::HANDLER_GATE);
assert!(gate.is_some(), "expected a gate match: {matches:?}");
assert!(
!matches
.iter()
.any(|m| m.relative_path.to_string_lossy() == "install.sh"),
"install.sh must not deploy when its enclosing gate fails: {matches:?}"
);
}
#[test]
fn dir_gate_with_routing_prefix_inside_passing_gate() {
let env = TempEnvironment::builder()
.pack("p")
.file("_darwin/_home/.bashrc", "# bashrc")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &HashMap::new())
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.relative_path.to_string_lossy(), "_home");
assert!(m.is_dir);
assert_eq!(m.handler, "symlink");
}
#[test]
fn mappings_gate_failing_drops_file() {
let env = TempEnvironment::builder()
.pack("p")
.file("install-mac.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let mut mappings_gates = HashMap::new();
mappings_gates.insert("install-mac.sh".to_string(), "linux".to_string());
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &mappings_gates)
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.handler, crate::handlers::HANDLER_GATE);
assert_eq!(m.options.get("gate_label"), Some(&"linux".to_string()));
}
#[test]
fn mappings_gate_passing_does_not_alter_dispatch() {
let env = TempEnvironment::builder()
.pack("p")
.file("install-mac.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let mut mappings_gates = HashMap::new();
mappings_gates.insert("install-mac.sh".to_string(), "darwin".to_string());
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &mappings_gates)
.unwrap();
assert_eq!(matches.len(), 1);
let m = &matches[0];
assert_eq!(m.handler, "symlink");
assert_eq!(m.relative_path.to_string_lossy(), "install-mac.sh");
}
#[test]
fn mappings_gate_glob_matches_subpath() {
let env = TempEnvironment::builder()
.pack("p")
.file("setup/foo.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let mut mappings_gates = HashMap::new();
mappings_gates.insert("setup/*.sh".to_string(), "linux".to_string());
let matches = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &mappings_gates)
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].handler, "symlink");
assert_eq!(matches[0].relative_path.to_string_lossy(), "setup");
}
#[test]
fn mappings_gate_conflict_with_basename_gate_errors() {
let env = TempEnvironment::builder()
.pack("p")
.file("install._darwin.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let mut mappings_gates = HashMap::new();
mappings_gates.insert("install._darwin.sh".to_string(), "linux".to_string());
let err = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &mappings_gates)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("gate-routing conflict"), "{msg}");
assert!(msg.contains("install._darwin.sh"), "{msg}");
}
#[test]
fn mappings_gate_unknown_label_errors() {
let env = TempEnvironment::builder()
.pack("p")
.file("foo.sh", "x")
.done()
.build();
let scanner = Scanner::new(env.fs.as_ref());
let pack = make_pack("p", env.dotfiles_root.join("p"));
let (gates, host) = host_pair("darwin", "aarch64");
let mut mappings_gates = HashMap::new();
mappings_gates.insert("foo.sh".to_string(), "darwn".to_string());
let err = scanner
.scan_pack(&pack, &default_rules(), &[], &gates, &host, &mappings_gates)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("darwn"), "{msg}");
assert!(msg.contains("[mappings.gates]"), "{msg}");
}
}