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}", "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
468fn 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; };
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
517fn 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 explain(root.path(), "aaaaaaaaaaaaaaaa")?;
542 Ok(())
543 }
544}