From d4f36b2093bdce5cfcac2fbaca3f32ba420dc557 Mon Sep 17 00:00:00 2001 From: Charalampos Mitrodimas Date: Sat, 22 Nov 2025 14:56:14 +0200 Subject: [PATCH 1/2] test(update): Show current behavior for --breaking with non-existent packages This test demonstrates the current behavior where invalid package specifications passed to `cargo update --breaking` are silently ignored instead of reporting an error. The test covers: 1. Non-existent packages are silently ignored 2. Mix of valid and invalid packages processes only valid ones 3. Transitive dependencies are silently ignored 4. Renamed, non-semver, no-breaking-update dependencies are silently ignored Split the existing test into separate cases for transitive vs renamed/non-semver/no-breaking-update dependencies. A subsequent commit will fix this behavior and update these tests to verify proper error reporting. Signed-off-by: Charalampos Mitrodimas --- tests/testsuite/update.rs | 74 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/testsuite/update.rs b/tests/testsuite/update.rs index a8dc8721d92..afdb862a054 100644 --- a/tests/testsuite/update.rs +++ b/tests/testsuite/update.rs @@ -2161,7 +2161,16 @@ fn update_breaking_specific_packages_that_wont_update() { Package::new("non-semver", "2.0.0").publish(); Package::new("transitive-incompatible", "2.0.0").publish(); - p.cargo("update -Zunstable-options --breaking compatible renamed-from non-semver transitive-compatible transitive-incompatible") + // Transitive dependencies are silently ignored + p.cargo("update -Zunstable-options --breaking transitive-compatible transitive-incompatible") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_stderr_data(str![[r#" + +"#]]) + .run(); + + // Renamed, non-semver, no-breaking-update dependencies are silently ignored + p.cargo("update -Zunstable-options --breaking compatible renamed-from non-semver") .masquerade_as_nightly_cargo(&["update-breaking"]) .with_stderr_data(str![[r#" [UPDATING] `[..]` index @@ -2750,3 +2759,66 @@ Caused by: "#]]) .run(); } + +#[cargo_test] +fn update_breaking_missing_package_error() { + Package::new("bar", "1.0.0").publish(); + Package::new("transitive", "1.0.0").publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.0.1" + edition = "2015" + authors = [] + + [dependencies] + bar = "1.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("generate-lockfile").run(); + + Package::new("bar", "2.0.0") + .add_dep(Dependency::new("transitive", "1.0.0").build()) + .publish(); + + // This test demonstrates the current buggy behavior where invalid package + // specs are silently ignored instead of reporting an error. A subsequent + // commit will fix this behavior and update this test to verify proper + // error reporting. + + // Non-existent package is silently ignored + p.cargo("update -Zunstable-options --breaking no_such_crate") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_stderr_data(str![[r#" + +"#]]) + .run(); + + // Valid package processes, invalid package silently ignored + p.cargo("update -Zunstable-options --breaking bar no_such_crate") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_stderr_data(str![[r#" +[UPDATING] `dummy-registry` index +[UPGRADING] bar ^1.0 -> ^2.0 +[LOCKING] 2 packages to latest compatible versions +[UPDATING] bar v1.0.0 -> v2.0.0 +[ADDING] transitive v1.0.0 + +"#]]) + .run(); + + // Transitive dependency is silently ignored (produces no output) + p.cargo("update -Zunstable-options --breaking transitive") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_stderr_data(str![[r#" + +"#]]) + .run(); +} From 145bec3fc1c0488714272f3ccd93e688c7895c8e Mon Sep 17 00:00:00 2001 From: Charalampos Mitrodimas Date: Sat, 22 Nov 2025 14:58:36 +0200 Subject: [PATCH 2/2] update: report error for non-matching package specs with --breaking When using `cargo update --breaking `, package specifications that don't match any upgradeable dependency are now properly reported as errors instead of being silently ignored. The implementation tracks which specs match direct dependencies during the upgrade process. After processing all workspace members, it validates that each requested spec either: 1. Matched a direct registry dependency (and was processed), or 2. Exists in the lockfile but cannot be upgraded Specs that match neither category produce clear error messages: - "did not match any packages" for completely non-existent packages - "matched a package... but did not match any direct dependencies" for transitive/non-upgradeable packages, with a note explaining that --breaking can only upgrade direct dependencies Multiple errors are collected and reported together for better UX. This fixes the confusing behavior where users could specify packages that don't exist without receiving any feedback. --- src/cargo/ops/cargo_update.rs | 56 ++++++++++++++++- tests/testsuite/update.rs | 111 ++++++++++++++++++++++++++++------ 2 files changed, 148 insertions(+), 19 deletions(-) diff --git a/src/cargo/ops/cargo_update.rs b/src/cargo/ops/cargo_update.rs index ccbbfa01bfd..a4d0b2e0f16 100644 --- a/src/cargo/ops/cargo_update.rs +++ b/src/cargo/ops/cargo_update.rs @@ -17,7 +17,7 @@ use crate::util::{CargoResult, VersionExt}; use crate::util::{OptVersionReq, style}; use anyhow::Context as _; use cargo_util_schemas::core::PartialVersion; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use semver::{Op, Version, VersionReq}; use std::cmp::Ordering; @@ -238,6 +238,8 @@ pub fn upgrade_manifests( let mut registry = ws.package_registry()?; registry.lock_patches(); + let mut remaining_specs: IndexSet<_> = to_update.iter().cloned().collect(); + for member in ws.members_mut().sorted() { debug!("upgrading manifest for `{}`", member.name()); @@ -252,11 +254,58 @@ pub fn upgrade_manifests( &mut registry, &mut upgrades, &mut upgrade_messages, + &mut remaining_specs, d, ) })?; } + if !remaining_specs.is_empty() { + let previous_resolve = ops::load_pkg_lockfile(ws)?; + let plural = if remaining_specs.len() == 1 { "" } else { "s" }; + + let mut error_msg = format!( + "package ID specification{plural} did not match any direct dependencies that could be upgraded" + ); + + let mut transitive_specs = Vec::new(); + for spec in &remaining_specs { + error_msg.push_str(&format!("\n {spec}")); + + // Check if spec is in the lockfile (could be transitive) + let in_lockfile = if let Some(ref resolve) = previous_resolve { + spec.query(resolve.iter()).is_ok() + } else { + false + }; + + // Check if spec matches any direct dependency in the workspace + let matches_direct_dep = ws.members().any(|member| { + member.dependencies().iter().any(|dep| { + spec.name() == dep.package_name().as_str() + && dep.source_id().is_registry() + && spec.url().map_or(true, |url| url == dep.source_id().url()) + && spec + .version() + .map_or(true, |v| dep.version_req().matches(&v)) + }) + }); + + // Track transitive specs for notes at the end + if in_lockfile && !matches_direct_dep { + transitive_specs.push(spec); + } + } + + for spec in transitive_specs { + error_msg.push_str(&format!( + "\nnote: `{spec}` exists as a transitive dependency but those are not available for upgrading through `--breaking`" + )); + } + + anyhow::bail!("{error_msg}"); + } + Ok(upgrades) } @@ -266,6 +315,7 @@ fn upgrade_dependency( registry: &mut PackageRegistry<'_>, upgrades: &mut UpgradeMap, upgrade_messages: &mut HashSet, + remaining_specs: &mut IndexSet, dependency: Dependency, ) -> CargoResult { let name = dependency.package_name(); @@ -367,6 +417,10 @@ fn upgrade_dependency( upgrades.insert((name.to_string(), dependency.source_id()), latest.clone()); + // Remove this spec from remaining_specs since we successfully upgraded it + remaining_specs + .retain(|spec| !(spec.name() == name.as_str() && dependency.source_id().is_registry())); + let req = OptVersionReq::Req(VersionReq::parse(&latest.to_string())?); let mut dep = dependency.clone(); dep.set_version_req(req); diff --git a/tests/testsuite/update.rs b/tests/testsuite/update.rs index afdb862a054..f6a23e55cdd 100644 --- a/tests/testsuite/update.rs +++ b/tests/testsuite/update.rs @@ -2161,19 +2161,30 @@ fn update_breaking_specific_packages_that_wont_update() { Package::new("non-semver", "2.0.0").publish(); Package::new("transitive-incompatible", "2.0.0").publish(); - // Transitive dependencies are silently ignored + // Test that transitive dependencies produce helpful errors p.cargo("update -Zunstable-options --breaking transitive-compatible transitive-incompatible") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" +[ERROR] package ID specifications did not match any direct dependencies that could be upgraded + transitive-compatible + transitive-incompatible +[NOTE] `transitive-compatible` exists as a transitive dependency but those are not available for upgrading through `--breaking` +[NOTE] `transitive-incompatible` exists as a transitive dependency but those are not available for upgrading through `--breaking` "#]]) .run(); - // Renamed, non-semver, no-breaking-update dependencies are silently ignored + // Test that renamed, non-semver, no-breaking-update dependencies produce errors p.cargo("update -Zunstable-options --breaking compatible renamed-from non-semver") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" -[UPDATING] `[..]` index +[UPDATING] `dummy-registry` index +[ERROR] package ID specifications did not match any direct dependencies that could be upgraded + compatible + renamed-from + non-semver "#]]) .run(); @@ -2285,13 +2296,23 @@ Caused by: // Spec version not matching our current dependencies p.cargo("update -Zunstable-options --breaking incompatible@2.0.0") .masquerade_as_nightly_cargo(&["update-breaking"]) - .with_stderr_data(str![[r#""#]]) + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + incompatible@2.0.0 + +"#]]) .run(); // Spec source not matching our current dependencies p.cargo("update -Zunstable-options --breaking https://alternative.com#incompatible@1.0.0") .masquerade_as_nightly_cargo(&["update-breaking"]) - .with_stderr_data(str![[r#""#]]) + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + https://alternative.com/#incompatible@1.0.0 + +"#]]) .run(); // Accepted spec @@ -2322,8 +2343,11 @@ Caused by: // Spec matches a dependency that will not be upgraded p.cargo("update -Zunstable-options --breaking compatible@1.0.0") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `[..]` index +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + compatible@1.0.0 "#]]) .run(); @@ -2331,12 +2355,22 @@ Caused by: // Non-existing versions p.cargo("update -Zunstable-options --breaking incompatible@9.0.0") .masquerade_as_nightly_cargo(&["update-breaking"]) - .with_stderr_data(str![[r#""#]]) + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + incompatible@9.0.0 + +"#]]) .run(); p.cargo("update -Zunstable-options --breaking compatible@9.0.0") .masquerade_as_nightly_cargo(&["update-breaking"]) - .with_stderr_data(str![[r#""#]]) + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + compatible@9.0.0 + +"#]]) .run(); } @@ -2397,8 +2431,11 @@ fn update_breaking_spec_version_transitive() { // But not the transitive one, because bar is not a workspace member p.cargo("update -Zunstable-options --breaking dep@1.1") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `[..]` index +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + dep@1.1 "#]]) .run(); @@ -2656,12 +2693,15 @@ fn update_breaking_pre_release_downgrade() { // The purpose of this test is // to demonstrate that `update --breaking` will not try to downgrade to the latest stable version (1.7.0), - // but will rather keep the latest pre-release (2.0.0-beta.21). + // but will error because the dependency uses an exact version (not caret). Package::new("bar", "1.7.0").publish(); p.cargo("update -Zunstable-options --breaking bar") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `dummy-registry` index +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + bar "#]]) .run(); @@ -2690,21 +2730,27 @@ fn update_breaking_pre_release_upgrade() { p.cargo("generate-lockfile").run(); - // TODO: `2.0.0-beta.21` can be upgraded to `2.0.0-beta.22` + // `2.0.0-beta.21` cannot be upgraded with --breaking because it uses an exact version (not caret) Package::new("bar", "2.0.0-beta.22").publish(); p.cargo("update -Zunstable-options --breaking bar") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `dummy-registry` index +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + bar "#]]) .run(); - // TODO: `2.0.0-beta.21` can be upgraded to `2.0.0` + // `2.0.0-beta.21` cannot be upgraded to `2.0.0` with --breaking because it uses an exact version (not caret) Package::new("bar", "2.0.0").publish(); p.cargo("update -Zunstable-options --breaking bar") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `dummy-registry` index +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + bar "#]]) .run(); @@ -2788,21 +2834,32 @@ fn update_breaking_missing_package_error() { .add_dep(Dependency::new("transitive", "1.0.0").build()) .publish(); - // This test demonstrates the current buggy behavior where invalid package - // specs are silently ignored instead of reporting an error. A subsequent - // commit will fix this behavior and update this test to verify proper - // error reporting. - - // Non-existent package is silently ignored + // Non-existent package reports an error p.cargo("update -Zunstable-options --breaking no_such_crate") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + no_such_crate "#]]) .run(); - // Valid package processes, invalid package silently ignored + // Valid package processes, invalid package reports error p.cargo("update -Zunstable-options --breaking bar no_such_crate") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) + .with_stderr_data(str![[r#" +[UPDATING] `dummy-registry` index +[UPGRADING] bar ^1.0 -> ^2.0 +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + no_such_crate + +"#]]) + .run(); + + // Successfully upgrade bar to add transitive to lockfile + p.cargo("update -Zunstable-options --breaking bar") .masquerade_as_nightly_cargo(&["update-breaking"]) .with_stderr_data(str![[r#" [UPDATING] `dummy-registry` index @@ -2814,10 +2871,28 @@ fn update_breaking_missing_package_error() { "#]]) .run(); - // Transitive dependency is silently ignored (produces no output) + // Transitive dependency reports helpful error p.cargo("update -Zunstable-options --breaking transitive") .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package ID specification did not match any direct dependencies that could be upgraded + transitive +[NOTE] `transitive` exists as a transitive dependency but those are not available for upgrading through `--breaking` + +"#]]) + .run(); + + // Multiple error types reported together + p.cargo("update -Zunstable-options --breaking no_such_crate transitive another_missing") + .masquerade_as_nightly_cargo(&["update-breaking"]) + .with_status(101) .with_stderr_data(str![[r#" +[ERROR] package ID specifications did not match any direct dependencies that could be upgraded + no_such_crate + transitive + another_missing +[NOTE] `transitive` exists as a transitive dependency but those are not available for upgrading through `--breaking` "#]]) .run();