1use std::path::Path;
6use std::process::Command;
7use std::time::SystemTime;
8
9use anyhow::Result;
10
11use crate::fingerprint::{ArtifactInfo, Unit};
12use crate::paths;
13use crate::util::{human_age, human_bytes, human_duration};
14use crate::who;
15
16#[derive(Debug, Clone, Copy)]
18pub enum Level {
19 Pass,
20 Info,
21 Warn,
22 Fail,
23}
24
25impl Level {
26 pub fn icon(&self) -> &'static str {
27 match self {
28 Level::Pass => crate::util::GLYPH_PASS,
29 Level::Info => crate::util::GLYPH_INFO,
30 Level::Warn => crate::util::GLYPH_WARN,
31 Level::Fail => crate::util::GLYPH_FAIL,
32 }
33 }
34}
35
36pub struct Check {
38 pub section: &'static str,
39 pub level: Level,
40 pub message: String,
41}
42
43pub const SEC_CACHE: &str = "shared cache";
44pub const SEC_CONFIG: &str = "configuration";
45pub const SEC_WRAPPER: &str = "wrapper";
46pub const SEC_FS: &str = "filesystem";
47pub const SEC_TC: &str = "toolchain";
48pub const SEC_MTIME: &str = "mtime discipline";
49
50fn check(section: &'static str, level: Level, message: impl Into<String>) -> Check {
51 Check {
52 section,
53 level,
54 message: message.into(),
55 }
56}
57
58pub fn run() -> Result<Vec<Check>> {
60 let mut checks = Vec::new();
61
62 let root = match crate::config::cache_root() {
65 Ok(root) => root,
66 Err(e) => {
67 return Ok(vec![check(
68 SEC_CACHE,
69 Level::Fail,
70 format!("could not resolve the shared cache dir ({e:#}) — run `epicenter setup`"),
71 )]);
72 }
73 };
74
75 checks.push(if root.is_dir() {
77 check(
78 SEC_CACHE,
79 Level::Pass,
80 format!("shared cache exists at {}", root.display()),
81 )
82 } else {
83 check(
84 SEC_CACHE,
85 Level::Fail,
86 format!(
87 "shared cache missing at {} — run `epicenter setup`",
88 root.display()
89 ),
90 )
91 });
92
93 checks.push(check_build_dir(&root));
95
96 checks.extend(check_ancestor_configs());
98
99 checks.extend(check_rustc_wrapper());
101
102 if root.starts_with("/mnt/") {
104 checks.push(check(
105 SEC_FS,
106 Level::Warn,
107 String::from(concat!(
108 "shared cache on a Windows-mounted drive — very slow I/O under WSL; ",
109 "prefer the default {cargo-cache-home} location"
110 )),
111 ));
112 } else {
113 checks.push(check(
114 SEC_FS,
115 Level::Pass,
116 format!("cache root on the local filesystem: {}", root.display()),
117 ));
118 }
119
120 if let Some(cross) = check_exdev(&root) {
122 checks.push(cross);
123 }
124
125 if let Ok(home) = paths::home() {
128 let legacy = home.join(".epicenter").join("config.toml");
129 if legacy.exists() {
130 checks.push(check(
131 SEC_CONFIG,
132 Level::Info,
133 "legacy ~/.epicenter/config.toml found — no longer used; safe to delete",
134 ));
135 }
136 }
137
138 let rows = match who::collect(&root) {
142 Ok(rows) => {
143 checks.extend(check_toolchain_generation(&rows));
144 Some(rows)
145 }
146 Err(e) => {
147 checks.push(check(
148 SEC_TC,
149 Level::Warn,
150 format!("could not read the shared cache: {e:#}"),
151 ));
152 None
153 }
154 };
155 checks.push(check_rustc());
156 checks.push(check_locking());
157 if let Some(rows) = &rows {
158 checks.extend(check_mtime_ordering(rows));
159 }
160
161 Ok(checks)
162}
163
164fn check_build_dir(root: &Path) -> Check {
167 let section: &'static str = SEC_CONFIG;
168 let value = match crate::config::cargo_configured("build-dir") {
169 Ok(Some(value)) => value,
170 Ok(None) => {
171 return check(
172 section,
173 Level::Warn,
174 "no build-dir in the global cargo config — run `epicenter setup`",
175 );
176 }
177 Err(e) => {
178 return check(
179 section,
180 Level::Warn,
181 format!("could not read the cargo config: {e:#}"),
182 );
183 }
184 };
185
186 let has_braces = value.contains('{') && value.contains('}');
187 if !has_braces {
188 if value.contains("C:\\") || value.contains("/mnt/") {
191 return check(
192 section,
193 Level::Fail,
194 String::from(concat!(
195 "build-dir value is a literal other-OS path — the cross-OS-safe form is ",
196 "'{cargo-cache-home}\\epicenter\\build'; run `epicenter setup`"
197 )),
198 );
199 }
200 return check(
201 section,
202 Level::Warn,
203 format!(
204 "build-dir = '{value}' is a literal path (no template) — fine locally, \
205 fragile when configs are shared across OSes; `epicenter setup` writes the template form"
206 ),
207 );
208 }
209
210 match paths::resolve_template(&value) {
211 Ok(resolved) if resolved == root => check(
212 section,
213 Level::Pass,
214 format!(
215 "build-dir template resolves to the shared cache: {}",
216 root.display()
217 ),
218 ),
219 Ok(resolved) => check(
220 section,
221 Level::Warn,
222 format!(
223 "config drift: the global build-dir resolves to {}, but cargo's effective \
224 build-dir is {} — an ancestor config may be winning; run `epicenter setup`",
225 resolved.display(),
226 root.display()
227 ),
228 ),
229 Err(e) => check(
230 section,
231 Level::Warn,
232 format!(
233 "could not expand the build-dir template locally ({e:#}) — cargo still \
234 expands it at build time; only epicenter's local view failed"
235 ),
236 ),
237 }
238}
239
240fn check_ancestor_configs() -> Vec<Check> {
244 let section: &'static str = SEC_CONFIG;
245 let mut out = Vec::new();
246 let home_cfg = crate::config::cargo_config_path().ok();
247 let mut dir = match std::env::current_dir() {
248 Ok(dir) => dir,
249 Err(_) => return out,
250 };
251
252 let mut found = false;
253 loop {
254 let candidate = dir.join(".cargo").join("config.toml");
255 let is_home = home_cfg.as_ref().map_or(false, |h| *h == candidate);
256 if candidate.exists() && !is_home {
257 found = true;
258 for key in ["build-dir", "rustc-wrapper"] {
259 let Some(ancestor) = crate::config::read_build_table_key(&candidate, key) else {
260 continue; };
262 let home_value = crate::config::cargo_configured(key).ok().flatten();
263 if home_value.as_deref() != Some(ancestor.as_str()) {
264 out.push(check(
265 section,
266 Level::Warn,
267 format!(
268 "{} sets {key} = '{ancestor}' differently from the global config \
269 (deep config wins per-key) — the shadowing poison vector",
270 candidate.display()
271 ),
272 ));
273 } else {
274 out.push(check(
275 section,
276 Level::Pass,
277 format!(
278 "{} sets {key} = '{ancestor}', matching the global config",
279 candidate.display()
280 ),
281 ));
282 }
283 }
284 }
285 match dir.parent() {
286 Some(parent) => dir = parent.to_path_buf(),
287 None => break,
288 }
289 }
290
291 if !found {
292 out.push(check(
293 section,
294 Level::Pass,
295 "no ancestor .cargo/config.toml shadows the global config",
296 ));
297 }
298 out
299}
300
301fn check_rustc_wrapper() -> Vec<Check> {
306 let section: &'static str = SEC_WRAPPER;
307 let mut out = Vec::new();
308
309 for var in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
310 if let Some(value) = std::env::var_os(var) {
311 out.push(check(
312 section,
313 Level::Warn,
314 format!(
315 "{var}={} — environment wins over config files",
316 value.to_string_lossy()
317 ),
318 ));
319 }
320 }
321
322 let configured = crate::config::cargo_configured("rustc-wrapper");
323 match configured {
324 Err(e) => out.push(check(section, Level::Warn, format!("could not read the cargo config: {e:#}"))),
325 Ok(None) => out.push(check(section,
326 Level::Info,
327 "no rustc-wrapper configured — kache/sccache are optional layers (`epicenter setup --with-wrapper kache`)",
328 )),
329 Ok(Some(name)) => match crate::util::which_path(&name) {
330 None => out.push(check(section,
331 Level::Warn,
332 format!(
333 "rustc-wrapper '{name}' is configured but not found in PATH or $CARGO_HOME/bin — \
334 builds fail until it is installed or removed (`epicenter setup --without-wrapper`)"
335 ),
336 )),
337 Some(path) => {
338 out.push(check(section,
339 Level::Pass,
340 format!("rustc-wrapper '{name}' discovered: {}", path.display()),
341 ));
342 if !crate::util::on_path(&name) {
343 out.push(check(section,
344 Level::Warn,
345 format!(
346 "'{name}' lives in the cargo bin dir but NOT on PATH — cargo spawns \
347 the wrapper by NAME at build time, so add the bin dir to PATH"
348 ),
349 ));
350 }
351 }
352 },
353 }
354 out
355}
356
357fn check_exdev(root: &Path) -> Option<Check> {
360 let section: &'static str = SEC_FS;
361 let target = std::env::current_dir().ok()?.join("target");
362 if !target.is_dir() {
363 return Some(check(
364 section,
365 Level::Info,
366 "no ./target next to this invocation — filesystem-match check skipped",
367 ));
368 }
369 match (device_id(&target), device_id(root)) {
370 (Some(target_dev), Some(root_dev)) if target_dev != root_dev => Some(check(
371 section,
372 Level::Warn,
373 format!(
374 "cache filesystem differs from ./target/ ({} vs {}) — final artifacts are \
375 copied per project, never hardlinked (expected when projects live on /mnt)",
376 root.display(),
377 target.display()
378 ),
379 )),
380 (Some(_), Some(_)) => Some(check(
381 section,
382 Level::Pass,
383 "cache and ./target/ share one filesystem — hardlink reuse is possible",
384 )),
385 _ => Some(check(
386 section,
387 Level::Info,
388 "filesystem identity not measurable on this platform",
389 )),
390 }
391}
392
393#[cfg(unix)]
394fn device_id(path: &Path) -> Option<u64> {
395 std::fs::metadata(path)
396 .ok()
397 .map(|meta| std::os::unix::fs::MetadataExt::dev(&meta))
398}
399
400#[cfg(not(unix))]
401fn device_id(path: &Path) -> Option<u64> {
402 use std::collections::hash_map::DefaultHasher;
409 use std::hash::{Hash, Hasher};
410
411 let canonical = path.canonicalize().ok()?;
412 let text = canonical.to_string_lossy();
413 let key = strip_volume_prefix(&text)?;
414 let mut hasher = DefaultHasher::new();
416 key.hash(&mut hasher);
417 Some(hasher.finish())
418}
419
420#[cfg(not(unix))]
422fn strip_volume_prefix(canonical: &str) -> Option<String> {
423 let rest = canonical.strip_prefix(r"\\?\")?;
424 let mut chars = rest.chars();
425 let (first, second) = (chars.next()?, chars.next()?);
426 if second == ':' {
427 Some(rest[..2].to_string()) } else if rest.starts_with("UNC\\") {
429 let unc = &rest["UNC\\".len()..];
430 let mut segments = unc.split('\\');
431 let server = segments.next()?;
432 let share = segments.next()?;
433 Some(format!(r"\\{server}\{share}"))
434 } else {
435 None
436 }
437}
438
439fn check_rustc() -> Check {
441 let section: &'static str = SEC_TC;
442 match Command::new("rustc").arg("--version").output() {
443 Ok(out) if out.status.success() => {
444 let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
445 check(
446 section,
447 Level::Info,
448 format!(
449 "{version} — after toolchain upgrades, run `epicenter prune` to drop old artifacts"
450 ),
451 )
452 }
453 _ => check(section, Level::Warn, "could not run `rustc --version`"),
454 }
455}
456
457fn check_toolchain_generation(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
467 if rows.is_empty() {
468 return vec![check(
469 SEC_TC,
470 Level::Info,
471 "shared cache holds no fingerprint units yet",
472 )];
473 }
474
475 let newest_art = rows.iter().filter_map(|(_, arts)| arts.newest).max();
476 let active_gen = rows
478 .iter()
479 .filter(|(_, arts)| arts.newest == newest_art)
480 .find_map(|(unit, _)| unit.json.as_ref().map(|json| json.rustc.clone()))
481 .unwrap_or_else(|| "unknown".into());
482
483 let mut checks = Vec::new();
484
485 match (newest_art, toolchain_mtime()) {
486 (Some(newest), Some(toolchain)) if newest < toolchain => checks.push(check(
487 SEC_TC,
488 Level::Fail,
489 format!(
490 "installed toolchain is newer than the newest cached unit ({}): the cache is \
491 stale — the next build recompiles it; outdated units can be reclaimed below",
492 human_age(newest)
493 ),
494 )),
495 (Some(newest), Some(_)) => checks.push(check(
496 SEC_TC,
497 Level::Pass,
498 format!(
499 "cache is aligned with the installed toolchain (newest unit {})",
500 human_age(newest)
501 ),
502 )),
503 _ => checks.push(check(
504 SEC_TC,
505 Level::Info,
506 "toolchain install time could not be read (`rustc --print sysroot` failed)",
507 )),
508 }
509
510 let stale: Vec<&(crate::fingerprint::Unit, crate::fingerprint::ArtifactInfo)> = rows
511 .iter()
512 .filter(|(unit, _)| who::rustc_of(unit) != active_gen)
513 .collect();
514 if !stale.is_empty() {
515 let stale_bytes: u64 = stale.iter().map(|(_, arts)| arts.bytes).sum();
516 checks.push(check(
517 SEC_TC,
518 Level::Warn,
519 format!(
520 "{} unit(s) on older toolchain generations ({}) — reclaim with `epicenter evict --stale`",
521 stale.len(),
522 human_bytes(stale_bytes)
523 ),
524 ));
525 }
526 checks
527}
528
529fn check_mtime_ordering(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
535 if rows.is_empty() {
536 return Vec::new();
537 }
538 let units: Vec<Unit> = rows.iter().map(|(unit, _)| unit.clone()).collect();
539 let edges = crate::fingerprint::dep_edges(&units);
540
541 let mut checked = 0usize;
542 let mut violations: Vec<(usize, usize)> = Vec::new();
543 for (dependent, deps) in edges.iter().enumerate() {
544 let own = rows[dependent].1.newest;
545 for &dep in deps {
546 checked += 1;
547 let (Some(own), Some(dep_newest)) = (own, rows[dep].1.newest) else {
548 continue;
549 };
550 if dep_newest > own {
551 violations.push((dependent, dep));
552 }
553 }
554 }
555
556 if violations.is_empty() {
557 return vec![check(
558 SEC_MTIME,
559 Level::Pass,
560 format!("every unit is at least as new as its dependencies ({checked} edge(s) checked)"),
561 )];
562 }
563
564 let mut checks = vec![check(
565 SEC_MTIME,
566 Level::Warn,
567 format!(
568 "{} unit(s) recompile once — a dependency is newer ({} edge(s) checked)",
569 violations.len(),
570 checked
571 ),
572 )];
573 for (dependent, dep) in violations.iter().take(3) {
574 let own = rows[*dependent].1.newest.expect("violation has timestamps");
575 let dep_newest = rows[*dep].1.newest.expect("violation has timestamps");
576 checks.push(check(
577 SEC_MTIME,
578 Level::Info,
579 format!(
580 "{} ← {} is {} newer (cargo: \"the dependency was rebuilt\")",
581 units[*dependent].dir_name,
582 units[*dep].dir_name,
583 human_duration(dep_newest.duration_since(own).unwrap_or_default())
584 ),
585 ));
586 }
587 if violations.len() > 3 {
588 checks.push(check(
589 SEC_MTIME,
590 Level::Info,
591 format!("… and {} more", violations.len() - 3),
592 ));
593 }
594 checks
595}
596
597fn check_locking() -> Check {
599 let env_on = std::env::var("CARGO_UNSTABLE_FINE_GRAIN_LOCKING")
600 .map(|value| is_truthy(&value))
601 .unwrap_or(false);
602 let config_on = crate::config::cargo_configured_in("unstable", "fine-grain-locking")
603 .ok()
604 .flatten()
605 .map(|value| is_truthy(&value))
606 .unwrap_or(false);
607
608 if env_on || config_on {
609 return check(
610 SEC_TC,
611 Level::Pass,
612 "fine-grain locking enabled — concurrent builds on the shared build-dir overlap \
613 instead of serializing",
614 );
615 }
616 if toolchain_is_nightly() {
617 check(
618 SEC_TC,
619 Level::Warn,
620 "fine-grain locking is available but OFF — concurrent builds serialize on the \
621 shared build-dir; enable with `[unstable] fine-grain-locking = true` in \
622 ~/.cargo/config.toml",
623 )
624 } else {
625 check(
626 SEC_TC,
627 Level::Info,
628 "one file lock guards the shared build-dir: concurrent builds serialize; \
629 fine-grain locking (`-Z fine-grain-locking`) is nightly-only",
630 )
631 }
632}
633
634fn is_truthy(value: &str) -> bool {
635 matches!(
636 value.trim().to_ascii_lowercase().as_str(),
637 "1" | "true" | "yes" | "on"
638 )
639}
640
641fn toolchain_mtime() -> Option<SystemTime> {
645 crate::vault::toolchain_install_time()
646}
647
648fn toolchain_is_nightly() -> bool {
649 Command::new("rustc")
650 .arg("--version")
651 .output()
652 .map(|out| String::from_utf8_lossy(&out.stdout).contains("nightly"))
653 .unwrap_or(false)
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use std::fs;
660 use std::time::Duration;
661
662 #[test]
663 fn truthiness_parsing() {
664 assert!(is_truthy("true"));
665 assert!(is_truthy("1"));
666 assert!(is_truthy(" YES "));
667 assert!(is_truthy("On"));
668 assert!(!is_truthy("false"));
669 assert!(!is_truthy("0"));
670 assert!(!is_truthy(""));
671 assert!(!is_truthy("nightly"));
672 }
673
674 #[test]
675 fn flags_older_toolchain_generations_for_reclaim() -> Result<()> {
676 let root = tempfile::tempdir()?;
679 let make_unit = |pkg: &str, hex: &str, rustc: &str, artifact: &str| -> Result<()> {
680 let dir = root.path().join("debug/.fingerprint").join(format!("{pkg}-{hex}"));
681 fs::create_dir_all(&dir)?;
682 fs::write(dir.join(format!("lib-{pkg}")), "0011223344556677")?;
683 fs::write(
684 dir.join(format!("lib-{pkg}.json")),
685 format!(r#"{{"rustc":{rustc},"features":"[]","declared_features":"[]","deps":[]}}"#),
686 )?;
687 fs::create_dir_all(root.path().join("debug/deps"))?;
688 fs::write(root.path().join("debug/deps").join(artifact), [0u8; 8])?;
689 Ok(())
690 };
691 make_unit("oldcrate", "aaaaaaaaaaaaaaaa", "111", "liboldcrate-aaaaaaaaaaaaaaaa.rlib")?;
692 std::thread::sleep(std::time::Duration::from_millis(20));
693 make_unit("newcrate", "bbbbbbbbbbbbbbbb", "222", "libnewcrate-bbbbbbbbbbbbbbbb.rlib")?;
694
695 let rows = who::collect(root.path())?;
696 let checks = check_toolchain_generation(&rows);
697 let reclaim = checks
698 .iter()
699 .find(|c| c.message.contains("older toolchain generations"))
700 .expect("reclaim warning expected");
701 assert_eq!(reclaim.level as u8, Level::Warn as u8);
702 assert!(reclaim.message.contains("1 unit(s)"), "got: {}", reclaim.message);
703 Ok(())
704 }
705
706 fn edge_build_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
710 let stamp = |path: &Path, secs: u64| -> Result<()> {
711 let time = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
712 fs::File::options().write(true).open(path)?.set_modified(time)?;
713 Ok(())
714 };
715 fs::create_dir_all(root.join("debug/deps"))?;
716
717 let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
718 fs::create_dir_all(&core)?;
719 fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
720 let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
721 fs::write(&core_rlib, [1u8; 32])?;
722 stamp(&core_rlib, dep_at)?;
723
724 let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
725 fs::create_dir_all(&unit)?;
726 fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
727 fs::write(
728 unit.join("lib-serde.json"),
729 r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
730 )?;
731 let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
732 fs::write(&unit_rlib, [1u8; 32])?;
733 stamp(&unit_rlib, unit_at)?;
734 Ok(())
735 }
736
737 #[test]
738 fn flags_units_older_than_their_dependencies() -> Result<()> {
739 let root = tempfile::tempdir()?;
740 edge_build_dir(root.path(), 1_700_000_600, 1_700_000_000)?;
741 let rows = who::collect(root.path())?;
742 let checks = check_mtime_ordering(&rows);
743 assert!(matches!(checks[0].level, Level::Warn), "{:?}", checks[0].message);
744 assert!(checks[0].message.contains("recompile once"), "got: {}", checks[0].message);
745 assert_eq!(checks.len(), 2, "one example follows the count");
746 assert!(checks[1].message.contains("serde-746d173581f1a314"));
747 assert!(checks[1].message.contains("serde_core-b468be6fa8c1f936"));
748 Ok(())
749 }
750
751 #[test]
752 fn ordering_passes_when_units_are_current() -> Result<()> {
753 let root = tempfile::tempdir()?;
754 edge_build_dir(root.path(), 1_700_000_000, 1_700_000_600)?;
755 let rows = who::collect(root.path())?;
756 let checks = check_mtime_ordering(&rows);
757 assert!(matches!(checks[0].level, Level::Pass));
758 assert!(checks[0].message.contains("at least as new"), "got: {}", checks[0].message);
759 assert_eq!(checks.len(), 1);
760 Ok(())
761 }
762
763 #[test]
764 fn staleness_requires_both_timestamps() {
765 let now = SystemTime::now();
766 let older = now - Duration::from_secs(60);
767 assert!(older < now, "sanity");
768 assert!(!(older >= now));
769 assert!(now > older);
770 }
771}