diff --git a/crates/cargo-test-support/src/compare.rs b/crates/cargo-test-support/src/compare.rs index e28c9459206..b9194fcd0f1 100644 --- a/crates/cargo-test-support/src/compare.rs +++ b/crates/cargo-test-support/src/compare.rs @@ -208,6 +208,7 @@ fn substitute_macros(input: &str) -> String { ("[ADDING]", " Adding"), ("[REMOVING]", " Removing"), ("[REMOVED]", " Removed"), + ("[UNCHANGED]", " Unchanged"), ("[DOCTEST]", " Doc-tests"), ("[PACKAGING]", " Packaging"), ("[PACKAGED]", " Packaged"), diff --git a/src/cargo/ops/cargo_generate_lockfile.rs b/src/cargo/ops/cargo_generate_lockfile.rs index e638c69390f..fa75ad1bb72 100644 --- a/src/cargo/ops/cargo_generate_lockfile.rs +++ b/src/cargo/ops/cargo_generate_lockfile.rs @@ -1,8 +1,11 @@ use crate::core::registry::PackageRegistry; use crate::core::resolver::features::{CliFeatures, HasDevUnits}; +use crate::core::shell::Verbosity; +use crate::core::Registry as _; use crate::core::{PackageId, PackageIdSpec, PackageIdSpecQuery}; use crate::core::{Resolve, SourceId, Workspace}; use crate::ops; +use crate::sources::source::QueryKind; use crate::util::cache_lock::CacheLockMode; use crate::util::config::Config; use crate::util::style; @@ -161,36 +164,137 @@ pub fn update_lockfile(ws: &Workspace<'_>, opts: &UpdateOptions<'_>) -> CargoRes let print_change = |status: &str, msg: String, color: &Style| { opts.config.shell().status_with_color(status, msg, color) }; - for (removed, added) in compare_dependency_graphs(&previous_resolve, &resolve) { + let mut unchanged_behind = 0; + for ResolvedPackageVersions { + removed, + added, + unchanged, + } in compare_dependency_graphs(&previous_resolve, &resolve) + { + fn format_latest(version: semver::Version) -> String { + let warn = style::WARN; + format!(" {warn}(latest: v{version}){warn:#}") + } + fn is_latest(candidate: &semver::Version, current: &semver::Version) -> bool { + current < candidate + // Only match pre-release if major.minor.patch are the same + && (candidate.pre.is_empty() + || (candidate.major == current.major + && candidate.minor == current.minor + && candidate.patch == current.patch)) + } + let possibilities = if let Some(query) = [added.iter(), unchanged.iter()] + .into_iter() + .flatten() + .next() + .filter(|s| s.source_id().is_registry()) + { + let query = + crate::core::dependency::Dependency::parse(query.name(), None, query.source_id())?; + loop { + match registry.query_vec(&query, QueryKind::Exact) { + std::task::Poll::Ready(res) => { + break res?; + } + std::task::Poll::Pending => registry.block_until_ready()?, + } + } + } else { + vec![] + }; + if removed.len() == 1 && added.len() == 1 { - let msg = if removed[0].source_id().is_git() { + let added = added.into_iter().next().unwrap(); + let removed = removed.into_iter().next().unwrap(); + + let latest = if !possibilities.is_empty() { + possibilities + .iter() + .map(|s| s.as_summary()) + .filter(|s| is_latest(s.version(), added.version())) + .map(|s| s.version().clone()) + .max() + .map(format_latest) + } else { + None + } + .unwrap_or_default(); + + let msg = if removed.source_id().is_git() { format!( - "{} -> #{}", - removed[0], - &added[0].source_id().precise_git_fragment().unwrap()[..8], + "{removed} -> #{}", + &added.source_id().precise_git_fragment().unwrap()[..8], ) } else { - format!("{} -> v{}", removed[0], added[0].version()) + format!("{removed} -> v{}{latest}", added.version()) }; // If versions differ only in build metadata, we call it an "update" // regardless of whether the build metadata has gone up or down. // This metadata is often stuff like git commit hashes, which are // not meaningfully ordered. - if removed[0].version().cmp_precedence(added[0].version()) == Ordering::Greater { + if removed.version().cmp_precedence(added.version()) == Ordering::Greater { print_change("Downgrading", msg, &style::WARN)?; } else { print_change("Updating", msg, &style::GOOD)?; } } else { for package in removed.iter() { - print_change("Removing", format!("{}", package), &style::ERROR)?; + print_change("Removing", format!("{package}"), &style::ERROR)?; } for package in added.iter() { - print_change("Adding", format!("{}", package), &style::NOTE)?; + let latest = if !possibilities.is_empty() { + possibilities + .iter() + .map(|s| s.as_summary()) + .filter(|s| is_latest(s.version(), package.version())) + .map(|s| s.version().clone()) + .max() + .map(format_latest) + } else { + None + } + .unwrap_or_default(); + + print_change("Adding", format!("{package}{latest}"), &style::NOTE)?; + } + } + for package in &unchanged { + let latest = if !possibilities.is_empty() { + possibilities + .iter() + .map(|s| s.as_summary()) + .filter(|s| is_latest(s.version(), package.version())) + .map(|s| s.version().clone()) + .max() + .map(format_latest) + } else { + None + }; + + if let Some(latest) = latest { + unchanged_behind += 1; + if opts.config.shell().verbosity() == Verbosity::Verbose { + opts.config.shell().status_with_color( + "Unchanged", + format!("{package}{latest}"), + &anstyle::Style::new().bold(), + )?; + } } } } + if opts.config.shell().verbosity() == Verbosity::Verbose { + opts.config.shell().note( + "to see how you depend on a package, run `cargo tree --invert --package @`", + )?; + } else { + if 0 < unchanged_behind { + opts.config.shell().note(format!( + "pass `--verbose` to see {unchanged_behind} unchanged dependencies behind latest" + ))?; + } + } if opts.dry_run { opts.config .shell() @@ -215,73 +319,87 @@ pub fn update_lockfile(ws: &Workspace<'_>, opts: &UpdateOptions<'_>) -> CargoRes } } + #[derive(Default, Clone, Debug)] + struct ResolvedPackageVersions { + removed: Vec, + added: Vec, + unchanged: Vec, + } fn compare_dependency_graphs( previous_resolve: &Resolve, resolve: &Resolve, - ) -> Vec<(Vec, Vec)> { + ) -> Vec { fn key(dep: PackageId) -> (&'static str, SourceId) { (dep.name().as_str(), dep.source_id()) } - // Removes all package IDs in `b` from `a`. Note that this is somewhat - // more complicated because the equality for source IDs does not take - // precise versions into account (e.g., git shas), but we want to take - // that into account here. - fn vec_subtract(a: &[PackageId], b: &[PackageId]) -> Vec { - a.iter() - .filter(|a| { - // If this package ID is not found in `b`, then it's definitely - // in the subtracted set. - let Ok(i) = b.binary_search(a) else { - return true; - }; + fn vec_subset(a: &[PackageId], b: &[PackageId]) -> Vec { + a.iter().filter(|a| !contains_id(b, a)).cloned().collect() + } - // If we've found `a` in `b`, then we iterate over all instances - // (we know `b` is sorted) and see if they all have different - // precise versions. If so, then `a` isn't actually in `b` so - // we'll let it through. - // - // Note that we only check this for non-registry sources, - // however, as registries contain enough version information in - // the package ID to disambiguate. - if a.source_id().is_registry() { - return false; - } - b[i..] - .iter() - .take_while(|b| a == b) - .all(|b| !a.source_id().has_same_precise_as(b.source_id())) - }) - .cloned() - .collect() + fn vec_intersection(a: &[PackageId], b: &[PackageId]) -> Vec { + a.iter().filter(|a| contains_id(b, a)).cloned().collect() + } + + // Check if a PackageId is present `b` from `a`. + // + // Note that this is somewhat more complicated because the equality for source IDs does not + // take precise versions into account (e.g., git shas), but we want to take that into + // account here. + fn contains_id(haystack: &[PackageId], needle: &PackageId) -> bool { + let Ok(i) = haystack.binary_search(needle) else { + return false; + }; + + // If we've found `a` in `b`, then we iterate over all instances + // (we know `b` is sorted) and see if they all have different + // precise versions. If so, then `a` isn't actually in `b` so + // we'll let it through. + // + // Note that we only check this for non-registry sources, + // however, as registries contain enough version information in + // the package ID to disambiguate. + if needle.source_id().is_registry() { + return true; + } + haystack[i..] + .iter() + .take_while(|b| &needle == b) + .any(|b| needle.source_id().has_same_precise_as(b.source_id())) } // Map `(package name, package source)` to `(removed versions, added versions)`. let mut changes = BTreeMap::new(); - let empty = (Vec::new(), Vec::new()); + let empty = ResolvedPackageVersions::default(); for dep in previous_resolve.iter() { changes .entry(key(dep)) .or_insert_with(|| empty.clone()) - .0 + .removed .push(dep); } for dep in resolve.iter() { changes .entry(key(dep)) .or_insert_with(|| empty.clone()) - .1 + .added .push(dep); } for v in changes.values_mut() { - let (ref mut old, ref mut new) = *v; + let ResolvedPackageVersions { + removed: ref mut old, + added: ref mut new, + unchanged: ref mut other, + } = *v; old.sort(); new.sort(); - let removed = vec_subtract(old, new); - let added = vec_subtract(new, old); + let removed = vec_subset(old, new); + let added = vec_subset(new, old); + let unchanged = vec_intersection(new, old); *old = removed; *new = added; + *other = unchanged; } debug!("{:#?}", changes); diff --git a/tests/testsuite/git.rs b/tests/testsuite/git.rs index 96d35a45993..6007f04732d 100644 --- a/tests/testsuite/git.rs +++ b/tests/testsuite/git.rs @@ -1372,7 +1372,7 @@ fn dep_with_changed_submodule() { sleep_ms(1000); // Update the dependency and carry on! println!("update"); - p.cargo("update -v") + p.cargo("update") .with_stderr("") .with_stderr(&format!( "[UPDATING] git repository `{}`\n\ diff --git a/tests/testsuite/local_registry.rs b/tests/testsuite/local_registry.rs index 4e00e0ecd62..3c08f4662b1 100644 --- a/tests/testsuite/local_registry.rs +++ b/tests/testsuite/local_registry.rs @@ -183,7 +183,7 @@ fn multiple_versions() { .file("src/lib.rs", "pub fn bar() {}") .publish(); - p.cargo("update -v") + p.cargo("update") .with_stderr("[UPDATING] bar v0.1.0 -> v0.2.0") .run(); } diff --git a/tests/testsuite/patch.rs b/tests/testsuite/patch.rs index 55cab58c8b5..d5ab1182170 100644 --- a/tests/testsuite/patch.rs +++ b/tests/testsuite/patch.rs @@ -2430,6 +2430,7 @@ fn can_update_with_alt_reg() { "\ [UPDATING] `alternative` index [UPDATING] `dummy-registry` index +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); diff --git a/tests/testsuite/registry.rs b/tests/testsuite/registry.rs index df174c0fa6a..3c73ce50101 100644 --- a/tests/testsuite/registry.rs +++ b/tests/testsuite/registry.rs @@ -1572,6 +1572,7 @@ fn update_multiple_packages() { [UPDATING] `[..]` index [UPDATING] a v0.1.0 -> v0.1.1 [UPDATING] b v0.1.0 -> v0.1.1 +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); diff --git a/tests/testsuite/replace.rs b/tests/testsuite/replace.rs index 6c31a023e79..df684187607 100644 --- a/tests/testsuite/replace.rs +++ b/tests/testsuite/replace.rs @@ -539,6 +539,7 @@ fn override_adds_some_deps() { "\ [UPDATING] git repository `file://[..]` [UPDATING] `dummy-registry` index +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); @@ -546,6 +547,7 @@ fn override_adds_some_deps() { .with_stderr( "\ [UPDATING] `dummy-registry` index +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); diff --git a/tests/testsuite/update.rs b/tests/testsuite/update.rs index 0d283d58079..6f90aacbdff 100644 --- a/tests/testsuite/update.rs +++ b/tests/testsuite/update.rs @@ -109,6 +109,7 @@ fn transitive_minor_update() { .with_stderr( "\ [UPDATING] `[..]` index +[NOTE] pass `--verbose` to see 2 unchanged dependencies behind latest ", ) .run(); @@ -160,6 +161,7 @@ fn conservative() { "\ [UPDATING] `[..]` index [UPDATING] serde v0.1.0 -> v0.1.1 +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); @@ -386,6 +388,7 @@ fn update_precise() { "\ [UPDATING] `[..]` index [DOWNGRADING] serde v0.2.1 -> v0.2.0 +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); @@ -520,6 +523,7 @@ fn update_precise_do_not_force_update_deps() { "\ [UPDATING] `[..]` index [UPDATING] serde v0.2.1 -> v0.2.2 +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest ", ) .run(); @@ -898,6 +902,7 @@ fn dry_run_update() { "\ [UPDATING] `[..]` index [UPDATING] serde v0.1.0 -> v0.1.1 +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest [WARNING] not updating lockfile due to dry run ", ) @@ -1473,3 +1478,128 @@ fn precise_yanked_multiple_presence() { let lockfile = p.read_lockfile(); assert!(lockfile.contains("\nname = \"bar\"\nversion = \"0.1.1\"")); } + +#[cargo_test] +fn report_behind() { + Package::new("two-ver", "0.1.0").publish(); + Package::new("two-ver", "0.2.0").publish(); + Package::new("pre", "1.0.0-alpha.0").publish(); + Package::new("pre", "1.0.0-alpha.1").publish(); + Package::new("breaking", "0.1.0").publish(); + Package::new("breaking", "0.2.0").publish(); + Package::new("breaking", "0.2.1-alpha.0").publish(); + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + + [dependencies] + breaking = "0.1" + pre = "=1.0.0-alpha.0" + two-ver = "0.2.0" + two-ver-one = { version = "0.1.0", package = "two-ver" } + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("generate-lockfile").run(); + Package::new("breaking", "0.1.1").publish(); + + p.cargo("update --dry-run") + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[UPDATING] breaking v0.1.0 -> v0.1.1 (latest: v0.2.0) +[NOTE] pass `--verbose` to see 2 unchanged dependencies behind latest +[WARNING] not updating lockfile due to dry run +", + ) + .run(); + + p.cargo("update --dry-run --verbose") + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[UPDATING] breaking v0.1.0 -> v0.1.1 (latest: v0.2.0) +[UNCHANGED] pre v1.0.0-alpha.0 (latest: v1.0.0-alpha.1) +[UNCHANGED] two-ver v0.1.0 (latest: v0.2.0) +[NOTE] to see how you depend on a package, run `cargo tree --invert --package @` +[WARNING] not updating lockfile due to dry run +", + ) + .run(); + + p.cargo("update").run(); + + p.cargo("update --dry-run") + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[NOTE] pass `--verbose` to see 3 unchanged dependencies behind latest +[WARNING] not updating lockfile due to dry run +", + ) + .run(); + + p.cargo("update --dry-run --verbose") + .with_stderr( + "\ +[UPDATING] `dummy-registry` index +[UNCHANGED] breaking v0.1.1 (latest: v0.2.0) +[UNCHANGED] pre v1.0.0-alpha.0 (latest: v1.0.0-alpha.1) +[UNCHANGED] two-ver v0.1.0 (latest: v0.2.0) +[NOTE] to see how you depend on a package, run `cargo tree --invert --package @` +[WARNING] not updating lockfile due to dry run +", + ) + .run(); +} + +#[cargo_test] +fn update_with_missing_feature() { + // Attempting to update a package to a version with a missing feature + // should produce a warning. + Package::new("bar", "0.1.0").feature("feat1", &[]).publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + + [dependencies] + bar = {version="0.1", features=["feat1"]} + "#, + ) + .file("src/lib.rs", "") + .build(); + p.cargo("generate-lockfile").run(); + + // Publish an update that is missing the feature. + Package::new("bar", "0.1.1").publish(); + + p.cargo("update") + .with_stderr( + "\ +[UPDATING] `[..]` index +[NOTE] pass `--verbose` to see 1 unchanged dependencies behind latest +", + ) + .run(); + + // Publish a fixed version, should not warn. + Package::new("bar", "0.1.2").feature("feat1", &[]).publish(); + p.cargo("update") + .with_stderr( + "\ +[UPDATING] `[..]` index +[UPDATING] bar v0.1.0 -> v0.1.2 +", + ) + .run(); +}