Skip to main content

epicenter_cli/
doctor.rs

1//! `epicenter doctor`: one check per known failure mode of a shared
2//! build-dir setup. Each check emits one line with a severity icon; the
3//! checks are grouped into sections for printing.
4
5use std::path::Path;
6use std::time::SystemTime;
7
8use anyhow::Result;
9
10use crate::fingerprint::{ArtifactInfo, Unit};
11use crate::paths;
12use crate::util::{human_age, human_bytes, human_duration};
13use crate::who;
14
15/// Severity of one check.
16#[derive(Debug, Clone, Copy)]
17pub enum Level {
18    Pass,
19    Info,
20    Warn,
21    Fail,
22}
23
24impl Level {
25    pub fn icon(&self) -> &'static str {
26        match self {
27            Level::Pass => crate::util::GLYPH_PASS,
28            Level::Info => crate::util::GLYPH_INFO,
29            Level::Warn => crate::util::GLYPH_WARN,
30            Level::Fail => crate::util::GLYPH_FAIL,
31        }
32    }
33}
34
35/// One doctor check result, tagged with its section for grouped printing.
36pub struct Check {
37    pub section: &'static str,
38    pub level: Level,
39    pub message: String,
40}
41
42pub const SEC_CACHE: &str = "shared cache";
43pub const SEC_CONFIG: &str = "configuration";
44pub const SEC_WRAPPER: &str = "wrapper";
45pub const SEC_FS: &str = "filesystem";
46pub const SEC_TC: &str = "toolchain";
47pub const SEC_MTIME: &str = "mtime discipline";
48
49fn check(section: &'static str, level: Level, message: impl Into<String>) -> Check {
50    Check {
51        section,
52        level,
53        message: message.into(),
54    }
55}
56
57/// The full checklist.
58pub fn run() -> Result<Vec<Check>> {
59    let mut checks = Vec::new();
60
61    // Everything hangs on resolving the cache dir; if it fails, say so in
62    // check language instead of crashing the whole report.
63    let root = match crate::config::trusted_cache_root() {
64        Ok(root) => root,
65        Err(e) => {
66            return Ok(vec![check(
67                SEC_CACHE,
68                Level::Fail,
69                format!("could not resolve the shared cache dir ({e:#}) — run `epicenter setup`"),
70            )]);
71        }
72    };
73
74    // Shared cache dir exists.
75    checks.push(if root.is_dir() {
76        check(
77            SEC_CACHE,
78            Level::Pass,
79            format!("shared cache exists at {}", root.display()),
80        )
81    } else {
82        check(
83            SEC_CACHE,
84            Level::Fail,
85            format!(
86                "shared cache missing at {} — run `epicenter setup`",
87                root.display()
88            ),
89        )
90    });
91
92    // Template cleanliness + drift (global build-dir vs cargo's answer).
93    checks.push(check_build_dir(&root));
94    checks.push(check_cache_permissions(&root));
95
96    // Ancestor configs shadowing keys per-key.
97    checks.extend(check_ancestor_configs());
98
99    // rustc-wrapper in env AND config, plus discoverability.
100    checks.extend(check_rustc_wrapper());
101
102    // Cache root on a slow filesystem.
103    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    // Cache vs this project's target/ on different mounts (EXDEV).
121    if let Some(cross) = check_exdev(&root) {
122        checks.push(cross);
123    }
124
125    // Migration note: the former ~/.epicenter/config.toml state file is no
126    // longer used (~/.cargo/config.toml is the single source of truth).
127    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    // Unit-graph facts, one scan: toolchain generations, then mtime ordering.
139    // Toolchain identity is part of every unit's fingerprint, so a rustc
140    // upgrade re-keys the whole cache; ordering decides what recompiles.
141    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
164/// Template cleanliness + drift: the global [build] build-dir value must be
165/// templated and must resolve to the dir cargo actually builds into.
166fn check_build_dir(root: &Path) -> Check {
167    let section: &'static str = SEC_CONFIG;
168    let value = match crate::paths::cargo_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        // A literal path from another OS (a C:\ value read by WSL cargo) is
189        // joined into LD_LIBRARY_PATH and breaks every build: reject it.
190        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
240/// Cache root ownership/mode: a shared, foreign-owned, or group/world-
241/// writable cache is a poisoning/secret-leak vector.
242fn check_cache_permissions(root: &Path) -> Check {
243    let section: &'static str = SEC_CACHE;
244    let meta = match std::fs::symlink_metadata(root) {
245        Ok(meta) => meta,
246        Err(err) => {
247            return check(
248                section,
249                Level::Fail,
250                format!("could not inspect cache root: {err}"),
251            );
252        }
253    };
254    if meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) || !meta.is_dir() {
255        return check(
256            section,
257            Level::Fail,
258            "cache root is a symlink/reparse point or not a directory",
259        );
260    }
261    #[cfg(unix)]
262    {
263        use std::os::unix::fs::MetadataExt;
264        if !crate::util::owned_by_us(&meta) {
265            return check(
266                section,
267                Level::Fail,
268                format!(
269                    "cache root owned by uid {} (not uid {}) — never share a mutable vault between users",
270                    meta.uid(),
271                    crate::util::euid()
272                ),
273            );
274        }
275        if meta.mode() & 0o077 != 0 {
276            return check(
277                section,
278                Level::Fail,
279                format!(
280                    "cache root mode {:o} is accessible by other users — run `chmod 700 {}`",
281                    meta.mode() & 0o777,
282                    root.display()
283                ),
284            );
285        }
286    }
287    #[cfg(windows)]
288    {
289        if let Err(err) = crate::util::ensure_windows_user_path(root, "cache root") {
290            return check(section, Level::Fail, format!("{err:#}"));
291        }
292        return check(
293            section,
294            Level::Warn,
295            "cache root is under %USERPROFILE%, but Windows ACLs are not inspected — single-user cache only",
296        );
297    }
298    #[cfg(not(windows))]
299    check(section, Level::Pass, "cache root is private (owner-only)")
300}
301
302/// Ancestor scan: from the CWD upward, any `.cargo/config.toml` that sets a
303/// key DIFFERENTLY from the global one wins silently (deep config wins
304/// per-key) — how a Windows literal once bricked WSL builds.
305fn check_ancestor_configs() -> Vec<Check> {
306    let section: &'static str = SEC_CONFIG;
307    let mut out = Vec::new();
308    let home_cfg = crate::config::cargo_config_path().ok();
309    let mut dir = match std::env::current_dir() {
310        Ok(dir) => dir,
311        Err(_) => return out,
312    };
313
314    let mut found = false;
315    loop {
316        for name in ["config.toml", "config"] {
317            let candidate = dir.join(".cargo").join(name);
318            let is_home = home_cfg.as_ref().map_or(false, |h| *h == candidate)
319                || home_cfg
320                    .as_ref()
321                    .and_then(|h| h.parent())
322                    .map(|p| p.join("config") == candidate)
323                    .unwrap_or(false);
324            if !candidate.exists() || is_home {
325                continue;
326            }
327            found = true;
328            for key in ["build-dir", "rustc-wrapper"] {
329                let Some(ancestor) = crate::config::read_build_table_key(&candidate, key) else {
330                    continue; // key absent there — nothing to shadow
331                };
332                let home_value = crate::config::cargo_configured(key).ok().flatten();
333                if home_value.as_deref() != Some(ancestor.as_str()) {
334                    out.push(check(
335                        section,
336                        Level::Warn,
337                        format!(
338                            "{} sets {key} = '{ancestor}' differently from the global config \
339                             (cargo applies it to builds) — epicenter ignores it for its own state",
340                            candidate.display()
341                        ),
342                    ));
343                } else {
344                    out.push(check(
345                        section,
346                        Level::Pass,
347                        format!(
348                            "{} sets {key} = '{ancestor}', matching the global config",
349                            candidate.display()
350                        ),
351                    ));
352                }
353            }
354        }
355        match dir.parent() {
356            Some(parent) => dir = parent.to_path_buf(),
357            None => break,
358        }
359    }
360
361    if !found {
362        out.push(check(
363            section,
364            Level::Pass,
365            "no ancestor .cargo/config(.toml) shadows the global config",
366        ));
367    }
368    out
369}
370
371/// Wrapper checks, merged across env + config:
372/// where it is configured (env wins over config files), whether it is
373/// discoverable (PATH or $CARGO_HOME/bin), and whether PATH is actually set
374/// (cargo spawns the wrapper by NAME at build time).
375fn check_rustc_wrapper() -> Vec<Check> {
376    let section: &'static str = SEC_WRAPPER;
377    let mut out = Vec::new();
378
379    for var in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
380        if let Some(value) = std::env::var_os(var) {
381            out.push(check(
382                section,
383                Level::Warn,
384                format!(
385                    "{var}={} — environment wins over config files",
386                    value.to_string_lossy()
387                ),
388            ));
389        }
390    }
391
392    let configured = crate::config::cargo_configured("rustc-wrapper");
393    match configured {
394        Err(e) => out.push(check(section, Level::Warn, format!("could not read the cargo config: {e:#}"))),
395        Ok(None) => out.push(check(section,
396            Level::Info,
397            "no rustc-wrapper configured — kache/sccache are optional layers (`epicenter setup --with-wrapper kache`)",
398        )),
399        Ok(Some(name)) => match crate::util::which_path(&name) {
400            None => out.push(check(section,
401                Level::Warn,
402                format!(
403                    "rustc-wrapper '{name}' is configured but not found in PATH or $CARGO_HOME/bin — \
404                     builds fail until it is installed or removed (`epicenter setup --without-wrapper`)"
405                ),
406            )),
407            Some(path) => {
408                out.push(check(section,
409                    Level::Pass,
410                    format!("rustc-wrapper '{name}' discovered: {}", path.display()),
411                ));
412                if !crate::util::on_path(&name) {
413                    out.push(check(section,
414                        Level::Warn,
415                        format!(
416                            "'{name}' lives in the cargo bin dir but NOT on PATH — cargo spawns \
417                             the wrapper by NAME at build time, so add the bin dir to PATH"
418                        ),
419                    ));
420                }
421            }
422        },
423    }
424    out
425}
426
427/// EXDEV class: cache on a different filesystem than this project's
428/// target/ — finals then always copy, never hardlink.
429fn check_exdev(root: &Path) -> Option<Check> {
430    let section: &'static str = SEC_FS;
431    let target = std::env::current_dir().ok()?.join("target");
432    if !target.is_dir() {
433        return Some(check(
434            section,
435            Level::Info,
436            "no ./target next to this invocation — filesystem-match check skipped",
437        ));
438    }
439    match (device_id(&target), device_id(root)) {
440        (Some(target_dev), Some(root_dev)) if target_dev != root_dev => Some(check(
441            section,
442            Level::Warn,
443            format!(
444                "cache filesystem differs from ./target/ ({} vs {}) — final artifacts are \
445                 copied per project, never hardlinked (expected when projects live on /mnt)",
446                root.display(),
447                target.display()
448            ),
449        )),
450        (Some(_), Some(_)) => Some(check(
451            section,
452            Level::Pass,
453            "cache and ./target/ share one filesystem — hardlink reuse is possible",
454        )),
455        _ => Some(check(
456            section,
457            Level::Info,
458            "filesystem identity not measurable on this platform",
459        )),
460    }
461}
462
463#[cfg(unix)]
464fn device_id(path: &Path) -> Option<u64> {
465    std::fs::metadata(path)
466        .ok()
467        .map(|meta| std::os::unix::fs::MetadataExt::dev(&meta))
468}
469
470#[cfg(not(unix))]
471fn device_id(path: &Path) -> Option<u64> {
472    // Windows std exposes no volume serial; the std-only proxy is the
473    // canonical path's volume prefix (`\\?\C:\…` → `C:`, `\\?\UNC\server\share…`
474    // → `\\server\share`) — enough to distinguish local drives, WSL mounts
475    // (`\\wsl$…`) and network shares. A true volume serial requires Win32
476    // FFI (`GetFileInformationByHandle`), which is deliberately avoided to
477    // keep the dependency tree std-only.
478    use std::collections::hash_map::DefaultHasher;
479    use std::hash::{Hash, Hasher};
480
481    let canonical = path.canonicalize().ok()?;
482    let text = canonical.to_string_lossy();
483    let key = strip_volume_prefix(&text)?;
484    // Hashing only needs determinism WITHIN one doctor run (two ids compared).
485    let mut hasher = DefaultHasher::new();
486    key.hash(&mut hasher);
487    Some(hasher.finish())
488}
489
490/// `\\?\C:\Users\…` → `C:`; `\\?\UNC\server\share\…` → `\\server\share`.
491#[cfg(not(unix))]
492fn strip_volume_prefix(canonical: &str) -> Option<String> {
493    let rest = canonical.strip_prefix(r"\\?\")?;
494    let mut chars = rest.chars();
495    let (_first, second) = (chars.next()?, chars.next()?);
496    if second == ':' {
497        Some(rest[..2].to_string()) // drive letter
498    } else if rest.starts_with("UNC\\") {
499        let unc = &rest["UNC\\".len()..];
500        let mut segments = unc.split('\\');
501        let server = segments.next()?;
502        let share = segments.next()?;
503        Some(format!(r"\\{server}\{share}"))
504    } else {
505        None
506    }
507}
508
509/// rustc version + the upgrade-prune hint.
510fn check_rustc() -> Check {
511    let section: &'static str = SEC_TC;
512    let output = crate::util::command_for("rustc")
513        .and_then(|mut command| Ok(command.arg("--version").output()?));
514    match output {
515        Ok(out) if out.status.success() => {
516            let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
517            check(
518                section,
519                Level::Info,
520                format!(
521                    "{version} — after toolchain upgrades, run `epicenter prune` to drop old artifacts"
522                ),
523            )
524        }
525        _ => check(section, Level::Warn, "could not run `rustc --version`"),
526    }
527}
528
529/// Cache freshness relative to the installed toolchain, plus reclaimable
530/// older generations.
531///
532/// The `rustc` fingerprint field changes with every toolchain update, which
533/// re-keys every unit. Two distinct consequences, kept separate on purpose:
534/// - **staleness (fail)** when the installed toolchain is newer than the
535///   newest cached unit — the next build recompiles everything;
536/// - **dead weight (warning)** when older generations still occupy space —
537///   nothing is broken, they can be reclaimed deliberately.
538fn check_toolchain_generation(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
539    if rows.is_empty() {
540        return vec![check(
541            SEC_TC,
542            Level::Info,
543            "shared cache holds no fingerprint units yet",
544        )];
545    }
546
547    let newest_art = rows.iter().filter_map(|(_, arts)| arts.newest).max();
548    // The generation of the most recently built unit is the active one.
549    let active_gen = rows
550        .iter()
551        .filter(|(_, arts)| arts.newest == newest_art)
552        .find_map(|(unit, _)| unit.json.as_ref().map(|json| json.rustc.clone()))
553        .unwrap_or_else(|| "unknown".into());
554
555    let mut checks = Vec::new();
556
557    match (newest_art, toolchain_mtime()) {
558        (Some(newest), Some(toolchain)) if newest < toolchain => checks.push(check(
559            SEC_TC,
560            Level::Fail,
561            format!(
562                "installed toolchain is newer than the newest cached unit ({}): the cache is \
563                 stale — the next build recompiles it; outdated units can be reclaimed below",
564                human_age(newest)
565            ),
566        )),
567        (Some(newest), Some(_)) => checks.push(check(
568            SEC_TC,
569            Level::Pass,
570            format!(
571                "cache is aligned with the installed toolchain (newest unit {})",
572                human_age(newest)
573            ),
574        )),
575        _ => checks.push(check(
576            SEC_TC,
577            Level::Info,
578            "toolchain install time could not be read (`rustc --print sysroot` failed)",
579        )),
580    }
581
582    let stale: Vec<&(crate::fingerprint::Unit, crate::fingerprint::ArtifactInfo)> = rows
583        .iter()
584        .filter(|(unit, _)| who::rustc_of(unit) != active_gen)
585        .collect();
586    if !stale.is_empty() {
587        let stale_bytes: u64 = stale.iter().map(|(_, arts)| arts.bytes).sum();
588        checks.push(check(
589            SEC_TC,
590            Level::Warn,
591            format!(
592                "{} unit(s) on older toolchain generations ({}) — reclaim with `epicenter evict --stale`",
593                stale.len(),
594                human_bytes(stale_bytes)
595            ),
596        ));
597    }
598    checks
599}
600
601/// Mtime discipline across the unit graph: cargo re-runs a unit when a
602/// dependency's artifact is newer than the unit's own outputs ("the
603/// dependency `X` was rebuilt"). Restored views preserve the stored order,
604/// but a view that mixed restored and locally rebuilt units can break it —
605/// each violation costs exactly one recompile, so this warns.
606fn check_mtime_ordering(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
607    if rows.is_empty() {
608        return Vec::new();
609    }
610    let units: Vec<Unit> = rows.iter().map(|(unit, _)| unit.clone()).collect();
611    let edges = crate::fingerprint::dep_edges(&units);
612
613    let mut checked = 0usize;
614    let mut violations: Vec<(usize, usize)> = Vec::new();
615    for (dependent, deps) in edges.iter().enumerate() {
616        let own = rows[dependent].1.newest;
617        for &dep in deps {
618            checked += 1;
619            let (Some(own), Some(dep_newest)) = (own, rows[dep].1.newest) else {
620                continue;
621            };
622            if dep_newest > own {
623                violations.push((dependent, dep));
624            }
625        }
626    }
627
628    if violations.is_empty() {
629        return vec![check(
630            SEC_MTIME,
631            Level::Pass,
632            format!(
633                "every unit is at least as new as its dependencies ({checked} edge(s) checked)"
634            ),
635        )];
636    }
637
638    let mut checks = vec![check(
639        SEC_MTIME,
640        Level::Warn,
641        format!(
642            "{} unit(s) recompile once — a dependency is newer ({} edge(s) checked)",
643            violations.len(),
644            checked
645        ),
646    )];
647    for (dependent, dep) in violations.iter().take(3) {
648        let own = rows[*dependent].1.newest.expect("violation has timestamps");
649        let dep_newest = rows[*dep].1.newest.expect("violation has timestamps");
650        checks.push(check(
651            SEC_MTIME,
652            Level::Info,
653            format!(
654                "{} ← {} is {} newer (cargo: \"the dependency was rebuilt\")",
655                units[*dependent].dir_name,
656                units[*dep].dir_name,
657                human_duration(dep_newest.duration_since(own).unwrap_or_default())
658            ),
659        ));
660    }
661    if violations.len() > 3 {
662        checks.push(check(
663            SEC_MTIME,
664            Level::Info,
665            format!("… and {} more", violations.len() - 3),
666        ));
667    }
668    checks
669}
670
671/// Build concurrency: is fine-grain locking enabled, and is it even available?
672fn check_locking() -> Check {
673    let env_on = std::env::var("CARGO_UNSTABLE_FINE_GRAIN_LOCKING")
674        .map(|value| is_truthy(&value))
675        .unwrap_or(false);
676    let config_on = crate::config::cargo_configured_in("unstable", "fine-grain-locking")
677        .ok()
678        .flatten()
679        .map(|value| is_truthy(&value))
680        .unwrap_or(false);
681
682    if env_on || config_on {
683        return check(
684            SEC_TC,
685            Level::Pass,
686            "fine-grain locking enabled — concurrent builds on the shared build-dir overlap \
687             instead of serializing",
688        );
689    }
690    if toolchain_is_nightly() {
691        check(
692            SEC_TC,
693            Level::Warn,
694            "fine-grain locking is available but OFF — concurrent builds serialize on the \
695             shared build-dir; enable with `[unstable] fine-grain-locking = true` in \
696             ~/.cargo/config.toml",
697        )
698    } else {
699        check(
700            SEC_TC,
701            Level::Info,
702            "one file lock guards the shared build-dir: concurrent builds serialize; \
703             fine-grain locking (`-Z fine-grain-locking`) is nightly-only",
704        )
705    }
706}
707
708fn is_truthy(value: &str) -> bool {
709    matches!(
710        value.trim().to_ascii_lowercase().as_str(),
711        "1" | "true" | "yes" | "on"
712    )
713}
714
715/// Install time of the active toolchain (the real `rustc` binary, falling
716/// back to the sysroot directory). The vault owns toolchain identity; this
717/// is the same reference its harvest freshness gate uses.
718fn toolchain_mtime() -> Option<SystemTime> {
719    crate::vault::toolchain_install_time()
720}
721
722fn toolchain_is_nightly() -> bool {
723    crate::util::command_for("rustc")
724        .and_then(|mut command| Ok(command.arg("--version").output()?))
725        .ok()
726        .map(|out| String::from_utf8_lossy(&out.stdout).contains("nightly"))
727        .unwrap_or(false)
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use std::fs;
734    use std::time::Duration;
735
736    #[test]
737    fn truthiness_parsing() {
738        assert!(is_truthy("true"));
739        assert!(is_truthy("1"));
740        assert!(is_truthy(" YES "));
741        assert!(is_truthy("On"));
742        assert!(!is_truthy("false"));
743        assert!(!is_truthy("0"));
744        assert!(!is_truthy(""));
745        assert!(!is_truthy("nightly"));
746    }
747
748    #[test]
749    fn flags_older_toolchain_generations_for_reclaim() -> Result<()> {
750        // Two units, two rustc generations: the most recently built one is
751        // "active"; the other must surface as a reclaim warning.
752        let root = tempfile::tempdir()?;
753        let make_unit = |pkg: &str, hex: &str, rustc: &str, artifact: &str| -> Result<()> {
754            let dir = root
755                .path()
756                .join("debug/.fingerprint")
757                .join(format!("{pkg}-{hex}"));
758            fs::create_dir_all(&dir)?;
759            fs::write(dir.join(format!("lib-{pkg}")), "0011223344556677")?;
760            fs::write(
761                dir.join(format!("lib-{pkg}.json")),
762                format!(
763                    r#"{{"rustc":{rustc},"features":"[]","declared_features":"[]","deps":[]}}"#
764                ),
765            )?;
766            fs::create_dir_all(root.path().join("debug/deps"))?;
767            fs::write(root.path().join("debug/deps").join(artifact), [0u8; 8])?;
768            Ok(())
769        };
770        make_unit(
771            "oldcrate",
772            "aaaaaaaaaaaaaaaa",
773            "111",
774            "liboldcrate-aaaaaaaaaaaaaaaa.rlib",
775        )?;
776        std::thread::sleep(std::time::Duration::from_millis(20));
777        make_unit(
778            "newcrate",
779            "bbbbbbbbbbbbbbbb",
780            "222",
781            "libnewcrate-bbbbbbbbbbbbbbbb.rlib",
782        )?;
783
784        let rows = who::collect(root.path())?;
785        let checks = check_toolchain_generation(&rows);
786        let reclaim = checks
787            .iter()
788            .find(|c| c.message.contains("older toolchain generations"))
789            .expect("reclaim warning expected");
790        assert_eq!(reclaim.level as u8, Level::Warn as u8);
791        assert!(
792            reclaim.message.contains("1 unit(s)"),
793            "got: {}",
794            reclaim.message
795        );
796        Ok(())
797    }
798
799    /// Two units with a resolved dependency edge (the serde → serde_core
800    /// pair); artifacts carry explicit stamps so the
801    /// ordering is deterministic.
802    fn edge_build_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
803        let stamp = |path: &Path, secs: u64| -> Result<()> {
804            let time = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
805            fs::File::options()
806                .write(true)
807                .open(path)?
808                .set_modified(time)?;
809            Ok(())
810        };
811        fs::create_dir_all(root.join("debug/deps"))?;
812
813        let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
814        fs::create_dir_all(&core)?;
815        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
816        let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
817        fs::write(&core_rlib, [1u8; 32])?;
818        stamp(&core_rlib, dep_at)?;
819
820        let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
821        fs::create_dir_all(&unit)?;
822        fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
823        fs::write(
824            unit.join("lib-serde.json"),
825            r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
826        )?;
827        let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
828        fs::write(&unit_rlib, [1u8; 32])?;
829        stamp(&unit_rlib, unit_at)?;
830        Ok(())
831    }
832
833    #[test]
834    fn flags_units_older_than_their_dependencies() -> Result<()> {
835        let root = tempfile::tempdir()?;
836        edge_build_dir(root.path(), 1_700_000_600, 1_700_000_000)?;
837        let rows = who::collect(root.path())?;
838        let checks = check_mtime_ordering(&rows);
839        assert!(
840            matches!(checks[0].level, Level::Warn),
841            "{:?}",
842            checks[0].message
843        );
844        assert!(
845            checks[0].message.contains("recompile once"),
846            "got: {}",
847            checks[0].message
848        );
849        assert_eq!(checks.len(), 2, "one example follows the count");
850        assert!(checks[1].message.contains("serde-746d173581f1a314"));
851        assert!(checks[1].message.contains("serde_core-b468be6fa8c1f936"));
852        Ok(())
853    }
854
855    #[test]
856    fn ordering_passes_when_units_are_current() -> Result<()> {
857        let root = tempfile::tempdir()?;
858        edge_build_dir(root.path(), 1_700_000_000, 1_700_000_600)?;
859        let rows = who::collect(root.path())?;
860        let checks = check_mtime_ordering(&rows);
861        assert!(matches!(checks[0].level, Level::Pass));
862        assert!(
863            checks[0].message.contains("at least as new"),
864            "got: {}",
865            checks[0].message
866        );
867        assert_eq!(checks.len(), 1);
868        Ok(())
869    }
870
871    #[test]
872    fn staleness_requires_both_timestamps() {
873        let now = SystemTime::now();
874        let older = now - Duration::from_secs(60);
875        assert!(older < now, "sanity");
876        assert!(!(older >= now));
877        assert!(now > older);
878    }
879}