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::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        // 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() || !meta.is_dir() {
255        return check(
256            section,
257            Level::Fail,
258            "cache root is a symlink 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    check(section, Level::Pass, "cache root is private (owner-only)")
288}
289
290/// Ancestor scan: from the CWD upward, any `.cargo/config.toml` that sets a
291/// key DIFFERENTLY from the global one wins silently (deep config wins
292/// per-key) — how a Windows literal once bricked WSL builds.
293fn check_ancestor_configs() -> Vec<Check> {
294    let section: &'static str = SEC_CONFIG;
295    let mut out = Vec::new();
296    let home_cfg = crate::config::cargo_config_path().ok();
297    let mut dir = match std::env::current_dir() {
298        Ok(dir) => dir,
299        Err(_) => return out,
300    };
301
302    let mut found = false;
303    loop {
304        let candidate = dir.join(".cargo").join("config.toml");
305        let is_home = home_cfg.as_ref().map_or(false, |h| *h == candidate);
306        if candidate.exists() && !is_home {
307            found = true;
308            for key in ["build-dir", "rustc-wrapper"] {
309                let Some(ancestor) = crate::config::read_build_table_key(&candidate, key) else {
310                    continue; // key absent there — nothing to shadow
311                };
312                let home_value = crate::config::cargo_configured(key).ok().flatten();
313                if home_value.as_deref() != Some(ancestor.as_str()) {
314                    out.push(check(
315                        section,
316                        Level::Fail,
317                        format!(
318                            "{} sets {key} = '{ancestor}' differently from the global config \
319                             (deep config wins per-key) — epicenter refuses project-controlled state",
320                            candidate.display()
321                        ),
322                    ));
323                } else {
324                    out.push(check(
325                        section,
326                        Level::Pass,
327                        format!(
328                            "{} sets {key} = '{ancestor}', matching the global config",
329                            candidate.display()
330                        ),
331                    ));
332                }
333            }
334        }
335        match dir.parent() {
336            Some(parent) => dir = parent.to_path_buf(),
337            None => break,
338        }
339    }
340
341    if !found {
342        out.push(check(
343            section,
344            Level::Pass,
345            "no ancestor .cargo/config.toml shadows the global config",
346        ));
347    }
348    out
349}
350
351/// Wrapper checks, merged across env + config:
352/// where it is configured (env wins over config files), whether it is
353/// discoverable (PATH or $CARGO_HOME/bin), and whether PATH is actually set
354/// (cargo spawns the wrapper by NAME at build time).
355fn check_rustc_wrapper() -> Vec<Check> {
356    let section: &'static str = SEC_WRAPPER;
357    let mut out = Vec::new();
358
359    for var in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
360        if let Some(value) = std::env::var_os(var) {
361            out.push(check(
362                section,
363                Level::Warn,
364                format!(
365                    "{var}={} — environment wins over config files",
366                    value.to_string_lossy()
367                ),
368            ));
369        }
370    }
371
372    let configured = crate::config::cargo_configured("rustc-wrapper");
373    match configured {
374        Err(e) => out.push(check(section, Level::Warn, format!("could not read the cargo config: {e:#}"))),
375        Ok(None) => out.push(check(section,
376            Level::Info,
377            "no rustc-wrapper configured — kache/sccache are optional layers (`epicenter setup --with-wrapper kache`)",
378        )),
379        Ok(Some(name)) => match crate::util::which_path(&name) {
380            None => out.push(check(section,
381                Level::Warn,
382                format!(
383                    "rustc-wrapper '{name}' is configured but not found in PATH or $CARGO_HOME/bin — \
384                     builds fail until it is installed or removed (`epicenter setup --without-wrapper`)"
385                ),
386            )),
387            Some(path) => {
388                out.push(check(section,
389                    Level::Pass,
390                    format!("rustc-wrapper '{name}' discovered: {}", path.display()),
391                ));
392                if !crate::util::on_path(&name) {
393                    out.push(check(section,
394                        Level::Warn,
395                        format!(
396                            "'{name}' lives in the cargo bin dir but NOT on PATH — cargo spawns \
397                             the wrapper by NAME at build time, so add the bin dir to PATH"
398                        ),
399                    ));
400                }
401            }
402        },
403    }
404    out
405}
406
407/// EXDEV class: cache on a different filesystem than this project's
408/// target/ — finals then always copy, never hardlink.
409fn check_exdev(root: &Path) -> Option<Check> {
410    let section: &'static str = SEC_FS;
411    let target = std::env::current_dir().ok()?.join("target");
412    if !target.is_dir() {
413        return Some(check(
414            section,
415            Level::Info,
416            "no ./target next to this invocation — filesystem-match check skipped",
417        ));
418    }
419    match (device_id(&target), device_id(root)) {
420        (Some(target_dev), Some(root_dev)) if target_dev != root_dev => Some(check(
421            section,
422            Level::Warn,
423            format!(
424                "cache filesystem differs from ./target/ ({} vs {}) — final artifacts are \
425                 copied per project, never hardlinked (expected when projects live on /mnt)",
426                root.display(),
427                target.display()
428            ),
429        )),
430        (Some(_), Some(_)) => Some(check(
431            section,
432            Level::Pass,
433            "cache and ./target/ share one filesystem — hardlink reuse is possible",
434        )),
435        _ => Some(check(
436            section,
437            Level::Info,
438            "filesystem identity not measurable on this platform",
439        )),
440    }
441}
442
443#[cfg(unix)]
444fn device_id(path: &Path) -> Option<u64> {
445    std::fs::metadata(path)
446        .ok()
447        .map(|meta| std::os::unix::fs::MetadataExt::dev(&meta))
448}
449
450#[cfg(not(unix))]
451fn device_id(path: &Path) -> Option<u64> {
452    // Windows std exposes no volume serial; the std-only proxy is the
453    // canonical path's volume prefix (`\\?\C:\…` → `C:`, `\\?\UNC\server\share…`
454    // → `\\server\share`) — enough to distinguish local drives, WSL mounts
455    // (`\\wsl$…`) and network shares. A true volume serial requires Win32
456    // FFI (`GetFileInformationByHandle`), which is deliberately avoided to
457    // keep the dependency tree std-only.
458    use std::collections::hash_map::DefaultHasher;
459    use std::hash::{Hash, Hasher};
460
461    let canonical = path.canonicalize().ok()?;
462    let text = canonical.to_string_lossy();
463    let key = strip_volume_prefix(&text)?;
464    // Hashing only needs determinism WITHIN one doctor run (two ids compared).
465    let mut hasher = DefaultHasher::new();
466    key.hash(&mut hasher);
467    Some(hasher.finish())
468}
469
470/// `\\?\C:\Users\…` → `C:`; `\\?\UNC\server\share\…` → `\\server\share`.
471#[cfg(not(unix))]
472fn strip_volume_prefix(canonical: &str) -> Option<String> {
473    let rest = canonical.strip_prefix(r"\\?\")?;
474    let mut chars = rest.chars();
475    let (first, second) = (chars.next()?, chars.next()?);
476    if second == ':' {
477        Some(rest[..2].to_string()) // drive letter
478    } else if rest.starts_with("UNC\\") {
479        let unc = &rest["UNC\\".len()..];
480        let mut segments = unc.split('\\');
481        let server = segments.next()?;
482        let share = segments.next()?;
483        Some(format!(r"\\{server}\{share}"))
484    } else {
485        None
486    }
487}
488
489/// rustc version + the upgrade-prune hint.
490fn check_rustc() -> Check {
491    let section: &'static str = SEC_TC;
492    let output = crate::util::command_for("rustc")
493        .and_then(|mut command| Ok(command.arg("--version").output()?));
494    match output {
495        Ok(out) if out.status.success() => {
496            let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
497            check(
498                section,
499                Level::Info,
500                format!(
501                    "{version} — after toolchain upgrades, run `epicenter prune` to drop old artifacts"
502                ),
503            )
504        }
505        _ => check(section, Level::Warn, "could not run `rustc --version`"),
506    }
507}
508
509/// Cache freshness relative to the installed toolchain, plus reclaimable
510/// older generations.
511///
512/// The `rustc` fingerprint field changes with every toolchain update, which
513/// re-keys every unit. Two distinct consequences, kept separate on purpose:
514/// - **staleness (fail)** when the installed toolchain is newer than the
515///   newest cached unit — the next build recompiles everything;
516/// - **dead weight (warning)** when older generations still occupy space —
517///   nothing is broken, they can be reclaimed deliberately.
518fn check_toolchain_generation(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
519    if rows.is_empty() {
520        return vec![check(
521            SEC_TC,
522            Level::Info,
523            "shared cache holds no fingerprint units yet",
524        )];
525    }
526
527    let newest_art = rows.iter().filter_map(|(_, arts)| arts.newest).max();
528    // The generation of the most recently built unit is the active one.
529    let active_gen = rows
530        .iter()
531        .filter(|(_, arts)| arts.newest == newest_art)
532        .find_map(|(unit, _)| unit.json.as_ref().map(|json| json.rustc.clone()))
533        .unwrap_or_else(|| "unknown".into());
534
535    let mut checks = Vec::new();
536
537    match (newest_art, toolchain_mtime()) {
538        (Some(newest), Some(toolchain)) if newest < toolchain => checks.push(check(
539            SEC_TC,
540            Level::Fail,
541            format!(
542                "installed toolchain is newer than the newest cached unit ({}): the cache is \
543                 stale — the next build recompiles it; outdated units can be reclaimed below",
544                human_age(newest)
545            ),
546        )),
547        (Some(newest), Some(_)) => checks.push(check(
548            SEC_TC,
549            Level::Pass,
550            format!(
551                "cache is aligned with the installed toolchain (newest unit {})",
552                human_age(newest)
553            ),
554        )),
555        _ => checks.push(check(
556            SEC_TC,
557            Level::Info,
558            "toolchain install time could not be read (`rustc --print sysroot` failed)",
559        )),
560    }
561
562    let stale: Vec<&(crate::fingerprint::Unit, crate::fingerprint::ArtifactInfo)> = rows
563        .iter()
564        .filter(|(unit, _)| who::rustc_of(unit) != active_gen)
565        .collect();
566    if !stale.is_empty() {
567        let stale_bytes: u64 = stale.iter().map(|(_, arts)| arts.bytes).sum();
568        checks.push(check(
569            SEC_TC,
570            Level::Warn,
571            format!(
572                "{} unit(s) on older toolchain generations ({}) — reclaim with `epicenter evict --stale`",
573                stale.len(),
574                human_bytes(stale_bytes)
575            ),
576        ));
577    }
578    checks
579}
580
581/// Mtime discipline across the unit graph: cargo re-runs a unit when a
582/// dependency's artifact is newer than the unit's own outputs ("the
583/// dependency `X` was rebuilt"). Restored views preserve the stored order,
584/// but a view that mixed restored and locally rebuilt units can break it —
585/// each violation costs exactly one recompile, so this warns.
586fn check_mtime_ordering(rows: &[(Unit, ArtifactInfo)]) -> Vec<Check> {
587    if rows.is_empty() {
588        return Vec::new();
589    }
590    let units: Vec<Unit> = rows.iter().map(|(unit, _)| unit.clone()).collect();
591    let edges = crate::fingerprint::dep_edges(&units);
592
593    let mut checked = 0usize;
594    let mut violations: Vec<(usize, usize)> = Vec::new();
595    for (dependent, deps) in edges.iter().enumerate() {
596        let own = rows[dependent].1.newest;
597        for &dep in deps {
598            checked += 1;
599            let (Some(own), Some(dep_newest)) = (own, rows[dep].1.newest) else {
600                continue;
601            };
602            if dep_newest > own {
603                violations.push((dependent, dep));
604            }
605        }
606    }
607
608    if violations.is_empty() {
609        return vec![check(
610            SEC_MTIME,
611            Level::Pass,
612            format!(
613                "every unit is at least as new as its dependencies ({checked} edge(s) checked)"
614            ),
615        )];
616    }
617
618    let mut checks = vec![check(
619        SEC_MTIME,
620        Level::Warn,
621        format!(
622            "{} unit(s) recompile once — a dependency is newer ({} edge(s) checked)",
623            violations.len(),
624            checked
625        ),
626    )];
627    for (dependent, dep) in violations.iter().take(3) {
628        let own = rows[*dependent].1.newest.expect("violation has timestamps");
629        let dep_newest = rows[*dep].1.newest.expect("violation has timestamps");
630        checks.push(check(
631            SEC_MTIME,
632            Level::Info,
633            format!(
634                "{} ← {} is {} newer (cargo: \"the dependency was rebuilt\")",
635                units[*dependent].dir_name,
636                units[*dep].dir_name,
637                human_duration(dep_newest.duration_since(own).unwrap_or_default())
638            ),
639        ));
640    }
641    if violations.len() > 3 {
642        checks.push(check(
643            SEC_MTIME,
644            Level::Info,
645            format!("… and {} more", violations.len() - 3),
646        ));
647    }
648    checks
649}
650
651/// Build concurrency: is fine-grain locking enabled, and is it even available?
652fn check_locking() -> Check {
653    let env_on = std::env::var("CARGO_UNSTABLE_FINE_GRAIN_LOCKING")
654        .map(|value| is_truthy(&value))
655        .unwrap_or(false);
656    let config_on = crate::config::cargo_configured_in("unstable", "fine-grain-locking")
657        .ok()
658        .flatten()
659        .map(|value| is_truthy(&value))
660        .unwrap_or(false);
661
662    if env_on || config_on {
663        return check(
664            SEC_TC,
665            Level::Pass,
666            "fine-grain locking enabled — concurrent builds on the shared build-dir overlap \
667             instead of serializing",
668        );
669    }
670    if toolchain_is_nightly() {
671        check(
672            SEC_TC,
673            Level::Warn,
674            "fine-grain locking is available but OFF — concurrent builds serialize on the \
675             shared build-dir; enable with `[unstable] fine-grain-locking = true` in \
676             ~/.cargo/config.toml",
677        )
678    } else {
679        check(
680            SEC_TC,
681            Level::Info,
682            "one file lock guards the shared build-dir: concurrent builds serialize; \
683             fine-grain locking (`-Z fine-grain-locking`) is nightly-only",
684        )
685    }
686}
687
688fn is_truthy(value: &str) -> bool {
689    matches!(
690        value.trim().to_ascii_lowercase().as_str(),
691        "1" | "true" | "yes" | "on"
692    )
693}
694
695/// Install time of the active toolchain (the real `rustc` binary, falling
696/// back to the sysroot directory). The vault owns toolchain identity; this
697/// is the same reference its harvest freshness gate uses.
698fn toolchain_mtime() -> Option<SystemTime> {
699    crate::vault::toolchain_install_time()
700}
701
702fn toolchain_is_nightly() -> bool {
703    crate::util::command_for("rustc")
704        .and_then(|mut command| Ok(command.arg("--version").output()?))
705        .ok()
706        .map(|out| String::from_utf8_lossy(&out.stdout).contains("nightly"))
707        .unwrap_or(false)
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use std::fs;
714    use std::time::Duration;
715
716    #[test]
717    fn truthiness_parsing() {
718        assert!(is_truthy("true"));
719        assert!(is_truthy("1"));
720        assert!(is_truthy(" YES "));
721        assert!(is_truthy("On"));
722        assert!(!is_truthy("false"));
723        assert!(!is_truthy("0"));
724        assert!(!is_truthy(""));
725        assert!(!is_truthy("nightly"));
726    }
727
728    #[test]
729    fn flags_older_toolchain_generations_for_reclaim() -> Result<()> {
730        // Two units, two rustc generations: the most recently built one is
731        // "active"; the other must surface as a reclaim warning.
732        let root = tempfile::tempdir()?;
733        let make_unit = |pkg: &str, hex: &str, rustc: &str, artifact: &str| -> Result<()> {
734            let dir = root
735                .path()
736                .join("debug/.fingerprint")
737                .join(format!("{pkg}-{hex}"));
738            fs::create_dir_all(&dir)?;
739            fs::write(dir.join(format!("lib-{pkg}")), "0011223344556677")?;
740            fs::write(
741                dir.join(format!("lib-{pkg}.json")),
742                format!(
743                    r#"{{"rustc":{rustc},"features":"[]","declared_features":"[]","deps":[]}}"#
744                ),
745            )?;
746            fs::create_dir_all(root.path().join("debug/deps"))?;
747            fs::write(root.path().join("debug/deps").join(artifact), [0u8; 8])?;
748            Ok(())
749        };
750        make_unit(
751            "oldcrate",
752            "aaaaaaaaaaaaaaaa",
753            "111",
754            "liboldcrate-aaaaaaaaaaaaaaaa.rlib",
755        )?;
756        std::thread::sleep(std::time::Duration::from_millis(20));
757        make_unit(
758            "newcrate",
759            "bbbbbbbbbbbbbbbb",
760            "222",
761            "libnewcrate-bbbbbbbbbbbbbbbb.rlib",
762        )?;
763
764        let rows = who::collect(root.path())?;
765        let checks = check_toolchain_generation(&rows);
766        let reclaim = checks
767            .iter()
768            .find(|c| c.message.contains("older toolchain generations"))
769            .expect("reclaim warning expected");
770        assert_eq!(reclaim.level as u8, Level::Warn as u8);
771        assert!(
772            reclaim.message.contains("1 unit(s)"),
773            "got: {}",
774            reclaim.message
775        );
776        Ok(())
777    }
778
779    /// Two units with a resolved dependency edge (the serde → serde_core
780    /// pair); artifacts carry explicit stamps so the
781    /// ordering is deterministic.
782    fn edge_build_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
783        let stamp = |path: &Path, secs: u64| -> Result<()> {
784            let time = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
785            fs::File::options()
786                .write(true)
787                .open(path)?
788                .set_modified(time)?;
789            Ok(())
790        };
791        fs::create_dir_all(root.join("debug/deps"))?;
792
793        let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
794        fs::create_dir_all(&core)?;
795        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
796        let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
797        fs::write(&core_rlib, [1u8; 32])?;
798        stamp(&core_rlib, dep_at)?;
799
800        let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
801        fs::create_dir_all(&unit)?;
802        fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
803        fs::write(
804            unit.join("lib-serde.json"),
805            r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
806        )?;
807        let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
808        fs::write(&unit_rlib, [1u8; 32])?;
809        stamp(&unit_rlib, unit_at)?;
810        Ok(())
811    }
812
813    #[test]
814    fn flags_units_older_than_their_dependencies() -> Result<()> {
815        let root = tempfile::tempdir()?;
816        edge_build_dir(root.path(), 1_700_000_600, 1_700_000_000)?;
817        let rows = who::collect(root.path())?;
818        let checks = check_mtime_ordering(&rows);
819        assert!(
820            matches!(checks[0].level, Level::Warn),
821            "{:?}",
822            checks[0].message
823        );
824        assert!(
825            checks[0].message.contains("recompile once"),
826            "got: {}",
827            checks[0].message
828        );
829        assert_eq!(checks.len(), 2, "one example follows the count");
830        assert!(checks[1].message.contains("serde-746d173581f1a314"));
831        assert!(checks[1].message.contains("serde_core-b468be6fa8c1f936"));
832        Ok(())
833    }
834
835    #[test]
836    fn ordering_passes_when_units_are_current() -> Result<()> {
837        let root = tempfile::tempdir()?;
838        edge_build_dir(root.path(), 1_700_000_000, 1_700_000_600)?;
839        let rows = who::collect(root.path())?;
840        let checks = check_mtime_ordering(&rows);
841        assert!(matches!(checks[0].level, Level::Pass));
842        assert!(
843            checks[0].message.contains("at least as new"),
844            "got: {}",
845            checks[0].message
846        );
847        assert_eq!(checks.len(), 1);
848        Ok(())
849    }
850
851    #[test]
852    fn staleness_requires_both_timestamps() {
853        let now = SystemTime::now();
854        let older = now - Duration::from_secs(60);
855        assert!(older < now, "sanity");
856        assert!(!(older >= now));
857        assert!(now > older);
858    }
859}