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.chars().take(8).collect::<String>(),
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((
312            usize::MAX,
313            "stale toolchain generation (rustc key differs)".into(),
314        ));
315    }
316    if arts.paths.is_empty() {
317        reasons.push((usize::MAX, "no artifacts on disk".into()));
318    }
319
320    println!(
321        "{} {} — {} · profile {}",
322        util::green("unit"),
323        util::heading(&unit.dir_name),
324        unit.kind.label(),
325        util::dim(&unit.profile)
326    );
327    println!();
328    println!("  {}", util::green("identity"));
329    println!(
330        "    {} {}",
331        util::dim(&format!("{:<13}", "dir suffix")),
332        unit.dir_suffix
333    );
334    println!(
335        "    {} {}  {}",
336        util::dim(&format!("{:<13}", "memo hash")),
337        unit.memo_hash.as_deref().unwrap_or("?"),
338        util::dim("(authoritative — different hash role than the dir suffix)")
339    );
340    println!(
341        "    {} {}  {}",
342        util::dim(&format!("{:<13}", "rustc")),
343        json.rustc,
344        if stale_id {
345            util::warn("[STALE generation]")
346        } else {
347            util::green("[current generation]")
348        }
349    );
350    println!();
351    println!("  {}", util::green("configuration"));
352    println!(
353        "    {} {}",
354        util::dim(&format!("{:<13}", "features")),
355        display_features(&json.features)
356    );
357    println!(
358        "    {} {}",
359        util::dim(&format!("{:<13}", "declared")),
360        display_features(&json.declared_features)
361    );
362    println!(
363        "    {} target {} · profile {} · path {} · config {} · compile_kind {}",
364        util::dim(&format!("{:<13}", "opaque hashes")),
365        short(&json.target, 10),
366        short(&json.profile, 10),
367        short(&json.path, 10),
368        short(&json.config, 10),
369        json.compile_kind
370    );
371    if !json.rustflags.is_empty() {
372        println!(
373            "    {} {}",
374            util::dim(&format!("{:<13}", "rustflags")),
375            json.rustflags.join(" ")
376        );
377    }
378    println!();
379    println!("  {}", util::green("artifacts"));
380    if arts.paths.is_empty() {
381        println!(
382            "    {} none on disk — this unit would recompile",
383            util::warn(util::GLYPH_WARN)
384        );
385    } else {
386        for (path, size, mtime) in arts.paths.iter().take(8) {
387            println!(
388                "    {:<42} {:>9}  {}",
389                path.file_name().map_or_else(
390                    || path.display().to_string(),
391                    |n| n.to_string_lossy().into_owned()
392                ),
393                human_bytes(*size),
394                util::dim(&format!(
395                    "{:>10} old",
396                    mtime.map(human_age).unwrap_or_else(|| "?".into())
397                ))
398            );
399        }
400        if arts.paths.len() > 8 {
401            println!("    … {} more", arts.paths.len() - 8);
402        }
403    }
404    let dep_info = unit.dep_info.as_ref().map(|p| {
405        p.file_name().map_or_else(
406            || p.display().to_string(),
407            |n| n.to_string_lossy().into_owned(),
408        )
409    });
410    println!(
411        "    {} {}",
412        util::dim(&format!("{:<13}", "dep-info")),
413        dep_info.as_deref().map_or_else(
414            || util::warn("MISSING (mandatory for freshness)"),
415            |name| name.to_string()
416        )
417    );
418    println!();
419    println!(
420        "  {} {}",
421        util::green("dependencies"),
422        util::dim("(freshness: dep's newest output mtime vs this unit's)")
423    );
424    if reasons.is_empty() && json.deps.is_empty() {
425        println!("    (none declared)");
426    }
427    for dep in &json.deps {
428        let target = crate::fingerprint::resolve_dep(&index, dep);
429        let clean = !reasons
430            .iter()
431            .any(|(at, _)| target.is_some() && Some(*at) == target);
432        println!(
433            "    {} {}  {}",
434            if clean {
435                util::green(util::GLYPH_PASS)
436            } else {
437                util::warn(util::GLYPH_WARN)
438            },
439            dep.name,
440            if clean {
441                util::dim("(fresh)")
442            } else {
443                util::warn("(drives this rebuild)")
444            }
445        );
446    }
447    println!();
448    println!("  {}", util::green("verdict"));
449    if reasons.is_empty() {
450        println!(
451            "    {} {}",
452            util::heading("FRESH"),
453            util::dim("— nothing would rebuild for this unit")
454        );
455    } else {
456        println!(
457            "    {} {}",
458            util::warn("DIRTY"),
459            util::dim("— would recompile because:")
460        );
461        for (_, reason) in &reasons {
462            println!("      {} {reason}", util::dim("·"));
463        }
464    }
465    Ok(())
466}
467
468/// Freshness rule (cargo's `DependencyRebuilt`): *a dependency's newest
469/// artifact is newer than this unit's newest artifact ⇒ dirty*. Edges join
470/// by memo hash — the package name alone is ambiguous (several units of one
471/// crate coexist) and extern names flip `-`/`_`. Returns the offending
472/// dependency's unit index with each reason, so callers can mark exact rows.
473fn why_stale(
474    all: &[(Unit, ArtifactInfo)],
475    self_unit: &Unit,
476    own_arts: &crate::fingerprint::ArtifactInfo,
477    deps: &[DepRef],
478    index: &std::collections::HashMap<String, usize>,
479) -> Vec<(usize, String)> {
480    let Some(own_newest) = own_arts.newest else {
481        return vec![(usize::MAX, "no artifacts on disk (missing unit)".into())];
482    };
483
484    let mut reasons = Vec::new();
485    for dep in deps {
486        let Some(target) = crate::fingerprint::resolve_dep(index, dep) else {
487            continue; // dependency not present in this build dir
488        };
489        let (dep_unit, dep_arts) = &all[target];
490        if dep_unit.dir_name == self_unit.dir_name {
491            continue;
492        }
493        if let Some(newest) = dep_arts.newest {
494            if newest > own_newest {
495                reasons.push((
496                    target,
497                    format!(
498                        "dependency {} has outputs newer than this unit (StaleDependency, {} old)",
499                        dep_unit.dir_name,
500                        human_age(newest)
501                    ),
502                ));
503            }
504        }
505    }
506    reasons
507}
508
509fn display_features(features: &[String]) -> String {
510    if features.is_empty() {
511        "- (none)".into()
512    } else {
513        features.join(", ")
514    }
515}
516
517/// Truncate a hash for display: first `width` chars.
518fn short(hash: &str, width: usize) -> String {
519    hash.chars().take(width).collect()
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::fs;
526
527    #[test]
528    fn explain_survives_multibyte_rustc_ids() -> Result<()> {
529        let root = tempfile::tempdir()?;
530        for name in ["foo-aaaaaaaaaaaaaaaa", "bar-aaaaaaaaaaaaaaaa"] {
531            let dir = root.path().join("debug/.fingerprint").join(name);
532            fs::create_dir_all(&dir)?;
533            let memo = format!("bin-{}", name.split('-').next().unwrap());
534            fs::write(dir.join(&memo), "0123456789abcdef")?;
535            fs::write(
536                dir.join(format!("{memo}.json")),
537                r#"{"rustc":"abcdefgé","features":"[]","declared_features":"[]","deps":[]}"#,
538            )?;
539        }
540        // Two matches, neither a lib → exercises the multi-match display path.
541        explain(root.path(), "aaaaaaaaaaaaaaaa")?;
542        Ok(())
543    }
544}