diff --git a/README.md b/README.md index c322ef1..ce27a89 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,26 @@ 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 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. + +`--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 command also writes a document when it cannot load the lockfile. Consumers +should ignore unknown fields because schema version 1 may add fields. + +Registry failures remain errors. Cache failures produce warnings and do not +change the result. + ## `--suggest-fix` `--suggest-fix` prints a `cargo update --precise` command for the newest eligible downgrade of @@ -52,14 +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. If the tool cannot find an eligible downgrade, it reports the requirement that -blocks one when it knows that requirement. +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 violations found -- `1` — Violations detected -- `2` — Runtime error +| 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 0e05858..da0193a 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_warning: Option, cache_max_age_hours: u64, retry_policy: RetryPolicy, /// Per-run memo of successfully fetched index records, keyed by crate @@ -218,22 +219,29 @@ impl CratesIoClient { cache_max_age_hours: u64, retry_policy: RetryPolicy, ) -> Self { + let mut cache = ResponseCache::load(cache_path); + let cache_warning = cache.take_load_warning(); + Self { transport, - cache: ResponseCache::load(cache_path), + cache, + cache_warning, 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}"); - } + pub fn take_cache_warning(&mut self) -> Option { + self.cache_warning.take() + } + + /// Saves the cache and returns any save error as a warning. + pub fn finish(self) -> Option { + self.cache + .save() + .err() + .map(|error| self.cache.save_warning(error)) } /// Sleeps for the inter-request rate limit window. Called only from the @@ -536,6 +544,46 @@ 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("does not change the dependency check result") + ); + } + #[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..d33b154 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -33,10 +33,18 @@ pub struct ResponseCache { path: Option, data: CacheData, dirty: bool, + load_warning: Option, +} + +/// A cache failure that does not stop the dependency check. +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 +52,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 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: {e}. Checking without cached responses" + ), + }); 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: {error}. Checking without cached responses" + ), + }); + CacheData { + version: CACHE_VERSION, + ..Default::default() + } + } } } else { CacheData { @@ -81,6 +105,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) -> CacheWarning { + CacheWarning { + path: self + .path + .clone() + .expect("cache save failures require a configured path"), + message: format!( + "Could not save cache: {error}. This does not change the dependency check result" + ), } } @@ -251,13 +292,22 @@ 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 without cached responses") + ); } #[test] @@ -290,8 +340,30 @@ 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); + assert_eq!(warning.path, path); + assert!( + warning + .message + .contains("does not change the dependency check result") + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 74b7530..3311aea 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 is {minimum_age}. Maximum age is {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,203 @@ 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, +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 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 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 = if cli.suggest_fix { + cli.min_age_days + } else { + None + }; + + 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_warning = client.take_cache_warning(); + if let Some(warning) = &initial_cache_warning { + 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_warning); // 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 +410,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..68fe9e7 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,10 +46,21 @@ 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 - /// the caller's job. + /// when crates.io confirms the version is missing) and the current time. pub fn evaluate( &self, pkg: &Package, @@ -58,7 +74,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 +229,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 +251,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..3dfef4a 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,292 @@ 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 normalized age policy used for a run. +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; +/// Package counts. Each lockfile entry contributes to one field. +#[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. +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, this contains + /// every investigation outcome and may be empty. + 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 + .expect("too-new violations require a minimum age"), + 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 + .expect("too-old violations require a maximum age"), + 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]) { +/// Prints the final result to standard output. +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); + + 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()); + } + + print_violations(&report.violations, &report.policy); + + if let Some(outcomes) = &report.suggestions { + print_suggestions(outcomes); + } +} + +pub fn print_suggestions(outcomes: &[crate::suggest::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; } - let has_suggestion = outcomes + let suggestions: Vec<_> = outcomes .iter() - .any(|o| matches!(o, Outcome::Suggest { .. })); - - if has_suggestion { - println!( - "\n💡 Suggested fixes for \"too new\" violations (apply top to bottom, then re-run):\n" - ); - - for outcome in outcomes { - if let Outcome::Suggest { + .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 them from top to bottom, then run this check again:" + ); + 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 +320,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. -"# + "\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 new file mode 100644 index 0000000..7f0316a --- /dev/null +++ b/src/report/json.rs @@ -0,0 +1,458 @@ +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 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()), + |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..76a5e8d 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -45,6 +45,18 @@ pub enum Outcome { package: String, locked_version: String, }, + Unavailable { + package: String, + locked_version: String, + reason: String, + }, +} + +/// Sent to the progress callback before each downgrade check. +pub struct SuggestionProgress { + pub current: usize, + pub total: usize, + pub package: String, } /// Whether `a` and `b` share Cargo's symmetric caret-compatible zone: major, @@ -438,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, @@ -449,6 +460,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 +474,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..7349ffb --- /dev/null +++ b/tests/ci_output_cli.rs @@ -0,0 +1,196 @@ +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") + ); +} + +#[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}" + ); + } +} diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index 0957e69..2d43f6c 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -119,21 +119,23 @@ 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}" ); - 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 @@ -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}" ); @@ -244,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}" ); } @@ -374,7 +379,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}" ); }