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[..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 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
465fn 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; };
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
514fn short(hash: &str, width: usize) -> String {
516 hash.chars().take(width).collect()
517}