From 66ce30af9db031a98b5610607cccaf6c8a265139 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 20:44:20 -0400 Subject: [PATCH 1/3] Improve CI output --- README.md | 64 ++- src/api.rs | 67 ++- src/cache.rs | 88 +++- src/main.rs | 460 +++++++++++++----- src/main/tests.rs | 158 ++++++ src/policy.rs | 46 +- src/report.rs | 406 ++++++++++++---- src/report/json.rs | 459 +++++++++++++++++ src/suggest.rs | 39 +- src/suggest/tests.rs | 2 + .../tests/generate_suggestions_tests.rs | 28 +- tests/ci_json_cli.rs | 137 ++++++ tests/ci_output_cli.rs | 109 +++++ tests/suggest_fix_cli.rs | 14 +- 14 files changed, 1811 insertions(+), 266 deletions(-) create mode 100644 src/main/tests.rs create mode 100644 src/report/json.rs create mode 100644 tests/ci_json_cli.rs create mode 100644 tests/ci_output_cli.rs diff --git a/README.md b/README.md index c322ef1..55f3677 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,62 @@ cargo-oxidate Cargo.lock --min-age-days 14 --max-age-days 730 | `--include-prerelease` | Consider prerelease versions as suggestion candidates (requires `--suggest-fix`); ordinary SemVer requirements (e.g. `^1.2`) still generally don't match prereleases, so most will still be rejected | | `--cache-path PATH` | Enable response caching at PATH (or set `CARGO_OXIDATE_CACHE_PATH`) | | `--cache-max-age-hours N` | Max age for cached version listings (default: 24) | +| `--quiet` | Suppress start and per-package progress messages | +| `--verbose` | Show each package as it is checked | +| `--format text\|json` | Render the final result as text (default) or JSON | At least one of `--min-age-days` or `--max-age-days` must be specified. +## CI output + +The final result is written to standard output. A concise start message and +operational diagnostics are written to standard error. Use `--verbose` to see +each package as it is checked, or `--quiet` to suppress start and progress +messages while retaining warnings, errors, and the final result. The two flags +cannot be combined. + +Use `--format json` for automation. It writes one newline-terminated JSON +document to standard output; progress and operational diagnostics remain on +standard error. Schema version 1 permits additive fields, and consumers should +ignore fields they do not recognize. + +### JSON schema + +JSON output has these top-level fields: `schema_version`, `status`, `lockfile`, +`policy`, `summary`, `violations`, `warnings`, `errors`, and `suggestions`. +`status` is `passed`, `violations`, or `error`, matching exit codes 0, 1, and 2. +The `summary` always includes the violation count and duration in milliseconds; +its package-count fields are `null` when the lockfile could not be loaded. + +Each violation has `package`, `version`, and `kind`. Age violations also include +`published_at`, `age_days`, and `threshold_days`; missing publish dates include +`reason`. Diagnostics include `category` and `message`, plus package, version, +or path context when available. Lookup diagnostics include `retryable`. + +`suggestions` is `null` unless `--suggest-fix` was requested. Once requested it +is always an array, including when no downgrade investigation was needed. Its +entries use `suggested`, `blocked`, `no_eligible_downgrade`, or `unavailable` +`kind` values and carry the applicable command, blocker, uncertainty, or +failure details. Fields may be added within schema version 1; removing a field +or changing its type or meaning requires a new schema version. + +For example: + +```json +{"schema_version":1,"status":"passed","lockfile":"Cargo.lock","policy":{"min_age_days":14,"max_age_days":null,"exclude_missing":false,"exempt":[]},"summary":{"total_packages":1,"checked_packages":1,"exempt_packages":0,"unsupported_packages":0,"excluded_missing_packages":0,"failed_packages":0,"not_checked_packages":0,"violations":0,"duration_ms":4},"violations":[],"warnings":[],"errors":[],"suggestions":null} +``` + +A lockfile that cannot be loaded still produces one document after parsing: + +```json +{"schema_version":1,"status":"error","lockfile":"missing.lock","policy":{"min_age_days":14,"max_age_days":null,"exclude_missing":false,"exempt":[]},"summary":{"total_packages":null,"checked_packages":null,"exempt_packages":null,"unsupported_packages":null,"excluded_missing_packages":null,"failed_packages":null,"not_checked_packages":null,"violations":0,"duration_ms":0},"violations":[],"warnings":[],"errors":[{"category":"input","message":"Failed to parse Cargo.lock"}],"suggestions":null} +``` + +`--exclude-missing` excludes only confirmed missing publish dates. Registry +lookup and response failures remain errors so CI can distinguish incomplete +checks from age-policy violations. Cache read and write failures are warnings; +the freshness check continues and its exit result is unchanged. + ## `--suggest-fix` `--suggest-fix` prints a `cargo update --precise` command for the newest eligible downgrade of @@ -52,14 +105,15 @@ treats every declared requirement, including optional and target-specific ones, Suggestions are best effort. The tool does not run Cargo's resolver or build your project, so Cargo can still reject a suggested command. Apply suggestions in order, then run the command again and -run your tests. If the tool cannot find an eligible downgrade, it reports the requirement that -blocks one when it knows that requirement. +run your tests. It reports whether a downgrade is blocked by a dependency +requirement, no eligible downgrade exists, or a downgrade could not be +determined because its version metadata was unavailable. ## Exit Codes -- `0` — No violations found -- `1` — Violations detected -- `2` — Runtime error +- `0` — No dependency age violations found +- `1` — Dependency age violations found +- `2` — A required check could not complete or input was invalid ## Caching diff --git a/src/api.rs b/src/api.rs index 0e05858..6d09466 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,7 +7,7 @@ use std::num::NonZeroU32; use std::path::Path; use std::time::Duration; -use crate::cache::ResponseCache; +use crate::cache::{CacheWarning, ResponseCache}; #[derive(Deserialize)] struct CrateVersionResponse { @@ -189,6 +189,7 @@ impl Default for RetryPolicy { pub struct CratesIoClient { transport: T, cache: ResponseCache, + cache_warnings: Vec, cache_max_age_hours: u64, retry_policy: RetryPolicy, /// Per-run memo of successfully fetched index records, keyed by crate @@ -218,22 +219,34 @@ impl CratesIoClient { cache_max_age_hours: u64, retry_policy: RetryPolicy, ) -> Self { + let mut cache = ResponseCache::load(cache_path); + let mut cache_warnings = Vec::new(); + if let Some(warning) = cache.take_load_warning() { + cache_warnings.push(warning); + } + Self { transport, - cache: ResponseCache::load(cache_path), + cache, + cache_warnings, cache_max_age_hours, retry_policy, fetched_index_records: HashMap::new(), } } - /// Consumes the client, saving the cache. Cache write failures are - /// reported to stderr rather than propagated, since a broken cache - /// directory shouldn't fail the whole run. - pub fn finish(self) { - if let Err(e) = self.cache.save() { - eprintln!("Warning: failed to save cache: {e}"); - } + /// Returns cache warnings gathered while setting up the client. + pub fn take_cache_warnings(&mut self) -> Vec { + std::mem::take(&mut self.cache_warnings) + } + + /// Consumes the client, saving its advisory cache. Save failures leave + /// the freshness result valid and are returned for final reporting. + pub fn finish(self) -> Option { + self.cache + .save() + .err() + .and_then(|error| self.cache.save_warning(error)) } /// Sleeps for the inter-request rate limit window. Called only from the @@ -536,6 +549,42 @@ mod tests { ) } + #[test] + fn finish_returns_cache_save_warning_without_failing_the_lookup() { + let dir = tempdir().unwrap(); + let blocking_parent = dir.path().join("not-a-directory"); + std::fs::write(&blocking_parent, "file").unwrap(); + let cache_path = blocking_parent.join("responses.json"); + let url = version_url("serde", "1.0.0"); + let transport = FakeTransport::new(); + transport.push( + &url, + ScriptedResponse::Http(200, version_body("2020-01-01T00:00:00Z")), + ); + let mut client = CratesIoClient::with_transport( + transport, + Some(&cache_path), + 24, + RetryPolicy { + retry_count: NonZeroU32::new(3).unwrap(), + retry_delay: Duration::ZERO, + pacing_delay: Duration::ZERO, + }, + ); + + assert!( + client + .fetch_publish_date("serde", "1.0.0") + .unwrap() + .is_some() + ); + let warning = client + .finish() + .expect("cache save failure should be reported"); + assert_eq!(warning.path, cache_path); + assert!(warning.message.contains("computed results remain valid")); + } + #[test] fn retries_429_then_succeeds() { let url = version_url("serde", "1.0.0"); diff --git a/src/cache.rs b/src/cache.rs index 33cb1e4..dc03def 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -33,10 +33,19 @@ pub struct ResponseCache { path: Option, data: CacheData, dirty: bool, + load_warning: Option, +} + +/// A non-fatal cache failure. Cache contents are advisory, so callers can +/// report this and continue the freshness check with an empty cache. +pub struct CacheWarning { + pub path: PathBuf, + pub message: String, } impl ResponseCache { pub fn load(path: Option<&Path>) -> Self { + let mut load_warning = None; let path = path.map(|p| p.to_path_buf()); let data = if let Some(ref p) = path { @@ -44,31 +53,47 @@ impl ResponseCache { Ok(contents) => match serde_json::from_str::(&contents) { Ok(data) if data.version == CACHE_VERSION => data, Ok(data) => { - eprintln!( - "Warning: unsupported cache version {} at {}, starting fresh", - data.version, - p.display() - ); + load_warning = Some(CacheWarning { + path: p.clone(), + message: format!( + "Cache version {} is unsupported; checking continues without cached responses", + data.version + ), + }); CacheData { version: CACHE_VERSION, ..Default::default() } } Err(e) => { - eprintln!( - "Warning: corrupted cache file at {}, starting fresh: {e}", - p.display() - ); + load_warning = Some(CacheWarning { + path: p.clone(), + message: format!( + "Could not read cache; checking continues without cached responses: {e}" + ), + }); CacheData { version: CACHE_VERSION, ..Default::default() } } }, - Err(_) => CacheData { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => CacheData { version: CACHE_VERSION, ..Default::default() }, + Err(error) => { + load_warning = Some(CacheWarning { + path: p.clone(), + message: format!( + "Could not read cache; checking continues without cached responses: {error}" + ), + }); + CacheData { + version: CACHE_VERSION, + ..Default::default() + } + } } } else { CacheData { @@ -81,9 +106,23 @@ impl ResponseCache { path, data, dirty: false, + load_warning, } } + pub fn take_load_warning(&mut self) -> Option { + self.load_warning.take() + } + + pub fn save_warning(&self, error: anyhow::Error) -> Option { + self.path.as_ref().map(|path| CacheWarning { + path: path.clone(), + message: format!( + "Could not save cache; computed results remain valid but could not be cached: {error}" + ), + }) + } + pub fn get_publish_date(&self, name: &str, version: &str) -> Option> { let key = format!("{name}/{version}"); self.data.publish_dates.get(&key).copied() @@ -251,13 +290,18 @@ mod tests { let path = dir.path().join("cache.json"); std::fs::write(&path, "{ not valid json").unwrap(); - let cache = ResponseCache::load(Some(&path)); + let mut cache = ResponseCache::load(Some(&path)); assert_eq!(cache.get_publish_date("anything", "0.0.1"), None); assert!( cache .get_all_versions("anything", Duration::hours(1)) .is_none() ); + let warning = cache + .take_load_warning() + .expect("corrupt cache should produce a warning"); + assert_eq!(warning.path, path); + assert!(warning.message.contains("checking continues")); } #[test] @@ -290,8 +334,28 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("does-not-exist.json"); - let cache = ResponseCache::load(Some(&path)); + let mut cache = ResponseCache::load(Some(&path)); assert_eq!(cache.get_publish_date("serde", "1.0.0"), None); + assert!(cache.take_load_warning().is_none()); + } + + #[test] + fn save_failure_has_a_nonfatal_warning() { + let dir = tempdir().unwrap(); + let blocking_parent = dir.path().join("not-a-directory"); + std::fs::write(&blocking_parent, "file").unwrap(); + let path = blocking_parent.join("cache.json"); + let mut cache = ResponseCache::load(Some(&path)); + cache.set_publish_date("serde", "1.0.0", sample_date()); + + let error = cache + .save() + .expect_err("a file cannot be a cache directory"); + let warning = cache + .save_warning(error) + .expect("configured cache path should produce a warning"); + assert_eq!(warning.path, path); + assert!(warning.message.contains("computed results remain valid")); } #[test] diff --git a/src/main.rs b/src/main.rs index 74b7530..1c4e4c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, ValueEnum}; use std::path::PathBuf; use std::process::ExitCode; +use std::time::Instant; mod api; mod cache; @@ -11,12 +11,36 @@ mod policy; mod report; mod suggest; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Verbosity { + Quiet, + Normal, + Verbose, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum OutputFormat { + #[default] + Text, + Json, +} + +impl Verbosity { + fn shows_start(self) -> bool { + !matches!(self, Self::Quiet) + } + + fn shows_progress(self) -> bool { + matches!(self, Self::Verbose) + } +} + #[derive(Parser, Debug)] #[command( name = "cargo-oxidate", version, about = "Check Cargo dependency freshness", - after_help = "By default, packages whose publish date cannot be determined are treated as violations. Use --exclude-missing to suppress them." + after_help = "By default, confirmed missing publish dates are reported as violations. Use --exclude-missing to exclude them; lookup failures remain errors." )] struct Cli { /// Path to the Cargo.lock file @@ -35,7 +59,7 @@ struct Cli { #[arg(long, value_delimiter = ',')] exempt: Vec, - /// Exclude packages whose publish date cannot be determined from violations (by default they are included) + /// Exclude confirmed missing publish dates from violations #[arg(long)] exclude_missing: bool, @@ -62,6 +86,115 @@ struct Cli { /// Maximum age in hours for cached all-versions responses #[arg(long, default_value_t = 24)] cache_max_age_hours: u64, + + /// Suppress start and progress messages + #[arg(long, conflicts_with = "verbose")] + quiet: bool, + + /// Show each package as it is checked + #[arg(long, conflicts_with = "quiet")] + verbose: bool, + + /// Render the final result as text or JSON + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +impl Cli { + fn verbosity(&self) -> Verbosity { + if self.quiet { + Verbosity::Quiet + } else if self.verbose { + Verbosity::Verbose + } else { + Verbosity::Normal + } + } +} + +fn print_start(lockfile: &std::path::Path, policy: &report::Policy) { + let minimum_age = policy + .min_age_days + .map_or_else(|| "not set".to_string(), |days| format!("{days} days")); + let maximum_age = policy + .max_age_days + .map_or_else(|| "not set".to_string(), |days| format!("{days} days")); + eprintln!( + "Checking dependency ages in {} (minimum age: {minimum_age}; maximum age: {maximum_age}).", + lockfile.display() + ); +} + +fn failed_report( + lockfile: PathBuf, + policy: report::Policy, + message: impl Into, + started: Instant, +) -> report::RunReport { + let message = message.into(); + eprintln!("error: {message}"); + let mut report = report::RunReport::failed(lockfile, policy, message); + report.duration = started.elapsed(); + report +} + +fn setup_failure_report( + lockfile: PathBuf, + policy: report::Policy, + freshness_policy: &policy::FreshnessPolicy, + packages: &[lockfile::Package], + message: impl Into, + started: Instant, +) -> report::RunReport { + let message = message.into(); + eprintln!("error: {message}"); + let mut report = report::RunReport::completed( + lockfile, + policy, + report::Summary { + total_packages: packages.len(), + ..Default::default() + }, + ); + let summary = report + .summary + .as_mut() + .expect("completed reports have coverage"); + for package in packages { + if !package.is_registry { + summary.unsupported_packages += 1; + } else if freshness_policy.is_exempt(&package.name) { + summary.exempt_packages += 1; + } else { + summary.not_checked_packages += 1; + } + } + report.required_errors.push(report::Diagnostic { + category: "input", + package: None, + version: None, + path: None, + message, + retryable: false, + }); + report.duration = started.elapsed(); + report +} + +fn record_cache_warnings( + report: &mut report::RunReport, + warnings: impl IntoIterator, +) { + for warning in warnings { + report.warnings.push(report::Diagnostic { + category: "cache", + package: None, + version: None, + path: Some(warning.path), + message: warning.message, + retryable: false, + }); + } } fn main() -> ExitCode { @@ -73,99 +206,201 @@ fn main() -> ExitCode { .collect(); let cli = Cli::parse_from(args); - match run(cli) { - Ok(has_violations) => { - if has_violations { - ExitCode::from(1) - } else { - ExitCode::from(0) + let verbosity = cli.verbosity(); + let suggestions_requested = cli.suggest_fix; + let output_format = cli.format; + let mut report = run(cli, verbosity); + if suggestions_requested && report.suggestions.is_none() { + report.suggestions = Some(Vec::new()); + } + let exit_code = report.exit_code(); + match output_format { + OutputFormat::Text => report::print_report(&report), + OutputFormat::Json => match report::render_json(&report) { + Ok(json) => println!("{json}"), + Err(error) => { + eprintln!("error: Could not render JSON report: {error}"); + return ExitCode::from(2); } - } - Err(e) => { - eprintln!("Error: {e:#}"); - ExitCode::from(2) - } + }, } + ExitCode::from(exit_code) } -/// Checks one package's publish date and produces any violations it triggers. -/// Logs a warning to stderr when the fetch errors out. -fn check_package( - client: &mut api::CratesIoClient, +/// Checks all lockfile entries through a client. The generic transport keeps +/// required-check behaviour testable without a public test-only CLI switch. +fn check_packages( + client: &mut api::CratesIoClient, freshness_policy: &policy::FreshnessPolicy, - pkg: &lockfile::Package, + packages: &[lockfile::Package], + supplied_lockfile: PathBuf, now: chrono::DateTime, -) -> Vec { - let result = client.fetch_publish_date(&pkg.name, &pkg.version); + mut on_check: Option<&mut dyn FnMut(&lockfile::Package)>, +) -> report::RunReport { + let started = Instant::now(); + let mut report = report::RunReport::completed( + supplied_lockfile, + freshness_policy.report_policy(), + report::Summary { + total_packages: packages.len(), + ..Default::default() + }, + ); + + for pkg in packages { + let summary = report + .summary + .as_mut() + .expect("completed reports have coverage"); + if !pkg.is_registry { + summary.unsupported_packages += 1; + continue; + } + if freshness_policy.is_exempt(&pkg.name) { + summary.exempt_packages += 1; + continue; + } - if let Err(ref e) = result { - let severity = match e { - api::FetchError::Retryable(_) => "transient", - api::FetchError::Permanent(_) => "permanent", - }; - eprintln!( - "\n Warning: {severity} error checking {}@{}: {e}", - pkg.name, pkg.version - ); + if let Some(on_check) = on_check.as_mut() { + on_check(pkg); + } + + match client.fetch_publish_date(&pkg.name, &pkg.version) { + Ok(Some(published)) => { + summary.checked_packages += 1; + report + .violations + .extend(freshness_policy.evaluate(pkg, Some(published), now)); + } + Ok(None) if freshness_policy.excludes_missing() => { + summary.excluded_missing_packages += 1; + } + Ok(None) => { + summary.checked_packages += 1; + report + .violations + .extend(freshness_policy.evaluate(pkg, None, now)); + } + Err(error) => { + eprintln!( + "error: Could not check {}@{}: {error}", + pkg.name, pkg.version + ); + summary.failed_packages += 1; + report.required_errors.push(report::Diagnostic { + category: "lookup", + package: Some(pkg.name.clone()), + version: Some(pkg.version.clone()), + path: None, + retryable: matches!(error, api::FetchError::Retryable(_)), + message: error.to_string(), + }); + } + } } - freshness_policy.evaluate(pkg, result.ok().flatten(), now) + report.duration = started.elapsed(); + report } -fn run(cli: Cli) -> Result { +fn run(cli: Cli, verbosity: Verbosity) -> report::RunReport { + let started = Instant::now(); + let supplied_lockfile = cli.cargo_lock.clone(); + let report_policy = report::Policy::new( + cli.min_age_days, + cli.max_age_days, + cli.exclude_missing, + cli.exempt.clone(), + ); let freshness_policy = policy::FreshnessPolicy::new( cli.min_age_days, cli.max_age_days, cli.exclude_missing, cli.exempt, - )?; + ); + let freshness_policy = match freshness_policy { + Ok(policy) => policy, + Err(error) => { + return failed_report(supplied_lockfile, report_policy, error.to_string(), started); + } + }; let suggest_min_age = cli.suggest_fix.then_some(cli.min_age_days).flatten(); - let working_dir = std::env::current_dir().context("Failed to get current directory")?; + let working_dir = match std::env::current_dir() { + Ok(directory) => directory, + Err(error) => { + return failed_report( + supplied_lockfile, + report_policy, + format!("Failed to get current directory: {error}"), + started, + ); + } + }; // Parse lockfile - let loaded_lockfile = lockfile::load(&cli.cargo_lock, &working_dir)?; + let loaded_lockfile = match lockfile::load(&cli.cargo_lock, &working_dir) { + Ok(lockfile) => lockfile, + Err(error) => { + return failed_report( + supplied_lockfile, + report_policy, + format!("{error:#}"), + started, + ); + } + }; let packages = loaded_lockfile.packages; // Build API client - let mut client = api::CratesIoClient::new( + let mut client = match api::CratesIoClient::new( cli.timeout, cli.cache_path.as_deref(), cli.cache_max_age_hours, - )?; - - // Check each package - let mut violations = Vec::new(); - let now = chrono::Utc::now(); - - let registry_packages: Vec<&lockfile::Package> = - packages.iter().filter(|p| p.is_registry).collect(); - - let total = registry_packages.len(); - for (i, pkg) in registry_packages.iter().enumerate() { - if freshness_policy.is_exempt(&pkg.name) { - continue; + ) { + Ok(client) => client, + Err(error) => { + return setup_failure_report( + supplied_lockfile, + report_policy, + &freshness_policy, + &packages, + error.to_string(), + started, + ); } + }; + let initial_cache_warnings = client.take_cache_warnings(); + for warning in &initial_cache_warnings { + eprintln!("warning: {}: {}", warning.path.display(), warning.message); + } - eprintln!( - " Checking [{}/{}] {}@{}", - i + 1, - total, - pkg.name, - pkg.version - ); - - violations.extend(check_package(&mut client, &freshness_policy, pkg, now)); + if verbosity.shows_start() { + print_start(&loaded_lockfile.path, &freshness_policy.report_policy()); } - // Print report - report::print_report(&violations); + let now = chrono::Utc::now(); + let mut print_check_progress = |pkg: &lockfile::Package| { + if verbosity.shows_progress() { + eprintln!("Checking {}@{}", pkg.name, pkg.version); + } + }; + let mut report = check_packages( + &mut client, + &freshness_policy, + &packages, + supplied_lockfile, + now, + Some(&mut print_check_progress), + ); + record_cache_warnings(&mut report, initial_cache_warnings); // Generate suggestions if requested - let has_too_new = violations + let has_too_new = report + .violations .iter() - .any(|v| matches!(v.kind, report::ViolationKind::TooNew(_))); + .any(|violation| matches!(violation.kind, report::ViolationKind::TooNew(_))); if let Some(min_age) = suggest_min_age && has_too_new { @@ -173,66 +408,71 @@ fn run(cli: Cli) -> Result { let (direct_requirements, manifest_warnings) = manifest::load_direct_requirements(lockfile_dir); - for warning in &manifest_warnings { - eprintln!(" Warning: {warning}"); + for warning in manifest_warnings { + eprintln!("warning: {warning}"); + report.warnings.push(report::Diagnostic { + category: "manifest", + package: None, + version: None, + path: None, + message: warning, + retryable: false, + }); } - if let Some(outcomes) = suggest::generate_suggestions( + let mut print_suggestion_progress = |progress: suggest::SuggestionProgress| { + if verbosity.shows_progress() { + eprintln!( + "Checking suggestion [{}/{}] {}", + progress.current, progress.total, progress.package + ); + } + }; + let outcomes = suggest::generate_suggestions( &mut client, - &violations, + &report.violations, &packages, &direct_requirements, lockfile_dir, min_age, cli.include_prerelease, now, - ) { - report::print_suggestions(&outcomes); + &mut print_suggestion_progress, + ) + .unwrap_or_default(); + for outcome in &outcomes { + if let suggest::Outcome::Unavailable { + package, + locked_version, + reason, + } = outcome + { + let message = format!( + "Could not determine a downgrade for {package}@{locked_version}: {reason}" + ); + eprintln!("warning: {message}"); + report.warnings.push(report::Diagnostic { + category: "suggestion", + package: Some(package.clone()), + version: Some(locked_version.clone()), + path: None, + message, + retryable: false, + }); + } } + report.suggestions = Some(outcomes); } - client.finish(); + if let Some(warning) = client.finish() { + eprintln!("warning: {}: {}", warning.path.display(), warning.message); + record_cache_warnings(&mut report, [warning]); + } + report.duration = started.elapsed(); - Ok(!violations.is_empty()) + report } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn suggest_fix_without_min_age_days_fails_to_parse() { - let result = - Cli::try_parse_from(["cargo-oxidate", "--suggest-fix", "--max-age-days", "30"]); - assert!(result.is_err()); - } - - #[test] - fn suggest_fix_with_min_age_days_parses() { - let result = Cli::try_parse_from(["cargo-oxidate", "--suggest-fix", "--min-age-days", "7"]); - assert!(result.is_ok()); - } - - #[test] - fn include_prerelease_without_suggest_fix_fails_to_parse() { - let result = Cli::try_parse_from([ - "cargo-oxidate", - "--include-prerelease", - "--min-age-days", - "7", - ]); - assert!(result.is_err()); - } - - #[test] - fn include_prerelease_with_suggest_fix_parses() { - let result = Cli::try_parse_from([ - "cargo-oxidate", - "--suggest-fix", - "--min-age-days", - "7", - "--include-prerelease", - ]); - assert!(result.is_ok()); - } -} +#[path = "main/tests.rs"] +mod tests; diff --git a/src/main/tests.rs b/src/main/tests.rs new file mode 100644 index 0000000..686727b --- /dev/null +++ b/src/main/tests.rs @@ -0,0 +1,158 @@ +use super::*; +use crate::api::test_support::{FakeTransport, ScriptedResponse}; +use std::num::NonZeroU32; +use std::time::Duration; + +fn package(name: &str, version: &str, is_registry: bool) -> lockfile::Package { + lockfile::Package { + name: name.to_string(), + version: version.to_string(), + is_registry, + source: None, + dependencies: vec![], + } +} + +fn client(transport: FakeTransport) -> api::CratesIoClient { + api::CratesIoClient::with_transport( + transport, + None, + 24, + api::RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::ZERO, + pacing_delay: Duration::ZERO, + }, + ) +} + +fn version_url(name: &str, version: &str) -> String { + format!("https://crates.io/api/v1/crates/{name}/{version}") +} + +fn version_body(created_at: chrono::DateTime) -> String { + format!(r#"{{"version":{{"created_at":"{created_at}"}}}}"#) +} + +#[test] +fn suggest_fix_without_min_age_days_fails_to_parse() { + let result = Cli::try_parse_from(["cargo-oxidate", "--suggest-fix", "--max-age-days", "30"]); + assert!(result.is_err()); +} + +#[test] +fn suggest_fix_with_min_age_days_parses() { + let result = Cli::try_parse_from(["cargo-oxidate", "--suggest-fix", "--min-age-days", "7"]); + assert!(result.is_ok()); +} + +#[test] +fn include_prerelease_without_suggest_fix_fails_to_parse() { + let result = Cli::try_parse_from([ + "cargo-oxidate", + "--include-prerelease", + "--min-age-days", + "7", + ]); + assert!(result.is_err()); +} + +#[test] +fn include_prerelease_with_suggest_fix_parses() { + let result = Cli::try_parse_from([ + "cargo-oxidate", + "--suggest-fix", + "--min-age-days", + "7", + "--include-prerelease", + ]); + assert!(result.is_ok()); +} + +#[test] +fn required_lookup_errors_take_precedence_and_keep_other_findings() { + let now = chrono::Utc::now(); + let transport = FakeTransport::new(); + transport.push( + &version_url("young", "1.0.0"), + ScriptedResponse::Http(200, version_body(now)), + ); + transport.push( + &version_url("missing", "1.0.0"), + ScriptedResponse::Http(404, String::new()), + ); + transport.push( + &version_url("broken", "1.0.0"), + ScriptedResponse::Http(400, String::new()), + ); + let policy = policy::FreshnessPolicy::new(Some(30), None, false, vec![]).unwrap(); + let report = check_packages( + &mut client(transport), + &policy, + &[ + package("young", "1.0.0", true), + package("missing", "1.0.0", true), + package("broken", "1.0.0", true), + package("local", "0.1.0", false), + ], + PathBuf::from("Cargo.lock"), + now, + None, + ); + + let summary = report.summary.as_ref().unwrap(); + assert_eq!(report.exit_code(), 2); + assert_eq!(summary.total_packages, 4); + assert_eq!(summary.checked_packages, 2); + assert_eq!(summary.failed_packages, 1); + assert_eq!(summary.unsupported_packages, 1); + assert_eq!(report.violations.len(), 2); + assert_eq!(report.required_errors.len(), 1); + assert_eq!(report.required_errors[0].package.as_deref(), Some("broken")); +} + +#[test] +fn excluded_missing_dates_are_not_required_errors() { + let now = chrono::Utc::now(); + let transport = FakeTransport::new(); + transport.push( + &version_url("missing", "1.0.0"), + ScriptedResponse::Http(404, String::new()), + ); + let policy = policy::FreshnessPolicy::new(Some(30), None, true, vec![]).unwrap(); + let report = check_packages( + &mut client(transport), + &policy, + &[package("missing", "1.0.0", true)], + PathBuf::from("Cargo.lock"), + now, + None, + ); + + assert_eq!(report.exit_code(), 0); + assert_eq!(report.summary.unwrap().excluded_missing_packages, 1); + assert!(report.violations.is_empty()); + assert!(report.required_errors.is_empty()); +} + +#[test] +fn unsupported_entries_are_counted_before_exemptions() { + let policy = policy::FreshnessPolicy::new(Some(30), None, false, vec!["same".into()]).unwrap(); + let report = check_packages( + &mut client(FakeTransport::new()), + &policy, + &[ + package("same", "1.0.0", false), + package("same", "1.0.0", true), + ], + PathBuf::from("Cargo.lock"), + chrono::Utc::now(), + None, + ); + + assert_eq!(report.exit_code(), 0); + let summary = report.summary.as_ref().unwrap(); + assert_eq!(summary.unsupported_packages, 1); + assert_eq!(summary.exempt_packages, 1); + assert_eq!(summary.checked_packages, 0); +} diff --git a/src/policy.rs b/src/policy.rs index fd98a06..85ff7fb 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -14,6 +14,7 @@ pub struct FreshnessPolicy { max_age_days: Option, exclude_missing: bool, exempt: HashSet, + effective_exemptions: Vec, } impl FreshnessPolicy { @@ -29,11 +30,15 @@ impl FreshnessPolicy { anyhow::bail!("At least one of --min-age-days or --max-age-days must be specified"); } + let effective_exemptions = + crate::report::Policy::new(min_age_days, max_age_days, exclude_missing, exempt).exempt; + Ok(Self { min_age_days, max_age_days, exclude_missing, - exempt: exempt.iter().map(|s| s.trim().to_string()).collect(), + exempt: effective_exemptions.iter().cloned().collect(), + effective_exemptions, }) } @@ -41,6 +46,19 @@ impl FreshnessPolicy { self.exempt.contains(name) } + pub fn report_policy(&self) -> crate::report::Policy { + crate::report::Policy { + min_age_days: self.min_age_days, + max_age_days: self.max_age_days, + exclude_missing: self.exclude_missing, + exempt: self.effective_exemptions.clone(), + } + } + + pub fn excludes_missing(&self) -> bool { + self.exclude_missing + } + /// Evaluates a package against the policy given its publish date (`None` /// if the lookup failed or found no date) and the current time, /// returning the violations triggered. Warning about a failed lookup is @@ -58,7 +76,9 @@ impl FreshnessPolicy { violations.push(Violation { package: pkg.name.clone(), version: pkg.version.clone(), - kind: ViolationKind::Unknown, + kind: ViolationKind::MissingPublishDate { + reason: "crates.io did not return this package version".to_string(), + }, }); } return violations; @@ -211,7 +231,10 @@ mod tests { let violations = policy.evaluate(&pkg(), None, now); assert_eq!(violations.len(), 1); - assert!(matches!(violations[0].kind, ViolationKind::Unknown)); + assert!(matches!( + violations[0].kind, + ViolationKind::MissingPublishDate { .. } + )); } #[test] @@ -230,4 +253,21 @@ mod tests { assert!(policy.is_exempt("serde")); assert!(!policy.is_exempt(" serde ")); } + + #[test] + fn effective_exemptions_are_trimmed_deduplicated_and_sorted() { + let policy = FreshnessPolicy::new( + Some(7), + None, + false, + vec![ + " zeta ".to_string(), + "alpha".to_string(), + "zeta".to_string(), + ], + ) + .unwrap(); + + assert_eq!(policy.report_policy().exempt, ["alpha", "zeta"]); + } } diff --git a/src/report.rs b/src/report.rs index 6c6e250..4c78997 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,5 +1,9 @@ -use crate::suggest::Outcome; use chrono::{DateTime, Utc}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +mod json; +pub use json::render_json; /// A package's publish date and its resulting age, shared by both /// age-threshold violation kinds. @@ -11,7 +15,7 @@ pub struct Aged { pub enum ViolationKind { TooNew(Aged), TooOld(Aged), - Unknown, + MissingPublishDate { reason: String }, } pub struct Violation { @@ -20,137 +24,323 @@ pub struct Violation { pub kind: ViolationKind, } -fn print_section(header: &str, violations: &[(&Violation, &Aged)]) { - if violations.is_empty() { - return; - } - println!(" {header}"); - let days_width = violations - .iter() - .map(|(_, aged)| aged.age_days.to_string().len()) - .max() - .unwrap_or(1); - for (v, aged) in violations { - let date_str = aged.published.format("%Y-%m-%d").to_string(); - println!( - " {} | {:>width$} days old | {} {}", - date_str, - aged.age_days, - v.package, - v.version, - width = days_width - ); +/// The effective age policy used for a run. This is data rather than CLI +/// arguments so every renderer reports the same policy after normalization. +pub struct Policy { + pub min_age_days: Option, + pub max_age_days: Option, + pub exclude_missing: bool, + pub exempt: Vec, +} + +impl Policy { + pub fn new( + min_age_days: Option, + max_age_days: Option, + exclude_missing: bool, + mut exempt: Vec, + ) -> Self { + exempt + .iter_mut() + .for_each(|name| *name = name.trim().to_string()); + exempt.retain(|name| !name.is_empty()); + exempt.sort(); + exempt.dedup(); + Self { + min_age_days, + max_age_days, + exclude_missing, + exempt, + } } - println!(); } -pub fn print_report(violations: &[Violation]) { - if violations.is_empty() { - println!("\n✅ All dependencies pass freshness checks."); - return; +/// Disjoint coverage counts for lockfile package entries. +#[derive(Default)] +pub struct Summary { + pub total_packages: usize, + pub checked_packages: usize, + pub exempt_packages: usize, + pub unsupported_packages: usize, + pub excluded_missing_packages: usize, + pub failed_packages: usize, + pub not_checked_packages: usize, +} + +pub struct Diagnostic { + pub category: &'static str, + pub package: Option, + pub version: Option, + pub path: Option, + pub message: String, + pub retryable: bool, +} + +/// The complete outcome of one invocation after argument parsing. It is the +/// single source for the text report today and structured output later. +pub struct RunReport { + /// The path supplied by the caller, retained even when resolution fails. + pub lockfile: PathBuf, + pub policy: Policy, + pub summary: Option, + pub violations: Vec, + pub required_errors: Vec, + pub warnings: Vec, + /// None when suggestions were not requested; otherwise every requested + /// investigation outcome is present, including an empty list. + pub suggestions: Option>, + pub duration: Duration, +} + +impl RunReport { + pub fn completed(lockfile: PathBuf, policy: Policy, summary: Summary) -> Self { + Self { + lockfile, + policy, + summary: Some(summary), + violations: Vec::new(), + required_errors: Vec::new(), + warnings: Vec::new(), + suggestions: None, + duration: Duration::ZERO, + } } - println!("\n❌ {} dependency violation(s) found:\n", violations.len()); + pub fn failed(lockfile: PathBuf, policy: Policy, message: impl Into) -> Self { + Self { + lockfile, + policy, + summary: None, + violations: Vec::new(), + required_errors: vec![Diagnostic { + category: "input", + package: None, + version: None, + path: None, + message: message.into(), + retryable: false, + }], + warnings: Vec::new(), + suggestions: None, + duration: Duration::ZERO, + } + } - // Group by kind - let too_new: Vec<_> = violations - .iter() - .filter_map(|v| match &v.kind { - ViolationKind::TooNew(aged) => Some((v, aged)), - _ => None, - }) - .collect(); - let too_old: Vec<_> = violations - .iter() - .filter_map(|v| match &v.kind { - ViolationKind::TooOld(aged) => Some((v, aged)), - _ => None, - }) - .collect(); - let unknown: Vec<_> = violations - .iter() - .filter(|v| matches!(v.kind, ViolationKind::Unknown)) - .collect(); + pub fn exit_code(&self) -> u8 { + if !self.required_errors.is_empty() { + 2 + } else if !self.violations.is_empty() { + 1 + } else { + 0 + } + } +} - print_section( - "🚨 Too New (younger than threshold - possible supply chain risk):", - &too_new, +fn print_policy(policy: &Policy, lockfile: &Path) { + println!("Dependency age policy"); + println!(" Lockfile: {}", lockfile.display()); + match policy.min_age_days { + Some(days) => println!(" Minimum age: {days} days"), + None => println!(" Minimum age: not set"), + } + match policy.max_age_days { + Some(days) => println!(" Maximum age: {days} days"), + None => println!(" Maximum age: not set"), + } + if !policy.exempt.is_empty() { + println!(" Exempt packages: {}", policy.exempt.join(", ")); + } + println!( + " Missing publish dates: {}", + if policy.exclude_missing { + "excluded" + } else { + "reported as violations" + } ); - print_section( - "⏰ Too Old (older than threshold - consider updating):", - &too_old, +} + +fn print_summary(summary: &Summary, violations: usize, duration: Duration) { + println!("Coverage"); + println!( + " packages: total={}, checked={}, exempt={}, unsupported={}, excluded missing={}, failed={}, not checked={}", + summary.total_packages, + summary.checked_packages, + summary.exempt_packages, + summary.unsupported_packages, + summary.excluded_missing_packages, + summary.failed_packages, + summary.not_checked_packages, ); + println!(" violations: {violations}"); + println!(" elapsed: {} ms", duration.as_millis()); +} - if !unknown.is_empty() { - println!(" ❓ Unknown (publish date could not be determined):"); - for v in &unknown { - println!( - " {:<10} | {:>15} | {} {}", - "unknown", "--", v.package, v.version - ); +fn print_violations(violations: &[Violation], policy: &Policy) { + if violations.is_empty() { + return; + } + + println!("Dependency age violations"); + for violation in violations { + match &violation.kind { + ViolationKind::TooNew(aged) => println!( + " {}@{}: Below minimum age {} days (published {}, {} days old)", + violation.package, + violation.version, + policy.min_age_days.unwrap_or_default(), + aged.published.format("%Y-%m-%d"), + aged.age_days, + ), + ViolationKind::TooOld(aged) => println!( + " {}@{}: Above maximum age {} days (published {}, {} days old)", + violation.package, + violation.version, + policy.max_age_days.unwrap_or_default(), + aged.published.format("%Y-%m-%d"), + aged.age_days, + ), + ViolationKind::MissingPublishDate { reason } => println!( + " {}@{}: Publish date unavailable: {reason}", + violation.package, violation.version + ), } - println!(); } } -pub fn print_suggestions(outcomes: &[Outcome]) { - if outcomes.is_empty() { - println!("\n⚠️ Could not check \"too new\" violations for compliant versions."); - println!(" The registry may have been unreachable, or their versions unparsable.\n"); - return; +/// Renders a completed or initialization-failed run. Operational messages +/// remain on stderr; this is the final result written to stdout. +pub fn print_report(report: &RunReport) { + if !report.required_errors.is_empty() { + println!("Dependency age check incomplete"); + } else if !report.violations.is_empty() { + println!("Dependency age violations found"); + } else if report + .summary + .as_ref() + .is_some_and(|summary| summary.checked_packages == 0) + { + println!("No eligible dependencies checked."); + } else { + println!("No dependency age violations found."); } + print_policy(&report.policy, &report.lockfile); - let has_suggestion = outcomes - .iter() - .any(|o| matches!(o, Outcome::Suggest { .. })); + if let Some(summary) = &report.summary { + print_summary(summary, report.violations.len(), report.duration); + } else { + println!("Coverage unavailable."); + println!("Elapsed: {} ms", report.duration.as_millis()); + } + + if !report.required_errors.is_empty() { + for error in &report.required_errors { + let retryability = if error.retryable { " (retryable)" } else { "" }; + match (&error.package, &error.version) { + (Some(package), Some(version)) => println!( + " error: could not check {package}@{version}{retryability}: {}", + error.message, + ), + _ => println!(" error{retryability}: {}", error.message), + } + } + } + + print_violations(&report.violations, &report.policy); + + print_warnings(&report.warnings); + if let Some(outcomes) = &report.suggestions { + print_suggestions(outcomes); + } +} - if has_suggestion { +fn print_warnings(warnings: &[Diagnostic]) { + for warning in warnings { + // An unavailable suggestion is already rendered as its own outcome. + // Keep its diagnostic structured for JSON and stderr without + // repeating the same message in the text result. + if warning.category == "suggestion" { + continue; + } + let context = match (&warning.package, &warning.version, &warning.path) { + (Some(package), Some(version), _) => format!(" for {package}@{version}"), + (_, _, Some(path)) => format!(" for {}", path.display()), + _ => String::new(), + }; println!( - "\n💡 Suggested fixes for \"too new\" violations (apply top to bottom, then re-run):\n" + "warning: {}{context}: {}", + warning.category, warning.message ); + } +} - for outcome in outcomes { - if let Outcome::Suggest { +pub fn print_suggestions(outcomes: &[crate::suggest::Outcome]) { + if outcomes.is_empty() { + return; + } + + let suggestions: Vec<_> = outcomes + .iter() + .filter_map(|outcome| match outcome { + crate::suggest::Outcome::Suggest { package_spec, locked_version, suggested_version, suggested_age_days, unverified_dependents, .. - } = outcome - { - let annotation = if unverified_dependents.is_empty() { - String::new() - } else { - format!( - " (requirement of {} unverified)", - unverified_dependents.join(", ") - ) - }; - println!( - " cargo update -p {package_spec}@{locked_version} --precise {suggested_version} # {suggested_age_days} days old{annotation}" - ); - } + } => Some(( + package_spec, + locked_version, + suggested_version, + suggested_age_days, + unverified_dependents, + )), + _ => None, + }) + .collect(); + + if !suggestions.is_empty() { + println!("\nSuggested downgrades (apply top to bottom, then re-run):"); + for ( + package_spec, + locked_version, + suggested_version, + suggested_age_days, + unverified_dependents, + ) in &suggestions + { + let annotation = if unverified_dependents.is_empty() { + String::new() + } else { + format!( + " (requirement of {} unverified)", + unverified_dependents.join(", ") + ) + }; + println!( + " cargo update -p {package_spec}@{locked_version} --precise {suggested_version} # {suggested_age_days} days old{annotation}" + ); } } - let blocked: Vec<&Outcome> = outcomes + let blocked: Vec<_> = outcomes .iter() - .filter(|o| !matches!(o, Outcome::Suggest { .. })) + .filter(|outcome| !matches!(outcome, crate::suggest::Outcome::Suggest { .. })) .collect(); - if !blocked.is_empty() { - println!("\n⛔ No compatible compliant version:\n"); + println!("\nOther downgrade outcomes:"); for outcome in blocked { match outcome { - Outcome::Blocked { + crate::suggest::Outcome::Blocked { package, locked_version, newest_compliant, blocker, } => { let source = match &blocker.version { - Some(v) => format!("{} {v}", blocker.name), + Some(version) => format!("{} {version}", blocker.name), None => blocker.name.clone(), }; let also_suggested = if blocker.also_suggested { @@ -161,31 +351,31 @@ pub fn print_suggestions(outcomes: &[Outcome]) { String::new() }; println!( - " {package} {locked_version}: newest compliant is {newest_compliant}, but {source} requires {}{also_suggested}", + " Downgrade blocked by dependency requirements for {package}@{locked_version}: newest eligible version is {newest_compliant}, but {source} requires {}{also_suggested}", blocker.req ); } - Outcome::NoCompliantVersion { + crate::suggest::Outcome::NoCompliantVersion { package, locked_version, - } => { - println!( - " {package} {locked_version}: no eligible downgrade at least the minimum age old within its compatible range" - ); - } - Outcome::Suggest { .. } => unreachable!(), + } => println!( + " No eligible downgrade found for {package}@{locked_version} within its compatible version range" + ), + crate::suggest::Outcome::Unavailable { + package, + locked_version, + reason, + } => println!( + " Could not determine a downgrade for {package}@{locked_version}: {reason}" + ), + crate::suggest::Outcome::Suggest { .. } => unreachable!(), } } } - if has_suggestion { + if !suggestions.is_empty() { println!( - r#" - Suggestions satisfy, on a best-effort basis, the version requirements verified from - Cargo.lock and your manifests. Requirements marked "unverified" above were not checked - and Cargo may still reject that suggestion. Source compatibility is not verified: build - or test after applying. -"# + "\nSuggestions satisfy, on a best-effort basis, version requirements verified from Cargo.lock and your manifests. Requirements marked \"unverified\" above were not checked, and Cargo may still reject a suggestion. Source compatibility is not verified: build or test after applying." ); } } diff --git a/src/report/json.rs b/src/report/json.rs new file mode 100644 index 0000000..7793d3c --- /dev/null +++ b/src/report/json.rs @@ -0,0 +1,459 @@ +use super::{Diagnostic, RunReport, Summary, Violation, ViolationKind}; +use crate::suggest::Outcome; +use chrono::SecondsFormat; +use serde::Serialize; + +#[derive(Serialize)] +struct JsonReport { + schema_version: u8, + status: &'static str, + lockfile: String, + policy: JsonPolicy, + summary: JsonSummary, + violations: Vec, + warnings: Vec, + errors: Vec, + suggestions: Option>, +} + +#[derive(Serialize)] +struct JsonPolicy { + min_age_days: Option, + max_age_days: Option, + exclude_missing: bool, + exempt: Vec, +} + +#[derive(Serialize)] +struct JsonSummary { + total_packages: Option, + checked_packages: Option, + exempt_packages: Option, + unsupported_packages: Option, + excluded_missing_packages: Option, + failed_packages: Option, + not_checked_packages: Option, + violations: usize, + duration_ms: u128, +} + +#[derive(Serialize)] +struct JsonViolation { + package: String, + version: String, + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + published_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + age_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + threshold_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +#[derive(Serialize)] +struct JsonDiagnostic { + category: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + package: Option, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + retryable: Option, +} + +#[derive(Serialize)] +struct JsonSuggestion { + kind: &'static str, + package: String, + locked_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + package_spec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + suggested_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + suggested_age_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + unverified_dependents: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + newest_compliant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blocker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +#[derive(Serialize)] +struct JsonBlocker { + name: String, + version: Option, + requirement: String, + also_suggested: bool, +} + +impl JsonSummary { + fn known(summary: &Summary, violations: usize, duration_ms: u128) -> Self { + Self { + total_packages: Some(summary.total_packages), + checked_packages: Some(summary.checked_packages), + exempt_packages: Some(summary.exempt_packages), + unsupported_packages: Some(summary.unsupported_packages), + excluded_missing_packages: Some(summary.excluded_missing_packages), + failed_packages: Some(summary.failed_packages), + not_checked_packages: Some(summary.not_checked_packages), + violations, + duration_ms, + } + } + + fn unavailable(violations: usize, duration_ms: u128) -> Self { + Self { + total_packages: None, + checked_packages: None, + exempt_packages: None, + unsupported_packages: None, + excluded_missing_packages: None, + failed_packages: None, + not_checked_packages: None, + violations, + duration_ms, + } + } +} + +fn violation_json(violation: &Violation, report: &RunReport) -> JsonViolation { + match &violation.kind { + ViolationKind::TooNew(aged) => JsonViolation { + package: violation.package.clone(), + version: violation.version.clone(), + kind: "too_new", + published_at: Some(aged.published.to_rfc3339_opts(SecondsFormat::Secs, true)), + age_days: Some(aged.age_days), + threshold_days: report.policy.min_age_days, + reason: None, + }, + ViolationKind::TooOld(aged) => JsonViolation { + package: violation.package.clone(), + version: violation.version.clone(), + kind: "too_old", + published_at: Some(aged.published.to_rfc3339_opts(SecondsFormat::Secs, true)), + age_days: Some(aged.age_days), + threshold_days: report.policy.max_age_days, + reason: None, + }, + ViolationKind::MissingPublishDate { reason } => JsonViolation { + package: violation.package.clone(), + version: violation.version.clone(), + kind: "missing_publish_date", + published_at: None, + age_days: None, + threshold_days: None, + reason: Some(reason.clone()), + }, + } +} + +fn diagnostic_json(diagnostic: &Diagnostic) -> JsonDiagnostic { + JsonDiagnostic { + category: diagnostic.category.to_string(), + message: diagnostic.message.clone(), + package: diagnostic.package.clone(), + version: diagnostic.version.clone(), + path: diagnostic + .path + .as_ref() + .map(|path| path.display().to_string()), + retryable: (diagnostic.category == "lookup").then_some(diagnostic.retryable), + } +} + +fn suggestion_json(outcome: &Outcome) -> JsonSuggestion { + match outcome { + Outcome::Suggest { + package, + package_spec, + locked_version, + suggested_version, + suggested_age_days, + unverified_dependents, + } => JsonSuggestion { + kind: "suggested", + package: package.clone(), + locked_version: locked_version.clone(), + package_spec: Some(package_spec.clone()), + command: Some(format!( + "cargo update -p {package_spec}@{locked_version} --precise {suggested_version}" + )), + suggested_version: Some(suggested_version.clone()), + suggested_age_days: Some(*suggested_age_days), + unverified_dependents: Some(unverified_dependents.clone()), + newest_compliant: None, + blocker: None, + reason: None, + }, + Outcome::Blocked { + package, + locked_version, + newest_compliant, + blocker, + } => JsonSuggestion { + kind: "blocked", + package: package.clone(), + locked_version: locked_version.clone(), + package_spec: None, + command: None, + suggested_version: None, + suggested_age_days: None, + unverified_dependents: None, + newest_compliant: Some(newest_compliant.clone()), + blocker: Some(JsonBlocker { + name: blocker.name.clone(), + version: blocker.version.clone(), + requirement: blocker.req.clone(), + also_suggested: blocker.also_suggested, + }), + reason: None, + }, + Outcome::NoCompliantVersion { + package, + locked_version, + } => JsonSuggestion { + kind: "no_eligible_downgrade", + package: package.clone(), + locked_version: locked_version.clone(), + package_spec: None, + command: None, + suggested_version: None, + suggested_age_days: None, + unverified_dependents: None, + newest_compliant: None, + blocker: None, + reason: None, + }, + Outcome::Unavailable { + package, + locked_version, + reason, + } => JsonSuggestion { + kind: "unavailable", + package: package.clone(), + locked_version: locked_version.clone(), + package_spec: None, + command: None, + suggested_version: None, + suggested_age_days: None, + unverified_dependents: None, + newest_compliant: None, + blocker: None, + reason: Some(reason.clone()), + }, + } +} + +/// Serializes one completed CLI report as schema version 1. Additive fields +/// are permitted within this version; consumers must ignore unknown fields. +pub fn render_json(report: &RunReport) -> serde_json::Result { + let summary = report.summary.as_ref().map_or_else( + || JsonSummary::unavailable(report.violations.len(), report.duration.as_millis()), + |summary| { + JsonSummary::known( + summary, + report.violations.len(), + report.duration.as_millis(), + ) + }, + ); + + serde_json::to_string(&JsonReport { + schema_version: 1, + status: match report.exit_code() { + 0 => "passed", + 1 => "violations", + _ => "error", + }, + lockfile: report.lockfile.display().to_string(), + policy: JsonPolicy { + min_age_days: report.policy.min_age_days, + max_age_days: report.policy.max_age_days, + exclude_missing: report.policy.exclude_missing, + exempt: report.policy.exempt.clone(), + }, + summary, + violations: report + .violations + .iter() + .map(|violation| violation_json(violation, report)) + .collect(), + warnings: report.warnings.iter().map(diagnostic_json).collect(), + errors: report.required_errors.iter().map(diagnostic_json).collect(), + suggestions: report + .suggestions + .as_ref() + .map(|outcomes| outcomes.iter().map(suggestion_json).collect()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::report::{Aged, Policy, RunReport, Summary, Violation}; + use serde_json::json; + + #[test] + fn error_status_preserves_partial_results_and_lookup_context() { + let mut report = RunReport::completed( + "Cargo.lock".into(), + Policy::new(Some(14), None, false, vec![]), + Summary { + total_packages: 2, + checked_packages: 1, + failed_packages: 1, + ..Default::default() + }, + ); + report.violations.push(Violation { + package: "young".into(), + version: "1.0.0".into(), + kind: ViolationKind::TooNew(Aged { + published: chrono::Utc::now(), + age_days: 1, + }), + }); + report.required_errors.push(Diagnostic { + category: "lookup", + package: Some("broken".into()), + version: Some("1.0.0".into()), + path: None, + message: "Client error 400".into(), + retryable: false, + }); + + let json: serde_json::Value = serde_json::from_str(&render_json(&report).unwrap()).unwrap(); + assert_eq!(json["schema_version"], 1); + assert_eq!(json["status"], "error"); + assert_eq!(json["summary"]["violations"], 1); + assert_eq!(json["errors"][0]["category"], "lookup"); + assert_eq!(json["errors"][0]["retryable"], false); + assert_eq!(json["violations"][0]["kind"], "too_new"); + } + + #[test] + fn failed_initialization_has_null_summary_and_suggestions() { + let mut report = RunReport::failed( + "missing.lock".into(), + Policy::new(Some(14), None, false, vec![]), + "Could not load lockfile", + ); + report.suggestions = Some(vec![]); + + let json: serde_json::Value = serde_json::from_str(&render_json(&report).unwrap()).unwrap(); + assert_eq!(json["status"], "error"); + assert_eq!(json["summary"]["total_packages"], serde_json::Value::Null); + assert_eq!(json["suggestions"], json!([])); + assert_eq!(json["errors"][0]["category"], "input"); + assert!(json["errors"][0].get("retryable").is_none()); + } + + #[test] + fn requested_suggestions_remain_an_array_when_no_downgrade_is_available() { + let mut report = RunReport::completed( + "Cargo.lock".into(), + Policy::new(Some(14), None, false, vec![]), + Summary::default(), + ); + report.suggestions = Some(vec![Outcome::Unavailable { + package: "widget".into(), + locked_version: "1.0.0".into(), + reason: "registry request failed".into(), + }]); + + let json: serde_json::Value = serde_json::from_str(&render_json(&report).unwrap()).unwrap(); + assert_eq!(json["suggestions"][0]["kind"], "unavailable"); + assert_eq!(json["suggestions"][0]["reason"], "registry request failed"); + } + + #[test] + fn suggestion_variants_preserve_commands_constraints_and_uncertainty() { + let mut report = RunReport::completed( + "Cargo.lock".into(), + Policy::new(Some(14), None, false, vec![]), + Summary::default(), + ); + report.suggestions = Some(vec![ + Outcome::Suggest { + package: "widget".into(), + package_spec: "registry+https://example.test#index#widget".into(), + locked_version: "2.0.0".into(), + suggested_version: "1.9.0".into(), + suggested_age_days: 30, + unverified_dependents: vec!["consumer".into()], + }, + Outcome::Blocked { + package: "blocked".into(), + locked_version: "2.0.0".into(), + newest_compliant: "1.9.0".into(), + blocker: crate::suggest::Blocker { + name: "consumer".into(), + version: Some("3.0.0".into()), + req: "^2".into(), + also_suggested: true, + }, + }, + Outcome::NoCompliantVersion { + package: "none".into(), + locked_version: "2.0.0".into(), + }, + Outcome::Unavailable { + package: "unavailable".into(), + locked_version: "2.0.0".into(), + reason: "registry request failed".into(), + }, + ]); + + let json: serde_json::Value = serde_json::from_str(&render_json(&report).unwrap()).unwrap(); + let suggestions = json["suggestions"].as_array().unwrap(); + assert_eq!(suggestions[0]["kind"], "suggested"); + assert_eq!( + suggestions[0]["command"], + "cargo update -p registry+https://example.test#index#widget@2.0.0 --precise 1.9.0" + ); + assert_eq!(suggestions[0]["unverified_dependents"], json!(["consumer"])); + assert_eq!(suggestions[1]["kind"], "blocked"); + assert_eq!(suggestions[1]["blocker"]["requirement"], "^2"); + assert_eq!(suggestions[1]["blocker"]["also_suggested"], true); + assert_eq!(suggestions[2]["kind"], "no_eligible_downgrade"); + assert_eq!(suggestions[3]["kind"], "unavailable"); + } + + #[test] + fn passed_zero_coverage_keeps_warnings_and_requested_empty_suggestions() { + let mut report = RunReport::completed( + "Cargo.lock".into(), + Policy::new(Some(14), None, false, vec![]), + Summary::default(), + ); + report.warnings.push(Diagnostic { + category: "cache", + package: None, + version: None, + path: Some("responses.json".into()), + message: "Could not save cache".into(), + retryable: false, + }); + report.suggestions = Some(vec![]); + + let json: serde_json::Value = serde_json::from_str(&render_json(&report).unwrap()).unwrap(); + assert_eq!(json["status"], "passed"); + assert_eq!(json["summary"]["checked_packages"], 0); + assert_eq!(json["warnings"][0]["category"], "cache"); + assert_eq!(json["warnings"][0]["path"], "responses.json"); + assert!(json["warnings"][0].get("retryable").is_none()); + assert_eq!(json["suggestions"], json!([])); + } +} diff --git a/src/suggest.rs b/src/suggest.rs index 943f3fd..b0bcb9e 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -45,6 +45,19 @@ pub enum Outcome { package: String, locked_version: String, }, + Unavailable { + package: String, + locked_version: String, + reason: String, + }, +} + +/// Progress through requested downgrade investigations. The caller owns +/// presentation so normal, quiet, and verbose CLI modes share one algorithm. +pub struct SuggestionProgress { + pub current: usize, + pub total: usize, + pub package: String, } /// Whether `a` and `b` share Cargo's symmetric caret-compatible zone: major, @@ -449,6 +462,7 @@ pub fn generate_suggestions( min_age_days: u64, allow_prerelease: bool, now: DateTime, + on_progress: &mut dyn FnMut(SuggestionProgress), ) -> Option> { let too_new: Vec<&Violation> = violations .iter() @@ -462,25 +476,30 @@ pub fn generate_suggestions( let (dependents_index, name_version_index) = build_indexes(all_packages); let mut outcomes = Vec::new(); - eprintln!("\nFetching version suggestions..."); for (i, violation) in too_new.iter().enumerate() { - eprintln!(" [{}/{}] {}", i + 1, too_new.len(), violation.package); + on_progress(SuggestionProgress { + current: i + 1, + total: too_new.len(), + package: violation.package.clone(), + }); let Ok(locked) = Version::parse(&violation.version) else { - eprintln!( - "\n Warning: failed to parse locked version for {}: {}", - violation.package, violation.version - ); + outcomes.push(Outcome::Unavailable { + package: violation.package.clone(), + locked_version: violation.version.clone(), + reason: "Locked version is not valid SemVer".to_string(), + }); continue; }; let versions = match client.fetch_all_versions(&violation.package) { Ok(versions) => versions, Err(e) => { - eprintln!( - "\n Warning: failed to fetch versions for {}: {e}", - violation.package - ); + outcomes.push(Outcome::Unavailable { + package: violation.package.clone(), + locked_version: violation.version.clone(), + reason: e.to_string(), + }); continue; } }; diff --git a/src/suggest/tests.rs b/src/suggest/tests.rs index 61963bb..a6f8bba 100644 --- a/src/suggest/tests.rs +++ b/src/suggest/tests.rs @@ -16,6 +16,7 @@ fn suggestions( direct_requirements: &[DirectRequirement], working_dir: &Path, ) -> Vec { + let mut no_progress = |_| {}; generate_suggestions( client, violations, @@ -25,6 +26,7 @@ fn suggestions( 30, false, now(), + &mut no_progress, ) .unwrap() } diff --git a/src/suggest/tests/generate_suggestions_tests.rs b/src/suggest/tests/generate_suggestions_tests.rs index a53aac1..5441215 100644 --- a/src/suggest/tests/generate_suggestions_tests.rs +++ b/src/suggest/tests/generate_suggestions_tests.rs @@ -506,8 +506,18 @@ fn an_unparsable_locked_version_does_not_abort_the_others() { let packages = vec![pkg("serde", "not-a-version", &[]), pkg("syn", "1.1.0", &[])]; let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - assert_eq!(outcomes.len(), 1); - assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); + assert_eq!(outcomes.len(), 2); + assert!(matches!( + &outcomes[0], + Outcome::Unavailable { + package, + locked_version, + reason, + } if package == "serde" + && locked_version == "not-a-version" + && reason == "Locked version is not valid SemVer" + )); + assert!(matches!(&outcomes[1], Outcome::Suggest { package, .. } if package == "syn")); } #[test] @@ -524,8 +534,16 @@ fn a_failed_fetch_does_not_abort_the_others() { let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.1.0", &[])]; let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - assert_eq!(outcomes.len(), 1); - assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); + assert_eq!(outcomes.len(), 2); + assert!(matches!( + &outcomes[0], + Outcome::Unavailable { + package, + locked_version, + .. + } if package == "serde" && locked_version == "1.0.0" + )); + assert!(matches!(&outcomes[1], Outcome::Suggest { package, .. } if package == "syn")); } #[test] @@ -547,6 +565,7 @@ fn no_too_new_violations_yields_none() { let mut client = fast_client(transport); let violations = vec![too_old("syn")]; + let mut no_progress = |_| {}; let outcomes = generate_suggestions( &mut client, &violations, @@ -556,6 +575,7 @@ fn no_too_new_violations_yields_none() { 30, false, now(), + &mut no_progress, ); assert!(outcomes.is_none()); diff --git a/tests/ci_json_cli.rs b/tests/ci_json_cli.rs new file mode 100644 index 0000000..7747e78 --- /dev/null +++ b/tests/ci_json_cli.rs @@ -0,0 +1,137 @@ +use chrono::{Duration, Utc}; +use serde_json::json; +use std::fs; +use std::process::{Command, Output}; +use tempfile::TempDir; + +const BIN: &str = env!("CARGO_BIN_EXE_cargo-oxidate"); + +fn fixture() -> (TempDir, std::path::PathBuf) { + let project = tempfile::tempdir().unwrap(); + fs::write( + project.path().join("Cargo.lock"), + r#"version = 4 + +[[package]] +name = "widget" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +"#, + ) + .unwrap(); + + let cache = project.path().join("responses.json"); + fs::write( + &cache, + serde_json::to_vec(&json!({ + "version": 1, + "publish_dates": { + "widget/1.0.0": (Utc::now() - Duration::days(1)).to_rfc3339(), + }, + "all_versions": {}, + "index_records": {}, + })) + .unwrap(), + ) + .unwrap(); + (project, cache) +} + +fn run(project: &TempDir, cache: &std::path::Path, args: &[&str]) -> Output { + Command::new(BIN) + .current_dir(project.path()) + .args(args) + .args([ + "--format", + "json", + "--min-age-days", + "30", + "--cache-path", + cache.to_str().unwrap(), + ]) + .output() + .unwrap() +} + +#[test] +fn json_is_one_document_with_the_same_violation_in_direct_and_cargo_forms() { + let (project, cache) = fixture(); + for args in [&[][..], &["oxidate"][..]] { + let output = run(&project, &cache, args); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.ends_with('\n')); + let report: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["status"], "violations"); + assert_eq!(report["summary"]["total_packages"], 1); + assert_eq!(report["summary"]["violations"], 1); + assert_eq!(report["violations"][0]["kind"], "too_new"); + assert!(report["warnings"].is_array()); + assert!(report["errors"].is_array()); + assert!(report["suggestions"].is_null()); + } +} + +#[test] +fn json_stdout_stays_parseable_at_every_verbosity() { + let (project, cache) = fixture(); + for args in [&["--quiet"][..], &["--verbose"][..]] { + let output = run(&project, &cache, args); + assert_eq!(output.status.code(), Some(1)); + serde_json::from_slice::(&output.stdout).unwrap(); + } +} + +#[test] +fn post_parse_input_error_is_a_json_document_with_null_package_counts() { + let project = tempfile::tempdir().unwrap(); + let output = Command::new(BIN) + .current_dir(project.path()) + .args(["--format", "json"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["status"], "error"); + assert!(report["summary"]["total_packages"].is_null()); + assert_eq!(report["errors"][0]["category"], "input"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .starts_with("error:") + ); + + let quiet = Command::new(BIN) + .current_dir(project.path()) + .args(["--format", "json", "--quiet"]) + .output() + .unwrap(); + assert_eq!(quiet.status.code(), Some(2)); + serde_json::from_slice::(&quiet.stdout).unwrap(); + assert!( + String::from_utf8(quiet.stderr) + .unwrap() + .starts_with("error:") + ); +} + +#[test] +fn invalid_format_is_left_to_clap() { + let project = tempfile::tempdir().unwrap(); + let output = Command::new(BIN) + .current_dir(project.path()) + .args(["--format", "yaml", "--min-age-days", "30"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("invalid value") + ); +} diff --git a/tests/ci_output_cli.rs b/tests/ci_output_cli.rs new file mode 100644 index 0000000..7b56590 --- /dev/null +++ b/tests/ci_output_cli.rs @@ -0,0 +1,109 @@ +use chrono::{Duration, Utc}; +use serde_json::json; +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +const BIN: &str = env!("CARGO_BIN_EXE_cargo-oxidate"); + +fn fixture() -> (TempDir, std::path::PathBuf) { + let project = tempfile::tempdir().unwrap(); + fs::write( + project.path().join("Cargo.lock"), + r#"version = 4 + +[[package]] +name = "widget" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +"#, + ) + .unwrap(); + + let cache = project.path().join("responses.json"); + fs::write( + &cache, + serde_json::to_vec(&json!({ + "version": 1, + "publish_dates": { + "widget/1.0.0": (Utc::now() - Duration::days(1)).to_rfc3339(), + }, + "all_versions": {}, + "index_records": {}, + })) + .unwrap(), + ) + .unwrap(); + + (project, cache) +} + +fn run(project: &TempDir, cache: &std::path::Path, args: &[&str]) -> std::process::Output { + Command::new(BIN) + .current_dir(project.path()) + .args(args) + .args([ + "--min-age-days", + "30", + "--cache-path", + cache.to_str().unwrap(), + ]) + .output() + .unwrap() +} + +#[test] +fn default_logs_are_concise_and_keep_results_on_stdout() { + let (project, cache) = fixture(); + let output = run(&project, &cache, &[]); + + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stdout.contains("Dependency age violations")); + assert!(stderr.contains("Checking dependency ages in")); + assert!(!stderr.contains("Checking widget@1.0.0")); +} + +#[test] +fn quiet_hides_start_and_verbose_shows_each_checked_package() { + let (project, cache) = fixture(); + let quiet = run(&project, &cache, &["--quiet"]); + assert_eq!(quiet.status.code(), Some(1)); + let quiet_stderr = String::from_utf8(quiet.stderr).unwrap(); + assert!(!quiet_stderr.contains("Checking dependency ages in")); + assert!(!quiet_stderr.contains("Checking widget@1.0.0")); + + let verbose = run(&project, &cache, &["--verbose"]); + assert_eq!(verbose.status.code(), Some(1)); + let verbose_stderr = String::from_utf8(verbose.stderr).unwrap(); + assert!(verbose_stderr.contains("Checking dependency ages in")); + assert!(verbose_stderr.contains("Checking widget@1.0.0")); +} + +#[test] +fn cargo_subcommand_form_honors_verbose_progress() { + let (project, cache) = fixture(); + let output = run(&project, &cache, &["oxidate", "--verbose"]); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("Checking widget@1.0.0") + ); +} + +#[test] +fn quiet_and_verbose_cannot_be_combined() { + let (project, cache) = fixture(); + let output = run(&project, &cache, &["--quiet", "--verbose"]); + + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("cannot be used with") + ); +} diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index 0957e69..2b9ba9d 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -119,17 +119,19 @@ fn suggest_fix_cli_reports_mixed_outcomes_and_preserves_project_files() { "stdout was:\n{stdout}" ); assert!( - stdout.contains("beta 1.5.0") && stdout.contains("Cargo.toml") && stdout.contains("^1.5"), + stdout.contains("Downgrade blocked by dependency requirements for beta@1.5.0") + && stdout.contains("Cargo.toml") + && stdout.contains("^1.5"), "stdout was:\n{stdout}" ); assert!( - stdout.contains("gamma 1.5.0") + stdout.contains("Downgrade blocked by dependency requirements for gamma@1.5.0") && stdout.contains("consumer 2.0.0") && stdout.contains("^1.5") ); assert!( stdout.contains( - "delta 1.5.0: no eligible downgrade at least the minimum age old within its compatible range" + "No eligible downgrade found for delta@1.5.0 within its compatible version range" ), "stdout was:\n{stdout}" ); @@ -179,7 +181,7 @@ fn suggest_fix_cli_reports_excluded_lower_versions_as_no_eligible_downgrade() { assert!( stdout.lines().any(|line| { line - == " delta 1.5.0: no eligible downgrade at least the minimum age old within its compatible range" + == " No eligible downgrade found for delta@1.5.0 within its compatible version range" }), "stdout was:\n{stdout}" ); @@ -374,7 +376,9 @@ fn suggest_fix_uses_manifest_beside_the_symlinks_real_lockfile() { "forbidden downgrade command was suggested; stdout was:\n{stdout}" ); assert!( - stdout.contains("alpha 1.5.0") && stdout.contains("Cargo.toml") && stdout.contains("^1.5"), + stdout.contains("Downgrade blocked by dependency requirements for alpha@1.5.0") + && stdout.contains("Cargo.toml") + && stdout.contains("^1.5"), "expected the real manifest's restriction on alpha to be reported; stdout was:\n{stdout}" ); } From b2eec67c1579b62f9b01482d1d50cab0fdcb9f73 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 20:53:22 -0400 Subject: [PATCH 2/3] Unslop --- README.md | 65 ++++++++++------------------------------ src/api.rs | 25 ++++++++-------- src/cache.rs | 38 +++++++++++++---------- src/main.rs | 18 ++++++----- src/policy.rs | 4 +-- src/report.rs | 33 ++++++++++---------- src/report/json.rs | 3 +- src/suggest.rs | 4 +-- tests/suggest_fix_cli.rs | 11 ++++--- 9 files changed, 87 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 55f3677..ce27a89 100644 --- a/README.md +++ b/README.md @@ -39,53 +39,17 @@ At least one of `--min-age-days` or `--max-age-days` must be specified. ## CI output -The final result is written to standard output. A concise start message and -operational diagnostics are written to standard error. Use `--verbose` to see -each package as it is checked, or `--quiet` to suppress start and progress -messages while retaining warnings, errors, and the final result. The two flags -cannot be combined. +The final report goes to standard output. Progress, warnings, and errors go to +standard error. `--quiet` hides start and progress messages, while `--verbose` +shows each package check. The flags cannot be combined. -Use `--format json` for automation. It writes one newline-terminated JSON -document to standard output; progress and operational diagnostics remain on -standard error. Schema version 1 permits additive fields, and consumers should -ignore fields they do not recognize. - -### JSON schema - -JSON output has these top-level fields: `schema_version`, `status`, `lockfile`, -`policy`, `summary`, `violations`, `warnings`, `errors`, and `suggestions`. +`--format json` writes one newline-terminated schema version 1 document. Its `status` is `passed`, `violations`, or `error`, matching exit codes 0, 1, and 2. -The `summary` always includes the violation count and duration in milliseconds; -its package-count fields are `null` when the lockfile could not be loaded. - -Each violation has `package`, `version`, and `kind`. Age violations also include -`published_at`, `age_days`, and `threshold_days`; missing publish dates include -`reason`. Diagnostics include `category` and `message`, plus package, version, -or path context when available. Lookup diagnostics include `retryable`. - -`suggestions` is `null` unless `--suggest-fix` was requested. Once requested it -is always an array, including when no downgrade investigation was needed. Its -entries use `suggested`, `blocked`, `no_eligible_downgrade`, or `unavailable` -`kind` values and carry the applicable command, blocker, uncertainty, or -failure details. Fields may be added within schema version 1; removing a field -or changing its type or meaning requires a new schema version. - -For example: - -```json -{"schema_version":1,"status":"passed","lockfile":"Cargo.lock","policy":{"min_age_days":14,"max_age_days":null,"exclude_missing":false,"exempt":[]},"summary":{"total_packages":1,"checked_packages":1,"exempt_packages":0,"unsupported_packages":0,"excluded_missing_packages":0,"failed_packages":0,"not_checked_packages":0,"violations":0,"duration_ms":4},"violations":[],"warnings":[],"errors":[],"suggestions":null} -``` - -A lockfile that cannot be loaded still produces one document after parsing: - -```json -{"schema_version":1,"status":"error","lockfile":"missing.lock","policy":{"min_age_days":14,"max_age_days":null,"exclude_missing":false,"exempt":[]},"summary":{"total_packages":null,"checked_packages":null,"exempt_packages":null,"unsupported_packages":null,"excluded_missing_packages":null,"failed_packages":null,"not_checked_packages":null,"violations":0,"duration_ms":0},"violations":[],"warnings":[],"errors":[{"category":"input","message":"Failed to parse Cargo.lock"}],"suggestions":null} -``` +The command also writes a document when it cannot load the lockfile. Consumers +should ignore unknown fields because schema version 1 may add fields. -`--exclude-missing` excludes only confirmed missing publish dates. Registry -lookup and response failures remain errors so CI can distinguish incomplete -checks from age-policy violations. Cache read and write failures are warnings; -the freshness check continues and its exit result is unchanged. +Registry failures remain errors. Cache failures produce warnings and do not +change the result. ## `--suggest-fix` @@ -105,15 +69,16 @@ treats every declared requirement, including optional and target-specific ones, Suggestions are best effort. The tool does not run Cargo's resolver or build your project, so Cargo can still reject a suggested command. Apply suggestions in order, then run the command again and -run your tests. It reports whether a downgrade is blocked by a dependency -requirement, no eligible downgrade exists, or a downgrade could not be -determined because its version metadata was unavailable. +run your tests. For each package without a suggestion, the command explains +which dependency blocks the downgrade or why it could not choose a version. ## Exit Codes -- `0` — No dependency age violations found -- `1` — Dependency age violations found -- `2` — A required check could not complete or input was invalid +| Code | Meaning | +|------|---------| +| `0` | No dependency age violations | +| `1` | Dependency age violations found | +| `2` | Invalid input or incomplete required check | ## Caching diff --git a/src/api.rs b/src/api.rs index 6d09466..da0193a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -189,7 +189,7 @@ impl Default for RetryPolicy { pub struct CratesIoClient { transport: T, cache: ResponseCache, - cache_warnings: Vec, + cache_warning: Option, cache_max_age_hours: u64, retry_policy: RetryPolicy, /// Per-run memo of successfully fetched index records, keyed by crate @@ -220,33 +220,28 @@ impl CratesIoClient { retry_policy: RetryPolicy, ) -> Self { let mut cache = ResponseCache::load(cache_path); - let mut cache_warnings = Vec::new(); - if let Some(warning) = cache.take_load_warning() { - cache_warnings.push(warning); - } + let cache_warning = cache.take_load_warning(); Self { transport, cache, - cache_warnings, + cache_warning, cache_max_age_hours, retry_policy, fetched_index_records: HashMap::new(), } } - /// Returns cache warnings gathered while setting up the client. - pub fn take_cache_warnings(&mut self) -> Vec { - std::mem::take(&mut self.cache_warnings) + pub fn take_cache_warning(&mut self) -> Option { + self.cache_warning.take() } - /// Consumes the client, saving its advisory cache. Save failures leave - /// the freshness result valid and are returned for final reporting. + /// Saves the cache and returns any save error as a warning. pub fn finish(self) -> Option { self.cache .save() .err() - .and_then(|error| self.cache.save_warning(error)) + .map(|error| self.cache.save_warning(error)) } /// Sleeps for the inter-request rate limit window. Called only from the @@ -582,7 +577,11 @@ mod tests { .finish() .expect("cache save failure should be reported"); assert_eq!(warning.path, cache_path); - assert!(warning.message.contains("computed results remain valid")); + assert!( + warning + .message + .contains("does not change the dependency check result") + ); } #[test] diff --git a/src/cache.rs b/src/cache.rs index dc03def..d33b154 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -36,8 +36,7 @@ pub struct ResponseCache { load_warning: Option, } -/// A non-fatal cache failure. Cache contents are advisory, so callers can -/// report this and continue the freshness check with an empty cache. +/// A cache failure that does not stop the dependency check. pub struct CacheWarning { pub path: PathBuf, pub message: String, @@ -56,7 +55,7 @@ impl ResponseCache { load_warning = Some(CacheWarning { path: p.clone(), message: format!( - "Cache version {} is unsupported; checking continues without cached responses", + "Cache version {} is unsupported. Checking without cached responses", data.version ), }); @@ -69,7 +68,7 @@ impl ResponseCache { load_warning = Some(CacheWarning { path: p.clone(), message: format!( - "Could not read cache; checking continues without cached responses: {e}" + "Could not read cache: {e}. Checking without cached responses" ), }); CacheData { @@ -86,7 +85,7 @@ impl ResponseCache { load_warning = Some(CacheWarning { path: p.clone(), message: format!( - "Could not read cache; checking continues without cached responses: {error}" + "Could not read cache: {error}. Checking without cached responses" ), }); CacheData { @@ -114,13 +113,16 @@ impl ResponseCache { self.load_warning.take() } - pub fn save_warning(&self, error: anyhow::Error) -> Option { - self.path.as_ref().map(|path| CacheWarning { - path: path.clone(), + pub fn save_warning(&self, error: anyhow::Error) -> CacheWarning { + CacheWarning { + path: self + .path + .clone() + .expect("cache save failures require a configured path"), message: format!( - "Could not save cache; computed results remain valid but could not be cached: {error}" + "Could not save cache: {error}. This does not change the dependency check result" ), - }) + } } pub fn get_publish_date(&self, name: &str, version: &str) -> Option> { @@ -301,7 +303,11 @@ mod tests { .take_load_warning() .expect("corrupt cache should produce a warning"); assert_eq!(warning.path, path); - assert!(warning.message.contains("checking continues")); + assert!( + warning + .message + .contains("Checking without cached responses") + ); } #[test] @@ -351,11 +357,13 @@ mod tests { let error = cache .save() .expect_err("a file cannot be a cache directory"); - let warning = cache - .save_warning(error) - .expect("configured cache path should produce a warning"); + let warning = cache.save_warning(error); assert_eq!(warning.path, path); - assert!(warning.message.contains("computed results remain valid")); + assert!( + warning + .message + .contains("does not change the dependency check result") + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 1c4e4c2..3311aea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ impl Verbosity { name = "cargo-oxidate", version, about = "Check Cargo dependency freshness", - after_help = "By default, confirmed missing publish dates are reported as violations. Use --exclude-missing to exclude them; lookup failures remain errors." + after_help = "By default, confirmed missing publish dates are reported as violations. Use --exclude-missing to exclude them. Lookup failures remain errors." )] struct Cli { /// Path to the Cargo.lock file @@ -120,7 +120,7 @@ fn print_start(lockfile: &std::path::Path, policy: &report::Policy) { .max_age_days .map_or_else(|| "not set".to_string(), |days| format!("{days} days")); eprintln!( - "Checking dependency ages in {} (minimum age: {minimum_age}; maximum age: {maximum_age}).", + "Checking dependency ages in {}. Minimum age is {minimum_age}. Maximum age is {maximum_age}.", lockfile.display() ); } @@ -227,8 +227,6 @@ fn main() -> ExitCode { ExitCode::from(exit_code) } -/// Checks all lockfile entries through a client. The generic transport keeps -/// required-check behaviour testable without a public test-only CLI switch. fn check_packages( client: &mut api::CratesIoClient, freshness_policy: &policy::FreshnessPolicy, @@ -325,7 +323,11 @@ fn run(cli: Cli, verbosity: Verbosity) -> report::RunReport { } }; - let suggest_min_age = cli.suggest_fix.then_some(cli.min_age_days).flatten(); + let suggest_min_age = if cli.suggest_fix { + cli.min_age_days + } else { + None + }; let working_dir = match std::env::current_dir() { Ok(directory) => directory, @@ -371,8 +373,8 @@ fn run(cli: Cli, verbosity: Verbosity) -> report::RunReport { ); } }; - let initial_cache_warnings = client.take_cache_warnings(); - for warning in &initial_cache_warnings { + let initial_cache_warning = client.take_cache_warning(); + if let Some(warning) = &initial_cache_warning { eprintln!("warning: {}: {}", warning.path.display(), warning.message); } @@ -394,7 +396,7 @@ fn run(cli: Cli, verbosity: Verbosity) -> report::RunReport { now, Some(&mut print_check_progress), ); - record_cache_warnings(&mut report, initial_cache_warnings); + record_cache_warnings(&mut report, initial_cache_warning); // Generate suggestions if requested let has_too_new = report diff --git a/src/policy.rs b/src/policy.rs index 85ff7fb..68fe9e7 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -60,9 +60,7 @@ impl FreshnessPolicy { } /// Evaluates a package against the policy given its publish date (`None` - /// if the lookup failed or found no date) and the current time, - /// returning the violations triggered. Warning about a failed lookup is - /// the caller's job. + /// when crates.io confirms the version is missing) and the current time. pub fn evaluate( &self, pkg: &Package, diff --git a/src/report.rs b/src/report.rs index 4c78997..5ad0d93 100644 --- a/src/report.rs +++ b/src/report.rs @@ -24,8 +24,7 @@ pub struct Violation { pub kind: ViolationKind, } -/// The effective age policy used for a run. This is data rather than CLI -/// arguments so every renderer reports the same policy after normalization. +/// The normalized age policy used for a run. pub struct Policy { pub min_age_days: Option, pub max_age_days: Option, @@ -55,7 +54,7 @@ impl Policy { } } -/// Disjoint coverage counts for lockfile package entries. +/// Package counts. Each lockfile entry contributes to one field. #[derive(Default)] pub struct Summary { pub total_packages: usize, @@ -76,8 +75,7 @@ pub struct Diagnostic { pub retryable: bool, } -/// The complete outcome of one invocation after argument parsing. It is the -/// single source for the text report today and structured output later. +/// The complete outcome of one invocation after argument parsing. pub struct RunReport { /// The path supplied by the caller, retained even when resolution fails. pub lockfile: PathBuf, @@ -86,8 +84,8 @@ pub struct RunReport { pub violations: Vec, pub required_errors: Vec, pub warnings: Vec, - /// None when suggestions were not requested; otherwise every requested - /// investigation outcome is present, including an empty list. + /// None when suggestions were not requested. Otherwise, this contains + /// every investigation outcome and may be empty. pub suggestions: Option>, pub duration: Duration, } @@ -189,7 +187,9 @@ fn print_violations(violations: &[Violation], policy: &Policy) { " {}@{}: Below minimum age {} days (published {}, {} days old)", violation.package, violation.version, - policy.min_age_days.unwrap_or_default(), + policy + .min_age_days + .expect("too-new violations require a minimum age"), aged.published.format("%Y-%m-%d"), aged.age_days, ), @@ -197,7 +197,9 @@ fn print_violations(violations: &[Violation], policy: &Policy) { " {}@{}: Above maximum age {} days (published {}, {} days old)", violation.package, violation.version, - policy.max_age_days.unwrap_or_default(), + policy + .max_age_days + .expect("too-old violations require a maximum age"), aged.published.format("%Y-%m-%d"), aged.age_days, ), @@ -209,8 +211,7 @@ fn print_violations(violations: &[Violation], policy: &Policy) { } } -/// Renders a completed or initialization-failed run. Operational messages -/// remain on stderr; this is the final result written to stdout. +/// Prints the final result to standard output. pub fn print_report(report: &RunReport) { if !report.required_errors.is_empty() { println!("Dependency age check incomplete"); @@ -257,9 +258,7 @@ pub fn print_report(report: &RunReport) { fn print_warnings(warnings: &[Diagnostic]) { for warning in warnings { - // An unavailable suggestion is already rendered as its own outcome. - // Keep its diagnostic structured for JSON and stderr without - // repeating the same message in the text result. + // Suggestion failures are rendered with the other suggestion outcomes. if warning.category == "suggestion" { continue; } @@ -302,7 +301,9 @@ pub fn print_suggestions(outcomes: &[crate::suggest::Outcome]) { .collect(); if !suggestions.is_empty() { - println!("\nSuggested downgrades (apply top to bottom, then re-run):"); + println!( + "\nSuggested downgrades. Apply them from top to bottom, then run this check again:" + ); for ( package_spec, locked_version, @@ -375,7 +376,7 @@ pub fn print_suggestions(outcomes: &[crate::suggest::Outcome]) { if !suggestions.is_empty() { println!( - "\nSuggestions satisfy, on a best-effort basis, version requirements verified from Cargo.lock and your manifests. Requirements marked \"unverified\" above were not checked, and Cargo may still reject a suggestion. Source compatibility is not verified: build or test after applying." + "\nThese suggestions check the version requirements in Cargo.lock and your manifests. They do not run Cargo's resolver, so Cargo may reject them. The check skipped requirements marked \"unverified\". Build or test after applying each suggestion." ); } } diff --git a/src/report/json.rs b/src/report/json.rs index 7793d3c..7f0316a 100644 --- a/src/report/json.rs +++ b/src/report/json.rs @@ -256,8 +256,7 @@ fn suggestion_json(outcome: &Outcome) -> JsonSuggestion { } } -/// Serializes one completed CLI report as schema version 1. Additive fields -/// are permitted within this version; consumers must ignore unknown fields. +/// Serializes a CLI report as schema version 1. pub fn render_json(report: &RunReport) -> serde_json::Result { let summary = report.summary.as_ref().map_or_else( || JsonSummary::unavailable(report.violations.len(), report.duration.as_millis()), diff --git a/src/suggest.rs b/src/suggest.rs index b0bcb9e..76a5e8d 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -52,8 +52,7 @@ pub enum Outcome { }, } -/// Progress through requested downgrade investigations. The caller owns -/// presentation so normal, quiet, and verbose CLI modes share one algorithm. +/// Sent to the progress callback before each downgrade check. pub struct SuggestionProgress { pub current: usize, pub total: usize, @@ -451,7 +450,6 @@ fn build_package_spec(name: &str, target_source: Option<&str>, is_ambiguous: boo } /// Generates outcomes for "too new" violations, or `None` when there are none. -/// Ignores packages whose version list cannot be fetched. #[allow(clippy::too_many_arguments)] pub fn generate_suggestions( client: &mut CratesIoClient, diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index 2b9ba9d..2d43f6c 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -135,7 +135,7 @@ fn suggest_fix_cli_reports_mixed_outcomes_and_preserves_project_files() { ), "stdout was:\n{stdout}" ); - assert!(stdout.contains("best-effort")); + assert!(stdout.contains("They do not run Cargo's resolver")); assert_eq!( fs::read(project.path().join("Cargo.toml")).unwrap(), manifest_before @@ -246,13 +246,16 @@ fn suggest_fix_cli_retains_best_effort_qualification() { stdout.contains("requirement of consumer unverified"), "stdout was:\n{stdout}" ); - assert!(stdout.contains("best-effort"), "stdout was:\n{stdout}"); assert!( - stdout.contains("apply top to bottom, then re-run"), + stdout.contains("They do not run Cargo's resolver"), "stdout was:\n{stdout}" ); assert!( - stdout.contains("or test after applying"), + stdout.contains("Apply them from top to bottom, then run this check again"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("Build or test after applying each suggestion"), "stdout was:\n{stdout}" ); } From d96ecbf9e12de7eb17cbae58c5d81ad00dde3632 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 21:10:52 -0400 Subject: [PATCH 3/3] Address comments --- src/report.rs | 32 ---------------- tests/ci_output_cli.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/src/report.rs b/src/report.rs index 5ad0d93..3dfef4a 100644 --- a/src/report.rs +++ b/src/report.rs @@ -235,45 +235,13 @@ pub fn print_report(report: &RunReport) { println!("Elapsed: {} ms", report.duration.as_millis()); } - if !report.required_errors.is_empty() { - for error in &report.required_errors { - let retryability = if error.retryable { " (retryable)" } else { "" }; - match (&error.package, &error.version) { - (Some(package), Some(version)) => println!( - " error: could not check {package}@{version}{retryability}: {}", - error.message, - ), - _ => println!(" error{retryability}: {}", error.message), - } - } - } - print_violations(&report.violations, &report.policy); - print_warnings(&report.warnings); if let Some(outcomes) = &report.suggestions { print_suggestions(outcomes); } } -fn print_warnings(warnings: &[Diagnostic]) { - for warning in warnings { - // Suggestion failures are rendered with the other suggestion outcomes. - if warning.category == "suggestion" { - continue; - } - let context = match (&warning.package, &warning.version, &warning.path) { - (Some(package), Some(version), _) => format!(" for {package}@{version}"), - (_, _, Some(path)) => format!(" for {}", path.display()), - _ => String::new(), - }; - println!( - "warning: {}{context}: {}", - warning.category, warning.message - ); - } -} - pub fn print_suggestions(outcomes: &[crate::suggest::Outcome]) { if outcomes.is_empty() { return; diff --git a/tests/ci_output_cli.rs b/tests/ci_output_cli.rs index 7b56590..7349ffb 100644 --- a/tests/ci_output_cli.rs +++ b/tests/ci_output_cli.rs @@ -107,3 +107,90 @@ fn quiet_and_verbose_cannot_be_combined() { .contains("cannot be used with") ); } + +#[test] +fn required_errors_stay_on_stderr_in_default_and_quiet_modes() { + let project = tempfile::tempdir().unwrap(); + fs::write(project.path().join("Cargo.lock"), "this is not a lockfile").unwrap(); + + for args in [&[][..], &["--quiet"][..]] { + let output = Command::new(BIN) + .current_dir(project.path()) + .args(args) + .args(["--min-age-days", "30"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2), "args: {args:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stdout.contains("Dependency age check incomplete")); + assert!(stdout.contains("Coverage unavailable.")); + assert!(!stdout.contains("error:"), "stdout was:\n{stdout}"); + assert_eq!( + stderr + .lines() + .filter(|line| line.starts_with("error: ")) + .count(), + 1, + "stderr was:\n{stderr}" + ); + assert!(stderr.contains("Cargo.lock")); + assert!(stderr.contains("parse error")); + assert!(!stderr.contains("Checking dependency ages in")); + } +} + +#[test] +fn optional_warnings_stay_on_stderr_in_default_and_quiet_modes() { + let project = tempfile::tempdir().unwrap(); + fs::write( + project.path().join("Cargo.lock"), + r#"version = 4 + +[[package]] +name = "local-widget" +version = "1.0.0" +"#, + ) + .unwrap(); + let cache = project.path().join("responses.json"); + for args in [&[][..], &["--quiet"][..]] { + fs::write(&cache, "not JSON").unwrap(); + let output = Command::new(BIN) + .current_dir(project.path()) + .args(args) + .args([ + "--min-age-days", + "30", + "--cache-path", + cache.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert_eq!( + output.status.code(), + Some(0), + "args: {args:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stdout.contains("No eligible dependencies checked.")); + assert!(stdout.contains("packages: total=1, checked=0")); + assert!(!stdout.contains("warning:"), "stdout was:\n{stdout}"); + assert_eq!( + stderr.matches("warning:").count(), + 1, + "stderr was:\n{stderr}" + ); + assert!(stderr.contains("Could not read cache")); + assert_eq!( + stderr.contains("Checking dependency ages in"), + args.is_empty(), + "stderr was:\n{stderr}" + ); + } +}