use std::path::{Component, Path};
use crate::fs::Fs;
use crate::gates::{parse_dir_gate_label, GateTable, HostFacts};
use crate::packs::{classify_pack_dir, PackDirSkip};
use crate::rules::{matched_ignore_pattern, SPECIAL_FILES};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IgnoreLayer {
Builtin,
Root,
Pack,
}
impl IgnoreLayer {
fn describe(self, pack: &str) -> String {
match self {
IgnoreLayer::Builtin => "dodot's default list".to_string(),
IgnoreLayer::Root => "the root .dodot.toml".to_string(),
IgnoreLayer::Pack => format!("pack {pack}'s .dodot.toml"),
}
}
}
pub(crate) struct EffectiveIgnore {
pub patterns: Vec<String>,
pub layer: IgnoreLayer,
}
impl EffectiveIgnore {
pub fn resolve(
fs: &dyn Fs,
dotfiles_root: &Path,
pack_path: &Path,
patterns: Vec<String>,
) -> Self {
let layer = if sets_pack_ignore(fs, &pack_path.join(".dodot.toml")) {
IgnoreLayer::Pack
} else if sets_pack_ignore(fs, &dotfiles_root.join(".dodot.toml")) {
IgnoreLayer::Root
} else {
IgnoreLayer::Builtin
};
EffectiveIgnore { patterns, layer }
}
pub fn root(fs: &dyn Fs, dotfiles_root: &Path, patterns: Vec<String>) -> Self {
let layer = if sets_pack_ignore(fs, &dotfiles_root.join(".dodot.toml")) {
IgnoreLayer::Root
} else {
IgnoreLayer::Builtin
};
EffectiveIgnore { patterns, layer }
}
}
fn sets_pack_ignore(fs: &dyn Fs, config_path: &Path) -> bool {
let Ok(text) = fs.read_to_string(config_path) else {
return false;
};
let Ok(value) = text.parse::<toml::Value>() else {
return false;
};
value
.get("pack")
.and_then(|pack| pack.get("ignore"))
.is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SkipRule {
Reserved { name: String },
Ignored {
name: String,
pattern: String,
layer: IgnoreLayer,
},
Hidden { name: String },
UnknownGate { name: String, label: String },
}
impl SkipRule {
pub fn short(&self, pack: &str) -> String {
match self {
SkipRule::Reserved { name } => format!("`{name}` is dodot's own"),
SkipRule::Ignored { pattern, layer, .. } => {
format!("[pack] ignore `{pattern}` ({})", layer.describe(pack))
}
SkipRule::Hidden { .. } => "hidden top-level name".to_string(),
SkipRule::UnknownGate { label, .. } => format!("undefined gate label `{label}`"),
}
}
pub fn reported(&self, pack: &str) -> String {
match self {
SkipRule::Reserved { name } => {
format!("`{name}` is a name dodot reads its own configuration from")
}
SkipRule::Ignored { pattern, layer, .. } => format!(
"matches `{pattern}` in [pack] ignore ({})",
layer.describe(pack)
),
SkipRule::Hidden { .. } => {
"a pack's top-level scan skips names starting with `.` (except .config)".to_string()
}
SkipRule::UnknownGate { label, .. } => format!(
"`_{label}` names a gate label dodot does not define, and a pack \
scan fails on it rather than reading it"
),
}
}
pub fn refusal(&self, source: &Path, in_pack: &Path, pack: &str) -> String {
let source = source.display();
match self {
SkipRule::Reserved { name } if name == ".dodot.toml" => format!(
"refusing to adopt {source}: `.dodot.toml` is dodot's own pack \
configuration file. Adopting one into a pack would replace that \
pack's configuration rather than add a managed file. Rename it if \
you want it deployed."
),
SkipRule::Reserved { .. } => format!(
"refusing to adopt {source}: `.dodotignore` is the marker that hides \
a pack from dodot. Adopting one into a pack would hide the whole \
pack rather than add a managed file. Rename it if you want it \
deployed."
),
SkipRule::Ignored {
name,
pattern,
layer,
} => format!(
"refusing to adopt {source}: it would land at `{}` in pack {pack}, \
where the pack scan reads `{name}` and skips it — `{name}` matches \
`{pattern}` in [pack] ignore ({}). No dodot run would read the \
entry. To manage it, override [pack] ignore for this pack in \
.dodot.toml.",
in_pack.display(),
layer.describe(pack),
),
SkipRule::Hidden { name } => format!(
"refusing to adopt {source}: it would land at `{}` in pack {pack}, \
and a pack's top-level scan skips names starting with `.` (except \
.config), so no dodot run would read `{name}`. No config setting \
changes that.",
in_pack.display(),
),
SkipRule::UnknownGate { name, label } => format!(
"refusing to adopt {source}: it would land at `{}` in pack {pack}, \
where `{name}` names a gate label dodot does not define. A pack scan \
stops on an undefined gate directory, so adopting this would make \
every later `dodot up` and `dodot status` fail on pack {pack}. \
Define `{label}` under [gates] in .dodot.toml, or rename the \
directory. Built-ins: darwin, linux, macos, arm64, aarch64, x86_64.",
in_pack.display(),
),
}
}
}
pub(crate) fn classify(
in_pack: &Path,
is_dir: bool,
ignore: &EffectiveIgnore,
gates: &GateTable,
host: &HostFacts,
) -> Option<SkipRule> {
scan_positions(in_pack, is_dir, gates, host)
.into_iter()
.find_map(|position| rule_for(&position, ignore, gates))
}
pub(crate) fn pack_dir_refusal(
pack_dir: &str,
ignore: &EffectiveIgnore,
dotfiles_root: &Path,
) -> Option<String> {
let skip = classify_pack_dir(pack_dir, &ignore.patterns)?;
let root = dotfiles_root.display();
let head = format!(
"refusing to adopt into pack `{pack_dir}`: it would be published at {root}/{pack_dir}, "
);
let body = match skip {
PackDirSkip::Ignored(pattern) => format!(
"which the scan of your dotfiles root skips — `{pack_dir}` matches `{pattern}` \
in [pack] ignore ({}). No `dodot up` and no `dodot status` would read the \
pack. Adopt into a pack dodot already reads with --into <pack>, or drop the \
pattern from [pack] ignore in the root .dodot.toml.",
ignore.layer.describe(pack_dir),
),
PackDirSkip::Hidden => "and the scan of your dotfiles root skips directories whose \
name starts with `.` (except .config), so no `dodot up` and no `dodot status` \
would read the pack. No config setting changes that. Adopt into a pack dodot \
already reads with --into <pack>."
.to_string(),
PackDirSkip::InvalidName => format!(
"and a pack directory name may hold only letters, digits, `_`, `-` and `.`, \
so the scan of your dotfiles root would pass `{pack_dir}` over and no dodot \
run would read the pack. Adopt into a pack dodot already reads with \
--into <pack>."
),
PackDirSkip::EmptyStem => format!(
"and `{pack_dir}` reads as an ordering prefix with no name after the \
separator, which makes every pack scan fail rather than skip it. Adopt into \
a pack dodot already reads with --into <pack>."
),
};
Some(head + &body)
}
struct ScanPosition {
name: String,
gate_label: Option<String>,
}
fn rule_for(
position: &ScanPosition,
ignore: &EffectiveIgnore,
gates: &GateTable,
) -> Option<SkipRule> {
let name = position.name.as_str();
if SPECIAL_FILES.contains(&name) {
return Some(SkipRule::Reserved {
name: name.to_string(),
});
}
if let Some(pattern) = matched_ignore_pattern(name, &ignore.patterns) {
return Some(SkipRule::Ignored {
name: name.to_string(),
pattern: pattern.to_string(),
layer: ignore.layer,
});
}
if name.starts_with('.') && name != ".config" {
return Some(SkipRule::Hidden {
name: name.to_string(),
});
}
if let Some(label) = &position.gate_label {
if gates.lookup(label).is_none() {
return Some(SkipRule::UnknownGate {
name: name.to_string(),
label: label.clone(),
});
}
}
None
}
fn scan_positions(
in_pack: &Path,
is_dir: bool,
gates: &GateTable,
host: &HostFacts,
) -> Vec<ScanPosition> {
let names: Vec<String> = in_pack
.components()
.filter_map(|component| match component {
Component::Normal(raw) => Some(raw.to_string_lossy().into_owned()),
_ => None,
})
.collect();
let mut positions = Vec::new();
for (index, name) in names.iter().enumerate() {
let position_is_dir = index + 1 < names.len() || is_dir;
let gate_label = if position_is_dir {
parse_dir_gate_label(name).map(str::to_string)
} else {
None
};
let descend = gate_label
.as_deref()
.and_then(|label| gates.lookup(label))
.is_some_and(|predicate| predicate.matches(host));
positions.push(ScanPosition {
name: name.clone(),
gate_label,
});
if !descend {
break;
}
}
positions
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ignore(patterns: &[&str]) -> EffectiveIgnore {
EffectiveIgnore {
patterns: patterns.iter().map(|p| p.to_string()).collect(),
layer: IgnoreLayer::Builtin,
}
}
fn gates() -> GateTable {
GateTable::with_builtins()
}
fn darwin() -> HostFacts {
HostFacts {
os: "darwin".into(),
arch: "x86_64".into(),
hostname: None,
username: None,
}
}
fn linux() -> HostFacts {
HostFacts {
os: "linux".into(),
arch: "x86_64".into(),
hostname: None,
username: None,
}
}
#[test]
fn only_the_first_component_is_classified() {
let ig = ignore(&["plugins", "*.lua"]);
assert_eq!(
classify(
&PathBuf::from("lua/plugins/init.lua"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
#[test]
fn an_ignored_first_component_names_that_component() {
let ig = ignore(&["lua"]);
let rule = classify(
&PathBuf::from("lua/plugins/init.lua"),
false,
&ig,
&gates(),
&linux(),
)
.expect("`lua` is ignored");
assert_eq!(
rule,
SkipRule::Ignored {
name: "lua".into(),
pattern: "lua".into(),
layer: IgnoreLayer::Builtin,
}
);
}
#[test]
fn a_passing_gate_directory_exposes_its_child_to_classification() {
let ig = ignore(&[".DS_Store"]);
let rule = classify(
&PathBuf::from("_darwin/.DS_Store"),
false,
&ig,
&gates(),
&darwin(),
)
.expect("a passing gate dir surfaces its children at pack-root level");
assert_eq!(
rule,
SkipRule::Ignored {
name: ".DS_Store".into(),
pattern: ".DS_Store".into(),
layer: IgnoreLayer::Builtin,
}
);
}
#[test]
fn a_failing_gate_directory_hides_its_child_from_classification() {
let ig = ignore(&[".DS_Store"]);
assert_eq!(
classify(
&PathBuf::from("_darwin/.DS_Store"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
#[test]
fn routing_prefixes_are_ordinary_names_and_stop_the_walk() {
let ig = ignore(&[]);
assert_eq!(
classify(
&PathBuf::from("_home/.gitconfig"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
#[test]
fn dot_config_is_the_hidden_rules_exception() {
let ig = ignore(&[]);
assert_eq!(
classify(
&PathBuf::from(".config"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
#[test]
fn reserved_wins_over_hidden() {
let ig = ignore(&[]);
let rule = classify(
&PathBuf::from(".dodot.toml"),
false,
&ig,
&gates(),
&linux(),
)
.unwrap();
assert!(matches!(rule, SkipRule::Reserved { .. }));
}
#[test]
fn ignore_wins_over_hidden() {
let ig = ignore(&[".DS_Store"]);
let rule = classify(
&PathBuf::from(".DS_Store"),
false,
&ig,
&gates(),
&linux(),
)
.unwrap();
assert!(matches!(rule, SkipRule::Ignored { .. }));
}
#[test]
fn an_undefined_gate_directory_is_not_adoptable() {
let ig = ignore(&[]);
let rule = classify(
&PathBuf::from("_bogus/init.lua"),
false,
&ig,
&gates(),
&linux(),
)
.expect("a pack scan fails on an undefined gate directory");
assert_eq!(
rule,
SkipRule::UnknownGate {
name: "_bogus".into(),
label: "bogus".into(),
}
);
}
#[test]
fn an_undefined_gate_name_on_a_file_is_adoptable() {
let ig = ignore(&[]);
assert_eq!(
classify(
&PathBuf::from("_bogus"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
#[test]
fn an_undefined_gate_directory_adopted_whole_is_not_adoptable() {
let ig = ignore(&[]);
let rule = classify(
&PathBuf::from("_bogus"),
true,
&ig,
&gates(),
&linux(),
)
.expect("a pack scan fails on an undefined gate directory");
assert!(matches!(rule, SkipRule::UnknownGate { .. }));
}
#[test]
fn hidden_wins_over_an_undefined_gate_label() {
let ig = ignore(&[]);
let rule = classify(
&PathBuf::from("._bogus/init.lua"),
false,
&ig,
&gates(),
&linux(),
)
.unwrap();
assert!(matches!(rule, SkipRule::Hidden { .. }));
}
#[test]
fn a_defined_gate_directory_stays_adoptable() {
let ig = ignore(&[]);
assert_eq!(
classify(
&PathBuf::from("_darwin/init.lua"),
false,
&ig,
&gates(),
&darwin()
),
None
);
}
#[test]
fn an_inferred_pack_name_the_root_scan_ignores_refuses() {
let ig = ignore(&["node_modules"]);
let message = pack_dir_refusal("node_modules", &ig, Path::new("/dotfiles"))
.expect("the root scan skips a pack named `node_modules`");
assert!(
message.contains("`node_modules` matches `node_modules`"),
"{message}"
);
assert!(message.contains("dodot's default list"), "{message}");
}
#[test]
fn a_hidden_inferred_pack_name_refuses() {
let ig = ignore(&[]);
let message = pack_dir_refusal(".foo", &ig, Path::new("/dotfiles"))
.expect("the root scan skips a dot-prefixed pack directory");
assert!(message.contains("starts with"), "{message}");
}
#[test]
fn an_invalid_inferred_pack_name_refuses() {
let ig = ignore(&[]);
let message = pack_dir_refusal("has space", &ig, Path::new("/dotfiles"))
.expect("the root scan skips a name outside the pack-name grammar");
assert!(message.contains("letters, digits"), "{message}");
}
#[test]
fn an_empty_stem_inferred_pack_name_refuses() {
let ig = ignore(&[]);
let message = pack_dir_refusal("010-", &ig, Path::new("/dotfiles"))
.expect("an empty-stem prefix fails every pack scan");
assert!(message.contains("ordering prefix"), "{message}");
}
#[test]
fn a_discoverable_pack_name_passes() {
let ig = ignore(&["node_modules"]);
assert_eq!(pack_dir_refusal("nvim", &ig, Path::new("/dotfiles")), None);
assert_eq!(
pack_dir_refusal("010-nvim", &ig, Path::new("/dotfiles")),
None
);
assert_eq!(
pack_dir_refusal(".config", &ig, Path::new("/dotfiles")),
None
);
}
#[test]
fn reserved_below_the_first_component_is_adoptable() {
let ig = ignore(&[]);
assert_eq!(
classify(
&PathBuf::from("lua/.dodot.toml"),
false,
&ig,
&gates(),
&linux()
),
None
);
}
}