1use std::collections::{HashMap, HashSet};
4use std::fs::File;
5use std::io::BufReader;
6use std::path::Path;
7
8use annotate_snippets::Group;
9use annotate_snippets::Level;
10use anyhow::Context as _;
11use cargo_util_schemas::core::PackageIdSpec;
12use itertools::Itertools as _;
13
14use crate::AlreadyPrintedError;
15use crate::CargoResult;
16use crate::GlobalContext;
17use crate::core::Workspace;
18use crate::core::compiler::CompileMode;
19use crate::core::compiler::UnitIndex;
20use crate::core::compiler::fingerprint::DirtyReason;
21use crate::core::compiler::fingerprint::FsStatus;
22use crate::core::compiler::fingerprint::StaleItem;
23use crate::ops::cargo_report::util::find_log_file;
24use crate::ops::cargo_report::util::unit_target_description;
25use crate::util::log_message::FingerprintStatus;
26use crate::util::log_message::LogMessage;
27use crate::util::log_message::Target;
28use crate::util::logger::RunId;
29use crate::util::style;
30
31const DEFAULT_DISPLAY_LIMIT: usize = 5;
32
33pub struct ReportRebuildsOptions {
34 pub id: Option<RunId>,
35}
36
37pub fn report_rebuilds(
38 gctx: &GlobalContext,
39 ws: Option<&Workspace<'_>>,
40 opts: ReportRebuildsOptions,
41) -> CargoResult<()> {
42 let Some((log, run_id)) = find_log_file(gctx, ws, opts.id.as_ref())? else {
43 let context = if let Some(ws) = ws {
44 format!(" for workspace at `{}`", ws.root().display())
45 } else {
46 String::new()
47 };
48 let (title, note) = if let Some(id) = &opts.id {
49 (
50 format!("session `{id}` not found{context}"),
51 "run `cargo report sessions` to list available sessions",
52 )
53 } else {
54 (
55 format!("no sessions found{context}"),
56 "run command with `-Z build-analysis` to generate log files",
57 )
58 };
59 let report = [Level::ERROR
60 .primary_title(title)
61 .element(Level::NOTE.message(note))];
62 gctx.shell().print_report(&report, false)?;
63 return Err(AlreadyPrintedError::new(anyhow::anyhow!("")).into());
64 };
65
66 let ctx = prepare_context(&log)
67 .with_context(|| format!("failed to analyze log at `{}`", log.display()))?;
68 let ws_root = ws.map(|ws| ws.root()).unwrap_or(gctx.cwd());
69
70 display_report(gctx, ctx, &run_id, ws_root)?;
71
72 Ok(())
73}
74
75struct Context {
76 root_rebuilds: Vec<RootRebuild>,
77 units: HashMap<UnitIndex, UnitInfo>,
78 total_cached: usize,
79 total_new: usize,
80 total_rebuilt: usize,
81}
82
83struct UnitInfo {
84 package_id: PackageIdSpec,
85 target: Target,
86 mode: CompileMode,
87}
88
89struct RootRebuild {
90 unit_index: UnitIndex,
91 reason: DirtyReason,
92 affected_units: Vec<UnitIndex>,
93}
94
95fn prepare_context(log: &Path) -> CargoResult<Context> {
96 let reader = BufReader::new(File::open(log)?);
97
98 let mut units: HashMap<UnitIndex, UnitInfo> = HashMap::new();
99 let mut dependencies: HashMap<UnitIndex, Vec<UnitIndex>> = HashMap::new();
100 let mut dirty_reasons: HashMap<UnitIndex, DirtyReason> = HashMap::new();
101 let mut total_cached = 0;
102 let mut total_new = 0;
103 let mut total_rebuilt = 0;
104
105 for (log_index, result) in serde_json::Deserializer::from_reader(reader)
106 .into_iter::<LogMessage>()
107 .enumerate()
108 {
109 let msg = match result {
110 Ok(msg) => msg,
111 Err(e) => {
112 tracing::warn!("failed to parse log message at index {log_index}: {e}");
113 continue;
114 }
115 };
116
117 match msg {
118 LogMessage::UnitRegistered {
119 package_id,
120 target,
121 mode,
122 index,
123 dependencies: deps,
124 ..
125 } => {
126 units.insert(
127 index,
128 UnitInfo {
129 package_id,
130 target,
131 mode,
132 },
133 );
134 dependencies.insert(index, deps);
135 }
136 LogMessage::UnitFingerprint {
137 index,
138 status,
139 cause,
140 ..
141 } => {
142 if let Some(reason) = cause {
143 dirty_reasons.insert(index, reason);
144 }
145 match status {
146 FingerprintStatus::Fresh => {
147 total_cached += 1;
148 }
149 FingerprintStatus::Dirty => {
150 total_rebuilt += 1;
151 }
152 FingerprintStatus::New => {
153 total_new += 1;
154 dirty_reasons.insert(index, DirtyReason::FreshBuild);
155 }
156 }
157 }
158 _ => {}
159 }
160 }
161
162 let mut reverse_deps: HashMap<UnitIndex, Vec<UnitIndex>> = HashMap::new();
164 for (unit_id, deps) in &dependencies {
165 for dep_id in deps {
166 reverse_deps.entry(*dep_id).or_default().push(*unit_id);
167 }
168 }
169
170 let rebuilt_units: HashSet<UnitIndex> = dirty_reasons.keys().copied().collect();
171
172 let root_rebuilds: Vec<_> = dirty_reasons
174 .iter()
175 .filter(|(unit_index, _)| {
176 let has_rebuilt_deps = dependencies
177 .get(unit_index)
178 .map(|deps| deps.iter().any(|dep| rebuilt_units.contains(dep)))
179 .unwrap_or_default();
180 !has_rebuilt_deps
181 })
182 .map(|(&unit_index, reason)| {
183 let affected_units = find_cascading_rebuilds(unit_index, &reverse_deps, &rebuilt_units);
184 RootRebuild {
185 unit_index,
186 reason: reason.clone(),
187 affected_units,
188 }
189 })
190 .sorted_by(|a, b| {
191 b.affected_units
192 .len()
193 .cmp(&a.affected_units.len())
194 .then_with(|| {
195 let a_name = units.get(&a.unit_index).map(|u| u.package_id.name());
196 let b_name = units.get(&b.unit_index).map(|u| u.package_id.name());
197 a_name.cmp(&b_name)
198 })
199 })
200 .collect();
201
202 Ok(Context {
203 root_rebuilds,
204 units,
205 total_cached,
206 total_new,
207 total_rebuilt,
208 })
209}
210
211fn find_cascading_rebuilds(
213 root_rebuild: UnitIndex,
214 dependents: &HashMap<UnitIndex, Vec<UnitIndex>>,
215 rebuilt_units: &HashSet<UnitIndex>,
216) -> Vec<UnitIndex> {
217 let mut affected = Vec::new();
218 let mut visited = HashSet::new();
219 let mut queue = vec![root_rebuild];
220 visited.insert(root_rebuild);
221
222 while let Some(unit) = queue.pop() {
223 if let Some(deps) = dependents.get(&unit) {
224 for &dep in deps {
225 if !visited.contains(&dep) && rebuilt_units.contains(&dep) {
226 visited.insert(dep);
227 affected.push(dep);
228 queue.push(dep);
229 }
230 }
231 }
232 }
233
234 affected.sort_unstable();
235 affected
236}
237
238fn display_report(
239 gctx: &GlobalContext,
240 ctx: Context,
241 run_id: &RunId,
242 ws_root: &Path,
243) -> CargoResult<()> {
244 let verbose = gctx.shell().verbosity() == crate::core::shell::Verbosity::Verbose;
245 let extra_verbose = gctx.extra_verbose();
246
247 let Context {
248 root_rebuilds,
249 units,
250 total_cached,
251 total_new,
252 total_rebuilt,
253 } = ctx;
254
255 let header = style::HEADER;
256 let subheader = style::LITERAL;
257 let mut shell = gctx.shell();
258 let stderr = shell.err();
259
260 writeln!(stderr, "{header}Session:{header:#} {run_id}")?;
261
262 let rebuilt_plural = plural(total_rebuilt);
264
265 writeln!(
266 stderr,
267 "{header}Status:{header:#} {total_rebuilt} unit{rebuilt_plural} rebuilt, {total_cached} cached, {total_new} new"
268 )?;
269 writeln!(stderr)?;
270
271 if total_rebuilt == 0 && total_new == 0 {
272 return Ok(());
274 }
275
276 if total_rebuilt == 0 && total_cached == 0 {
277 return Ok(());
279 }
280
281 let root_rebuild_count = root_rebuilds.len();
283 let cascading_count: usize = root_rebuilds.iter().map(|r| r.affected_units.len()).sum();
284
285 let root_plural = plural(root_rebuild_count);
286 let cascading_plural = plural(cascading_count);
287
288 writeln!(stderr, "{header}Rebuild impact:{header:#}",)?;
289 writeln!(
290 stderr,
291 " root rebuilds: {root_rebuild_count} unit{root_plural}"
292 )?;
293 writeln!(
294 stderr,
295 " cascading: {cascading_count} unit{cascading_plural}"
296 )?;
297 writeln!(stderr)?;
298
299 let display_limit = if verbose {
301 root_rebuilds.len()
302 } else {
303 DEFAULT_DISPLAY_LIMIT.min(root_rebuilds.len())
304 };
305 let truncated_count = root_rebuilds.len().saturating_sub(display_limit);
306
307 if truncated_count > 0 {
308 let count = root_rebuilds.len();
309 writeln!(
310 stderr,
311 "{header}Root rebuilds:{header:#} (top {display_limit} of {count} by impact)",
312 )?;
313 } else {
314 writeln!(stderr, "{header}Root rebuilds:{header:#}",)?;
315 }
316
317 for (idx, root_rebuild) in root_rebuilds.iter().take(display_limit).enumerate() {
318 let unit_desc = units
319 .get(&root_rebuild.unit_index)
320 .map(unit_description)
321 .expect("must have the unit");
322
323 let reason_str = format_dirty_reason(&root_rebuild.reason, &units, ws_root);
324
325 writeln!(
326 stderr,
327 " {subheader}{idx}. {unit_desc}:{subheader:#} {reason_str}",
328 )?;
329
330 if root_rebuild.affected_units.is_empty() {
331 writeln!(stderr, " impact: no cascading rebuilds")?;
332 } else {
333 let count = root_rebuild.affected_units.len();
334 let plural = plural(count);
335 writeln!(
336 stderr,
337 " impact: {count} dependent unit{plural} rebuilt"
338 )?;
339
340 if extra_verbose {
341 for affected in &root_rebuild.affected_units {
342 if let Some(affected) = units.get(affected) {
343 let desc = unit_description(affected);
344 writeln!(stderr, " - {desc}")?;
345 }
346 }
347 }
348 }
349 }
350
351 drop(shell);
353 let has_cascading_rebuilds = root_rebuilds.iter().any(|rr| !rr.affected_units.is_empty());
354
355 if !verbose && truncated_count > 0 {
356 writeln!(gctx.shell().err())?;
357 let note = "pass `--verbose` to show all root rebuilds";
358 gctx.shell().print_report(
359 &[Group::with_title(Level::NOTE.secondary_title(note))],
360 false,
361 )?;
362 } else if !extra_verbose && has_cascading_rebuilds {
363 writeln!(gctx.shell().err())?;
364 let note = "pass `-vv` to show all affected rebuilt unit lists";
365 gctx.shell().print_report(
366 &[Group::with_title(Level::NOTE.secondary_title(note))],
367 false,
368 )?;
369 }
370
371 Ok(())
372}
373
374fn unit_description(unit: &UnitInfo) -> String {
375 let name = unit.package_id.name();
376 let version = unit
377 .package_id
378 .version()
379 .map(|v| v.to_string())
380 .unwrap_or_else(|| "<n/a>".into());
381 let target = unit_target_description(&unit.target, unit.mode);
382
383 let literal = style::LITERAL;
384 let nop = style::NOP;
385
386 format!("{literal}{name}@{version}{literal:#}{nop}{target}{nop:#}")
387}
388
389fn plural(len: usize) -> &'static str {
390 if len == 1 { "" } else { "s" }
391}
392
393fn format_dirty_reason(
394 reason: &DirtyReason,
395 units: &HashMap<UnitIndex, UnitInfo>,
396 ws_root: &Path,
397) -> String {
398 match reason {
399 DirtyReason::RustcChanged => "toolchain changed".to_string(),
400 DirtyReason::FeaturesChanged { old, new } => {
401 format!("activated features changed: {old} -> {new}")
402 }
403 DirtyReason::DeclaredFeaturesChanged { old, new } => {
404 format!("declared features changed: {old} -> {new}")
405 }
406 DirtyReason::TargetConfigurationChanged => "target configuration changed".to_string(),
407 DirtyReason::PathToSourceChanged => "path to source changed".to_string(),
408 DirtyReason::ProfileConfigurationChanged => "profile configuration changed".to_string(),
409 DirtyReason::RustflagsChanged { old, new } => {
410 let old = old.join(", ");
411 let new = new.join(", ");
412 format!("rustflags changed: {old} -> {new}")
413 }
414 DirtyReason::ConfigSettingsChanged => "config settings changed".to_string(),
415 DirtyReason::CompileKindChanged => "compile target changed".to_string(),
416 DirtyReason::FsStatusOutdated(status) => match status {
417 FsStatus::Stale => "filesystem status stale".to_string(),
418 FsStatus::StaleItem(item) => match item {
419 StaleItem::MissingFile { path } => {
420 let path = path.strip_prefix(ws_root).unwrap_or(path).display();
421 format!("file missing: {path}")
422 }
423 StaleItem::UnableToReadFile { path } => {
424 let path = path.strip_prefix(ws_root).unwrap_or(path).display();
425 format!("unable to read file: {path}")
426 }
427 StaleItem::FailedToReadMetadata { path } => {
428 let path = path.strip_prefix(ws_root).unwrap_or(path).display();
429 format!("failed to read file metadata: {path}")
430 }
431 StaleItem::FileSizeChanged {
432 path,
433 old_size: old,
434 new_size: new,
435 } => {
436 let path = path.strip_prefix(ws_root).unwrap_or(path).display();
437 format!("file size changed: {path} ({old} -> {new} bytes)")
438 }
439 StaleItem::ChangedFile { stale, .. } => {
440 let path = stale.strip_prefix(ws_root).unwrap_or(stale).display();
441 format!("file modified: {path}")
442 }
443 StaleItem::ChangedChecksum {
444 source,
445 stored_checksum: old,
446 new_checksum: new,
447 } => {
448 let path = source.strip_prefix(ws_root).unwrap_or(source).display();
449 format!("file checksum changed: {path} ({old} -> {new})")
450 }
451 StaleItem::MissingChecksum { path } => {
452 let path = path.strip_prefix(ws_root).unwrap_or(path).display();
453 format!("checksum missing: {path}")
454 }
455 StaleItem::ChangedEnv {
456 var,
457 previous,
458 current,
459 } => {
460 let old = previous.as_deref().unwrap_or("<unset>");
461 let new = current.as_deref().unwrap_or("<unset>");
462 format!("environment variable changed ({var}): {old} -> {new}")
463 }
464 },
465 FsStatus::StaleDepFingerprint { unit } => units
466 .get(unit)
467 .map(|u| format!("dependency rebuilt: {}", unit_description(u)))
468 .unwrap_or_else(|| format!("dependency rebuilt: unit {unit}")),
469 FsStatus::StaleDependency { unit, .. } => units
470 .get(unit)
471 .map(|u| format!("dependency rebuilt: {}", unit_description(u)))
472 .unwrap_or_else(|| format!("dependency rebuilt: unit {unit}")),
473 FsStatus::UpToDate { .. } => "up to date".to_string(),
474 },
475 DirtyReason::EnvVarChanged {
476 name,
477 old_value,
478 new_value,
479 } => {
480 let old = old_value.as_deref().unwrap_or("<unset>");
481 let new = new_value.as_deref().unwrap_or("<unset>");
482 format!("environment variable changed ({name}): {old} -> {new}")
483 }
484 DirtyReason::EnvVarsChanged { old, new } => {
485 format!("environment variables changed: {old} -> {new}")
486 }
487 DirtyReason::LocalFingerprintTypeChanged { old, new } => {
488 format!("local fingerprint type changed: {old} -> {new}")
489 }
490 DirtyReason::NumberOfDependenciesChanged { old, new } => {
491 format!("number of dependencies changed: {old} -> {new}")
492 }
493 DirtyReason::UnitDependencyNameChanged { old, new } => {
494 format!("dependency name changed: {old} -> {new}")
495 }
496 DirtyReason::UnitDependencyInfoChanged { unit } => units
497 .get(unit)
498 .map(|u| format!("dependency info changed: {}", unit_description(u)))
499 .unwrap_or_else(|| "dependency info changed".to_string()),
500 DirtyReason::LocalLengthsChanged => "local lengths changed".to_string(),
501 DirtyReason::PrecalculatedComponentsChanged { old, new } => {
502 format!("precalculated components changed: {old} -> {new}")
503 }
504 DirtyReason::ChecksumUseChanged { old } => {
505 if *old {
506 "checksum use changed: enabled -> disabled".to_string()
507 } else {
508 "checksum use changed: disabled -> enabled".to_string()
509 }
510 }
511 DirtyReason::DepInfoOutputChanged { old, new } => {
512 let old = old.strip_prefix(ws_root).unwrap_or(old).display();
513 let new = new.strip_prefix(ws_root).unwrap_or(new).display();
514 format!("dependency info output changed: {old} -> {new}")
515 }
516 DirtyReason::RerunIfChangedOutputFileChanged { old, new } => {
517 let old = old.strip_prefix(ws_root).unwrap_or(old).display();
518 let new = new.strip_prefix(ws_root).unwrap_or(new).display();
519 format!("rerun-if-changed output file changed: {old} -> {new}")
520 }
521 DirtyReason::RerunIfChangedOutputPathsChanged { old, new } => {
522 let old = old.len();
523 let new = new.len();
524 format!("rerun-if-changed paths changed: {old} path(s) -> {new} path(s)",)
525 }
526 DirtyReason::NothingObvious => "nothing obvious".to_string(),
527 DirtyReason::Forced => "forced rebuild".to_string(),
528 DirtyReason::FreshBuild => "fresh build".to_string(),
529 }
530}