use tracing::{debug, info};
use crate::gates::{GateTable, HostFacts};
use crate::handlers;
use crate::packs::context::ExecutionContext;
use crate::packs::Pack;
use crate::rules::{self, Scanner};
use crate::Result;
pub fn collect_pack_intents(
pack: &Pack,
ctx: &ExecutionContext,
) -> Result<Vec<crate::operations::HandlerIntent>> {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
let root_config = ctx.config_manager.root_config()?;
let (registry, _secret_registry) = crate::preprocessing::default_registry(
&pack_config.preprocessor,
&root_config.secret,
ctx.paths.as_ref(),
ctx.command_runner.clone(),
)?;
collect_pack_intents_inner(pack, ctx, &pack_config, Some(®istry))
}
pub fn collect_pack_intents_with_preprocessors(
pack: &Pack,
ctx: &ExecutionContext,
preprocessors: Option<&crate::preprocessing::PreprocessorRegistry>,
) -> Result<Vec<crate::operations::HandlerIntent>> {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
collect_pack_intents_inner(pack, ctx, &pack_config, preprocessors)
}
#[derive(Debug, Default, Clone)]
pub struct PackPlan {
pub intents: Vec<crate::operations::HandlerIntent>,
pub warnings: Vec<String>,
}
pub fn plan_pack(
pack: &Pack,
ctx: &ExecutionContext,
mode: crate::preprocessing::PreprocessMode,
) -> Result<PackPlan> {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
let root_config = ctx.config_manager.root_config()?;
let (registry, _secret_registry) = crate::preprocessing::default_registry(
&pack_config.preprocessor,
&root_config.secret,
ctx.paths.as_ref(),
ctx.command_runner.clone(),
)?;
plan_pack_inner(pack, ctx, &pack_config, Some(®istry), mode)
}
fn build_gate_table(pack_config: &crate::config::DodotConfig) -> Result<GateTable> {
let mut table = GateTable::with_builtins();
if !pack_config.gates.is_empty() {
table.merge_user(&pack_config.gates)?;
}
Ok(table)
}
pub(crate) fn filter_pre_preprocess_gates(
entries: Vec<crate::rules::PackEntry>,
gates: &GateTable,
host: &HostFacts,
pack_name: &str,
mappings_gates: &std::collections::HashMap<String, String>,
) -> Result<Vec<crate::rules::PackEntry>> {
use crate::gates::{parse_basename_gate, BasenameGate};
use crate::rules::GateFailure;
let compiled_mapping_gates = crate::gates::compile_mapping_gates(mappings_gates, pack_name)?;
let make_failure = |label: &str, pred: &crate::gates::GatePredicate| -> GateFailure {
let host_desc: Vec<String> = pred
.matchers
.iter()
.map(|(dim, _)| {
let actual = host.get(*dim).unwrap_or("<unset>");
format!("{}={}", dim.as_str(), actual)
})
.collect();
GateFailure {
label: label.to_string(),
predicate: pred.describe(),
host: host_desc.join(", "),
}
};
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
if entry.gate_failure.is_some() {
out.push(entry);
continue;
}
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let basename_gate = parse_basename_gate(&filename);
let rel_str = crate::gates::rel_path_for_glob(&entry.relative_path);
let mapping_match: Option<&str> = compiled_mapping_gates
.iter()
.find(|(pat, _)| pat.matches(&rel_str))
.map(|(_, label)| *label);
if let (BasenameGate::Found { .. }, Some(map_label)) = (&basename_gate, mapping_match) {
return Err(crate::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()
)));
}
match basename_gate {
BasenameGate::Found { label, stripped } => {
let pred = gates.lookup(label).ok_or_else(|| {
crate::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);
out.push(crate::rules::PackEntry {
relative_path: stripped_rel,
absolute_path: entry.absolute_path,
is_dir: entry.is_dir,
gate_failure: None,
});
} else {
out.push(crate::rules::PackEntry {
relative_path: entry.relative_path,
absolute_path: entry.absolute_path,
is_dir: entry.is_dir,
gate_failure: Some(make_failure(label, pred)),
});
}
}
BasenameGate::None => {
if let Some(map_label) = mapping_match {
let pred = gates.lookup(map_label).ok_or_else(|| {
crate::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) {
out.push(entry);
} else {
out.push(crate::rules::PackEntry {
relative_path: entry.relative_path,
absolute_path: entry.absolute_path,
is_dir: entry.is_dir,
gate_failure: Some(make_failure(map_label, pred)),
});
}
} else {
out.push(entry);
}
}
}
}
Ok(out)
}
fn collect_pack_intents_inner(
pack: &Pack,
ctx: &ExecutionContext,
pack_config: &crate::config::DodotConfig,
preprocessors: Option<&crate::preprocessing::PreprocessorRegistry>,
) -> Result<Vec<crate::operations::HandlerIntent>> {
plan_pack_inner(
pack,
ctx,
pack_config,
preprocessors,
crate::preprocessing::PreprocessMode::Active,
)
.map(|p| p.intents)
}
fn plan_pack_inner(
pack: &Pack,
ctx: &ExecutionContext,
pack_config: &crate::config::DodotConfig,
preprocessors: Option<&crate::preprocessing::PreprocessorRegistry>,
mode: crate::preprocessing::PreprocessMode,
) -> Result<PackPlan> {
let rules = crate::config::mappings_to_rules(&pack_config.mappings);
let gates = build_gate_table(pack_config)?;
let host = ctx.host_facts.as_ref();
if !crate::gates::pack_os_active(&pack_config.pack.os, host) {
debug!(
pack = %pack.name,
allowed = ?pack_config.pack.os,
current_os = %host.os,
"pack inactive on this OS, returning empty plan"
);
return Ok(PackPlan {
intents: Vec::new(),
warnings: Vec::new(),
});
}
let scanner = Scanner::new(ctx.fs.as_ref());
let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore, &gates, host)?;
debug!(pack = %pack.name, entries = entries.len(), "walked pack directory");
let entries = filter_pre_preprocess_gates(
entries,
&gates,
host,
&pack.name,
&pack_config.mappings.gates,
)?;
let preprocess_result = if let Some(registry) = preprocessors {
if !registry.is_empty() && pack_config.preprocessor.enabled {
crate::preprocessing::pipeline::preprocess_pack(
entries,
registry,
pack,
ctx.fs.as_ref(),
ctx.datastore.as_ref(),
ctx.paths.as_ref(),
mode,
ctx.force,
)?
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
}
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
};
let all_entries = preprocess_result.merged_entries();
let mut matches = scanner.match_entries(
&all_entries,
&rules,
&pack.name,
&gates,
host,
&pack_config.mappings.gates,
)?;
debug!(pack = %pack.name, files = matches.len(), "matched rules");
for m in &mut matches {
if let Some(source) = preprocess_result.source_map.get(&m.absolute_path) {
m.preprocessor_source = Some(source.clone());
}
if let Some(bytes) = preprocess_result.rendered_bytes.get(&m.absolute_path) {
m.rendered_bytes = Some(bytes.clone());
}
}
let groups = rules::group_by_handler(&matches);
let registry = handlers::create_registry(ctx.fs.as_ref());
let order = rules::handler_execution_order(&groups, ®istry);
debug!(pack = %pack.name, handlers = ?order, "handler execution order");
let mut all_intents = Vec::new();
let mut all_warnings = Vec::new();
for skipped in &preprocess_result.skipped {
let display_path = display_path_relative_to_home(&skipped.deployed_path, ctx);
let detail = match skipped.state {
crate::preprocessing::divergence::DivergenceState::OutputChanged => {
"deployed file was edited since the last `dodot up`"
}
crate::preprocessing::divergence::DivergenceState::BothChanged => {
"both the source template and the deployed file were edited since the last `dodot up`"
}
_ => "deployed file diverges from the cached baseline",
};
let warning = format!(
"preserved {} ({}). Run `dodot transform check` to reconcile, or re-run with --force to overwrite.",
display_path, detail,
);
tracing::warn!(pack = %pack.name, file = %skipped.virtual_relative.display(), "{warning}");
all_warnings.push(warning);
}
for handler_name in &order {
let handler = match registry.get(handler_name.as_str()) {
Some(h) => h,
None => {
debug!(pack = %pack.name, handler = %handler_name, "skipping unknown handler");
continue;
}
};
if ctx.no_provision && handler.category() == handlers::HandlerCategory::CodeExecution {
debug!(pack = %pack.name, handler = %handler_name, "skipping code-execution handler (--no-provision)");
continue;
}
if let Some(handler_matches) = groups.get(handler_name) {
let intents = handler.to_intents(
handler_matches,
&pack.config,
ctx.paths.as_ref(),
ctx.fs.as_ref(),
)?;
debug!(
pack = %pack.name,
handler = %handler_name,
intents = intents.len(),
"generated intents"
);
all_intents.extend(intents);
let warnings =
handler.warnings_for_matches(handler_matches, &pack.config, ctx.paths.as_ref());
for w in &warnings {
tracing::warn!(pack = %pack.name, handler = %handler_name, "{w}");
}
all_warnings.extend(warnings);
}
}
if cfg!(target_os = "macos") {
all_warnings.extend(missing_target_hints(&all_intents, ctx));
}
info!(
pack = %pack.name,
intents = all_intents.len(),
warnings = all_warnings.len(),
"collected intents"
);
Ok(PackPlan {
intents: all_intents,
warnings: all_warnings,
})
}
fn display_path_relative_to_home(path: &std::path::Path, ctx: &ExecutionContext) -> String {
let home = ctx.paths.home_dir();
match path.strip_prefix(home) {
Ok(rel) => format!("~/{}", rel.display()),
Err(_) => path.display().to_string(),
}
}
fn missing_target_hints(
intents: &[crate::operations::HandlerIntent],
ctx: &ExecutionContext,
) -> Vec<String> {
use std::collections::BTreeSet;
let app_support = ctx.paths.app_support_dir();
if app_support == ctx.paths.xdg_config_home() {
return Vec::new();
}
let mut needed: BTreeSet<String> = BTreeSet::new();
for intent in intents {
if let crate::operations::HandlerIntent::Link { user_path, .. } = intent {
if let Ok(rel) = user_path.strip_prefix(app_support) {
if let Some(first) = rel.components().find_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
}) {
needed.insert(first);
}
}
}
}
if needed.is_empty() {
return Vec::new();
}
let mut missing: Vec<String> = Vec::new();
for folder in &needed {
let target = app_support.join(folder);
if !ctx.fs.exists(&target) {
missing.push(folder.clone());
}
}
if missing.is_empty() {
return Vec::new();
}
let cache_dir = ctx.paths.probes_brew_cache_dir();
let now = crate::probe::brew::now_secs_unix();
let matches = crate::probe::brew::match_folders_to_installed_casks(
&missing,
ctx.command_runner.as_ref(),
&cache_dir,
now,
ctx.fs.as_ref(),
true,
);
missing
.into_iter()
.map(|folder| match matches.folder_to_token.get(&folder) {
Some(token) => format!(
"cask `{token}` is installed but `{folder}/` is missing — \
entries will deploy, but the app may not have created its \
config directory yet (try launching it once)"
),
None => format!(
"target directory `{}/{folder}` doesn't exist yet — entries will \
deploy but no matching installed app appears to provide it",
app_support.display()
),
})
.collect()
}
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use std::sync::Arc;
use super::super::test_support::{make_context, MockCommandRunner, TestUpCommand};
use super::super::{
collect_pack_intents, execute, execute_intents, prepare_packs, run_handler_pipeline,
};
use super::{collect_pack_intents_with_preprocessors, plan_pack};
use crate::config::ConfigManager;
use crate::datastore::CommandRunner;
use crate::datastore::FilesystemDataStore;
use crate::fs::Fs;
use crate::packs::Pack;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
#[test]
fn preprocessing_identity_file_deploys_via_symlink_handler() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "host = localhost")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
let intents =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap();
assert_eq!(intents.len(), 1, "intents: {intents:?}");
match &intents[0] {
crate::operations::HandlerIntent::Link {
pack: p,
handler,
source,
user_path,
} => {
assert_eq!(p, "app");
assert_eq!(handler, "symlink");
assert!(
source.to_string_lossy().contains("preprocessed"),
"source should be in preprocessed dir: {}",
source.display()
);
let user_str = user_path.to_string_lossy();
assert!(
!user_str.contains("identity"),
"user_path should not have .identity: {user_str}"
);
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn preprocessing_mixed_pack_deploys_both() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "preprocessed content")
.file("plain.txt", "regular content")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
let intents =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap();
assert_eq!(intents.len(), 2, "intents: {intents:?}");
let intent_sources: Vec<String> = intents
.iter()
.filter_map(|i| match i {
crate::operations::HandlerIntent::Link { source, .. } => {
Some(source.to_string_lossy().to_string())
}
_ => None,
})
.collect();
let has_preprocessed = intent_sources.iter().any(|s| s.contains("preprocessed"));
let has_regular = intent_sources
.iter()
.any(|s| s.contains("dotfiles/app/plain.txt"));
assert!(
has_preprocessed,
"should have a preprocessed source: {intent_sources:?}"
);
assert!(
has_regular,
"should have a regular source: {intent_sources:?}"
);
}
#[test]
fn preprocessing_collision_detected() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "preprocessed")
.file("config.toml", "regular")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
let err =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap_err();
assert!(
matches!(err, crate::DodotError::PreprocessorCollision { .. }),
"expected PreprocessorCollision, got: {err}"
);
}
#[test]
fn preprocessing_disabled_via_config_treats_files_as_regular() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "content")
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[preprocessor]\nenabled = false\n",
)
.unwrap();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
let intents =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
crate::operations::HandlerIntent::Link { user_path, .. } => {
let user_str = user_path.to_string_lossy();
assert!(
user_str.contains("identity"),
"with preprocessing disabled, file should keep .identity extension: {user_str}"
);
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn preprocessing_no_registry_works_like_before() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"vim".into(),
env.dotfiles_root.join("vim"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("vim"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents_with_preprocessors(&pack, &ctx, None).unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
crate::operations::HandlerIntent::Link { source, .. } => {
assert!(
source.to_string_lossy().contains("vim/vimrc"),
"source should be the pack file: {}",
source.display()
);
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn preprocessing_end_to_end_deploy_and_verify_content() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "host = localhost\nport = 5432")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
let intents =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap();
let user_path = match &intents[0] {
crate::operations::HandlerIntent::Link { user_path, .. } => user_path.clone(),
other => panic!("expected Link intent, got: {other:?}"),
};
let results = execute_intents(intents, &ctx).unwrap();
assert!(
results.iter().all(|r| r.success),
"all operations should succeed: {results:?}"
);
assert!(
ctx.fs.exists(&user_path),
"user file should exist at: {}",
user_path.display()
);
assert!(
ctx.fs.is_symlink(&user_path),
"user file should be a symlink"
);
let content = ctx.fs.read_to_string(&user_path).unwrap();
assert_eq!(content, "host = localhost\nport = 5432");
}
#[test]
fn preprocessing_error_propagates_through_pipeline() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bad.tar.gz", "this is not valid gzip data at all")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"tools".into(),
env.dotfiles_root.join("tools"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("tools"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::unarchive::UnarchivePreprocessor::new(),
));
let err =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap_err();
assert!(
matches!(err, crate::DodotError::PreprocessorError { .. }),
"expected PreprocessorError, got: {err}"
);
}
#[test]
fn preprocessing_multiple_types_in_registry() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "identity content")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let mut registry = crate::preprocessing::PreprocessorRegistry::new();
registry.register(Box::new(
crate::preprocessing::identity::IdentityPreprocessor::new(),
));
registry.register(Box::new(
crate::preprocessing::unarchive::UnarchivePreprocessor::new(),
));
let intents =
collect_pack_intents_with_preprocessors(&pack, &ctx, Some(®istry)).unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
crate::operations::HandlerIntent::Link { source, .. } => {
assert!(source.to_string_lossy().contains("preprocessed"));
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn collect_pack_intents_uses_default_registry() {
use flate2::write::GzEncoder;
use flate2::Compression;
let env = TempEnvironment::builder()
.pack("tools")
.file("placeholder", "")
.done()
.build();
let archive_path = env.dotfiles_root.join("tools/payload.tar.gz");
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut builder = tar::Builder::new(enc);
let content = b"#!/bin/sh\necho hi";
let mut header = tar::Header::new_gnu();
header.set_path("mytool").unwrap();
header.set_size(content.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append(&header, &content[..]).unwrap();
let enc = builder.into_inner().unwrap();
enc.finish().unwrap();
let ctx = make_context(&env);
let pack = Pack::new(
"tools".into(),
env.dotfiles_root.join("tools"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("tools"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
let has_expanded_source = intents.iter().any(|i| match i {
crate::operations::HandlerIntent::Link { source, .. } => {
source.to_string_lossy().contains("preprocessed")
&& source.to_string_lossy().contains("mytool")
}
_ => false,
});
assert!(
has_expanded_source,
"production collect_pack_intents should expand .tar.gz via the default registry. Intents: {intents:?}"
);
}
#[test]
fn template_deploys_rendered_content_via_symlink_handler() {
let env = TempEnvironment::builder()
.pack("app")
.file(
"config.toml.tmpl",
"name = \"{{ name }}\"\nos = \"{{ dodot.os }}\"",
)
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
let user_path = match &intents[0] {
crate::operations::HandlerIntent::Link { user_path, .. } => user_path.clone(),
other => panic!("expected Link intent, got: {other:?}"),
};
let results = execute_intents(intents, &ctx).unwrap();
assert!(
results.iter().all(|r| r.success),
"expected success: {results:?}"
);
let content = ctx.fs.read_to_string(&user_path).unwrap();
let expected_os = std::env::consts::OS;
assert_eq!(content, format!("name = \"Alice\"\nos = \"{expected_os}\""));
}
#[test]
fn template_with_shell_handler_sources_rendered_content() {
let env = TempEnvironment::builder()
.pack("tools")
.file("aliases.sh.tmpl", "alias hello='echo {{ greeting }}'")
.config("[preprocessor.template.vars]\ngreeting = \"world\"\n")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"tools".into(),
env.dotfiles_root.join("tools"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("tools"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
crate::operations::HandlerIntent::Stage {
handler, source, ..
} => {
assert_eq!(handler, "shell", "shell handler should own this");
let content = ctx.fs.read_to_string(source).unwrap();
assert_eq!(content, "alias hello='echo world'");
}
other => panic!("expected Stage intent, got: {other:?}"),
}
}
#[test]
fn template_respects_per_pack_var_overrides() {
let env = TempEnvironment::builder()
.pack("app")
.file("greeting.tmpl", "hello {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Bob\"\n")
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[preprocessor.template.vars]\nname = \"Alice\"\n",
)
.unwrap();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
match &intents[0] {
crate::operations::HandlerIntent::Link { source, .. } => {
let content = ctx.fs.read_to_string(source).unwrap();
assert_eq!(content, "hello Bob", "pack-level override should win");
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn template_disabled_via_config_treats_files_as_regular() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = \"{{ name }}\"")
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[preprocessor]\nenabled = false\n",
)
.unwrap();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
crate::operations::HandlerIntent::Link {
source, user_path, ..
} => {
assert!(
source.to_string_lossy().ends_with("config.toml.tmpl"),
"source: {}",
source.display()
);
assert!(
user_path.to_string_lossy().contains(".tmpl"),
"user_path should keep .tmpl extension: {}",
user_path.display()
);
}
other => panic!("expected Link intent, got: {other:?}"),
}
}
#[test]
fn template_render_error_surfaces_with_source_path() {
let env = TempEnvironment::builder()
.pack("app")
.file("bad.tmpl", "value = \"{{ undefined_var }}\"")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let err = collect_pack_intents(&pack, &ctx).unwrap_err();
match err {
crate::DodotError::TemplateRender { source_file, .. } => {
assert!(
source_file.ends_with("bad.tmpl"),
"source_file: {}",
source_file.display()
);
}
other => panic!("expected TemplateRender, got: {other:?}"),
}
}
#[test]
fn template_reserved_var_fails_fast() {
let env = TempEnvironment::builder()
.pack("app")
.file("file.txt", "x")
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[preprocessor.template.vars]\ndodot = \"pwn\"\n",
)
.unwrap();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let err = collect_pack_intents(&pack, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::TemplateReservedVar { ref name } if name == "dodot"),
"got: {err}"
);
}
#[test]
fn template_with_install_handler_sentinel_reflects_rendered_content() {
let env = TempEnvironment::builder()
.pack("setup")
.file(
"install.sh.tmpl",
"#!/bin/sh\necho \"installing on {{ dodot.os }}\"",
)
.done()
.build();
let mut ctx = make_context(&env);
ctx.no_provision = false;
let pack = Pack::new(
"setup".into(),
env.dotfiles_root.join("setup"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("setup"))
.unwrap()
.to_handler_config(),
);
let intents = collect_pack_intents(&pack, &ctx).unwrap();
let (sentinel, rendered_path) = match &intents[0] {
crate::operations::HandlerIntent::Run {
sentinel,
arguments,
..
} => (
sentinel.clone(),
std::path::PathBuf::from(arguments.last().unwrap()),
),
other => panic!("expected Run intent, got: {other:?}"),
};
assert!(sentinel.starts_with("install.sh-"));
let content = ctx.fs.read_to_string(&rendered_path).unwrap();
assert!(
content.contains(std::env::consts::OS),
"rendered content should have OS substituted: {content}"
);
}
#[test]
fn plan_pack_surfaces_divergence_warnings() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let first = plan_pack(&pack, &ctx, crate::preprocessing::PreprocessMode::Active).unwrap();
assert!(
first.warnings.iter().all(|w| !w.contains("preserved")),
"first deploy must not produce a preservation warning: {:?}",
first.warnings
);
let deployed = env
.paths
.handler_data_dir("app", "preprocessed")
.join("config.toml");
env.fs.write_file(&deployed, b"name = USER EDITED").unwrap();
let second = plan_pack(&pack, &ctx, crate::preprocessing::PreprocessMode::Active).unwrap();
let preserved: Vec<&String> = second
.warnings
.iter()
.filter(|w| w.contains("preserved"))
.collect();
assert_eq!(
preserved.len(),
1,
"expected one preservation warning, got: {:?}",
second.warnings
);
let w = preserved[0];
assert!(
w.contains("config.toml"),
"warning should name the file: {w}"
);
assert!(
w.contains("transform check"),
"warning should mention transform check: {w}"
);
assert!(w.contains("--force"), "warning should mention --force: {w}");
assert_eq!(
env.fs.read_to_string(&deployed).unwrap(),
"name = USER EDITED"
);
}
#[test]
fn plan_pack_force_overwrites_and_skips_warning() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let mut ctx = make_context(&env);
let pack = Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
);
let _ = plan_pack(&pack, &ctx, crate::preprocessing::PreprocessMode::Active).unwrap();
let deployed = env
.paths
.handler_data_dir("app", "preprocessed")
.join("config.toml");
env.fs.write_file(&deployed, b"name = USER EDITED").unwrap();
ctx.force = true;
let plan = plan_pack(&pack, &ctx, crate::preprocessing::PreprocessMode::Active).unwrap();
assert!(
plan.warnings.iter().all(|w| !w.contains("preserved")),
"force=true must not emit preservation warnings: {:?}",
plan.warnings
);
assert_eq!(
env.fs.read_to_string(&deployed).unwrap(),
"name = original",
"force must overwrite the user's edit with the rendered content"
);
}
}