use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::config::{mappings_to_rules, ConfigManager};
use crate::fs::Fs;
use crate::gates::{pack_os_active, GateTable, HostFacts};
use crate::handlers::{create_registry, ExecutionPhase};
use crate::packs::scan_packs;
use crate::rules::{RuleMatch, Scanner};
use super::error::{Result, SafetyLockError};
use super::roots::ResolvedRoot;
const DOTFILES_CONFIG_FILE: &str = ".dodot.toml";
pub const MAX_INVENTORY_PATHS: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InventoryCategory {
Shell,
CodeExecution,
Path,
External,
Link,
Other,
}
impl InventoryCategory {
pub const PROMPT_ORDER: [InventoryCategory; 6] = [
InventoryCategory::Shell,
InventoryCategory::CodeExecution,
InventoryCategory::Path,
InventoryCategory::External,
InventoryCategory::Link,
InventoryCategory::Other,
];
pub fn for_phase(phase: ExecutionPhase) -> Option<Self> {
match phase {
ExecutionPhase::Filter => None,
ExecutionPhase::ShellInit => Some(InventoryCategory::Shell),
ExecutionPhase::Provision | ExecutionPhase::Setup => {
Some(InventoryCategory::CodeExecution)
}
ExecutionPhase::PathExport => Some(InventoryCategory::Path),
ExecutionPhase::External => Some(InventoryCategory::External),
ExecutionPhase::Link => Some(InventoryCategory::Link),
}
}
pub fn label(self) -> &'static str {
match self {
InventoryCategory::Shell => "shell",
InventoryCategory::CodeExecution => "code execution",
InventoryCategory::Path => "path",
InventoryCategory::External => "external",
InventoryCategory::Link => "link",
InventoryCategory::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct InventoryEntry {
pub category: InventoryCategory,
pub relative_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CategoryCount {
pub category: InventoryCategory,
pub files: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootInventory {
pub counts: Vec<CategoryCount>,
pub sample: Vec<InventoryEntry>,
pub omitted: usize,
}
impl RootInventory {
pub fn total_files(&self) -> usize {
self.counts.iter().map(|count| count.files).sum()
}
}
pub fn build_inventory(
root: &ResolvedRoot,
fs: &dyn Fs,
host: &HostFacts,
) -> Result<RootInventory> {
let root_path = root.as_path();
let config = ConfigManager::new(root_path)
.and_then(|manager| manager.root_config().map(|config| (manager, config)));
let (manager, root_config) =
config.map_err(|error| unusable_config(root_path.join(DOTFILES_CONFIG_FILE), error))?;
let discovered = scan_packs(fs, root_path, &root_config.pack.ignore)
.map_err(|error| unusable_routing(root_path, error))?;
let runner = crate::datastore::NoopCommandRunner;
let registry = create_registry(fs, &runner);
let scanner = Scanner::new(fs);
let mut counts: BTreeMap<InventoryCategory, usize> = BTreeMap::new();
let mut entries: Vec<InventoryEntry> = Vec::new();
for pack in &discovered.packs {
let pack_config = manager
.config_for_pack(&pack.path)
.map_err(|error| unusable_config(pack.path.join(DOTFILES_CONFIG_FILE), error))?;
if !pack_os_active(&pack_config.pack.os, host) {
continue;
}
let owner = |declared| {
gate_configuration_owner(root_path, pack, &pack_config, &root_config, declared)
};
let mut gates = GateTable::with_builtins();
if !pack_config.gates.is_empty() {
gates.merge_user(&pack_config.gates).map_err(|error| {
unusable_config(
owner(broken_gate_entry_owner(&root_config, &pack_config)),
error,
)
})?;
}
let rules = mappings_to_rules(&pack_config.mappings);
let matches = scanner
.scan_pack(
pack,
&rules,
&pack_config.pack.ignore,
&gates,
host,
&pack_config.mappings.gates,
)
.map_err(|error| {
let declared = broken_mapping_entry_owner(&root_config, &pack_config);
unusable_scan(&pack.path, &owner(declared), error)
})?;
for matched in matches {
let Some(category) = category_of(&matched.handler, ®istry) else {
continue;
};
*counts.entry(category).or_default() += 1;
entries.push(InventoryEntry {
category,
relative_path: Path::new(&pack.name)
.join(source_relative_path(&pack.path, &matched)),
});
}
}
entries.sort();
let recognized = entries.len();
entries.truncate(MAX_INVENTORY_PATHS);
Ok(RootInventory {
counts: InventoryCategory::PROMPT_ORDER
.iter()
.filter_map(|category| {
counts.get(category).map(|&files| CategoryCount {
category: *category,
files,
})
})
.collect(),
omitted: recognized - entries.len(),
sample: entries,
})
}
fn source_relative_path<'a>(pack_path: &Path, matched: &'a RuleMatch) -> &'a Path {
matched
.absolute_path
.strip_prefix(pack_path)
.unwrap_or(matched.relative_path.as_path())
}
fn category_of(
handler: &str,
registry: &std::collections::HashMap<String, Box<dyn crate::handlers::Handler + '_>>,
) -> Option<InventoryCategory> {
match registry.get(handler) {
Some(handler) => InventoryCategory::for_phase(handler.phase()),
None => Some(InventoryCategory::Other),
}
}
fn unusable_config(config_file: PathBuf, error: crate::error::DodotError) -> SafetyLockError {
SafetyLockError::DotfilesConfigUnusable {
config_file,
reason: error.to_string(),
}
}
fn unusable_routing(
directory: impl Into<PathBuf>,
error: crate::error::DodotError,
) -> SafetyLockError {
SafetyLockError::PackRoutingUnusable {
directory: directory.into(),
reason: error.to_string(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GateLayer {
Root,
Pack,
}
fn gate_entry_is_valid(label: &str, dimensions: &HashMap<String, String>) -> bool {
let one = HashMap::from([(label.to_string(), dimensions.clone())]);
GateTable::with_builtins().merge_user(&one).is_ok()
}
fn broken_gate_entry_owner(
root_config: &crate::config::DodotConfig,
pack_config: &crate::config::DodotConfig,
) -> Option<GateLayer> {
let mut labels: Vec<&String> = pack_config.gates.keys().collect();
labels.sort();
labels.into_iter().find_map(|label| {
let dimensions = pack_config.gates.get(label)?;
if gate_entry_is_valid(label, dimensions) {
return None;
}
let root_owns = root_config
.gates
.get(label)
.is_some_and(|root_dimensions| !gate_entry_is_valid(label, root_dimensions));
Some(if root_owns {
GateLayer::Root
} else {
GateLayer::Pack
})
})
}
fn broken_mapping_entry_owner(
root_config: &crate::config::DodotConfig,
pack_config: &crate::config::DodotConfig,
) -> Option<GateLayer> {
let mut globs: Vec<&String> = pack_config.mappings.gates.keys().collect();
globs.sort();
globs.into_iter().find_map(|glob| {
let label = pack_config.mappings.gates.get(glob)?;
let one = HashMap::from([(glob.clone(), label.clone())]);
if crate::gates::compile_mapping_gates(&one, "<attribution>").is_ok() {
return None;
}
Some(if root_config.mappings.gates.get(glob) == Some(label) {
GateLayer::Root
} else {
GateLayer::Pack
})
})
}
fn gate_configuration_owner(
root_path: &Path,
pack: &crate::packs::Pack,
pack_config: &crate::config::DodotConfig,
root_config: &crate::config::DodotConfig,
declared: Option<GateLayer>,
) -> PathBuf {
let inherited = pack_config.gates == root_config.gates
&& pack_config.mappings.gates == root_config.mappings.gates;
match declared {
Some(GateLayer::Root) => root_path.join(DOTFILES_CONFIG_FILE),
Some(GateLayer::Pack) => pack.path.join(DOTFILES_CONFIG_FILE),
None if inherited => root_path.join(DOTFILES_CONFIG_FILE),
None => pack.path.join(DOTFILES_CONFIG_FILE),
}
}
fn unusable_scan(
pack_path: &Path,
gate_config_file: &Path,
error: crate::error::DodotError,
) -> SafetyLockError {
if matches!(error, crate::error::DodotError::Config(_)) {
unusable_config(gate_config_file.to_path_buf(), error)
} else {
unusable_routing(pack_path, error)
}
}
#[cfg(test)]
mod tests {
use crate::fs::OsFs;
use crate::testing::{TempEnvironment, TempEnvironmentBuilder};
use super::super::roots::{RootIdentity, RootSource};
use super::*;
fn host() -> HostFacts {
HostFacts::for_tests("darwin", "arm64")
}
fn inventory_of(env: &TempEnvironment) -> RootInventory {
build_inventory(&resolved(env), env.fs.as_ref(), &host()).unwrap()
}
fn resolved(env: &TempEnvironment) -> ResolvedRoot {
ResolvedRoot::new(
RootIdentity::new(canonical_root(env)).unwrap(),
RootSource::Git,
)
}
fn canonical_root(env: &TempEnvironment) -> PathBuf {
std::fs::canonicalize(&env.dotfiles_root).unwrap()
}
fn sample_of(inventory: &RootInventory) -> Vec<(InventoryCategory, String)> {
inventory
.sample
.iter()
.map(|entry| {
(
entry.category,
entry.relative_path.to_string_lossy().into_owned(),
)
})
.collect()
}
fn counts_of(inventory: &RootInventory) -> Vec<(InventoryCategory, usize)> {
inventory
.counts
.iter()
.map(|count| (count.category, count.files))
.collect()
}
fn representative_pack(builder: TempEnvironmentBuilder, name: &str) -> TempEnvironmentBuilder {
builder
.pack(name)
.file("aliases.sh", "alias g=git")
.file("install.sh", "#!/bin/sh")
.file("Brewfile", "brew \"jq\"")
.file("externals.toml", "")
.file("bin/tool", "#!/bin/sh")
.file("config", "x")
.done()
}
#[test]
fn every_recognized_file_lands_in_exactly_one_category() {
let env = representative_pack(TempEnvironment::builder(), "tools").build();
let inventory = inventory_of(&env);
assert_eq!(
counts_of(&inventory),
[
(InventoryCategory::Shell, 1),
(InventoryCategory::CodeExecution, 2),
(InventoryCategory::Path, 1),
(InventoryCategory::External, 1),
(InventoryCategory::Link, 1),
]
);
assert_eq!(inventory.total_files(), 6);
assert_eq!(inventory.omitted, 0);
assert_eq!(
sample_of(&inventory),
[
(InventoryCategory::Shell, "tools/aliases.sh".into()),
(InventoryCategory::CodeExecution, "tools/Brewfile".into()),
(InventoryCategory::CodeExecution, "tools/install.sh".into()),
(InventoryCategory::Path, "tools/bin".into()),
(InventoryCategory::External, "tools/externals.toml".into()),
(InventoryCategory::Link, "tools/config".into()),
]
);
}
#[test]
fn detail_is_ordered_by_category_then_path() {
let env = TempEnvironment::builder()
.pack("zsh")
.file("zshrc.sh", "")
.done()
.pack("apps")
.file("gitconfig", "")
.file("install.sh", "")
.done()
.pack("alacritty")
.file("alacritty.toml", "")
.done()
.build();
let inventory = inventory_of(&env);
assert_eq!(
sample_of(&inventory),
[
(InventoryCategory::Shell, "zsh/zshrc.sh".into()),
(InventoryCategory::CodeExecution, "apps/install.sh".into()),
(InventoryCategory::Link, "alacritty/alacritty.toml".into()),
(InventoryCategory::Link, "apps/gitconfig".into()),
]
);
assert_eq!(sample_of(&inventory_of(&env)), sample_of(&inventory));
}
#[test]
fn detail_stops_at_ten_paths_and_counts_what_it_dropped() {
let mut builder = TempEnvironment::builder().pack("links");
for index in 0..20 {
builder = builder.file(&format!("config{index:02}"), "");
}
let env = representative_pack(builder.done(), "tools").build();
let inventory = inventory_of(&env);
assert_eq!(inventory.sample.len(), MAX_INVENTORY_PATHS);
assert_eq!(inventory.total_files(), 26);
assert_eq!(inventory.omitted, 16);
assert_eq!(
inventory.omitted + inventory.sample.len(),
inventory.total_files(),
"the cap lost paths instead of counting them"
);
assert_eq!(
counts_of(&inventory),
[
(InventoryCategory::Shell, 1),
(InventoryCategory::CodeExecution, 2),
(InventoryCategory::Path, 1),
(InventoryCategory::External, 1),
(InventoryCategory::Link, 21),
]
);
assert_eq!(
sample_of(&inventory)[..6],
[
(InventoryCategory::Shell, "tools/aliases.sh".into()),
(InventoryCategory::CodeExecution, "tools/Brewfile".into()),
(InventoryCategory::CodeExecution, "tools/install.sh".into()),
(InventoryCategory::Path, "tools/bin".into()),
(InventoryCategory::External, "tools/externals.toml".into()),
(InventoryCategory::Link, "links/config00".into()),
]
);
}
#[test]
fn custom_handler_mappings_are_honoured() {
let env = TempEnvironment::builder()
.pack("nvim")
.config(
"[mappings]\n\
path = \"scripts\"\n\
shell = [\"*.zsh\"]\n",
)
.file("scripts/tool", "")
.file("profile.zsh", "")
.file("aliases.sh", "")
.done()
.build();
let inventory = inventory_of(&env);
assert_eq!(
sample_of(&inventory),
[
(InventoryCategory::Shell, "nvim/profile.zsh".into()),
(InventoryCategory::Path, "nvim/scripts".into()),
(InventoryCategory::Link, "nvim/aliases.sh".into()),
]
);
}
#[test]
fn what_deploys_nothing_is_not_counted() {
let env = TempEnvironment::builder()
.pack("archive")
.file("vimrc", "")
.ignored()
.done()
.pack("vim")
.config("[mappings]\nignore = [\"notes.md\"]\n")
.file("vimrc", "")
.file("notes.md", "")
.file("README.md", "")
.file("linux-only.sh._linux", "")
.done()
.build();
let inventory = inventory_of(&env);
assert_eq!(counts_of(&inventory), [(InventoryCategory::Link, 1)]);
assert_eq!(
sample_of(&inventory),
[(InventoryCategory::Link, "vim/vimrc".into())],
"a filtered, gated, or ignored-pack file reached the prompt"
);
}
#[test]
fn packs_gated_off_this_host_are_not_counted() {
let env = TempEnvironment::builder()
.pack("linux-tools")
.config("[pack]\nos = [\"linux\"]\n")
.file("aliases.sh", "")
.done()
.pack("vim")
.file("vimrc", "")
.done()
.build();
assert_eq!(
sample_of(&inventory_of(&env)),
[(InventoryCategory::Link, "vim/vimrc".into())]
);
}
#[test]
fn a_passing_gate_is_listed_under_the_name_the_root_carries() {
let env = TempEnvironment::builder()
.pack("zsh")
.file("aliases._darwin.sh", "alias g=git")
.done()
.pack("vim")
.file("_darwin/vimrc", "")
.done()
.build();
let inventory = inventory_of(&env);
assert_eq!(
counts_of(&inventory),
[(InventoryCategory::Shell, 1), (InventoryCategory::Link, 1)],
"the passing gates changed what the entries were classified as"
);
assert_eq!(
sample_of(&inventory),
[
(InventoryCategory::Shell, "zsh/aliases._darwin.sh".into()),
(InventoryCategory::Link, "vim/_darwin/vimrc".into()),
],
"the prompt showed a path that does not exist under the root"
);
}
#[test]
fn a_directory_entry_counts_once_and_is_not_descended_into() {
let env = TempEnvironment::builder()
.pack("vim")
.file("colors/one.vim", "")
.file("colors/two.vim", "")
.file("after/ftplugin/rust.vim", "")
.done()
.build();
let inventory = inventory_of(&env);
assert_eq!(counts_of(&inventory), [(InventoryCategory::Link, 2)]);
assert_eq!(
sample_of(&inventory),
[
(InventoryCategory::Link, "vim/after".into()),
(InventoryCategory::Link, "vim/colors".into()),
]
);
}
#[test]
fn a_template_is_listed_by_its_source_name_without_being_rendered() {
let env = TempEnvironment::builder()
.pack("git")
.file("gitconfig.tmpl", "[user]\n name = {{ env.USER }}\n")
.done()
.build();
let before = env.list_dir_names(&env.data_dir);
assert_eq!(
sample_of(&inventory_of(&env)),
[(InventoryCategory::Link, "git/gitconfig.tmpl".into())]
);
assert_eq!(
env.list_dir_names(&env.data_dir),
before,
"building the inventory wrote Dodot state"
);
}
#[test]
fn the_implementation_reads_no_candidate_contents() {
let implementation = include_str!("inventory.rs")
.split_once("#[cfg(test)]")
.expect("this file carries a test module")
.0;
let code = implementation
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in [
"preprocess",
"render",
"secret",
"read_to_string",
"read_file",
"to_intents",
"DataStore",
"walk_pack_recursive",
] {
assert!(
!code.contains(forbidden),
"the inventory reaches past routing metadata through `{forbidden}`"
);
}
}
#[test]
fn invalid_configuration_names_the_offending_file() {
for (pack_config, root_config, offender) in [
(Some("not valid toml"), None, "vim/.dodot.toml"),
(None, Some("[pack]\nos = [\"linux\"]\n"), ".dodot.toml"),
] {
let mut builder = TempEnvironment::builder().pack("vim").file("vimrc", "");
if let Some(contents) = pack_config {
builder = builder.config(contents);
}
let env = builder.done().build();
if let Some(contents) = root_config {
std::fs::write(env.dotfiles_root.join(".dodot.toml"), contents).unwrap();
}
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join(offender) && !reason.is_empty()
),
"unexpected error: {error}"
);
}
}
#[test]
fn configuration_the_walk_rejects_names_the_pack_config_file() {
for pack_config in [
"[mappings.gates]\n\"[unclosed\" = \"darwin\"\n",
"[mappings.gates]\n\"aliases.sh\" = \"no-such-label\"\n",
"[gates]\nsomething-else = { os = \"darwin\" }\n",
"[mappings.gates]\n\"*.sh\" = \"darwin\"\n",
] {
let env = TempEnvironment::builder()
.pack("vim")
.config(pack_config)
.file("aliases.sh", "")
.file("profile._no-such-label.sh", "")
.done()
.build();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join("vim/.dodot.toml")
&& !reason.is_empty()
),
"unexpected error for config {pack_config:?}: {error}"
);
}
}
#[test]
fn configuration_inherited_from_the_root_names_the_root_config_file() {
for root_config in [
"[mappings.gates]\n\"[unclosed\" = \"darwin\"\n",
"[mappings.gates]\n\"aliases.sh\" = \"no-such-label\"\n",
"",
"[mappings.gates]\n\"*.sh\" = \"darwin\"\n",
] {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "")
.file("profile._no-such-label.sh", "")
.done()
.build();
std::fs::write(env.dotfiles_root.join(".dodot.toml"), root_config).unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join(DOTFILES_CONFIG_FILE)
&& !reason.is_empty()
),
"unexpected error for root config {root_config:?}: {error}"
);
}
}
#[test]
fn a_broken_root_is_named_even_when_the_pack_also_configures_gates() {
for (root_config, pack_config) in [
(
"[gates]\nbroken = { nonsense = \"x\" }\n",
"[gates]\nlaptop = { os = \"darwin\" }\n",
),
(
"[mappings.gates]\n\"[unclosed\" = \"darwin\"\n",
"[mappings.gates]\n\"vimrc\" = \"darwin\"\n",
),
] {
let env = TempEnvironment::builder()
.pack("vim")
.config(pack_config)
.file("aliases.sh", "")
.file("vimrc", "")
.done()
.build();
std::fs::write(env.dotfiles_root.join(".dodot.toml"), root_config).unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join(DOTFILES_CONFIG_FILE)
&& !reason.is_empty()
),
"unexpected error for root {root_config:?} + pack {pack_config:?}: {error}"
);
}
}
#[test]
fn a_mapping_the_walk_never_consults_does_not_claim_another_failure() {
let dormant = "[mappings.gates]\n\"*.txt\" = \"no-such-label\"\n";
let env = TempEnvironment::builder()
.pack("vim")
.config(dormant)
.file("vimrc", "")
.done()
.build();
assert_eq!(
sample_of(&inventory_of(&env)),
[(InventoryCategory::Link, "vim/vimrc".into())],
"a mapping matching nothing was treated as a defect"
);
let env = TempEnvironment::builder()
.pack("vim")
.config("[gates]\nlaptop = { os = \"darwin\" }\n")
.file("a._foo.sh", "")
.done()
.build();
std::fs::write(env.dotfiles_root.join(DOTFILES_CONFIG_FILE), dormant).unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join("vim/.dodot.toml")
&& reason.contains("foo")
),
"a dormant root mapping claimed a filename-gate failure: {error}"
);
}
#[test]
fn the_layer_that_declared_the_failing_entry_is_the_one_named() {
struct Case {
root: &'static str,
pack: &'static str,
owner: &'static str,
}
for case in [
Case {
root: "[mappings.gates]\n\"aliases.sh\" = \"laptop\"\n",
pack: "[gates]\nlaptop = { os = \"darwin\" }\n\
[mappings.gates]\n\"[unclosed\" = \"darwin\"\n",
owner: "vim/.dodot.toml",
},
Case {
root: "[mappings.gates]\n\"[unclosed\" = \"darwin\"\n",
pack: "[gates]\nbroken = { nonsense = \"x\" }\n",
owner: "vim/.dodot.toml",
},
Case {
root: "[gates]\nbroken = { nonsense = \"x\" }\n",
pack: "[mappings.gates]\n\"aliases.sh\" = \"darwin\"\n",
owner: ".dodot.toml",
},
] {
let env = TempEnvironment::builder()
.pack("vim")
.config(case.pack)
.file("aliases.sh", "")
.file("vimrc", "")
.done()
.build();
std::fs::write(env.dotfiles_root.join(".dodot.toml"), case.root).unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, .. }
if config_file == &canonical_root(&env).join(case.owner)
),
"root {:?} + pack {:?} should have named {}: {error}",
case.root,
case.pack,
case.owner
);
}
}
#[test]
fn the_reported_defect_and_the_named_file_are_the_same_entry() {
for (root, pack, owner, quoted) in [
(
"[gates]\naaa = { nonsense = \"x\" }\n",
"[gates]\nzzz = { nonsense = \"x\" }\n",
".dodot.toml",
"aaa",
),
(
"[gates]\nzzz = { nonsense = \"x\" }\n",
"[gates]\naaa = { nonsense = \"x\" }\n",
"vim/.dodot.toml",
"aaa",
),
(
"[mappings.gates]\n\"[a\" = \"darwin\"\n",
"[mappings.gates]\n\"[z\" = \"darwin\"\n",
".dodot.toml",
"[a",
),
(
"[mappings.gates]\n\"[z\" = \"darwin\"\n",
"[mappings.gates]\n\"[a\" = \"darwin\"\n",
"vim/.dodot.toml",
"[a",
),
] {
let env = TempEnvironment::builder()
.pack("vim")
.config(pack)
.file("vimrc", "")
.done()
.build();
std::fs::write(env.dotfiles_root.join(DOTFILES_CONFIG_FILE), root).unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, reason }
if config_file == &canonical_root(&env).join(owner)
&& reason.contains(quoted)
),
"root {root:?} + pack {pack:?} should report {quoted:?} against {owner}: {error}"
);
}
}
#[test]
fn a_pack_that_breaks_an_inherited_label_is_named_for_it() {
let env = TempEnvironment::builder()
.pack("vim")
.config("[gates]\nlaptop = { nonsense = \"x\" }\n")
.file("vimrc", "")
.done()
.build();
std::fs::write(
env.dotfiles_root.join(".dodot.toml"),
"[gates]\nlaptop = { os = \"darwin\" }\n",
)
.unwrap();
let error =
build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("accepted");
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, .. }
if config_file == &canonical_root(&env).join("vim/.dodot.toml")
),
"unexpected error: {error}"
);
}
#[test]
fn a_root_layer_a_pack_completes_is_not_an_error() {
let env = TempEnvironment::builder()
.pack("vim")
.config("[gates]\nlaptop = { os = \"darwin\" }\n")
.file("aliases.sh", "")
.done()
.build();
std::fs::write(
env.dotfiles_root.join(".dodot.toml"),
"[mappings.gates]\n\"aliases.sh\" = \"laptop\"\n",
)
.unwrap();
let inventory = build_inventory(&resolved(&env), env.fs.as_ref(), &host())
.expect("a root mapping a pack-defined label was refused");
assert_eq!(
sample_of(&inventory),
[(InventoryCategory::Shell, "vim/aliases.sh".into())]
);
}
#[test]
fn a_root_that_cannot_be_routed_fails_rather_than_reporting_nothing() {
let env = TempEnvironment::builder().build();
for name in ["010-vim", "020-vim"] {
std::fs::create_dir(env.dotfiles_root.join(name)).unwrap();
}
let error = build_inventory(&resolved(&env), env.fs.as_ref(), &host()).expect_err("routed");
assert!(
matches!(
&error,
SafetyLockError::PackRoutingUnusable { directory, .. }
if directory == &canonical_root(&env)
),
"unexpected error: {error}"
);
}
#[test]
fn an_empty_root_inventories_to_nothing() {
let env = TempEnvironment::builder().build();
let inventory = build_inventory(&resolved(&env), &OsFs::new(), &host()).unwrap();
assert!(inventory.counts.is_empty());
assert!(inventory.sample.is_empty());
assert_eq!(inventory.omitted, 0);
assert_eq!(inventory.total_files(), 0);
}
#[test]
fn categories_follow_handler_phases() {
use ExecutionPhase::*;
assert_eq!(
InventoryCategory::for_phase(ShellInit),
Some(InventoryCategory::Shell)
);
assert_eq!(
InventoryCategory::for_phase(Setup),
Some(InventoryCategory::CodeExecution)
);
assert_eq!(
InventoryCategory::for_phase(Provision),
Some(InventoryCategory::CodeExecution)
);
assert_eq!(
InventoryCategory::for_phase(PathExport),
Some(InventoryCategory::Path)
);
assert_eq!(
InventoryCategory::for_phase(External),
Some(InventoryCategory::External)
);
assert_eq!(
InventoryCategory::for_phase(Link),
Some(InventoryCategory::Link)
);
assert_eq!(
InventoryCategory::for_phase(Filter),
None,
"a handler that deploys nothing was counted"
);
}
#[test]
fn an_unknown_handler_counts_as_other() {
let fs = OsFs::new();
let runner = crate::datastore::NoopCommandRunner;
let registry = create_registry(&fs, &runner);
assert_eq!(
category_of("a-handler-from-the-future", ®istry),
Some(InventoryCategory::Other)
);
assert_eq!(
category_of(crate::handlers::HANDLER_SHELL, ®istry),
Some(InventoryCategory::Shell)
);
assert_eq!(
category_of(crate::handlers::HANDLER_IGNORE, ®istry),
None
);
}
#[test]
fn category_ordering_is_the_prompt_priority() {
let mut shuffled = vec![
InventoryCategory::Other,
InventoryCategory::Link,
InventoryCategory::CodeExecution,
InventoryCategory::External,
InventoryCategory::Shell,
InventoryCategory::Path,
];
shuffled.sort();
assert_eq!(shuffled, InventoryCategory::PROMPT_ORDER);
}
#[test]
fn entries_sort_by_category_then_relative_path() {
let mut entries = [
InventoryEntry {
category: InventoryCategory::Link,
relative_path: PathBuf::from("vim/vimrc"),
},
InventoryEntry {
category: InventoryCategory::Shell,
relative_path: PathBuf::from("zsh/zshrc"),
},
InventoryEntry {
category: InventoryCategory::Shell,
relative_path: PathBuf::from("bash/bashrc"),
},
];
entries.sort();
assert_eq!(
entries
.iter()
.map(|entry| entry.relative_path.to_str().unwrap())
.collect::<Vec<_>>(),
["bash/bashrc", "zsh/zshrc", "vim/vimrc"]
);
}
#[test]
fn counts_stay_whole_when_paths_are_capped() {
let inventory = RootInventory {
counts: vec![
CategoryCount {
category: InventoryCategory::Shell,
files: 12,
},
CategoryCount {
category: InventoryCategory::Link,
files: 30,
},
],
sample: Vec::new(),
omitted: 42,
};
assert_eq!(inventory.total_files(), 42);
assert!(MAX_INVENTORY_PATHS < inventory.total_files());
}
#[test]
fn category_labels_are_stable() {
assert_eq!(
InventoryCategory::PROMPT_ORDER
.iter()
.map(|category| category.label())
.collect::<Vec<_>>(),
[
"shell",
"code execution",
"path",
"external",
"link",
"other"
]
);
}
}