#
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}