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