diff --git a/e2e/cli/test_prepare b/e2e/cli/test_prepare index 67eef27473..9bdc341665 100644 --- a/e2e/cli/test_prepare +++ b/e2e/cli/test_prepare @@ -256,9 +256,10 @@ cat >requirements.txt <<'EOF' requests==2.31.0 EOF -# Test 1: No output directory exists → should be stale +# Test 1: No output directory exists → should be stale with reason rm -rf .venv assert_contains "mise prepare --dry-run" "custom_venv" +assert_contains "mise prepare --dry-run" "does not exist" # Test 2: Output exists and is newer than sources → should be fresh # Use explicit timestamps: source=2023, output=2024 @@ -269,10 +270,11 @@ touch -t 202401010000 .venv # Set directory mtime too (mkdir sets it # In dry-run mode, fresh providers produce no output, so check provider name is absent assert_not_contains "mise prepare --dry-run" "custom_venv" -# Test 3: Sources updated after output → should be stale +# Test 3: Sources updated after output → should be stale with reason # source=2025 (newer than output=2024) touch -t 202501010000 requirements.txt # Jan 1, 2025 assert_contains "mise prepare --dry-run" "custom_venv" +assert_contains "mise prepare --dry-run" "is newer than outputs" # Clean up rm -rf .venv requirements.txt diff --git a/src/cli/prepare.rs b/src/cli/prepare.rs index a1a72cffa7..99916ebbb8 100644 --- a/src/cli/prepare.rs +++ b/src/cli/prepare.rs @@ -76,8 +76,8 @@ impl Prepare { PrepareStepResult::Ran(id) => { miseprintln!("Prepared: {}", id); } - PrepareStepResult::WouldRun(id) => { - miseprintln!("[dry-run] Would prepare: {}", id); + PrepareStepResult::WouldRun(id, reason) => { + miseprintln!("[dry-run] Would prepare: {} ({})", id, reason); } PrepareStepResult::Fresh(id) => { debug!("Fresh: {}", id); diff --git a/src/prepare/engine.rs b/src/prepare/engine.rs index 5c3a4ea29d..01af784219 100644 --- a/src/prepare/engine.rs +++ b/src/prepare/engine.rs @@ -37,13 +37,50 @@ pub struct PrepareOptions { pub auto_only: bool, } +/// Result of a freshness check with human-readable reason +#[derive(Debug, Clone)] +pub enum FreshnessResult { + /// Outputs are up to date + Fresh, + /// No outputs defined — always run + NoOutputs, + /// Output was created this session (e.g., auto-created venv) + SessionStale(String), + /// Some output files/dirs don't exist yet + OutputsMissing(String), + /// Sources are newer than outputs + Stale(String), + /// No sources exist — consider fresh + NoSources, + /// Forced by user request + Forced, +} + +impl FreshnessResult { + pub fn is_fresh(&self) -> bool { + matches!(self, FreshnessResult::Fresh | FreshnessResult::NoSources) + } + + pub fn reason(&self) -> &str { + match self { + FreshnessResult::Fresh => "up to date", + FreshnessResult::NoOutputs => "no outputs defined", + FreshnessResult::SessionStale(r) => r, + FreshnessResult::OutputsMissing(r) => r, + FreshnessResult::Stale(r) => r, + FreshnessResult::NoSources => "no sources", + FreshnessResult::Forced => "forced", + } + } +} + /// Result of a prepare step #[derive(Debug)] pub enum PrepareStepResult { /// Step ran successfully Ran(String), - /// Step would have run (dry-run mode) - WouldRun(String), + /// Step would have run (dry-run mode) — (id, reason) + WouldRun(String, String), /// Step was skipped because outputs are fresh Fresh(String), /// Step was skipped by user request @@ -70,7 +107,7 @@ impl PrepareResult { self.steps.iter().any(|s| { matches!( s, - PrepareStepResult::Ran(_) | PrepareStepResult::WouldRun(_) + PrepareStepResult::Ran(_) | PrepareStepResult::WouldRun(_, _) ) }) } @@ -260,13 +297,18 @@ impl PrepareEngine { } /// Check if any auto-enabled provider has stale outputs (without running) - /// Returns the IDs of stale providers - pub fn check_staleness(&self) -> Vec<&str> { + /// Returns the IDs and reasons of stale providers + pub fn check_staleness(&self) -> Vec<(&str, String)> { self.providers .iter() .filter(|p| p.is_auto()) - .filter(|p| !self.check_freshness(p.as_ref()).unwrap_or(true)) - .map(|p| p.id()) + .filter_map(|p| { + let result = self.check_freshness(p.as_ref()); + match result { + Ok(r) if !r.is_fresh() => Some((p.id(), r.reason().to_string())), + _ => None, + } + }) .collect() } @@ -301,20 +343,21 @@ impl PrepareEngine { continue; } - let is_fresh = if opts.force { - false + let freshness = if opts.force { + FreshnessResult::Forced } else { self.check_freshness(provider.as_ref())? }; - if !is_fresh { + if !freshness.is_fresh() { + let reason = freshness.reason().to_string(); let cmd = provider.prepare_command()?; let outputs = provider.outputs(); let touch = provider.touch_outputs(); if opts.dry_run { // Just record that it would run, let CLI handle output - results.push(PrepareStepResult::WouldRun(id)); + results.push(PrepareStepResult::WouldRun(id, reason)); } else { to_run.push(PrepareJob { id, @@ -377,32 +420,65 @@ impl PrepareEngine { } /// Check if outputs are newer than sources (stateless mtime comparison) - fn check_freshness(&self, provider: &dyn PrepareProvider) -> Result { + /// Returns a FreshnessResult with a human-readable reason + fn check_freshness(&self, provider: &dyn PrepareProvider) -> Result { let sources = provider.sources(); let outputs = provider.outputs(); if outputs.is_empty() { - return Ok(false); // No outputs defined, always run to be safe + return Ok(FreshnessResult::NoOutputs); } // Check if any output was created this session (before prepare ran) // This handles the case where venv is auto-created but packages aren't installed yet for output in &outputs { if super::is_output_stale(output) { - return Ok(false); // Created this session, needs prepare + return Ok(FreshnessResult::SessionStale(format!( + "{} created this session", + output.display() + ))); } } - // Note: empty sources is handled below - last_modified([]) returns None, - // and if outputs don't exist either, (_, None) takes precedence → stale + // Check for missing outputs + for output in &outputs { + if !output.exists() { + return Ok(FreshnessResult::OutputsMissing(format!( + "{} does not exist", + output.display() + ))); + } + } let sources_mtime = Self::last_modified(&sources)?; let outputs_mtime = Self::last_modified(&outputs)?; match (sources_mtime, outputs_mtime) { - (Some(src), Some(out)) => Ok(src <= out), // Fresh if outputs newer or equal to sources - (_, None) => Ok(false), // No outputs exist, not fresh (takes precedence) - (None, _) => Ok(true), // No sources exist, consider fresh + (Some(src), Some(out)) if src > out => { + // Find which source is newest to provide a helpful reason + let newest_source = sources + .iter() + .filter(|p| p.exists()) + .filter_map(|p| { + let mtime = if p.is_dir() { + Self::newest_file_in_dir(p, 3) + } else { + p.metadata().ok().and_then(|m| m.modified().ok()) + }; + mtime.map(|m| (p, m)) + }) + .max_by_key(|(_, m)| *m) + .map(|(p, _)| p.display().to_string()) + .unwrap_or_else(|| "sources".to_string()); + Ok(FreshnessResult::Stale(format!( + "{newest_source} is newer than outputs" + ))) + } + (Some(_), Some(_)) => Ok(FreshnessResult::Fresh), + (_, None) => Ok(FreshnessResult::Stale( + "could not determine modification time of outputs".to_string(), + )), + (None, _) => Ok(FreshnessResult::NoSources), } } diff --git a/src/prepare/mod.rs b/src/prepare/mod.rs index 385f988973..68b7bc275f 100644 --- a/src/prepare/mod.rs +++ b/src/prepare/mod.rs @@ -115,8 +115,12 @@ pub fn notify_if_stale(config: &Arc) { let stale = engine.check_staleness(); if !stale.is_empty() { - let providers = stale.join(", "); - warn!("prepare: {providers} may need update, run `mise prep`"); + let providers: Vec = stale + .iter() + .map(|(id, reason)| format!("{id} ({reason})")) + .collect(); + let summary = providers.join(", "); + warn!("prepare: {summary} — run `mise prep`"); } }