From da5d0195fa59129326891e51295dfae53b753c60 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Tue, 30 Dec 2025 12:53:48 -0500 Subject: [PATCH 1/7] fix(fingerprint): derive unit name by index For cascading rebuild reasons we log unit index so that we know why the exact unit of the fingerprint is dirty. Previously we log package name but had no way to reference back to the actual unit. --- .../core/compiler/fingerprint/dirty_reason.rs | 63 ++++++++++--------- src/cargo/core/compiler/fingerprint/mod.rs | 24 ++++--- src/cargo/core/compiler/job_queue/mod.rs | 14 ++++- tests/testsuite/artifact_dep.rs | 2 +- tests/testsuite/doc.rs | 2 +- tests/testsuite/freshness.rs | 10 +-- tests/testsuite/freshness_checksum.rs | 10 +-- tests/testsuite/lto.rs | 4 +- 8 files changed, 73 insertions(+), 56 deletions(-) diff --git a/src/cargo/core/compiler/fingerprint/dirty_reason.rs b/src/cargo/core/compiler/fingerprint/dirty_reason.rs index dddb850eddc..8496899d4b4 100644 --- a/src/cargo/core/compiler/fingerprint/dirty_reason.rs +++ b/src/cargo/core/compiler/fingerprint/dirty_reason.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fmt; use std::fmt::Debug; @@ -73,11 +74,7 @@ pub enum DirtyReason { new: InternedString, }, UnitDependencyInfoChanged { - old_name: InternedString, - old_fingerprint: u64, - - new_name: InternedString, - new_fingerprint: u64, + unit: u64, }, FsStatusOutdated(FsStatus), NothingObvious, @@ -148,7 +145,13 @@ impl DirtyReason { } } - pub fn present_to(&self, s: &mut Shell, unit: &Unit, root: &Path) -> CargoResult<()> { + pub fn present_to( + &self, + s: &mut Shell, + unit: &Unit, + root: &Path, + index_to_unit: &HashMap, + ) -> CargoResult<()> { match self { DirtyReason::RustcChanged => s.dirty_because(unit, "the toolchain changed"), DirtyReason::FeaturesChanged { .. } => { @@ -220,8 +223,12 @@ impl DirtyReason { unit, format_args!("name of dependency changed ({old} => {new})"), ), - DirtyReason::UnitDependencyInfoChanged { .. } => { - s.dirty_because(unit, "dependency info changed") + DirtyReason::UnitDependencyInfoChanged { unit: dep_unit } => { + let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap(); + s.dirty_because( + unit, + format_args!("info of dependency `{dep_name}` changed"), + ) } DirtyReason::FsStatusOutdated(status) => match status { FsStatus::Stale => s.dirty_because(unit, "stale, unknown reason"), @@ -301,19 +308,23 @@ impl DirtyReason { ), }, FsStatus::StaleDependency { - name, + unit: dep_unit, dep_mtime, max_mtime, - .. } => { + let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap(); let after = Self::after(*max_mtime, *dep_mtime, "last build"); s.dirty_because( unit, - format_args!("the dependency {name} was rebuilt ({after})"), + format_args!("the dependency `{dep_name}` was rebuilt ({after})"), ) } - FsStatus::StaleDepFingerprint { name } => { - s.dirty_because(unit, format_args!("the dependency {name} was rebuilt")) + FsStatus::StaleDepFingerprint { unit: dep_unit } => { + let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap(); + s.dirty_because( + unit, + format_args!("the dependency `{dep_name}` was rebuilt"), + ) } FsStatus::UpToDate { .. } => { unreachable!() @@ -529,8 +540,8 @@ mod json_schema { str![[r#" { "dirty_reason": "unit-dependency-name-changed", - "old": "old_dep", - "new": "new_dep" + "new": "new_dep", + "old": "old_dep" } "#]] .is_json() @@ -539,21 +550,13 @@ mod json_schema { #[test] fn unit_dependency_info_changed() { - let reason = DirtyReason::UnitDependencyInfoChanged { - old_name: "serde".into(), - old_fingerprint: 0x1234567890abcdef, - new_name: "serde".into(), - new_fingerprint: 0xfedcba0987654321, - }; + let reason = DirtyReason::UnitDependencyInfoChanged { unit: 15 }; assert_data_eq!( to_json(&reason), str![[r#" { "dirty_reason": "unit-dependency-info-changed", - "new_fingerprint": 18364757930599072545, - "new_name": "serde", - "old_fingerprint": 1311768467294899695, - "old_name": "serde" + "unit": 15 } "#]] .is_json() @@ -647,7 +650,7 @@ mod json_schema { #[test] fn fs_status_stale_dependency() { let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDependency { - name: "serde".into(), + unit: 42, dep_mtime: FileTime::from_unix_time(1730567892, 789000000), max_mtime: FileTime::from_unix_time(1730567890, 123000000), }); @@ -659,7 +662,7 @@ mod json_schema { "dirty_reason": "fs-status-outdated", "fs_status": "stale-dependency", "max_mtime": 1730567890123.0, - "name": "serde" + "unit": 42 } "#]] .is_json() @@ -668,16 +671,14 @@ mod json_schema { #[test] fn fs_status_stale_dep_fingerprint() { - let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDepFingerprint { - name: "tokio".into(), - }); + let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDepFingerprint { unit: 42 }); assert_data_eq!( to_json(&reason), str![[r#" { "dirty_reason": "fs-status-outdated", "fs_status": "stale-dep-fingerprint", - "name": "tokio" + "unit": 42 } "#]] .is_json() diff --git a/src/cargo/core/compiler/fingerprint/mod.rs b/src/cargo/core/compiler/fingerprint/mod.rs index c06312de433..69f316422ef 100644 --- a/src/cargo/core/compiler/fingerprint/mod.rs +++ b/src/cargo/core/compiler/fingerprint/mod.rs @@ -657,6 +657,10 @@ pub struct Fingerprint { /// The rustc target. This is only relevant for `.json` files, otherwise /// the metadata hash segregates the units. compile_kind: u64, + /// Unit index for this fingerprint, used for tracing cascading rebuilds. + /// Not persisted to disk as indices can change between builds. + #[serde(skip)] + index: u64, /// Description of whether the filesystem status for this unit is up to date /// or should be considered stale. #[serde(skip)] @@ -685,15 +689,15 @@ pub enum FsStatus { /// A dependency was stale. StaleDependency { - name: InternedString, + unit: u64, #[serde(serialize_with = "serialize_file_time")] dep_mtime: FileTime, #[serde(serialize_with = "serialize_file_time")] max_mtime: FileTime, }, - /// A dependency was stale. - StaleDepFingerprint { name: InternedString }, + /// A dependency's fingerprint was stale. + StaleDepFingerprint { unit: u64 }, /// This unit is up-to-date. All outputs and their corresponding mtime are /// listed in the payload here for other dependencies to compare against. @@ -1011,6 +1015,7 @@ impl Fingerprint { rustflags: Vec::new(), config: 0, compile_kind: 0, + index: 0, fs_status: FsStatus::Stale, outputs: Vec::new(), } @@ -1184,10 +1189,7 @@ impl Fingerprint { if a.fingerprint.hash_u64() != b.fingerprint.hash_u64() { return DirtyReason::UnitDependencyInfoChanged { - new_name: a.name, - new_fingerprint: a.fingerprint.hash_u64(), - old_name: b.name, - old_fingerprint: b.fingerprint.hash_u64(), + unit: a.fingerprint.index, }; } } @@ -1263,7 +1265,9 @@ impl Fingerprint { | FsStatus::StaleItem(_) | FsStatus::StaleDependency { .. } | FsStatus::StaleDepFingerprint { .. } => { - self.fs_status = FsStatus::StaleDepFingerprint { name: dep.name }; + self.fs_status = FsStatus::StaleDepFingerprint { + unit: dep.fingerprint.index, + }; return Ok(()); } }; @@ -1305,7 +1309,7 @@ impl Fingerprint { ); self.fs_status = FsStatus::StaleDependency { - name: dep.name, + unit: dep.fingerprint.index, dep_mtime: *dep_mtime, max_mtime: *max_mtime, }; @@ -1637,6 +1641,7 @@ fn calculate_normal( memoized_hash: Mutex::new(None), config: Hasher::finish(&config), compile_kind, + index: build_runner.bcx.unit_to_index[unit], rustflags: extra_flags, fs_status: FsStatus::Stale, outputs, @@ -1703,6 +1708,7 @@ See https://doc.rust-lang.org/cargo/reference/build-scripts.html#rerun-if-change deps, outputs: if overridden { Vec::new() } else { vec![output] }, rustflags, + index: build_runner.bcx.unit_to_index[unit], // Most of the other info is blank here as we don't really include it // in the execution of the build script, but... this may be a latent diff --git a/src/cargo/core/compiler/job_queue/mod.rs b/src/cargo/core/compiler/job_queue/mod.rs index fdaceac904a..265646e07ba 100644 --- a/src/cargo/core/compiler/job_queue/mod.rs +++ b/src/cargo/core/compiler/job_queue/mod.rs @@ -182,6 +182,9 @@ struct DrainState<'gctx> { next_id: u32, timings: Timings<'gctx>, + /// Map from unit index to unit, for looking up dependency information. + index_to_unit: HashMap, + /// Tokens that are currently owned by this Cargo, and may be "associated" /// with a rustc process. They may also be unused, though if so will be /// dropped on the next loop iteration. @@ -495,6 +498,12 @@ impl<'gctx> JobQueue<'gctx> { progress, next_id: 0, timings: self.timings, + index_to_unit: build_runner + .bcx + .unit_to_index + .iter() + .map(|(unit, &index)| (index, unit.clone())) + .collect(), tokens: Vec::new(), pending_queue: Vec::new(), print: DiagnosticPrinter::new( @@ -1159,8 +1168,9 @@ impl<'gctx> DrainState<'gctx> { // being a compiled package. Dirty(dirty_reason) => { if !dirty_reason.is_fresh_build() { - gctx.shell() - .verbose(|shell| dirty_reason.present_to(shell, unit, ws_root))?; + gctx.shell().verbose(|shell| { + dirty_reason.present_to(shell, unit, ws_root, &self.index_to_unit) + })?; } if unit.mode.is_doc() { diff --git a/tests/testsuite/artifact_dep.rs b/tests/testsuite/artifact_dep.rs index 1d893b88e3b..1b35ea850fe 100644 --- a/tests/testsuite/artifact_dep.rs +++ b/tests/testsuite/artifact_dep.rs @@ -2708,7 +2708,7 @@ fn calc_bin_artifact_fingerprint() { [DIRTY] bar v0.5.0 ([ROOT]/foo/bar): the file `bar/src/main.rs` has changed ([..]) [COMPILING] bar v0.5.0 ([ROOT]/foo/bar) [RUNNING] `rustc --crate-name bar [..]` -[DIRTY] foo v0.1.0 ([ROOT]/foo): the dependency bar was rebuilt +[DIRTY] foo v0.1.0 ([ROOT]/foo): the dependency `bar` was rebuilt [CHECKING] foo v0.1.0 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo [..]` [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s diff --git a/tests/testsuite/doc.rs b/tests/testsuite/doc.rs index 5193cbe8da1..cdd923ae77c 100644 --- a/tests/testsuite/doc.rs +++ b/tests/testsuite/doc.rs @@ -3147,7 +3147,7 @@ fn rebuild_tracks_env_in_dep() { [CHECKING] bar v0.1.0 [RUNNING] `rustc --crate-name bar [..]` [RUNNING] `rustdoc [..]--crate-name bar [..]` -[DIRTY] foo v0.0.0 ([ROOT]/foo): the dependency bar was rebuilt +[DIRTY] foo v0.0.0 ([ROOT]/foo): the dependency `bar` was rebuilt [DOCUMENTING] foo v0.0.0 ([ROOT]/foo) [RUNNING] `rustdoc [..]--crate-name foo [..]` [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s diff --git a/tests/testsuite/freshness.rs b/tests/testsuite/freshness.rs index 7d626bbb559..ec30ae30155 100644 --- a/tests/testsuite/freshness.rs +++ b/tests/testsuite/freshness.rs @@ -178,10 +178,10 @@ fn rebuild_sub_package_then_while_package() { p.cargo("build -v") .with_stderr_data(str![[r#" [FRESH] b v0.0.1 ([ROOT]/foo/b) -[DIRTY] a v0.0.1 ([ROOT]/foo/a): the dependency b was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] a v0.0.1 ([ROOT]/foo/a): the dependency `b` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] a v0.0.1 ([ROOT]/foo/a) [RUNNING] `rustc --crate-name a [..] -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency b was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `b` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo [..] src/lib.rs [..] [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s @@ -613,7 +613,7 @@ fn rebuild_tests_if_lib_changes() { p.cargo("build").run(); p.cargo("test -v --test foo-test") .with_stderr_data(str![[r#" -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency foo was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `foo` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo_test [..]` [FINISHED] `test` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s @@ -1699,10 +1699,10 @@ fn bust_patched_dep() { [DIRTY] registry1 v0.1.0 ([ROOT]/foo/reg1new): the file `reg1new/src/lib.rs` has changed ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] registry1 v0.1.0 ([ROOT]/foo/reg1new) [RUNNING] `rustc --crate-name registry1 [..] -[DIRTY] registry2 v0.1.0: the dependency registry1 was rebuilt +[DIRTY] registry2 v0.1.0: the dependency `registry1` was rebuilt [COMPILING] registry2 v0.1.0 [RUNNING] `rustc --crate-name registry2 [..] -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency registry2 was rebuilt +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `registry2` was rebuilt [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo [..] [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s diff --git a/tests/testsuite/freshness_checksum.rs b/tests/testsuite/freshness_checksum.rs index eedc1f43de9..b8016673cc9 100644 --- a/tests/testsuite/freshness_checksum.rs +++ b/tests/testsuite/freshness_checksum.rs @@ -322,10 +322,10 @@ fn rebuild_sub_package_then_while_package() { .masquerade_as_nightly_cargo(&["checksum-freshness"]) .with_stderr_data(str![[r#" [FRESH] b v0.0.1 ([ROOT]/foo/b) -[DIRTY] a v0.0.1 ([ROOT]/foo/a): the dependency b was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] a v0.0.1 ([ROOT]/foo/a): the dependency `b` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] a v0.0.1 ([ROOT]/foo/a) [RUNNING] `rustc --crate-name a [..] -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency b was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `b` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo [..] src/lib.rs [..] [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s @@ -783,7 +783,7 @@ fn rebuild_tests_if_lib_changes() { p.cargo("test -Zchecksum-freshness -v --test foo-test") .masquerade_as_nightly_cargo(&["checksum-freshness"]) .with_stderr_data(str![[r#" -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency foo was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `foo` was rebuilt ([TIME_DIFF_AFTER_LAST_BUILD]) [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo_test [..]` [FINISHED] `test` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s @@ -1725,10 +1725,10 @@ fn bust_patched_dep() { [DIRTY] registry1 v0.1.0 ([ROOT]/foo/reg1new): file size changed (0 != 11) for `reg1new/src/lib.rs` [COMPILING] registry1 v0.1.0 ([ROOT]/foo/reg1new) [RUNNING] `rustc --crate-name registry1 [..] -[DIRTY] registry2 v0.1.0: the dependency registry1 was rebuilt +[DIRTY] registry2 v0.1.0: the dependency `registry1` was rebuilt [COMPILING] registry2 v0.1.0 [RUNNING] `rustc --crate-name registry2 [..] -[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency registry2 was rebuilt +[DIRTY] foo v0.0.1 ([ROOT]/foo): the dependency `registry2` was rebuilt [COMPILING] foo v0.0.1 ([ROOT]/foo) [RUNNING] `rustc --crate-name foo [..] [FINISHED] `dev` profile [unoptimized + debuginfo] target(s) in [ELAPSED]s diff --git a/tests/testsuite/lto.rs b/tests/testsuite/lto.rs index 072de9cc083..01c0176f5cc 100644 --- a/tests/testsuite/lto.rs +++ b/tests/testsuite/lto.rs @@ -672,7 +672,7 @@ fn dylib() { [COMPILING] registry-shared v0.0.1 [FRESH] registry v0.0.1 [RUNNING] `rustc --crate-name registry_shared [..]-C embed-bitcode=no [..]` -[DIRTY] bar v0.0.0 ([..]): dependency info changed +[DIRTY] bar v0.0.0 ([ROOT]/foo/bar): info of dependency `registry-shared` changed [COMPILING] bar v0.0.0 ([ROOT]/foo/bar) [RUNNING] `rustc --crate-name bar [..]--crate-type dylib [..]-C embed-bitcode=no [..]` [FINISHED] `release` profile [optimized] target(s) in [ELAPSED]s @@ -692,7 +692,7 @@ fn dylib() { [FRESH] registry-shared v0.0.1 [COMPILING] registry v0.0.1 [RUNNING] `rustc --crate-name registry [..]` -[DIRTY] bar v0.0.0 ([..]): dependency info changed +[DIRTY] bar v0.0.0 ([ROOT]/foo/bar): info of dependency `registry` changed [COMPILING] bar v0.0.0 ([ROOT]/foo/bar) [RUNNING] `rustc --crate-name bar [..]--crate-type dylib [..]-C embed-bitcode=no [..]` [RUNNING] `rustc --crate-name bar [..]-C lto [..]--test [..]` From ff69f7aa869f09c082201dc67a0d76070a696994 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Mon, 29 Dec 2025 19:08:27 -0500 Subject: [PATCH 2/7] test(report): `cargo report rebuilds` --- tests/testsuite/cargo_report_rebuilds/mod.rs | 519 +++++++++++++++++++ tests/testsuite/main.rs | 1 + 2 files changed, 520 insertions(+) create mode 100644 tests/testsuite/cargo_report_rebuilds/mod.rs diff --git a/tests/testsuite/cargo_report_rebuilds/mod.rs b/tests/testsuite/cargo_report_rebuilds/mod.rs new file mode 100644 index 00000000000..85f379c582f --- /dev/null +++ b/tests/testsuite/cargo_report_rebuilds/mod.rs @@ -0,0 +1,519 @@ +//! Tests for `cargo report rebuilds`. + +use crate::prelude::*; +use crate::utils::cargo_process; + +use cargo_test_support::basic_manifest; +use cargo_test_support::paths; +use cargo_test_support::project; +use cargo_test_support::str; + +#[cargo_test] +fn gated_stable_channel() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("report rebuilds") + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn gated_unstable_options() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("report rebuilds") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn no_log() { + cargo_process("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn no_log_for_the_current_workspace() { + let foo = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + foo.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + // one log file got generated. + let _ = paths::log_file(0); + + let bar = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + bar.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn no_rebuild_data() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn basic_rebuild() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.change_file("src/lib.rs", "// touched"); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn all_fresh() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn with_dependencies() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + edition = "2021" + + [dependencies] + dep = { path = "dep" } + "#, + ) + .file("src/lib.rs", "") + .file( + "dep/Cargo.toml", + r#" + [package] + name = "dep" + edition = "2021" + + [dependencies] + nested = { path = "../nested" } + "#, + ) + .file("dep/src/lib.rs", "") + .file( + "nested/Cargo.toml", + r#" + [package] + name = "nested" + edition = "2021" + + [dependencies] + deep = { path = "../deep" } + "#, + ) + .file("nested/src/lib.rs", "") + .file( + "deep/Cargo.toml", + r#" + [package] + name = "deep" + edition = "2021" + + [dependencies] + deeper = { path = "../deeper" } + "#, + ) + .file("deep/src/lib.rs", "") + .file("deeper/Cargo.toml", &basic_manifest("deeper", "0.0.0")) + .file("deeper/src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.change_file("deeper/src/lib.rs", "// touched"); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis -vv") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn multiple_root_causes() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["pkg1", "pkg2", "pkg3", "pkg4", "pkg5", "pkg6"] + resolver = "2" + "#, + ) + .file("pkg1/Cargo.toml", &basic_manifest("pkg1", "0.0.0")) + .file("pkg1/src/lib.rs", "") + .file("pkg2/Cargo.toml", &basic_manifest("pkg2", "0.0.0")) + .file("pkg2/src/lib.rs", "") + .file("pkg3/Cargo.toml", &basic_manifest("pkg3", "0.0.0")) + .file("pkg3/src/lib.rs", "") + .file("pkg4/Cargo.toml", &basic_manifest("pkg4", "0.0.0")) + .file( + "pkg4/src/lib.rs", + "fn f() { let _ = option_env!(\"__CARGO_TEST_MY_FOO\");}", + ) + .file("pkg5/Cargo.toml", &basic_manifest("pkg5", "0.0.0")) + .file("pkg5/src/lib.rs", "") + .file("pkg6/Cargo.toml", &basic_manifest("pkg6", "0.0.0")) + .file("pkg6/src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.change_file( + "pkg1/Cargo.toml", + r#" + [package] + name = "pkg1" + edition = "2021" + + [features] + feat = [] + "#, + ); + p.change_file("pkg2/src/lib.rs", "// touched"); + p.change_file( + "pkg3/Cargo.toml", + r#" + [package] + name = "pkg3" + edition = "2024" + "#, + ); + p.change_file("pkg5/src/lib.rs", "// touched"); + p.change_file("pkg6/src/lib.rs", "// touched"); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .env("__CARGO_TEST_MY_FOO", "1") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis --verbose") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn shared_dep_cascading() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["foo", "bar"] + resolver = "2" + + [workspace.dependencies] + common = { path = "common" } + "#, + ) + .file( + "foo/Cargo.toml", + r#" + [package] + name = "foo" + edition = "2021" + + [dependencies] + common = { workspace = true } + "#, + ) + .file("foo/src/lib.rs", "") + .file( + "bar/Cargo.toml", + r#" + [package] + name = "bar" + edition = "2021" + + [dependencies] + common = { workspace = true } + "#, + ) + .file("bar/src/lib.rs", "") + .file("common/Cargo.toml", &basic_manifest("common", "0.0.0")) + .file("common/src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.change_file("common/src/lib.rs", "// touched"); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.cargo("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn outside_workspace() { + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + p.change_file("src/lib.rs", "// touched"); + p.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + cargo_process("report rebuilds -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} + +#[cargo_test] +fn with_manifest_path() { + let foo = project() + .at("foo") + .file("Cargo.toml", &basic_manifest("foo", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + foo.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + let bar = project() + .at("bar") + .file("Cargo.toml", &basic_manifest("bar", "0.0.0")) + .file("src/lib.rs", "") + .build(); + + bar.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + bar.change_file("src/lib.rs", "// touched"); + bar.cargo("check -Zbuild-analysis") + .env("CARGO_BUILD_ANALYSIS_ENABLED", "true") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .run(); + + foo.cargo("report rebuilds --manifest-path ../bar/Cargo.toml -Zbuild-analysis") + .masquerade_as_nightly_cargo(&["build-analysis"]) + .with_status(1) + .with_stderr_data(str![[r#" +[ERROR] unrecognized subcommand 'rebuilds' + +Usage: cargo report [OPTIONS] + +For more information, try '--help'. + +"#]]) + .run(); +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 9817b179556..1793b968efd 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -52,6 +52,7 @@ mod cargo_publish; mod cargo_read_manifest; mod cargo_remove; mod cargo_report; +mod cargo_report_rebuilds; mod cargo_report_sessions; mod cargo_report_timings; mod cargo_run; From 5c5dbcfb086a0e10635c720500c3960cc7bc2226 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Mon, 29 Dec 2025 19:45:13 -0500 Subject: [PATCH 3/7] feat(report): `cargo report rebuilds` CLI setup --- src/bin/cargo/commands/report.rs | 22 +++ src/cargo/ops/cargo_report/mod.rs | 1 + src/cargo/ops/cargo_report/rebuilds.rs | 34 +++++ src/cargo/ops/mod.rs | 2 + .../cargo_report/help/stdout.term.svg | 38 ++--- tests/testsuite/cargo_report_rebuilds/mod.rs | 140 ++++-------------- 6 files changed, 105 insertions(+), 132 deletions(-) create mode 100644 src/cargo/ops/cargo_report/rebuilds.rs diff --git a/src/bin/cargo/commands/report.rs b/src/bin/cargo/commands/report.rs index 2f5717e21f7..e31edb19133 100644 --- a/src/bin/cargo/commands/report.rs +++ b/src/bin/cargo/commands/report.rs @@ -44,6 +44,11 @@ pub fn cli() -> Command { .default_value("10"), ), ) + .subcommand( + subcommand("rebuilds") + .about("Reports rebuild reasons from previous sessions (unstable)") + .arg_manifest_path(), + ) } pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { @@ -75,6 +80,19 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { ops::report_sessions(gctx, ws.as_ref(), opts)?; Ok(()) } + Some(("rebuilds", args)) => { + gctx.cli_unstable().fail_if_stable_command( + gctx, + "report rebuilds", + 15844, + "build-analysis", + gctx.cli_unstable().build_analysis, + )?; + let ws = args.workspace(gctx).ok(); + let opts = rebuilds_opts(args)?; + ops::report_rebuilds(gctx, ws.as_ref(), opts)?; + Ok(()) + } Some((cmd, _)) => { unreachable!("unexpected command {}", cmd) } @@ -112,3 +130,7 @@ fn sessions_opts(args: &ArgMatches) -> CargoResult { Ok(ops::ReportSessionsOptions { limit }) } + +fn rebuilds_opts(_args: &ArgMatches) -> CargoResult { + Ok(ops::ReportRebuildsOptions {}) +} diff --git a/src/cargo/ops/cargo_report/mod.rs b/src/cargo/ops/cargo_report/mod.rs index d265418f3c1..8d26055f9bb 100644 --- a/src/cargo/ops/cargo_report/mod.rs +++ b/src/cargo/ops/cargo_report/mod.rs @@ -1,3 +1,4 @@ +pub mod rebuilds; pub mod sessions; pub mod timings; pub mod util; diff --git a/src/cargo/ops/cargo_report/rebuilds.rs b/src/cargo/ops/cargo_report/rebuilds.rs new file mode 100644 index 00000000000..ef3709f9edf --- /dev/null +++ b/src/cargo/ops/cargo_report/rebuilds.rs @@ -0,0 +1,34 @@ +//! The `cargo report rebuilds` command. + +use annotate_snippets::Level; + +use crate::AlreadyPrintedError; +use crate::CargoResult; +use crate::GlobalContext; +use crate::core::Workspace; +use crate::ops::cargo_report::util::list_log_files; + +pub struct ReportRebuildsOptions {} + +pub fn report_rebuilds( + gctx: &GlobalContext, + ws: Option<&Workspace<'_>>, + _opts: ReportRebuildsOptions, +) -> CargoResult<()> { + let Some((_log, _run_id)) = list_log_files(gctx, ws)?.next() else { + let context = if let Some(ws) = ws { + format!(" for workspace at `{}`", ws.root().display()) + } else { + String::new() + }; + let title = format!("no sessions found{context}"); + let note = "run command with `-Z build-analysis` to generate log files"; + let report = [Level::ERROR + .primary_title(title) + .element(Level::NOTE.message(note))]; + gctx.shell().print_report(&report, false)?; + return Err(AlreadyPrintedError::new(anyhow::anyhow!("")).into()); + }; + + Ok(()) +} diff --git a/src/cargo/ops/mod.rs b/src/cargo/ops/mod.rs index 84e557760c7..b56aff448ed 100644 --- a/src/cargo/ops/mod.rs +++ b/src/cargo/ops/mod.rs @@ -17,6 +17,8 @@ pub use self::cargo_package::check_yanked; pub use self::cargo_package::package; pub use self::cargo_pkgid::pkgid; pub use self::cargo_read_manifest::read_package; +pub use self::cargo_report::rebuilds::ReportRebuildsOptions; +pub use self::cargo_report::rebuilds::report_rebuilds; pub use self::cargo_report::sessions::ReportSessionsOptions; pub use self::cargo_report::sessions::report_sessions; pub use self::cargo_report::timings::ReportTimingsOptions; diff --git a/tests/testsuite/cargo_report/help/stdout.term.svg b/tests/testsuite/cargo_report/help/stdout.term.svg index c611c9d62b3..b423e7a967b 100644 --- a/tests/testsuite/cargo_report/help/stdout.term.svg +++ b/tests/testsuite/cargo_report/help/stdout.term.svg @@ -1,4 +1,4 @@ - + (ft: &FileTime, s: S) -> Result -where - S: serde::Serializer, -{ - let secs_as_millis = ft.unix_seconds() as f64 * 1000.0; - let nanos_as_millis = ft.nanoseconds() as f64 / 1_000_000.0; - (secs_as_millis + nanos_as_millis).serialize(s) +mod serde_file_time { + use filetime::FileTime; + use serde::Deserialize; + use serde::Serialize; + + /// Serialize FileTime as milliseconds with nano. + pub(super) fn serialize(ft: &FileTime, s: S) -> Result + where + S: serde::Serializer, + { + let secs_as_millis = ft.unix_seconds() as f64 * 1000.0; + let nanos_as_millis = ft.nanoseconds() as f64 / 1_000_000.0; + (secs_as_millis + nanos_as_millis).serialize(s) + } + + /// Deserialize FileTime from milliseconds with nano. + pub(super) fn deserialize<'de, D>(d: D) -> Result + where + D: serde::Deserializer<'de>, + { + let millis = f64::deserialize(d)?; + let secs = (millis / 1000.0) as i64; + let nanos = ((millis % 1000.0) * 1_000_000.0) as u32; + Ok(FileTime::from_unix_time(secs, nanos)) + } } impl Serialize for DepFingerprint { @@ -827,7 +844,7 @@ enum LocalFingerprint { } /// See [`FsStatus::StaleItem`]. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "stale_item", rename_all = "kebab-case")] pub enum StaleItem { MissingFile { @@ -846,10 +863,10 @@ pub enum StaleItem { }, ChangedFile { reference: PathBuf, - #[serde(serialize_with = "serialize_file_time")] + #[serde(with = "serde_file_time")] reference_mtime: FileTime, stale: PathBuf, - #[serde(serialize_with = "serialize_file_time")] + #[serde(with = "serde_file_time")] stale_mtime: FileTime, }, ChangedChecksum { @@ -1166,8 +1183,8 @@ impl Fingerprint { } (a, b) => { return DirtyReason::LocalFingerprintTypeChanged { - old: b.kind(), - new: a.kind(), + old: b.kind().to_owned(), + new: a.kind().to_owned(), }; } } diff --git a/src/cargo/util/log_message.rs b/src/cargo/util/log_message.rs index d742de06026..c354bb740d7 100644 --- a/src/cargo/util/log_message.rs +++ b/src/cargo/util/log_message.rs @@ -146,7 +146,7 @@ pub enum LogMessage { /// Status of the rebuild detection fingerprint of this unit status: FingerprintStatus, /// Reason why the unit is dirty and needs rebuilding. - #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] cause: Option, }, } From 8972d4e23ee5a6c4287a4ecb114b63f1f0f288db Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Tue, 30 Dec 2025 22:38:36 -0500 Subject: [PATCH 5/7] refactor(report): extract target description formatter --- src/cargo/ops/cargo_report/timings.rs | 25 ++--------------------- src/cargo/ops/cargo_report/util.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/cargo/ops/cargo_report/timings.rs b/src/cargo/ops/cargo_report/timings.rs index 5d649183813..b860683b8d1 100644 --- a/src/cargo/ops/cargo_report/timings.rs +++ b/src/cargo/ops/cargo_report/timings.rs @@ -18,7 +18,6 @@ use crate::AlreadyPrintedError; use crate::CargoResult; use crate::GlobalContext; use crate::core::Workspace; -use crate::core::compiler::CompileMode; use crate::core::compiler::timings::CompilationSection; use crate::core::compiler::timings::UnitData; use crate::core::compiler::timings::report::RenderContext; @@ -27,6 +26,7 @@ use crate::core::compiler::timings::report::compute_concurrency; use crate::core::compiler::timings::report::round_to_centisecond; use crate::core::compiler::timings::report::write_html; use crate::ops::cargo_report::util::list_log_files; +use crate::ops::cargo_report::util::unit_target_description; use crate::util::log_message::FingerprintStatus; use crate::util::log_message::LogMessage; use crate::util::log_message::Target; @@ -189,28 +189,7 @@ fn prepare_context(log: &Path, run_id: &RunId) -> CargoResult target_str.push_str(" (test)"), - CompileMode::Build => {} - CompileMode::Check { test: true } => target_str.push_str(" (check-test)"), - CompileMode::Check { test: false } => target_str.push_str(" (check)"), - CompileMode::Doc { .. } => target_str.push_str(" (doc)"), - CompileMode::Doctest => target_str.push_str(" (doc test)"), - CompileMode::Docscrape => target_str.push_str(" (doc scrape)"), - CompileMode::RunCustomBuild => target_str.push_str(" (run)"), - } + let target_str = unit_target_description(&target, mode); let mode_str = if mode.is_run_custom_build() { "run-custom-build" diff --git a/src/cargo/ops/cargo_report/util.rs b/src/cargo/ops/cargo_report/util.rs index 0ebfe716df0..5bffb091b03 100644 --- a/src/cargo/ops/cargo_report/util.rs +++ b/src/cargo/ops/cargo_report/util.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use crate::CargoResult; use crate::GlobalContext; use crate::core::Workspace; +use crate::core::compiler::CompileMode; use crate::util::BuildLogger; +use crate::util::log_message::Target; use crate::util::logger::RunId; /// Lists log files by calling a callback for each valid log file. @@ -57,3 +59,30 @@ pub fn list_log_files( Ok(Box::new(walk)) } + +pub fn unit_target_description(target: &Target, mode: CompileMode) -> String { + // This is pretty similar to how the current `core::compiler::timings` + // renders `core::manifest::Target`. However, our target is + // a simplified type so we cannot reuse the same logic here. + let mut target_str = if target.kind == "lib" && mode == CompileMode::Build { + // Special case for brevity, since most dependencies hit this path. + "".to_string() + } else if target.kind == "build-script" { + " build-script".to_string() + } else { + format!(r#" {} "{}""#, target.name, target.kind) + }; + + match mode { + CompileMode::Test => target_str.push_str(" (test)"), + CompileMode::Build => {} + CompileMode::Check { test: true } => target_str.push_str(" (check-test)"), + CompileMode::Check { test: false } => target_str.push_str(" (check)"), + CompileMode::Doc { .. } => target_str.push_str(" (doc)"), + CompileMode::Doctest => target_str.push_str(" (doc test)"), + CompileMode::Docscrape => target_str.push_str(" (doc scrape)"), + CompileMode::RunCustomBuild => target_str.push_str(" (run)"), + } + + target_str +} From 95aaa1cbc49bcfac993d217e6672d7ce1742db3c Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Tue, 30 Dec 2025 23:37:21 -0500 Subject: [PATCH 6/7] fix(timing): stop showing target name and kind also for check mode Since check are also hit by most dependencies. --- src/cargo/core/compiler/timings/mod.rs | 7 ++++--- src/cargo/ops/cargo_report/util.rs | 17 +++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/cargo/core/compiler/timings/mod.rs b/src/cargo/core/compiler/timings/mod.rs index 1d8331182b1..198f3350c03 100644 --- a/src/cargo/core/compiler/timings/mod.rs +++ b/src/cargo/core/compiler/timings/mod.rs @@ -196,9 +196,10 @@ impl<'gctx> Timings<'gctx> { if !self.enabled { return; } - let mut target = if unit.target.is_lib() && unit.mode == CompileMode::Build { - // Special case for brevity, since most dependencies hit - // this path. + let mut target = if unit.target.is_lib() + && matches!(unit.mode, CompileMode::Build | CompileMode::Check { .. }) + { + // Special case for brevity, since most dependencies hit this path. "".to_string() } else { format!(" {}", unit.target.description_named()) diff --git a/src/cargo/ops/cargo_report/util.rs b/src/cargo/ops/cargo_report/util.rs index 5bffb091b03..682eac2aa30 100644 --- a/src/cargo/ops/cargo_report/util.rs +++ b/src/cargo/ops/cargo_report/util.rs @@ -64,14 +64,15 @@ pub fn unit_target_description(target: &Target, mode: CompileMode) -> String { // This is pretty similar to how the current `core::compiler::timings` // renders `core::manifest::Target`. However, our target is // a simplified type so we cannot reuse the same logic here. - let mut target_str = if target.kind == "lib" && mode == CompileMode::Build { - // Special case for brevity, since most dependencies hit this path. - "".to_string() - } else if target.kind == "build-script" { - " build-script".to_string() - } else { - format!(r#" {} "{}""#, target.name, target.kind) - }; + let mut target_str = + if target.kind == "lib" && matches!(mode, CompileMode::Build | CompileMode::Check { .. }) { + // Special case for brevity, since most dependencies hit this path. + "".to_string() + } else if target.kind == "build-script" { + " build-script".to_string() + } else { + format!(r#" {} "{}""#, target.name, target.kind) + }; match mode { CompileMode::Test => target_str.push_str(" (test)"), From 872f02c1e8fa1392c8044a7842ce736e9bd6df50 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Tue, 30 Dec 2025 23:31:01 -0500 Subject: [PATCH 7/7] feat(report): add `cargo report rebuilds` Adds a new command to analyze rebuild reasons from previous sessions. The report includes: * overview of rebuild/cached/new unit counts * root rebuilds sorted by number of cascading rebuilds * `-v` to show all root rebuilds (default showing 5) * `-vv` to show affected unit lists (default collapsed) This command doesn't have filtering by package or reason yet. Can be added when we have use cases. Example output: ```console Session: 20251231T204416809Z-a5db680cc3bc96e4 Status: 3 units rebuilt, 0 cached, 0 new Rebuild impact: root rebuilds: 1 unit cascading: 2 units Root rebuilds: 0. common@0.0.0 (check): file modified: common/src/lib.rs impact: 2 dependent units rebuilt [NOTE] pass `-vv` to show all affected rebuilt unit lists ``` ```console $ cargo report rebuilds --verbose` Session: 20251231T204416809Z-a5db680cc3bc96e4 Status: 6 units rebuilt, 0 cached, 0 new Rebuild impact: root rebuilds: 6 units cascading: 0 units Root rebuilds: 0. pkg1@0.0.0 (check): declared features changed: [] -> ["feat"] impact: no cascading rebuilds 1. pkg2@0.0.0 (check): file modified: pkg2/src/lib.rs impact: no cascading rebuilds 2. pkg3@0.0.0 (check): target configuration changed impact: no cascading rebuilds 3. pkg4@0.0.0 (check): environment variable changed (__CARGO_TEST_MY_FOO): -> 1 impact: no cascading rebuilds 4. pkg5@0.0.0 (check): file modified: pkg5/src/lib.rs impact: no cascading rebuilds 5. pkg6@0.0.0 (check): file modified: pkg6/src/lib.rs impact: no cascading rebuilds ``` --- src/cargo/ops/cargo_report/rebuilds.rs | 486 ++++++++++++++++++- tests/testsuite/cargo_report_rebuilds/mod.rs | 154 +++++- 2 files changed, 629 insertions(+), 11 deletions(-) diff --git a/src/cargo/ops/cargo_report/rebuilds.rs b/src/cargo/ops/cargo_report/rebuilds.rs index ef3709f9edf..ae0c3a66e96 100644 --- a/src/cargo/ops/cargo_report/rebuilds.rs +++ b/src/cargo/ops/cargo_report/rebuilds.rs @@ -1,12 +1,33 @@ //! The `cargo report rebuilds` command. +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::BufReader; +use std::path::Path; + +use annotate_snippets::Group; use annotate_snippets::Level; +use anyhow::Context as _; +use cargo_util_schemas::core::PackageIdSpec; +use itertools::Itertools as _; use crate::AlreadyPrintedError; use crate::CargoResult; use crate::GlobalContext; use crate::core::Workspace; +use crate::core::compiler::CompileMode; +use crate::core::compiler::fingerprint::DirtyReason; +use crate::core::compiler::fingerprint::FsStatus; +use crate::core::compiler::fingerprint::StaleItem; use crate::ops::cargo_report::util::list_log_files; +use crate::ops::cargo_report::util::unit_target_description; +use crate::util::log_message::FingerprintStatus; +use crate::util::log_message::LogMessage; +use crate::util::log_message::Target; +use crate::util::logger::RunId; +use crate::util::style; + +const DEFAULT_DISPLAY_LIMIT: usize = 5; pub struct ReportRebuildsOptions {} @@ -15,7 +36,7 @@ pub fn report_rebuilds( ws: Option<&Workspace<'_>>, _opts: ReportRebuildsOptions, ) -> CargoResult<()> { - let Some((_log, _run_id)) = list_log_files(gctx, ws)?.next() else { + let Some((log, run_id)) = list_log_files(gctx, ws)?.next() else { let context = if let Some(ws) = ws { format!(" for workspace at `{}`", ws.root().display()) } else { @@ -30,5 +51,468 @@ pub fn report_rebuilds( return Err(AlreadyPrintedError::new(anyhow::anyhow!("")).into()); }; + let ctx = prepare_context(&log) + .with_context(|| format!("failed to analyze log at `{}`", log.display()))?; + let ws_root = ws.map(|ws| ws.root()).unwrap_or(gctx.cwd()); + + display_report(gctx, ctx, &run_id, ws_root)?; + Ok(()) } + +struct Context { + root_rebuilds: Vec, + units: HashMap, + total_cached: usize, + total_new: usize, + total_rebuilt: usize, +} + +struct UnitInfo { + package_id: PackageIdSpec, + target: Target, + mode: CompileMode, +} + +struct RootRebuild { + unit_index: u64, + reason: DirtyReason, + affected_units: Vec, +} + +fn prepare_context(log: &Path) -> CargoResult { + let reader = BufReader::new(File::open(log)?); + + let mut units: HashMap = HashMap::new(); + let mut dependencies: HashMap> = HashMap::new(); + let mut dirty_reasons: HashMap = HashMap::new(); + let mut total_cached = 0; + let mut total_new = 0; + let mut total_rebuilt = 0; + + for (log_index, result) in serde_json::Deserializer::from_reader(reader) + .into_iter::() + .enumerate() + { + let msg = match result { + Ok(msg) => msg, + Err(e) => { + tracing::warn!("failed to parse log message at index {log_index}: {e}"); + continue; + } + }; + + match msg { + LogMessage::UnitRegistered { + package_id, + target, + mode, + index, + dependencies: deps, + .. + } => { + units.insert( + index, + UnitInfo { + package_id, + target, + mode, + }, + ); + dependencies.insert(index, deps); + } + LogMessage::UnitFingerprint { + index, + status, + cause, + .. + } => { + if let Some(reason) = cause { + dirty_reasons.insert(index, reason); + } + match status { + FingerprintStatus::Fresh => { + total_cached += 1; + } + FingerprintStatus::Dirty => { + total_rebuilt += 1; + } + FingerprintStatus::New => { + total_new += 1; + dirty_reasons.insert(index, DirtyReason::FreshBuild); + } + } + } + _ => {} + } + } + + // reverse dependency graph (dependents of each unit) + let mut reverse_deps: HashMap> = HashMap::new(); + for (unit_id, deps) in &dependencies { + for dep_id in deps { + reverse_deps.entry(*dep_id).or_default().push(*unit_id); + } + } + + let rebuilt_units: HashSet = dirty_reasons.keys().copied().collect(); + + // Root rebuilds: units that rebuilt but none of their dependencies rebuilt + let root_rebuilds: Vec<_> = dirty_reasons + .iter() + .filter(|(unit_index, _)| { + let has_rebuilt_deps = dependencies + .get(unit_index) + .map(|deps| deps.iter().any(|dep| rebuilt_units.contains(dep))) + .unwrap_or_default(); + !has_rebuilt_deps + }) + .map(|(&unit_index, reason)| { + let affected_units = find_cascading_rebuilds(unit_index, &reverse_deps, &rebuilt_units); + RootRebuild { + unit_index, + reason: reason.clone(), + affected_units, + } + }) + .sorted_by(|a, b| { + b.affected_units + .len() + .cmp(&a.affected_units.len()) + .then_with(|| { + let a_name = units.get(&a.unit_index).map(|u| u.package_id.name()); + let b_name = units.get(&b.unit_index).map(|u| u.package_id.name()); + a_name.cmp(&b_name) + }) + }) + .collect(); + + Ok(Context { + root_rebuilds, + units, + total_cached, + total_new, + total_rebuilt, + }) +} + +/// Finds all units that were rebuilt as a cascading effect of the given root rebuild. +fn find_cascading_rebuilds( + root_rebuild: u64, + dependents: &HashMap>, + rebuilt_units: &HashSet, +) -> Vec { + let mut affected = Vec::new(); + let mut visited = HashSet::new(); + let mut queue = vec![root_rebuild]; + visited.insert(root_rebuild); + + while let Some(unit) = queue.pop() { + if let Some(deps) = dependents.get(&unit) { + for &dep in deps { + if !visited.contains(&dep) && rebuilt_units.contains(&dep) { + visited.insert(dep); + affected.push(dep); + queue.push(dep); + } + } + } + } + + affected.sort_unstable(); + affected +} + +fn display_report( + gctx: &GlobalContext, + ctx: Context, + run_id: &RunId, + ws_root: &Path, +) -> CargoResult<()> { + let verbose = gctx.shell().verbosity() == crate::core::shell::Verbosity::Verbose; + let extra_verbose = gctx.extra_verbose(); + + let Context { + root_rebuilds, + units, + total_cached, + total_new, + total_rebuilt, + } = ctx; + + let header = style::HEADER; + let subheader = style::LITERAL; + let mut shell = gctx.shell(); + let stderr = shell.err(); + + writeln!(stderr, "{header}Session:{header:#} {run_id}")?; + + // Render summary + let rebuilt_plural = plural(total_rebuilt); + + writeln!( + stderr, + "{header}Status:{header:#} {total_rebuilt} unit{rebuilt_plural} rebuilt, {total_cached} cached, {total_new} new" + )?; + writeln!(stderr)?; + + if total_rebuilt == 0 && total_new == 0 { + // Don't show detailed report if all units are cached. + return Ok(()); + } + + if total_rebuilt == 0 && total_cached == 0 { + // Don't show detailed report if all units are new build. + return Ok(()); + } + + // Render root rebuilds and cascading count + let root_rebuild_count = root_rebuilds.len(); + let cascading_count: usize = root_rebuilds.iter().map(|r| r.affected_units.len()).sum(); + + let root_plural = plural(root_rebuild_count); + let cascading_plural = plural(cascading_count); + + writeln!(stderr, "{header}Rebuild impact:{header:#}",)?; + writeln!( + stderr, + " root rebuilds: {root_rebuild_count} unit{root_plural}" + )?; + writeln!( + stderr, + " cascading: {cascading_count} unit{cascading_plural}" + )?; + writeln!(stderr)?; + + // Render each root rebuilds + let display_limit = if verbose { + root_rebuilds.len() + } else { + DEFAULT_DISPLAY_LIMIT.min(root_rebuilds.len()) + }; + let truncated_count = root_rebuilds.len().saturating_sub(display_limit); + + if truncated_count > 0 { + let count = root_rebuilds.len(); + writeln!( + stderr, + "{header}Root rebuilds:{header:#} (top {display_limit} of {count} by impact)", + )?; + } else { + writeln!(stderr, "{header}Root rebuilds:{header:#}",)?; + } + + for (idx, root_rebuild) in root_rebuilds.iter().take(display_limit).enumerate() { + let unit_desc = units + .get(&root_rebuild.unit_index) + .map(unit_description) + .expect("must have the unit"); + + let reason_str = format_dirty_reason(&root_rebuild.reason, &units, ws_root); + + writeln!( + stderr, + " {subheader}{idx}. {unit_desc}:{subheader:#} {reason_str}", + )?; + + if root_rebuild.affected_units.is_empty() { + writeln!(stderr, " impact: no cascading rebuilds")?; + } else { + let count = root_rebuild.affected_units.len(); + let plural = plural(count); + writeln!( + stderr, + " impact: {count} dependent unit{plural} rebuilt" + )?; + + if extra_verbose { + for affected in &root_rebuild.affected_units { + if let Some(affected) = units.get(affected) { + let desc = unit_description(affected); + writeln!(stderr, " - {desc}")?; + } + } + } + } + } + + // Render --verbose notes + drop(shell); + let has_cascading_rebuilds = root_rebuilds.iter().any(|rr| !rr.affected_units.is_empty()); + + if !verbose && truncated_count > 0 { + writeln!(gctx.shell().err())?; + let note = "pass `--verbose` to show all root rebuilds"; + gctx.shell().print_report( + &[Group::with_title(Level::NOTE.secondary_title(note))], + false, + )?; + } else if !extra_verbose && has_cascading_rebuilds { + writeln!(gctx.shell().err())?; + let note = "pass `-vv` to show all affected rebuilt unit lists"; + gctx.shell().print_report( + &[Group::with_title(Level::NOTE.secondary_title(note))], + false, + )?; + } + + Ok(()) +} + +fn unit_description(unit: &UnitInfo) -> String { + let name = unit.package_id.name(); + let version = unit + .package_id + .version() + .map(|v| v.to_string()) + .unwrap_or_else(|| "".into()); + let target = unit_target_description(&unit.target, unit.mode); + + let literal = style::LITERAL; + let nop = style::NOP; + + format!("{literal}{name}@{version}{literal:#}{nop}{target}{nop:#}") +} + +fn plural(len: usize) -> &'static str { + if len == 1 { "" } else { "s" } +} + +fn format_dirty_reason( + reason: &DirtyReason, + units: &HashMap, + ws_root: &Path, +) -> String { + match reason { + DirtyReason::RustcChanged => "toolchain changed".to_string(), + DirtyReason::FeaturesChanged { old, new } => { + format!("activated features changed: {old} -> {new}") + } + DirtyReason::DeclaredFeaturesChanged { old, new } => { + format!("declared features changed: {old} -> {new}") + } + DirtyReason::TargetConfigurationChanged => "target configuration changed".to_string(), + DirtyReason::PathToSourceChanged => "path to source changed".to_string(), + DirtyReason::ProfileConfigurationChanged => "profile configuration changed".to_string(), + DirtyReason::RustflagsChanged { old, new } => { + let old = old.join(", "); + let new = new.join(", "); + format!("rustflags changed: {old} -> {new}") + } + DirtyReason::ConfigSettingsChanged => "config settings changed".to_string(), + DirtyReason::CompileKindChanged => "compile target changed".to_string(), + DirtyReason::FsStatusOutdated(status) => match status { + FsStatus::Stale => "filesystem status stale".to_string(), + FsStatus::StaleItem(item) => match item { + StaleItem::MissingFile { path } => { + let path = path.strip_prefix(ws_root).unwrap_or(path).display(); + format!("file missing: {path}") + } + StaleItem::UnableToReadFile { path } => { + let path = path.strip_prefix(ws_root).unwrap_or(path).display(); + format!("unable to read file: {path}") + } + StaleItem::FailedToReadMetadata { path } => { + let path = path.strip_prefix(ws_root).unwrap_or(path).display(); + format!("failed to read file metadata: {path}") + } + StaleItem::FileSizeChanged { + path, + old_size: old, + new_size: new, + } => { + let path = path.strip_prefix(ws_root).unwrap_or(path).display(); + format!("file size changed: {path} ({old} -> {new} bytes)") + } + StaleItem::ChangedFile { stale, .. } => { + let path = stale.strip_prefix(ws_root).unwrap_or(stale).display(); + format!("file modified: {path}") + } + StaleItem::ChangedChecksum { + source, + stored_checksum: old, + new_checksum: new, + } => { + let path = source.strip_prefix(ws_root).unwrap_or(source).display(); + format!("file checksum changed: {path} ({old} -> {new})") + } + StaleItem::MissingChecksum { path } => { + let path = path.strip_prefix(ws_root).unwrap_or(path).display(); + format!("checksum missing: {path}") + } + StaleItem::ChangedEnv { + var, + previous, + current, + } => { + let old = previous.as_deref().unwrap_or(""); + let new = current.as_deref().unwrap_or(""); + format!("environment variable changed ({var}): {old} -> {new}") + } + }, + FsStatus::StaleDepFingerprint { unit } => units + .get(unit) + .map(|u| format!("dependency rebuilt: {}", unit_description(u))) + .unwrap_or_else(|| format!("dependency rebuilt: unit {unit}")), + FsStatus::StaleDependency { unit, .. } => units + .get(unit) + .map(|u| format!("dependency rebuilt: {}", unit_description(u))) + .unwrap_or_else(|| format!("dependency rebuilt: unit {unit}")), + FsStatus::UpToDate { .. } => "up to date".to_string(), + }, + DirtyReason::EnvVarChanged { + name, + old_value, + new_value, + } => { + let old = old_value.as_deref().unwrap_or(""); + let new = new_value.as_deref().unwrap_or(""); + format!("environment variable changed ({name}): {old} -> {new}") + } + DirtyReason::EnvVarsChanged { old, new } => { + format!("environment variables changed: {old} -> {new}") + } + DirtyReason::LocalFingerprintTypeChanged { old, new } => { + format!("local fingerprint type changed: {old} -> {new}") + } + DirtyReason::NumberOfDependenciesChanged { old, new } => { + format!("number of dependencies changed: {old} -> {new}") + } + DirtyReason::UnitDependencyNameChanged { old, new } => { + format!("dependency name changed: {old} -> {new}") + } + DirtyReason::UnitDependencyInfoChanged { unit } => units + .get(unit) + .map(|u| format!("dependency info changed: {}", unit_description(u))) + .unwrap_or_else(|| "dependency info changed".to_string()), + DirtyReason::LocalLengthsChanged => "local lengths changed".to_string(), + DirtyReason::PrecalculatedComponentsChanged { old, new } => { + format!("precalculated components changed: {old} -> {new}") + } + DirtyReason::ChecksumUseChanged { old } => { + if *old { + "checksum use changed: enabled -> disabled".to_string() + } else { + "checksum use changed: disabled -> enabled".to_string() + } + } + DirtyReason::DepInfoOutputChanged { old, new } => { + let old = old.strip_prefix(ws_root).unwrap_or(old).display(); + let new = new.strip_prefix(ws_root).unwrap_or(new).display(); + format!("dependency info output changed: {old} -> {new}") + } + DirtyReason::RerunIfChangedOutputFileChanged { old, new } => { + let old = old.strip_prefix(ws_root).unwrap_or(old).display(); + let new = new.strip_prefix(ws_root).unwrap_or(new).display(); + format!("rerun-if-changed output file changed: {old} -> {new}") + } + DirtyReason::RerunIfChangedOutputPathsChanged { old, new } => { + let old = old.len(); + let new = new.len(); + format!("rerun-if-changed paths changed: {old} path(s) -> {new} path(s)",) + } + DirtyReason::NothingObvious => "nothing obvious".to_string(), + DirtyReason::Forced => "forced rebuild".to_string(), + DirtyReason::FreshBuild => "fresh build".to_string(), + } +} diff --git a/tests/testsuite/cargo_report_rebuilds/mod.rs b/tests/testsuite/cargo_report_rebuilds/mod.rs index 8644b377395..8aada08d77f 100644 --- a/tests/testsuite/cargo_report_rebuilds/mod.rs +++ b/tests/testsuite/cargo_report_rebuilds/mod.rs @@ -106,7 +106,12 @@ fn no_rebuild_data() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 0 units rebuilt, 0 cached, 1 new + + +"#]]) .run(); } @@ -131,7 +136,19 @@ fn basic_rebuild() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 1 unit rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 0 units + +Root rebuilds: + 0. foo@0.0.0 (check): file modified: src/lib.rs + impact: no cascading rebuilds + +"#]]) .run(); } @@ -155,7 +172,12 @@ fn all_fresh() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 0 units rebuilt, 1 cached, 0 new + + +"#]]) .run(); } @@ -228,12 +250,42 @@ fn with_dependencies() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 5 units rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 4 units + +Root rebuilds: + 0. deeper@0.0.0 (check): file modified: deeper/src/lib.rs + impact: 4 dependent units rebuilt + +[NOTE] pass `-vv` to show all affected rebuilt unit lists + +"#]]) .run(); p.cargo("report rebuilds -Zbuild-analysis -vv") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 5 units rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 4 units + +Root rebuilds: + 0. deeper@0.0.0 (check): file modified: deeper/src/lib.rs + impact: 4 dependent units rebuilt + - deep@0.0.0 (check) + - dep@0.0.0 (check) + - foo@0.0.0 (check) + - nested@0.0.0 (check) + +"#]]) .run(); } @@ -301,12 +353,56 @@ fn multiple_root_causes() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 6 units rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 6 units + cascading: 0 units + +Root rebuilds: (top 5 of 6 by impact) + 0. pkg1@0.0.0 (check): declared features changed: [] -> ["feat"] + impact: no cascading rebuilds + 1. pkg2@0.0.0 (check): file modified: pkg2/src/lib.rs + impact: no cascading rebuilds + 2. pkg3@0.0.0 (check): target configuration changed + impact: no cascading rebuilds + 3. pkg4@0.0.0 (check): environment variable changed (__CARGO_TEST_MY_FOO): -> 1 + impact: no cascading rebuilds + 4. pkg5@0.0.0 (check): file modified: pkg5/src/lib.rs + impact: no cascading rebuilds + +[NOTE] pass `--verbose` to show all root rebuilds + +"#]]) .run(); p.cargo("report rebuilds -Zbuild-analysis --verbose") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 6 units rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 6 units + cascading: 0 units + +Root rebuilds: + 0. pkg1@0.0.0 (check): declared features changed: [] -> ["feat"] + impact: no cascading rebuilds + 1. pkg2@0.0.0 (check): file modified: pkg2/src/lib.rs + impact: no cascading rebuilds + 2. pkg3@0.0.0 (check): target configuration changed + impact: no cascading rebuilds + 3. pkg4@0.0.0 (check): environment variable changed (__CARGO_TEST_MY_FOO): -> 1 + impact: no cascading rebuilds + 4. pkg5@0.0.0 (check): file modified: pkg5/src/lib.rs + impact: no cascading rebuilds + 5. pkg6@0.0.0 (check): file modified: pkg6/src/lib.rs + impact: no cascading rebuilds + +"#]]) .run(); } @@ -366,7 +462,21 @@ fn shared_dep_cascading() { p.cargo("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 3 units rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 2 units + +Root rebuilds: + 0. common@0.0.0 (check): file modified: common/src/lib.rs + impact: 2 dependent units rebuilt + +[NOTE] pass `-vv` to show all affected rebuilt unit lists + +"#]]) .run(); } @@ -390,7 +500,19 @@ fn outside_workspace() { cargo_process("report rebuilds -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 1 unit rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 0 units + +Root rebuilds: + 0. foo@0.0.0 (check): file modified: foo/src/lib.rs + impact: no cascading rebuilds + +"#]]) .run(); } @@ -426,6 +548,18 @@ fn with_manifest_path() { foo.cargo("report rebuilds --manifest-path ../bar/Cargo.toml -Zbuild-analysis") .masquerade_as_nightly_cargo(&["build-analysis"]) - .with_stderr_data(str![""]) + .with_stderr_data(str![[r#" +Session: [..] +Status: 1 unit rebuilt, 0 cached, 0 new + +Rebuild impact: + root rebuilds: 1 unit + cascading: 0 units + +Root rebuilds: + 0. bar@0.0.0 (check): file modified: src/lib.rs + impact: no cascading rebuilds + +"#]]) .run(); }