cargo/ops/cargo_report/
rebuilds.rs

1//! The `cargo report rebuilds` command.
2
3use 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    // reverse dependency graph (dependents of each unit)
163    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    // Root rebuilds: units that rebuilt but none of their dependencies rebuilt
173    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
211/// Finds all units that were rebuilt as a cascading effect of the given root rebuild.
212fn 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    // Render summary
263    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        // Don't show detailed report if all units are cached.
273        return Ok(());
274    }
275
276    if total_rebuilt == 0 && total_cached == 0 {
277        // Don't show detailed report if all units are new build.
278        return Ok(());
279    }
280
281    // Render root rebuilds and cascading count
282    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    // Render each root rebuilds
300    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    // Render --verbose notes
352    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}