Skip to main content

epicenter_cli/
who.rs

1//! `epicenter who` / `epicenter why` — unit ownership forensics over the
2//! shared build dir. Ground truth source: `.fingerprint/`
3//! unit dirs (via the [`crate::fingerprint`] reader), artifacts attached by
4//! suffix, staleness reasoned with the mtime rule.
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use anyhow::{Context, Result, bail};
10
11use crate::fingerprint::{ArtifactInfo, DepRef, Unit, UnitKind, artifacts_for, read_units};
12use crate::util::{self, human_age, human_bytes};
13
14/// Fully attached unit list, sorted by size (largest first).
15pub(crate) fn collect(build_root: &Path) -> Result<Vec<(Unit, ArtifactInfo)>> {
16    let mut rows: Vec<(Unit, ArtifactInfo)> = read_units(build_root)?
17        .into_iter()
18        .map(|unit| {
19            let arts = artifacts_for(build_root, &unit);
20            (unit, arts)
21        })
22        .collect();
23    rows.sort_by_key(|(_, arts)| std::cmp::Reverse(arts.bytes));
24    Ok(rows)
25}
26
27/// The rustc identity used by most units counts as the current toolchain
28/// generation; every other cohort is stale.
29pub(crate) fn rustc_cohorts(rows: &[(Unit, ArtifactInfo)]) -> BTreeMap<String, usize> {
30    let mut cohorts: BTreeMap<String, usize> = BTreeMap::new();
31    for (unit, _) in rows {
32        let id = unit
33            .json
34            .as_ref()
35            .map(|json| json.rustc.clone())
36            .unwrap_or_else(|| "unknown".into());
37        *cohorts.entry(id).or_default() += 1;
38    }
39    cohorts
40}
41
42pub(crate) fn rustc_of(unit: &Unit) -> String {
43    unit.json
44        .as_ref()
45        .map(|json| json.rustc.clone())
46        .unwrap_or_else(|| "unknown".into())
47}
48
49fn deps_of(unit: &Unit) -> usize {
50    unit.json.as_ref().map_or(0, |json| json.deps.len())
51}
52
53/// `epicenter who [query]` — units grouped by package, sorted by size.
54pub fn run(build_root: &Path, filter: Option<&str>) -> Result<()> {
55    let rows = collect(build_root).context("reading fingerprint units")?;
56    if rows.is_empty() {
57        println!("no fingerprint units found under {}", build_root.display());
58        return Ok(());
59    }
60
61    let cohorts = rustc_cohorts(&rows);
62    let current = cohorts
63        .iter()
64        .max_by_key(|(_, count)| **count)
65        .map(|(id, _)| id.clone())
66        .unwrap_or_default();
67
68    // Group by package (only matched rows), order packages by total size.
69    let mut packages: BTreeMap<String, Vec<(&Unit, &ArtifactInfo)>> = BTreeMap::new();
70    let mut shown = 0usize;
71    let mut stale_count = 0usize;
72    for (unit, arts) in &rows {
73        let matches = filter.map_or(true, |query| {
74            unit.dir_name.contains(query)
75                || unit.package.contains(query)
76                || unit.dir_suffix.contains(query)
77        });
78        if !matches {
79            continue;
80        }
81        shown += 1;
82        if rustc_of(unit) != current {
83            stale_count += 1;
84        }
85        packages
86            .entry(unit.package.clone())
87            .or_default()
88            .push((unit, arts));
89    }
90
91    let mut package_rows: Vec<(String, u64, Vec<(&Unit, &ArtifactInfo)>)> = packages
92        .into_iter()
93        .map(|(pkg, units)| {
94            let total = units.iter().map(|(_, arts)| arts.bytes).sum();
95            (pkg, total, units)
96        })
97        .collect();
98    package_rows.sort_by_key(|(_, total, _)| std::cmp::Reverse(*total));
99
100    // Summary header.
101    let current_short = short(&current, 10);
102    let stale_list: Vec<String> = cohorts
103        .iter()
104        .filter(|(id, _)| **id != current)
105        .map(|(id, count)| format!("{} ({} unit(s))", short(id, 10), count))
106        .collect();
107    println!(
108        "{}",
109        util::heading(&format!(
110            "epicenter who — {} unit(s) in {} package(s) · {} on a STALE toolchain generation",
111            shown,
112            package_rows.len(),
113            stale_count
114        ))
115    );
116    println!(
117        "  {} {} — current rustc {}",
118        util::dim("toolchain generations:"),
119        cohorts.len(),
120        util::green(&current_short)
121    );
122    if !stale_list.is_empty() {
123        println!(
124            "  {} {}",
125            util::dim("stale:"),
126            util::warn(&stale_list.join(", "))
127        );
128    }
129
130    for (pkg, total, units) in &package_rows {
131        println!();
132        println!(
133            "{} {}",
134            util::green(pkg),
135            util::dim(&format!(
136                "— {} unit(s), {}",
137                units.len(),
138                human_bytes(*total)
139            ))
140        );
141        for (unit, arts) in units {
142            let stale = rustc_of(unit) != current;
143            let age = arts.newest.map(human_age).unwrap_or_else(|| "?".into());
144            let deps = format!(
145                "{} dep{}",
146                deps_of(unit),
147                if deps_of(unit) == 1 { "" } else { "s" }
148            );
149            let tag = if stale {
150                util::warn("[STALE]")
151            } else {
152                util::green("[current]")
153            };
154            println!(
155                "  {} {} {:>9}  {:>9} old  {}  {}",
156                util::dim(&format!("{:<10}", unit.kind.label())),
157                unit.dir_suffix,
158                human_bytes(arts.bytes),
159                age,
160                util::dim(&format!("{deps:>6}")),
161                tag,
162            );
163            if arts.paths.is_empty() {
164                println!(
165                    "  {} {}",
166                    util::warn(util::GLYPH_WARN),
167                    util::warn("artifacts missing — would recompile")
168                );
169            }
170        }
171    }
172
173    println!();
174    println!(
175        "{}",
176        util::dim(
177            "legend: current = rustc generation in use · STALE = older toolchain or config era \
178             (evictable) — details: epicenter why <dir hash>"
179        )
180    );
181    Ok(())
182}
183
184/// Does a unit match a user query (dir name, dir hash, or package name)?
185pub(crate) fn unit_matches(unit: &Unit, query: &str) -> bool {
186    unit.dir_name == query
187        || unit.dir_suffix == query
188        || unit.package == query
189        || unit.dir_name.contains(query)
190        || unit.package.contains(query)
191}
192
193/// `epicenter why <query>` — the surgical single-unit report.
194pub fn explain(build_root: &Path, query: &str) -> Result<()> {
195    let rows = collect(build_root).context("reading fingerprint units")?;
196
197    let matches: Vec<&(Unit, ArtifactInfo)> = rows
198        .iter()
199        .filter(|(unit, _)| unit_matches(unit, query))
200        .collect();
201
202    match matches.len() {
203        0 => bail!("no unit matches {query:?} — run `epicenter who` to list units"),
204        // Single match: the surgical report.
205        1 => explain_one(&rows, matches[0]),
206        n => {
207            let cohorts = rustc_cohorts(&rows);
208            let current = cohorts
209                .iter()
210                .max_by_key(|(_, count)| **count)
211                .map(|(id, _)| id.clone())
212                .unwrap_or_default();
213
214            // For a package query, the unit cargo actually uses as a
215            // dependency is the CURRENT-generation lib. Explain it
216            // surgically; everything else becomes an evict-candidate list.
217            let preferred = matches
218                .iter()
219                .filter(|(unit, _)| {
220                    unit.kind == UnitKind::Lib
221                        && unit
222                            .json
223                            .as_ref()
224                            .map_or(false, |json| json.rustc == current)
225                })
226                .max_by_key(|(_, arts)| arts.newest);
227
228            if let Some(&preferred) = preferred {
229                explain_one(&rows, preferred)?;
230
231                let cur_key = (preferred.0.dir_name.as_str(), preferred.0.profile.as_str());
232                let evictables: Vec<&(Unit, ArtifactInfo)> = matches
233                    .iter()
234                    .copied()
235                    .filter(|(unit, _)| (unit.dir_name.as_str(), unit.profile.as_str()) != cur_key)
236                    .collect();
237                let evictable_bytes: u64 = evictables.iter().map(|(_, arts)| arts.bytes).sum();
238                if !evictables.is_empty() {
239                    println!();
240                    println!(
241                        "{} other fingerprint dir(s) — {} {}:",
242                        evictables.len(),
243                        util::dim("evict candidates"),
244                        util::green(&human_bytes(evictable_bytes)),
245                    );
246                    for (unit, arts) in &evictables {
247                        println!(
248                            "  {} · {} · {} · {}",
249                            unit.dir_name,
250                            util::dim(unit.kind.label()),
251                            arts.newest.map(human_age).unwrap_or_else(|| "?".into()),
252                            if arts.paths.is_empty() {
253                                util::warn("artifacts MISSING (husk)")
254                            } else {
255                                util::dim("artifacts present")
256                            },
257                        );
258                    }
259                    println!(
260                        "  {} {}",
261                        util::dim("evict individually:"),
262                        util::green("epicenter evict --unit <hash> --yes")
263                    );
264                }
265            } else {
266                println!(
267                    "{n} matches for {query:?} — none on the current toolchain \
268                     generation; narrow to a dir hash (latest ages first):"
269                );
270                for (unit, arts) in &matches {
271                    let rustc_id = unit
272                        .json
273                        .as_ref()
274                        .map(|json| json.rustc.clone())
275                        .unwrap_or_else(|| "unknown".into());
276                    println!(
277                        "  {} · {} · {} · rustc {}",
278                        unit.dir_name,
279                        unit.kind.label(),
280                        arts.newest.map(human_age).unwrap_or_else(|| "?".into()),
281                        &rustc_id[..rustc_id.len().min(8)],
282                    );
283                }
284            }
285            Ok(())
286        }
287    }
288}
289
290fn explain_one(all: &[(Unit, ArtifactInfo)], picked: &(Unit, ArtifactInfo)) -> Result<()> {
291    let (unit, arts) = picked;
292    let json = unit
293        .json
294        .as_ref()
295        .context("unit has no readable fingerprint JSON")?;
296
297    let cohorts = rustc_cohorts(all);
298    let current = cohorts
299        .iter()
300        .max_by_key(|(_, count)| **count)
301        .map(|(id, _)| id.clone())
302        .unwrap_or_default();
303    let stale_id = json.rustc != current;
304
305    // (dependency unit index, reason) for stale deps; `usize::MAX` for the
306    // unit-level reasons that belong to no edge.
307    let units: Vec<Unit> = all.iter().map(|(unit, _)| unit.clone()).collect();
308    let index = crate::fingerprint::memo_index(&units);
309    let mut reasons: Vec<(usize, String)> = why_stale(all, unit, arts, &json.deps, &index);
310    if stale_id {
311        reasons.push((usize::MAX, "stale toolchain generation (rustc key differs)".into()));
312    }
313    if arts.paths.is_empty() {
314        reasons.push((usize::MAX, "no artifacts on disk".into()));
315    }
316
317    println!(
318        "{} {} — {} · profile {}",
319        util::green("unit"),
320        util::heading(&unit.dir_name),
321        unit.kind.label(),
322        util::dim(&unit.profile)
323    );
324    println!();
325    println!("  {}", util::green("identity"));
326    println!(
327        "    {} {}",
328        util::dim(&format!("{:<13}", "dir suffix")),
329        unit.dir_suffix
330    );
331    println!(
332        "    {} {}  {}",
333        util::dim(&format!("{:<13}", "memo hash")),
334        unit.memo_hash.as_deref().unwrap_or("?"),
335        util::dim("(authoritative — different hash role than the dir suffix)")
336    );
337    println!(
338        "    {} {}  {}",
339        util::dim(&format!("{:<13}", "rustc")),
340        json.rustc,
341        if stale_id {
342            util::warn("[STALE generation]")
343        } else {
344            util::green("[current generation]")
345        }
346    );
347    println!();
348    println!("  {}", util::green("configuration"));
349    println!(
350        "    {} {}",
351        util::dim(&format!("{:<13}", "features")),
352        display_features(&json.features)
353    );
354    println!(
355        "    {} {}",
356        util::dim(&format!("{:<13}", "declared")),
357        display_features(&json.declared_features)
358    );
359    println!(
360        "    {} target {} · profile {} · path {} · config {} · compile_kind {}",
361        util::dim(&format!("{:<13}", "opaque hashes")),
362        short(&json.target, 10),
363        short(&json.profile, 10),
364        short(&json.path, 10),
365        short(&json.config, 10),
366        json.compile_kind
367    );
368    if !json.rustflags.is_empty() {
369        println!(
370            "    {} {}",
371            util::dim(&format!("{:<13}", "rustflags")),
372            json.rustflags.join(" ")
373        );
374    }
375    println!();
376    println!("  {}", util::green("artifacts"));
377    if arts.paths.is_empty() {
378        println!(
379            "    {} none on disk — this unit would recompile",
380            util::warn(util::GLYPH_WARN)
381        );
382    } else {
383        for (path, size, mtime) in arts.paths.iter().take(8) {
384            println!(
385                "    {:<42} {:>9}  {}",
386                path.file_name().map_or_else(
387                    || path.display().to_string(),
388                    |n| n.to_string_lossy().into_owned()
389                ),
390                human_bytes(*size),
391                util::dim(&format!(
392                    "{:>10} old",
393                    mtime.map(human_age).unwrap_or_else(|| "?".into())
394                ))
395            );
396        }
397        if arts.paths.len() > 8 {
398            println!("    … {} more", arts.paths.len() - 8);
399        }
400    }
401    let dep_info = unit.dep_info.as_ref().map(|p| {
402        p.file_name().map_or_else(
403            || p.display().to_string(),
404            |n| n.to_string_lossy().into_owned(),
405        )
406    });
407    println!(
408        "    {} {}",
409        util::dim(&format!("{:<13}", "dep-info")),
410        dep_info.as_deref().map_or_else(
411            || util::warn("MISSING (mandatory for freshness)"),
412            |name| name.to_string()
413        )
414    );
415    println!();
416    println!(
417        "  {} {}",
418        util::green("dependencies"),
419        util::dim("(freshness: dep's newest output mtime vs this unit's)")
420    );
421    if reasons.is_empty() && json.deps.is_empty() {
422        println!("    (none declared)");
423    }
424    for dep in &json.deps {
425        let target = crate::fingerprint::resolve_dep(&index, dep);
426        let clean = !reasons
427            .iter()
428            .any(|(at, _)| target.is_some() && Some(*at) == target);
429        println!(
430            "    {} {}  {}",
431            if clean {
432                util::green(util::GLYPH_PASS)
433            } else {
434                util::warn(util::GLYPH_WARN)
435            },
436            dep.name,
437            if clean {
438                util::dim("(fresh)")
439            } else {
440                util::warn("(drives this rebuild)")
441            }
442        );
443    }
444    println!();
445    println!("  {}", util::green("verdict"));
446    if reasons.is_empty() {
447        println!(
448            "    {} {}",
449            util::heading("FRESH"),
450            util::dim("— nothing would rebuild for this unit")
451        );
452    } else {
453        println!(
454            "    {} {}",
455            util::warn("DIRTY"),
456            util::dim("— would recompile because:")
457        );
458        for (_, reason) in &reasons {
459            println!("      {} {reason}", util::dim("·"));
460        }
461    }
462    Ok(())
463}
464
465/// Freshness rule (cargo's `DependencyRebuilt`): *a dependency's newest
466/// artifact is newer than this unit's newest artifact ⇒ dirty*. Edges join
467/// by memo hash — the package name alone is ambiguous (several units of one
468/// crate coexist) and extern names flip `-`/`_`. Returns the offending
469/// dependency's unit index with each reason, so callers can mark exact rows.
470fn why_stale(
471    all: &[(Unit, ArtifactInfo)],
472    self_unit: &Unit,
473    own_arts: &crate::fingerprint::ArtifactInfo,
474    deps: &[DepRef],
475    index: &std::collections::HashMap<String, usize>,
476) -> Vec<(usize, String)> {
477    let Some(own_newest) = own_arts.newest else {
478        return vec![(usize::MAX, "no artifacts on disk (missing unit)".into())];
479    };
480
481    let mut reasons = Vec::new();
482    for dep in deps {
483        let Some(target) = crate::fingerprint::resolve_dep(index, dep) else {
484            continue; // dependency not present in this build dir
485        };
486        let (dep_unit, dep_arts) = &all[target];
487        if dep_unit.dir_name == self_unit.dir_name {
488            continue;
489        }
490        if let Some(newest) = dep_arts.newest {
491            if newest > own_newest {
492                reasons.push((
493                    target,
494                    format!(
495                        "dependency {} has outputs newer than this unit (StaleDependency, {} old)",
496                        dep_unit.dir_name,
497                        human_age(newest)
498                    ),
499                ));
500            }
501        }
502    }
503    reasons
504}
505
506fn display_features(features: &[String]) -> String {
507    if features.is_empty() {
508        "- (none)".into()
509    } else {
510        features.join(", ")
511    }
512}
513
514/// Truncate a hash for display: first `width` chars.
515fn short(hash: &str, width: usize) -> String {
516    hash.chars().take(width).collect()
517}