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::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/// Severity of one check.
17#[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
36/// One doctor check result, tagged with its section for grouped printing.
37pub 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
58/// The full checklist.
59pub fn run() -> Result<Vec<Check>> {
60    let mut checks = Vec::new();
61
62    // Everything hangs on resolving the cache dir; if it fails, say so in
63    // check language instead of crashing the whole report.
64    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    // Shared cache dir exists.
76    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    // Template cleanliness + drift (global build-dir vs cargo's answer).
94    checks.push(check_build_dir(&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/// Ancestor scan: from the CWD upward, any `.cargo/config.toml` that sets a
241/// key DIFFERENTLY from the global one wins silently (deep config wins
242/// per-key) — how a Windows literal once bricked WSL builds.
243fn 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; // key absent there — nothing to shadow
261                };
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
301/// Wrapper checks, merged across env + config:
302/// where it is configured (env wins over config files), whether it is
303/// discoverable (PATH or $CARGO_HOME/bin), and whether PATH is actually set
304/// (cargo spawns the wrapper by NAME at build time).
305fn 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
357/// EXDEV class: cache on a different filesystem than this project's
358/// target/ — finals then always copy, never hardlink.
359fn 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    // Windows std exposes no volume serial; the std-only proxy is the
403    // canonical path's volume prefix (`\\?\C:\…` → `C:`, `\\?\UNC\server\share…`
404    // → `\\server\share`) — enough to distinguish local drives, WSL mounts
405    // (`\\wsl$…`) and network shares. A true volume serial requires Win32
406    // FFI (`GetFileInformationByHandle`), which is deliberately avoided to
407    // keep the dependency tree std-only.
408    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    // Hashing only needs determinism WITHIN one doctor run (two ids compared).
415    let mut hasher = DefaultHasher::new();
416    key.hash(&mut hasher);
417    Some(hasher.finish())
418}
419
420/// `\\?\C:\Users\…` → `C:`; `\\?\UNC\server\share\…` → `\\server\share`.
421#[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()) // drive letter
428    } 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
439/// rustc version + the upgrade-prune hint.
440fn 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
457/// Cache freshness relative to the installed toolchain, plus reclaimable
458/// older generations.
459///
460/// The `rustc` fingerprint field changes with every toolchain update, which
461/// re-keys every unit. Two distinct consequences, kept separate on purpose:
462/// - **staleness (fail)** when the installed toolchain is newer than the
463///   newest cached unit — the next build recompiles everything;
464/// - **dead weight (warning)** when older generations still occupy space —
465///   nothing is broken, they can be reclaimed deliberately.
466fn 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    // The generation of the most recently built unit is the active one.
477    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
529/// Mtime discipline across the unit graph: cargo re-runs a unit when a
530/// dependency's artifact is newer than the unit's own outputs ("the
531/// dependency `X` was rebuilt"). Restored views preserve the stored order,
532/// but a view that mixed restored and locally rebuilt units can break it —
533/// each violation costs exactly one recompile, so this warns.
534fn 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
597/// Build concurrency: is fine-grain locking enabled, and is it even available?
598fn 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
641/// Install time of the active toolchain (the real `rustc` binary, falling
642/// back to the sysroot directory). The vault owns toolchain identity; this
643/// is the same reference its harvest freshness gate uses.
644fn 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        // Two units, two rustc generations: the most recently built one is
677        // "active"; the other must surface as a reclaim warning.
678        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    /// Two units with a resolved dependency edge (the serde → serde_core
707    /// pair); artifacts carry explicit stamps so the
708    /// ordering is deterministic.
709    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}