use std::sync::Arc;
use serde::Serialize;
use tracing::{debug, info};
use crate::config::ConfigManager;
use crate::datastore::DataStore;
use crate::execution::Executor;
use crate::fs::Fs;
use crate::handlers;
use crate::operations::OperationResult;
use crate::packs::{self, Pack};
use crate::paths::Pather;
use crate::rules::{self, Scanner};
use crate::Result;
pub struct ExecutionContext {
pub fs: Arc<dyn Fs>,
pub datastore: Arc<dyn DataStore>,
pub paths: Arc<dyn Pather>,
pub config_manager: Arc<ConfigManager>,
pub dry_run: bool,
pub no_provision: bool,
pub provision_rerun: bool,
pub force: bool,
}
impl ExecutionContext {
pub fn production(dotfiles_root: &std::path::Path) -> crate::Result<Self> {
let paths = Arc::new(
crate::paths::XdgPather::builder()
.dotfiles_root(dotfiles_root)
.build()?,
);
let fs: Arc<dyn Fs> = Arc::new(crate::fs::OsFs::new());
let runner: Arc<dyn crate::datastore::CommandRunner> =
Arc::new(crate::datastore::ShellCommandRunner);
let datastore: Arc<dyn DataStore> = Arc::new(crate::datastore::FilesystemDataStore::new(
fs.clone(),
paths.clone(),
runner,
));
let config_manager = Arc::new(ConfigManager::new(dotfiles_root)?);
Ok(Self {
fs,
datastore,
paths,
config_manager,
dry_run: false,
no_provision: false,
provision_rerun: false,
force: false,
})
}
}
#[derive(Debug, Serialize)]
pub struct PackResult {
pub pack_name: String,
pub success: bool,
pub operations: Vec<OperationResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ExecuteResult {
pub pack_results: Vec<PackResult>,
pub total_packs: usize,
pub successful_packs: usize,
pub failed_packs: usize,
}
impl ExecuteResult {
pub fn is_success(&self) -> bool {
self.failed_packs == 0
}
}
pub trait Command: Send + Sync {
fn name(&self) -> &str;
fn execute_for_pack(&self, pack: &Pack, ctx: &ExecutionContext) -> Result<PackResult>;
}
pub fn execute(
command: &dyn Command,
pack_filter: Option<&[String]>,
ctx: &ExecutionContext,
) -> Result<ExecuteResult> {
info!(command = command.name(), "starting command");
let root_config = ctx.config_manager.root_config()?;
debug!(
ignore_patterns = ?root_config.pack.ignore,
"loaded root config"
);
let mut all_packs = packs::discover_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
info!(
count = all_packs.len(),
root = %ctx.paths.dotfiles_root().display(),
"discovered packs"
);
if let Some(names) = pack_filter {
let _warnings = validate_pack_names(names, ctx)?;
debug!(filter = ?names, "applying pack filter");
all_packs.retain(|p| names.iter().any(|n| n == &p.name));
info!(count = all_packs.len(), "packs after filter");
}
let total_packs = all_packs.len();
let mut pack_results = Vec::with_capacity(total_packs);
let mut successful = 0;
let mut failed = 0;
for mut pack in all_packs {
info!(pack = %pack.name, "processing pack");
match ctx.config_manager.config_for_pack(&pack.path) {
Ok(pack_config) => {
debug!(pack = %pack.name, "loaded pack config");
pack.config = pack_config.to_handler_config();
}
Err(e) => {
info!(pack = %pack.name, error = %e, "pack config error, skipping");
failed += 1;
pack_results.push(PackResult {
pack_name: pack.name.clone(),
success: false,
operations: Vec::new(),
error: Some(format!("config error: {e}")),
});
continue;
}
}
match command.execute_for_pack(&pack, ctx) {
Ok(result) => {
if result.success {
info!(pack = %pack.name, ops = result.operations.len(), "pack succeeded");
successful += 1;
} else {
info!(pack = %pack.name, ops = result.operations.len(), "pack completed with errors");
failed += 1;
}
pack_results.push(result);
}
Err(e) => {
info!(pack = %pack.name, error = %e, "pack failed");
failed += 1;
pack_results.push(PackResult {
pack_name: pack.name.clone(),
success: false,
operations: Vec::new(),
error: Some(e.to_string()),
});
}
}
}
info!(
total = total_packs,
successful = successful,
failed = failed,
"command complete"
);
Ok(ExecuteResult {
pack_results,
total_packs,
successful_packs: successful,
failed_packs: failed,
})
}
pub fn prepare_packs(pack_filter: Option<&[String]>, ctx: &ExecutionContext) -> Result<Vec<Pack>> {
let root_config = ctx.config_manager.root_config()?;
let mut all_packs = packs::discover_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
info!(count = all_packs.len(), "discovered packs");
if let Some(names) = pack_filter {
let _warnings = validate_pack_names(names, ctx)?;
debug!(filter = ?names, "applying pack filter");
all_packs.retain(|p| names.iter().any(|n| n == &p.name));
info!(count = all_packs.len(), "packs after filter");
}
let mut configured = Vec::with_capacity(all_packs.len());
for mut pack in all_packs {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
debug!(pack = %pack.name, "loaded pack config");
pack.config = pack_config.to_handler_config();
configured.push(pack);
}
Ok(configured)
}
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 registry = crate::preprocessing::default_registry(
&pack_config.preprocessor.template,
ctx.paths.as_ref(),
)?;
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)
}
fn collect_pack_intents_inner(
pack: &Pack,
ctx: &ExecutionContext,
pack_config: &crate::config::DodotConfig,
preprocessors: Option<&crate::preprocessing::PreprocessorRegistry>,
) -> Result<Vec<crate::operations::HandlerIntent>> {
let rules = crate::config::mappings_to_rules(&pack_config.mappings);
let scanner = Scanner::new(ctx.fs.as_ref());
let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore)?;
debug!(pack = %pack.name, entries = entries.len(), "walked pack directory");
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(),
)?
} 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);
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());
}
}
let groups = rules::group_by_handler(&matches);
let order = rules::handler_execution_order(&groups);
debug!(pack = %pack.name, handlers = ?order, "handler execution order");
let registry = handlers::create_registry(ctx.fs.as_ref());
let mut all_intents = Vec::new();
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);
}
}
info!(pack = %pack.name, intents = all_intents.len(), "collected intents");
Ok(all_intents)
}
pub fn execute_intents(
intents: Vec<crate::operations::HandlerIntent>,
ctx: &ExecutionContext,
) -> Result<Vec<OperationResult>> {
let count = intents.len();
info!(
intents = count,
dry_run = ctx.dry_run,
force = ctx.force,
"executing intents"
);
let auto_chmod = ctx.config_manager.root_config()?.path.auto_chmod_exec;
let executor = Executor::new(
ctx.datastore.as_ref(),
ctx.fs.as_ref(),
ctx.paths.as_ref(),
ctx.dry_run,
ctx.force,
ctx.provision_rerun,
auto_chmod,
);
executor.execute(intents)
}
pub fn run_handler_pipeline(pack: &Pack, ctx: &ExecutionContext) -> Result<Vec<OperationResult>> {
let intents = collect_pack_intents(pack, ctx)?;
execute_intents(intents, ctx)
}
pub fn validate_pack_names(names: &[String], ctx: &ExecutionContext) -> crate::Result<Vec<String>> {
let mut warnings = Vec::new();
for name in names {
let pack_dir = ctx.paths.pack_path(name);
if !ctx.fs.exists(&pack_dir) {
return Err(crate::DodotError::PackNotFound { name: name.clone() });
}
if ctx.fs.exists(&pack_dir.join(".dodotignore")) {
warnings.push(format!("warning: pack '{}' is ignored, skipping", name));
}
}
Ok(warnings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::testing::TempEnvironment;
use std::sync::Mutex;
struct MockCommandRunner {
calls: Mutex<Vec<String>>,
}
impl MockCommandRunner {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
}
}
}
impl CommandRunner for MockCommandRunner {
fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
let cmd_str = format!("{} {}", executable, arguments.join(" "));
self.calls.lock().unwrap().push(cmd_str.trim().to_string());
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
fn make_context(env: &TempEnvironment) -> ExecutionContext {
let runner = Arc::new(MockCommandRunner::new());
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner,
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
dry_run: false,
no_provision: true, provision_rerun: false,
force: false,
}
}
struct TestUpCommand;
impl Command for TestUpCommand {
fn name(&self) -> &str {
"test-up"
}
fn execute_for_pack(&self, pack: &Pack, ctx: &ExecutionContext) -> Result<PackResult> {
let operations = run_handler_pipeline(pack, ctx)?;
let success = operations.iter().all(|r| r.success);
Ok(PackResult {
pack_name: pack.name.clone(),
success,
operations,
error: None,
})
}
}
#[test]
fn execute_discovers_and_processes_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_context(&env);
let result = execute(&TestUpCommand, None, &ctx).unwrap();
assert_eq!(result.total_packs, 2);
assert_eq!(result.successful_packs, 2);
assert_eq!(result.failed_packs, 0);
assert!(result.is_success());
for pr in &result.pack_results {
assert!(pr.success, "pack {} failed", pr.pack_name);
assert!(
!pr.operations.is_empty(),
"pack {} has no operations",
pr.pack_name
);
}
}
#[test]
fn execute_filters_by_pack_name() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("git")
.file("gitconfig", "x")
.done()
.pack("zsh")
.file("zshrc", "x")
.done()
.build();
let ctx = make_context(&env);
let filter = vec!["vim".into(), "zsh".into()];
let result = execute(&TestUpCommand, Some(&filter), &ctx).unwrap();
assert_eq!(result.total_packs, 2);
let names: Vec<&str> = result
.pack_results
.iter()
.map(|r| r.pack_name.as_str())
.collect();
assert!(names.contains(&"vim"));
assert!(names.contains(&"zsh"));
assert!(!names.contains(&"git"));
}
#[test]
fn execute_skips_dodotignored_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.build();
let ctx = make_context(&env);
let result = execute(&TestUpCommand, None, &ctx).unwrap();
assert_eq!(result.total_packs, 1);
assert_eq!(result.pack_results[0].pack_name, "vim");
}
#[test]
fn run_handler_pipeline_creates_symlinks() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack {
name: "vim".into(),
path: env.dotfiles_root.join("vim"),
config: ctx
.config_manager
.config_for_pack(&env.dotfiles_root.join("vim"))
.unwrap()
.to_handler_config(),
};
let results = run_handler_pipeline(&pack, &ctx).unwrap();
assert!(results.iter().all(|r| r.success));
let vim_symlink_dir = ctx.paths.handler_data_dir("vim", "symlink");
assert!(ctx.fs.exists(&vim_symlink_dir));
}
#[test]
fn dry_run_produces_results_without_side_effects() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let runner = Arc::new(MockCommandRunner::new());
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner,
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
let ctx = ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
dry_run: true,
no_provision: true,
provision_rerun: false,
force: false,
};
let result = execute(&TestUpCommand, None, &ctx).unwrap();
assert!(result.is_success());
assert!(!result.pack_results[0].operations.is_empty());
let vim_symlink_dir = ctx.paths.handler_data_dir("vim", "symlink");
assert!(!ctx.fs.exists(&vim_symlink_dir));
}
#[test]
fn no_provision_skips_install_handler() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("install.sh", "#!/bin/sh\necho setup")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack {
name: "vim".into(),
path: env.dotfiles_root.join("vim"),
config: ctx
.config_manager
.config_for_pack(&env.dotfiles_root.join("vim"))
.unwrap()
.to_handler_config(),
};
let results = run_handler_pipeline(&pack, &ctx).unwrap();
for r in &results {
assert!(
!matches!(r.operation, crate::operations::Operation::RunCommand { .. }),
"RunCommand should be skipped with no_provision"
);
}
}
#[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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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("readme.txt", "regular content")
.done()
.build();
let ctx = make_context(&env);
let pack = Pack {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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/readme.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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "vim".into(),
path: env.dotfiles_root.join("vim"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "tools".into(),
path: env.dotfiles_root.join("tools"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "tools".into(),
path: env.dotfiles_root.join("tools"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "tools".into(),
path: env.dotfiles_root.join("tools"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "app".into(),
path: env.dotfiles_root.join("app"),
config: 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 {
name: "setup".into(),
path: env.dotfiles_root.join("setup"),
config: 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}"
);
}
}