use tracing::{debug, info};
use crate::commands::{
handler_description, handler_symbol, DisplayConflict, DisplayDiff, DisplayFile, DisplayNote,
DisplayPack, PackStatusResult,
};
use crate::config::mappings_to_rules;
use crate::conflicts;
use crate::datastore::DidRunStatus;
use crate::handlers::run_once::{file_checksum, run_once_status_messages};
use crate::handlers::{
self, HANDLER_GATE, HANDLER_HOMEBREW, HANDLER_IGNORE, HANDLER_INSTALL, HANDLER_NIX,
HANDLER_SKIP, HANDLER_SYMLINK,
};
use crate::operations::HandlerIntent;
use crate::packs::orchestration::{self, ExecutionContext};
use crate::packs::{self};
use crate::rules::Scanner;
use crate::Result;
enum Health {
Pending,
PendingConflict { reason: String },
Deployed,
DeployedWithError { label: String, reason: String },
Broken(String),
Stale(String),
RanOlderVersion { label: String },
Skipped,
Gated {
label: String,
expected: String,
actual: String,
},
}
impl Health {
fn style(&self) -> &'static str {
match self {
Health::Pending => "pending",
Health::PendingConflict { .. } => "warning",
Health::Deployed => "deployed",
Health::DeployedWithError { .. } => "broken",
Health::Broken(_) => "broken",
Health::Stale(_) => "stale",
Health::RanOlderVersion { .. } => "stale",
Health::Skipped => "skipped",
Health::Gated { .. } => "skipped",
}
}
fn label(&self, handler: &str) -> String {
match self {
Health::Pending | Health::PendingConflict { .. } => match handler {
"symlink" => "pending".into(),
"shell" => "not sourced".into(),
"path" => "not in PATH".into(),
"install" | "homebrew" | "nix" => run_once_status_messages(handler).pending,
_ => "pending".into(),
},
Health::Deployed => match handler {
"symlink" => "deployed".into(),
"shell" => "sourced".into(),
"path" => "in PATH".into(),
"install" | "homebrew" | "nix" => run_once_status_messages(handler).deployed,
_ => "deployed".into(),
},
Health::DeployedWithError { label, .. } => label.clone(),
Health::Broken(reason) => reason.clone(),
Health::Stale(reason) => reason.clone(),
Health::RanOlderVersion { label } => label.clone(),
Health::Skipped => "skipped".into(),
Health::Gated { label, .. } => format!("gated out ({label})"),
}
}
fn footnote_reason(&self) -> Option<String> {
match self {
Health::PendingConflict { reason } => Some(reason.clone()),
Health::DeployedWithError { reason, .. } => Some(reason.clone()),
Health::Gated {
expected, actual, ..
} => Some(format!("expected {expected}; got {actual}")),
_ => None,
}
}
}
fn describe_blocking_target(
user_target: &std::path::Path,
fs: &dyn crate::fs::Fs,
home: &std::path::Path,
) -> String {
let display = if let Ok(rel) = user_target.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
user_target.display().to_string()
};
let kind = if fs.is_dir(user_target) {
"directory"
} else {
"file"
};
format!("{display} (existing {kind}) — `dodot up` will refuse without `--force`")
}
fn format_path_relative_to_home(path: &std::path::Path, home: &std::path::Path) -> String {
if let Ok(rel) = path.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
path.display().to_string()
}
}
fn intent_display_name(
source: &std::path::Path,
pack_path: &std::path::Path,
preprocessed_dir: &std::path::Path,
) -> String {
if let Ok(rel) = source.strip_prefix(pack_path) {
return rel.to_string_lossy().into_owned();
}
if let Ok(rel) = source.strip_prefix(preprocessed_dir) {
return rel.to_string_lossy().into_owned();
}
source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
fn verify_symlink(
source: &std::path::Path,
user_target: &std::path::Path,
pack: &str,
ctx: &ExecutionContext,
) -> Health {
let filename = match source.file_name() {
Some(f) => f,
None => return Health::Pending,
};
let data_link = ctx
.paths
.handler_data_dir(pack, HANDLER_SYMLINK)
.join(filename);
if !ctx.fs.is_symlink(&data_link) {
if ctx.fs.exists(&data_link) {
return Health::Broken("broken: data link exists but is not a symlink".into());
}
if !ctx.fs.is_symlink(user_target) && ctx.fs.exists(user_target) {
if crate::equivalence::is_equivalent(user_target, source, ctx.fs.as_ref()) {
return Health::Pending;
}
let reason =
describe_blocking_target(user_target, ctx.fs.as_ref(), ctx.paths.home_dir());
return Health::PendingConflict { reason };
}
return Health::Pending;
}
match ctx.fs.readlink(&data_link) {
Ok(target) if target == source => {}
Ok(target) => {
return Health::Broken(format!("broken: data link points to {}", target.display()));
}
Err(_) => return Health::Broken("broken: cannot read data link".into()),
}
if !ctx.fs.exists(source) {
return Health::Broken("broken: source file missing".into());
}
if ctx.fs.is_symlink(user_target) {
match ctx.fs.readlink(user_target) {
Ok(link_target) if link_target == data_link => {
Health::Deployed
}
Ok(_) => {
Health::Stale("stale: user link points elsewhere, re-deploy to fix".into())
}
Err(_) => Health::Broken("broken: cannot read user link".into()),
}
} else if ctx.fs.exists(user_target) {
if crate::equivalence::is_equivalent(user_target, source, ctx.fs.as_ref()) {
Health::Stale("stale: user link missing, re-deploy to fix".into())
} else {
Health::Broken("conflict: non-symlink file at target path".into())
}
} else {
Health::Stale("stale: user link missing, re-deploy to fix".into())
}
}
fn verify_staged(
source: &std::path::Path,
pack: &str,
handler: &str,
ctx: &ExecutionContext,
) -> Health {
let filename = match source.file_name() {
Some(f) => f,
None => return Health::Pending,
};
let data_link = ctx.paths.handler_data_dir(pack, handler).join(filename);
if !ctx.fs.is_symlink(&data_link) {
if ctx.fs.exists(&data_link) {
return Health::Broken("broken: data link exists but is not a symlink".into());
}
return Health::Pending;
}
match ctx.fs.readlink(&data_link) {
Ok(target) if target == source => {}
Ok(target) => {
return Health::Broken(format!("broken: data link points to {}", target.display()));
}
Err(_) => return Health::Broken("broken: cannot read data link".into()),
}
if !ctx.fs.exists(source) {
return Health::Broken("broken: source file missing".into());
}
if handler == "shell" {
let filename_str = filename.to_string_lossy();
let sidecar = crate::shell::error_sidecar_path(ctx.paths.as_ref(), pack, &filename_str);
if ctx.fs.exists(&sidecar) {
if let Ok(body) = ctx.fs.read_to_string(&sidecar) {
let reason = body.trim().to_string();
if !reason.is_empty() {
return Health::DeployedWithError {
label: "syntax error".into(),
reason,
};
}
}
}
if let Some((label, reason)) = recent_runtime_failures(source, pack, &filename_str, ctx) {
return Health::DeployedWithError { label, reason };
}
}
Health::Deployed
}
fn run_once_health(
file: &std::path::Path,
pack: &str,
display_name: &str,
handler: &str,
ctx: &ExecutionContext,
show_diff: bool,
out_diffs: &mut Vec<DisplayDiff>,
) -> Health {
if !ctx.fs.exists(file) {
return Health::Broken("broken: source file missing".into());
}
let filename = match file.file_name() {
Some(f) => f.to_string_lossy().into_owned(),
None => return Health::Pending,
};
let current_hash = match file_checksum(ctx.fs.as_ref(), file) {
Ok(h) => h,
Err(e) => return Health::Broken(format!("broken: cannot hash source file: {e}")),
};
let messages = run_once_status_messages(handler);
let status = match ctx
.datastore
.did_run(pack, handler, &filename, ¤t_hash)
{
Ok(s) => s,
Err(e) => return Health::Broken(format!("broken: datastore error: {e}")),
};
match status {
DidRunStatus::NeverRan => Health::Pending,
DidRunStatus::RanCurrent => Health::Deployed,
DidRunStatus::RanDifferent {
previous_snapshot, ..
} => {
let current_bytes = ctx.fs.read_file(file).ok();
let label = match (previous_snapshot.as_deref(), current_bytes.as_deref()) {
(Some(prev), Some(cur)) => {
let summary = line_summary(prev, cur);
if show_diff {
out_diffs.push(DisplayDiff {
pack: display_name.to_string(),
file: filename.clone(),
handler: handler.to_string(),
body: unified_diff(&filename, prev, cur),
});
}
format!("{} ({})", messages.ran_different, summary)
}
_ => {
format!("{} (no diff data)", messages.ran_different)
}
};
Health::RanOlderVersion { label }
}
}
}
fn line_summary(prev: &[u8], cur: &[u8]) -> String {
let prev_s = String::from_utf8_lossy(prev);
let cur_s = String::from_utf8_lossy(cur);
let patch = diffy::create_patch(&prev_s, &cur_s);
let mut added = 0usize;
let mut removed = 0usize;
for hunk in patch.hunks() {
for line in hunk.lines() {
match line {
diffy::Line::Insert(_) => added += 1,
diffy::Line::Delete(_) => removed += 1,
diffy::Line::Context(_) => {}
}
}
}
format!(
"{added} {} added, {removed} removed",
if added == 1 { "line" } else { "lines" },
)
}
fn unified_diff(filename: &str, prev: &[u8], cur: &[u8]) -> String {
let prev_s = String::from_utf8_lossy(prev);
let cur_s = String::from_utf8_lossy(cur);
let mut opts = diffy::DiffOptions::default();
opts.set_original_filename(format!("{filename} (previous run)"))
.set_modified_filename(format!("{filename} (current)"));
opts.create_patch(&prev_s, &cur_s).to_string()
}
const RUNTIME_FAILURE_WINDOW: usize = 5;
const STATUS_STDERR_BUDGET: usize = 240;
fn recent_runtime_failures(
source: &std::path::Path,
pack: &str,
filename: &str,
ctx: &ExecutionContext,
) -> Option<(String, String)> {
let profiles = crate::probe::shell_init::read_recent_profiles(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
RUNTIME_FAILURE_WINDOW,
)
.ok()?;
if profiles.is_empty() {
return None;
}
let target_str = source.to_string_lossy();
let mut runs_seen = 0;
let mut runs_failed = 0;
let mut last_failure_exit: Option<i32> = None;
let mut last_failure_stderr: Option<String> = None;
for profile in &profiles {
if let Some(entry) = profile
.entries
.iter()
.find(|e| e.phase == "source" && e.target == target_str)
{
runs_seen += 1;
if entry.exit_status != 0 {
runs_failed += 1;
if last_failure_exit.is_none() {
last_failure_exit = Some(entry.exit_status);
last_failure_stderr = profile
.errors
.iter()
.find(|er| er.target == target_str)
.map(|er| er.message.trim_end().to_string())
.filter(|s| !s.is_empty());
}
}
}
}
let last_exit = last_failure_exit?;
if runs_failed == 0 {
return None;
}
let label = format!("exited {last_exit} ({runs_failed}/{runs_seen})");
let mut reason = format!(
"non-zero exit in {runs_failed} of {runs_seen} recent shell startups (last failure: exit {last_exit})."
);
if let Some(stderr) = last_failure_stderr {
reason.push_str(" stderr: ");
reason.push_str(&truncate_for_footnote(&stderr, STATUS_STDERR_BUDGET));
}
reason.push_str(&format!(
" Run `dodot probe shell-init {pack}/{filename}` for per-run history and full stderr."
));
Some((label, reason))
}
fn truncate_for_footnote(stderr: &str, budget: usize) -> String {
let one_line = stderr.replace('\n', " ↵ ");
if one_line.chars().count() <= budget {
return one_line;
}
let truncated: String = one_line.chars().take(budget).collect();
format!("{truncated}…")
}
pub fn status(pack_filter: Option<&[String]>, ctx: &ExecutionContext) -> Result<PackStatusResult> {
info!("starting status command");
let mut warnings = Vec::new();
if let Some(names) = pack_filter {
warnings = orchestration::validate_pack_names(names, ctx)?;
}
let root_config = ctx.config_manager.root_config()?;
let packs::DiscoveredPacks {
packs: mut all_packs,
ignored: mut ignored_packs,
} = packs::scan_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 {
all_packs.retain(|p| names.iter().any(|n| n == &p.display_name || n == &p.name));
ignored_packs.retain(|name| {
names
.iter()
.any(|n| n == name || n == crate::packs::display_name_for(name))
});
}
let registry = handlers::create_registry(ctx.fs.as_ref(), ctx.command_runner.as_ref());
let host = ctx.host_facts.as_ref();
let mut display_packs = Vec::new();
let mut notes: Vec<DisplayNote> = Vec::new();
let mut inactive_packs: Vec<String> = Vec::new();
let mut diffs: Vec<DisplayDiff> = Vec::new();
let mut pack_intents = Vec::new();
let mut active_packs: Vec<(String, String, std::path::PathBuf)> = Vec::new();
for mut pack in all_packs {
info!(pack = %pack.display_name, "checking pack status");
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
pack.config = pack_config.to_handler_config();
if !crate::gates::pack_os_active(&pack_config.pack.os, host) {
inactive_packs.push(format!(
"{} (os={}, current={})",
pack.display_name,
pack_config.pack.os.join(","),
host.os
));
continue;
}
active_packs.push((
pack.name.clone(),
pack.display_name.clone(),
pack.path.clone(),
));
let rules = mappings_to_rules(&pack_config.mappings);
let scanner = Scanner::new(ctx.fs.as_ref());
let gates = {
let mut t = crate::gates::GateTable::with_builtins();
if !pack_config.gates.is_empty() {
t.merge_user(&pack_config.gates)?;
}
t
};
let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore, &gates, host)?;
let entries = orchestration::filter_pre_preprocess_gates(
entries,
&gates,
host,
&pack.name,
&pack_config.mappings.gates,
)?;
let preprocess_result = if pack_config.preprocessor.enabled {
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(),
)?;
if !registry.is_empty() {
match crate::preprocessing::pipeline::preprocess_pack(
entries,
®istry,
&pack,
ctx.fs.as_ref(),
ctx.datastore.as_ref(),
ctx.paths.as_ref(),
crate::preprocessing::PreprocessMode::Passive,
false,
) {
Ok(r) => r,
Err(err) => {
warnings.push(format!(
"preprocessing failed for pack '{}': {}",
pack.display_name, err
));
crate::preprocessing::pipeline::PreprocessResult::passthrough(Vec::new())
}
}
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
}
} else {
crate::preprocessing::pipeline::PreprocessResult::passthrough(entries)
};
let all_entries = preprocess_result.merged_entries();
let matches = scanner.match_entries(
&all_entries,
&rules,
&pack.name,
&gates,
host,
&pack_config.mappings.gates,
)?;
let intents_for_pack: Vec<HandlerIntent> = match orchestration::plan_pack(
&pack,
ctx,
crate::preprocessing::PreprocessMode::Passive,
) {
Ok(plan) => {
warnings.extend(plan.warnings);
let intents = plan.intents.clone();
pack_intents.push((pack.display_name.clone(), plan.intents));
intents
}
Err(err) => {
warnings.push(format!(
"could not collect intents for pack '{}'; conflict detection may be incomplete: {}",
pack.display_name, err
));
Vec::new()
}
};
let mut files = Vec::new();
for m in &matches {
if m.handler == HANDLER_IGNORE {
continue;
}
if m.handler == HANDLER_SYMLINK {
continue;
}
let rel_str = m.relative_path.to_string_lossy().into_owned();
let health = match m.handler.as_str() {
h if h == HANDLER_SKIP => Health::Skipped,
h if h == HANDLER_GATE => {
let label = m
.options
.get("gate_label")
.cloned()
.unwrap_or_else(|| "<unknown>".into());
let expected = m
.options
.get("gate_predicate")
.cloned()
.unwrap_or_else(|| "<unknown>".into());
let actual = m
.options
.get("gate_host")
.cloned()
.unwrap_or_else(|| "<unknown>".into());
Health::Gated {
label,
expected,
actual,
}
}
"shell" | "path" => verify_staged(&m.absolute_path, &pack.name, &m.handler, ctx),
h if h == HANDLER_INSTALL || h == HANDLER_HOMEBREW || h == HANDLER_NIX => {
run_once_health(
&m.absolute_path,
&pack.name,
&pack.display_name,
&m.handler,
ctx,
ctx.show_diff,
&mut diffs,
)
}
_ => {
let handler = registry.get(m.handler.as_str());
let deployed = handler
.and_then(|h| {
h.check_status(&m.absolute_path, &pack.name, ctx.datastore.as_ref())
.ok()
})
.map(|s| s.deployed)
.unwrap_or(false);
if deployed {
Health::Deployed
} else {
Health::Pending
}
}
};
let status_label = health.label(&m.handler);
let note_ref = health.footnote_reason().map(|reason| {
notes.push(DisplayNote {
body: reason,
hint: None,
});
notes.len() as u32
});
files.push(DisplayFile {
name: rel_str.clone(),
symbol: handler_symbol(&m.handler).into(),
description: handler_description(&m.handler, &rel_str, None),
status: health.style().into(),
status_label,
handler: m.handler.clone(),
note_ref,
});
}
let home = ctx.paths.home_dir();
let preprocessed_dir = ctx.paths.handler_data_dir(&pack.name, "preprocessed");
for intent in &intents_for_pack {
let HandlerIntent::Link {
source, user_path, ..
} = intent
else {
continue;
};
let name = intent_display_name(source, &pack.path, &preprocessed_dir);
let user_target_display = format_path_relative_to_home(user_path, home);
let health = verify_symlink(source, user_path, &pack.name, ctx);
let status_label = health.label(HANDLER_SYMLINK);
let note_ref = health.footnote_reason().map(|reason| {
notes.push(DisplayNote {
body: reason,
hint: None,
});
notes.len() as u32
});
files.push(DisplayFile {
name: name.clone(),
symbol: handler_symbol(HANDLER_SYMLINK).into(),
description: handler_description(
HANDLER_SYMLINK,
&name,
Some(&user_target_display),
),
status: health.style().into(),
status_label,
handler: HANDLER_SYMLINK.into(),
note_ref,
});
}
display_packs.push(DisplayPack::new(pack.display_name.clone(), files));
}
let detected_conflicts = conflicts::detect_cross_pack_conflicts(&pack_intents, ctx.fs.as_ref());
let home = ctx.paths.home_dir();
let display_conflicts: Vec<DisplayConflict> = detected_conflicts
.iter()
.map(|c| DisplayConflict::from_conflict(c, home))
.collect();
if !display_conflicts.is_empty() {
info!(
count = display_conflicts.len(),
"cross-pack conflicts detected"
);
} else {
debug!("no cross-pack conflicts");
}
if ctx.check_drift {
warnings.extend(collect_drift_warnings(ctx, &active_packs)?);
}
let ignored_display: Vec<String> = ignored_packs
.iter()
.map(|d| crate::packs::display_name_for(d).to_string())
.collect();
Ok(PackStatusResult {
message: None,
dry_run: false,
packs: display_packs,
warnings,
notes,
conflicts: display_conflicts,
ignored_packs: ignored_display,
inactive_packs,
view_mode: ctx.view_mode.as_str().into(),
group_mode: ctx.group_mode.as_str().into(),
diffs,
})
}
fn collect_drift_warnings(
ctx: &ExecutionContext,
active_packs: &[(String, String, std::path::PathBuf)],
) -> Result<Vec<String>> {
use crate::external::{detect_drift_for_pack, DriftKind};
use crate::handlers::externals::EXTERNALS_TOML;
let mut out = Vec::new();
let git_runner = crate::external::ShellGitRunner::new();
for (on_disk_name, display_name, pack_path) in active_packs {
let toml_path = pack_path.join(EXTERNALS_TOML);
if !ctx.fs.exists(&toml_path) {
continue;
}
let bytes = match ctx.fs.read_file(&toml_path) {
Ok(b) => b,
Err(e) => {
out.push(format!(
"{display_name}: drift check skipped — cannot read externals.toml: {e}"
));
continue;
}
};
let reports = detect_drift_for_pack(
on_disk_name,
&bytes,
ctx.paths.as_ref(),
ctx.fs.as_ref(),
Some(&git_runner),
)?;
for r in reports {
match r.kind {
DriftKind::Clean => {}
DriftKind::Drifted => out.push(format!(
"{display_name} / {}: drift — {}",
r.entry_name, r.detail
)),
DriftKind::Missing => out.push(format!(
"{display_name} / {}: deployed copy missing — {}",
r.entry_name, r.detail
)),
DriftKind::CheckFailed => out.push(format!(
"{display_name} / {}: drift check errored — {}",
r.entry_name, r.detail
)),
DriftKind::NotImplemented => out.push(format!(
"{display_name} / {}: drift check not implemented ({})",
r.entry_name, r.detail
)),
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{intent_display_name, line_summary, unified_diff};
use std::path::Path;
#[test]
fn intent_display_name_pack_relative_for_pack_file() {
let name = intent_display_name(
Path::new("/dot/iina/_app/foo/bar.conf"),
Path::new("/dot/iina"),
Path::new("/data/iina/preprocessed"),
);
assert_eq!(name, "_app/foo/bar.conf");
}
#[test]
fn intent_display_name_strips_preprocessed_prefix_for_rendered_files() {
let name = intent_display_name(
Path::new("/data/iina/preprocessed/subdir/config.toml"),
Path::new("/dot/iina"),
Path::new("/data/iina/preprocessed"),
);
assert_eq!(name, "subdir/config.toml");
}
#[test]
fn intent_display_name_falls_back_to_basename_for_unrelated_paths() {
let name = intent_display_name(
Path::new("/elsewhere/foo.conf"),
Path::new("/dot/iina"),
Path::new("/data/iina/preprocessed"),
);
assert_eq!(name, "foo.conf");
}
#[test]
fn line_summary_counts_added_and_removed_lines() {
let prev = "alpha\nbeta\ngamma\n";
let cur = "alpha\nbeta-prime\ngamma\ndelta\n";
let summary = line_summary(prev.as_bytes(), cur.as_bytes());
assert_eq!(summary, "2 lines added, 1 removed");
}
#[test]
fn line_summary_singular_for_one_added() {
let prev = "alpha\nbeta\n";
let cur = "alpha\nbeta\ngamma\n";
let summary = line_summary(prev.as_bytes(), cur.as_bytes());
assert_eq!(summary, "1 line added, 0 removed");
}
#[test]
fn line_summary_zero_when_inputs_match() {
let s = "echo hi\n";
let summary = line_summary(s.as_bytes(), s.as_bytes());
assert_eq!(summary, "0 lines added, 0 removed");
}
#[test]
fn unified_diff_carries_per_run_filename_decorations() {
let prev = "echo old\n";
let cur = "echo new\n";
let body = unified_diff("install.sh", prev.as_bytes(), cur.as_bytes());
assert!(
body.contains("--- install.sh (previous run)"),
"expected previous-run header in diff, got: {body}"
);
assert!(
body.contains("+++ install.sh (current)"),
"expected current header in diff, got: {body}"
);
assert!(
body.contains("-echo old"),
"expected removed line in diff, got: {body}"
);
assert!(
body.contains("+echo new"),
"expected added line in diff, got: {body}"
);
}
use super::{run_once_health, Health};
use crate::commands::DisplayDiff;
use crate::fs::Fs;
use crate::handlers::HANDLER_INSTALL;
use crate::packs::orchestration::ExecutionContext;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
fn ctx_for(env: &TempEnvironment) -> ExecutionContext {
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::Result;
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager =
Arc::new(ConfigManager::new(&env.dotfiles_root).expect("test config manager"));
ExecutionContext {
fs: env.fs.clone(),
datastore,
paths: env.paths.clone(),
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
check_drift: false,
show_diff: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
host_facts: Arc::new(crate::gates::HostFacts::detect()),
}
}
#[test]
fn run_once_health_pending_when_no_sentinel() {
let env = TempEnvironment::builder()
.pack("vim")
.file("install.sh", "echo hi")
.done()
.build();
let ctx = ctx_for(&env);
let abs = env.dotfiles_root.join("vim/install.sh");
let mut diffs = Vec::new();
let h = run_once_health(&abs, "vim", "vim", HANDLER_INSTALL, &ctx, false, &mut diffs);
assert!(matches!(h, Health::Pending));
assert!(diffs.is_empty());
}
#[test]
fn run_once_health_deployed_when_current_hash_matches() {
let env = TempEnvironment::builder()
.pack("vim")
.file("install.sh", "echo hi")
.done()
.build();
let ctx = ctx_for(&env);
let abs = env.dotfiles_root.join("vim/install.sh");
let checksum = crate::handlers::run_once::file_checksum(env.fs.as_ref(), &abs).unwrap();
let dir = env.paths.handler_data_dir("vim", HANDLER_INSTALL);
env.fs.mkdir_all(&dir).unwrap();
env.fs
.write_file(
&dir.join(format!("install.sh-{checksum}")),
b"completed|100",
)
.unwrap();
let mut diffs = Vec::new();
let h = run_once_health(&abs, "vim", "vim", HANDLER_INSTALL, &ctx, false, &mut diffs);
assert!(matches!(h, Health::Deployed));
assert!(diffs.is_empty());
}
#[test]
fn run_once_health_older_version_carries_line_summary_when_snapshot_present() {
let env = TempEnvironment::builder()
.pack("vim")
.file("install.sh", "echo new\necho line2\n")
.done()
.build();
let ctx = ctx_for(&env);
let abs = env.dotfiles_root.join("vim/install.sh");
let dir = env.paths.handler_data_dir("vim", HANDLER_INSTALL);
env.fs.mkdir_all(&dir).unwrap();
env.fs
.write_file(&dir.join("install.sh-aaaaaaaaaaaaaaaa"), b"completed|100")
.unwrap();
env.fs
.write_file(
&dir.join("install.sh-aaaaaaaaaaaaaaaa.snapshot"),
b"echo old\n",
)
.unwrap();
let mut diffs = Vec::new();
let h = run_once_health(&abs, "vim", "vim", HANDLER_INSTALL, &ctx, false, &mut diffs);
match h {
Health::RanOlderVersion { label } => {
assert!(
label.contains("older version"),
"label should mention older version, got: {label}"
);
assert!(
label.contains("lines added") && label.contains("removed"),
"label should contain a (N+ M-) summary, got: {label}"
);
}
_ => panic!("expected RanOlderVersion"),
}
assert!(diffs.is_empty());
}
#[test]
fn run_once_health_older_version_no_snapshot_falls_back_to_label_only() {
let env = TempEnvironment::builder()
.pack("vim")
.file("install.sh", "echo new\n")
.done()
.build();
let ctx = ctx_for(&env);
let abs = env.dotfiles_root.join("vim/install.sh");
let dir = env.paths.handler_data_dir("vim", HANDLER_INSTALL);
env.fs.mkdir_all(&dir).unwrap();
env.fs
.write_file(&dir.join("install.sh-aaaaaaaaaaaaaaaa"), b"completed|100")
.unwrap();
let mut diffs = Vec::new();
let h = run_once_health(&abs, "vim", "vim", HANDLER_INSTALL, &ctx, true, &mut diffs);
match h {
Health::RanOlderVersion { label } => {
assert!(
label.contains("older version") && label.contains("no diff data"),
"label should mention `no diff data`, got: {label}"
);
}
_ => panic!("expected RanOlderVersion"),
}
assert!(
diffs.is_empty(),
"no snapshot should yield no diff entry, got {} entries",
diffs.len()
);
}
#[test]
fn run_once_health_show_diff_emits_diff_payload_when_snapshot_present() {
let env = TempEnvironment::builder()
.pack("vim")
.file("install.sh", "echo new\n")
.done()
.build();
let ctx = ctx_for(&env);
let abs = env.dotfiles_root.join("vim/install.sh");
let dir = env.paths.handler_data_dir("vim", HANDLER_INSTALL);
env.fs.mkdir_all(&dir).unwrap();
env.fs
.write_file(&dir.join("install.sh-aaaaaaaaaaaaaaaa"), b"completed|100")
.unwrap();
env.fs
.write_file(
&dir.join("install.sh-aaaaaaaaaaaaaaaa.snapshot"),
b"echo old\n",
)
.unwrap();
let mut diffs: Vec<DisplayDiff> = Vec::new();
let _h = run_once_health(
&abs,
"vim",
"vim-display",
HANDLER_INSTALL,
&ctx,
true,
&mut diffs,
);
assert_eq!(diffs.len(), 1, "expected exactly one diff entry");
let d = &diffs[0];
assert_eq!(d.pack, "vim-display");
assert_eq!(d.file, "install.sh");
assert_eq!(d.handler, HANDLER_INSTALL);
assert!(d.body.contains("install.sh (previous run)"));
assert!(d.body.contains("install.sh (current)"));
assert!(d.body.contains("-echo old"));
assert!(d.body.contains("+echo new"));
}
}