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 syntax_checker: Arc<dyn crate::shell::SyntaxChecker>,
pub command_runner: Arc<dyn crate::datastore::CommandRunner>,
pub dry_run: bool,
pub no_provision: bool,
pub provision_rerun: bool,
pub force: bool,
pub view_mode: crate::commands::ViewMode,
pub group_mode: crate::commands::GroupMode,
pub verbose: bool,
}
impl ExecutionContext {
pub fn production(dotfiles_root: &std::path::Path, verbose: bool) -> crate::Result<Self> {
let config_manager = Arc::new(ConfigManager::new(dotfiles_root)?);
let mut paths_builder = crate::paths::XdgPather::builder().dotfiles_root(dotfiles_root);
if let Ok(root_config) = config_manager.root_config() {
if !root_config.symlink.app_uses_library {
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp/dodot-unknown-home"));
let xdg = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| home.join(".config"));
paths_builder = paths_builder.app_support_dir(xdg);
}
}
let paths = Arc::new(paths_builder.build()?);
let fs: Arc<dyn Fs> = Arc::new(crate::fs::OsFs::new());
let runner: Arc<dyn crate::datastore::CommandRunner> =
Arc::new(crate::datastore::ShellCommandRunner::new(verbose));
let datastore: Arc<dyn DataStore> = Arc::new(crate::datastore::FilesystemDataStore::new(
fs.clone(),
paths.clone(),
runner.clone(),
));
Ok(Self {
fs,
datastore,
paths,
config_manager,
syntax_checker: Arc::new(crate::shell::SystemSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: false,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::default(),
group_mode: crate::commands::GroupMode::default(),
verbose,
})
}
}
#[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.display_name || 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.display_name || 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 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 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 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(),
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);
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()
}
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 resolve_pack_dir_name(input: &str, ctx: &ExecutionContext) -> crate::Result<String> {
let root_config = ctx.config_manager.root_config()?;
let scanned = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
if let Some(p) = scanned
.packs
.iter()
.find(|p| p.display_name == *input || p.name == *input)
{
return Ok(p.name.clone());
}
if let Some(dir) = scanned
.ignored
.iter()
.find(|d| d.as_str() == input || packs::display_name_for(d) == input)
{
return Ok(dir.clone());
}
Err(crate::DodotError::PackNotFound { name: input.into() })
}
pub fn validate_pack_names(names: &[String], ctx: &ExecutionContext) -> crate::Result<Vec<String>> {
let root_config = ctx.config_manager.root_config()?;
let scanned = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
let mut warnings = Vec::new();
for input in names {
if scanned
.packs
.iter()
.any(|p| p.display_name == *input || p.name == *input)
{
continue;
}
if scanned
.ignored
.iter()
.any(|dir| dir == input || packs::display_name_for(dir) == input)
{
warnings.push(format!("warning: pack '{}' is ignored, skipping", input));
continue;
}
return Err(crate::DodotError::PackNotFound {
name: input.clone(),
});
}
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<dyn crate::datastore::CommandRunner> = Arc::new(MockCommandRunner::new());
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
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,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true, provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: 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.display_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_filter_resolves_display_name_to_prefixed_pack() {
let env = TempEnvironment::builder()
.pack("010-brew")
.file("Brewfile", "x")
.done()
.pack("nvim")
.file("init.lua", "x")
.done()
.build();
let ctx = make_context(&env);
let filter = vec!["brew".into()];
let result = execute(&TestUpCommand, Some(&filter), &ctx).unwrap();
assert_eq!(result.total_packs, 1);
assert_eq!(result.pack_results[0].pack_name, "brew");
}
#[test]
fn execute_filter_accepts_raw_directory_name_as_fallback() {
let env = TempEnvironment::builder()
.pack("010-brew")
.file("Brewfile", "x")
.done()
.build();
let ctx = make_context(&env);
let filter = vec!["010-brew".into()];
let result = execute(&TestUpCommand, Some(&filter), &ctx).unwrap();
assert_eq!(result.total_packs, 1);
assert_eq!(result.pack_results[0].pack_name, "brew");
}
#[test]
fn resolve_pack_dir_name_finds_pack_by_display_name() {
let env = TempEnvironment::builder()
.pack("010-nvim")
.file("init.lua", "x")
.done()
.build();
let ctx = make_context(&env);
let resolved = resolve_pack_dir_name("nvim", &ctx).unwrap();
assert_eq!(resolved, "010-nvim");
}
#[test]
fn resolve_pack_dir_name_finds_pack_by_raw_directory_name() {
let env = TempEnvironment::builder()
.pack("010-nvim")
.file("init.lua", "x")
.done()
.build();
let ctx = make_context(&env);
let resolved = resolve_pack_dir_name("010-nvim", &ctx).unwrap();
assert_eq!(resolved, "010-nvim");
}
#[test]
fn resolve_pack_dir_name_errors_on_unknown_pack() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_context(&env);
let err = resolve_pack_dir_name("nope", &ctx).unwrap_err();
assert!(matches!(
err,
crate::DodotError::PackNotFound { ref name } if name == "nope"
));
}
#[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::new(
"vim".into(),
env.dotfiles_root.join("vim"),
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<dyn crate::datastore::CommandRunner> = Arc::new(MockCommandRunner::new());
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
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,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: true,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: 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::new(
"vim".into(),
env.dotfiles_root.join("vim"),
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::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("readme.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/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::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"
);
}
}