use tracing::{debug, info};
use crate::execution::Executor;
use crate::operations::OperationResult;
use crate::packs::{self, Pack};
use crate::Result;
pub use crate::packs::context::ExecutionContext;
pub use crate::packs::types::{Command, ExecuteResult, PackResult};
mod planning;
mod resolve;
#[cfg(test)]
mod test_support;
pub(crate) use planning::filter_pre_preprocess_gates;
pub use planning::{
collect_pack_intents, collect_pack_intents_with_preprocessors, plan_pack, PackPlan,
};
pub use resolve::{resolve_pack_dir_name, validate_pack_names};
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;
let host = ctx.host_facts.as_ref();
for mut pack in all_packs {
info!(pack = %pack.name, "processing pack");
let pack_config = 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();
pack_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;
}
};
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, skipping"
);
successful += 1;
pack_results.push(PackResult {
pack_name: pack.name.clone(),
success: true,
operations: Vec::new(),
error: None,
});
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 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)
}
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use std::sync::Arc;
use super::test_support::{make_context, MockCommandRunner, TestUpCommand};
use super::*;
use crate::config::ConfigManager;
use crate::datastore::{CommandRunner, FilesystemDataStore};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
#[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 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,
host_facts: Arc::new(crate::gates::HostFacts::detect()),
};
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"
);
}
}
}