Skip to main content

epicenter_cli/
exec.rs

1//! `epicenter exec` — run one command against a fresh per-job build dir
2//! seeded from the vault.
3//!
4//! A job gets its own directory under `{cargo-cache-home}/epicenter/work/<id>/`
5//! that plays the role of Cargo's build dir for the child process
6//! (`CARGO_BUILD_BUILD_DIR` — no config file is written). The vault's unit
7//! groups are hard-linked in first, so the build starts warm; the units the
8//! job produces can be harvested back. Because every job owns its build dir,
9//! jobs never contend on Cargo's per-build-dir lock, and the job dir itself
10//! is a disposable view: deleting it removes no state the vault still holds.
11//!
12//! stdout and stderr of the command pass through untouched; epicenter's own
13//! frame lines go to stderr, and the command's exit code becomes epicenter's
14//! exit code.
15
16use std::collections::HashSet;
17use std::fs;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::process::ExitStatus;
21use std::time::{Instant, SystemTime, UNIX_EPOCH};
22
23use walkdir::WalkDir;
24
25use anyhow::{Context, Result, bail};
26
27use crate::fingerprint;
28use crate::util::{self, GLYPH_FAIL, GLYPH_INFO, GLYPH_PASS, human_bytes, human_duration};
29use crate::vault;
30
31/// `{cargo-cache-home}/epicenter/work` — the home of per-job working dirs,
32/// a sibling of `build/` and `store/`.
33pub fn work_root(build_root: &Path) -> PathBuf {
34    build_root.parent().unwrap_or(build_root).join("work")
35}
36
37/// One job dir as the read-only summary sees it.
38#[derive(Debug, Clone)]
39pub struct WorkDirInfo {
40    pub name: String,
41    pub files: u64,
42    pub bytes: u64,
43    /// Newest file mtime — the dir's last activity.
44    pub newest: Option<SystemTime>,
45}
46
47/// What the `work/` tier currently holds (read-only summary).
48#[derive(Debug, Default)]
49pub struct WorkTier {
50    pub dirs: usize,
51    pub files: u64,
52    pub bytes: u64,
53    /// Newest file mtime across all job dirs — the tier's last activity.
54    pub newest: Option<SystemTime>,
55    /// Per-dir detail, oldest activity first (the most collectible on top).
56    pub entries: Vec<WorkDirInfo>,
57}
58
59/// Summarize the job dirs under `work/`.
60pub fn work_tier(build_root: &Path) -> Result<WorkTier> {
61    let mut tier = WorkTier::default();
62    let Ok(entries) = std::fs::read_dir(work_root(build_root)) else {
63        return Ok(tier);
64    };
65    for entry in entries.flatten() {
66        if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
67            continue;
68        }
69        let name = entry.file_name().to_string_lossy().into_owned();
70        if name.starts_with('.') {
71            continue;
72        }
73        if std::fs::symlink_metadata(entry.path())
74            .map(|meta| util::is_reparse_point(&meta))
75            .unwrap_or(true)
76        {
77            continue;
78        }
79        let mut info = WorkDirInfo {
80            name,
81            files: 0,
82            bytes: 0,
83            newest: None,
84        };
85        for file in WalkDir::new(entry.path())
86            .min_depth(1)
87            .into_iter()
88            .flatten()
89        {
90            if !file.file_type().is_file() {
91                continue;
92            }
93            if let Ok(meta) = file.metadata() {
94                info.files += 1;
95                info.bytes += meta.len();
96                if let Ok(modified) = meta.modified() {
97                    if info.newest.map_or(true, |current| modified > current) {
98                        info.newest = Some(modified);
99                    }
100                }
101            }
102        }
103        tier.dirs += 1;
104        tier.files += info.files;
105        tier.bytes += info.bytes;
106        if let Some(modified) = info.newest {
107            if tier.newest.map_or(true, |current| modified > current) {
108                tier.newest = Some(modified);
109            }
110        }
111        tier.entries.push(info);
112    }
113    tier.entries.sort_by_key(|info| info.newest);
114    Ok(tier)
115}
116
117/// Does a live build hold this build dir? Cargo takes an exclusive advisory
118/// lock on `<build-dir>/<profile>/.cargo-build-lock` for the duration of a
119/// build; the lock file itself survives idle builds, so only lock contention
120/// means "in use". Shared by `exec` (reuse refusal) and `evict` (view guards).
121///
122/// `cargo_locks` returns the held descriptors so a caller that is about to
123/// delete/enter a view can keep Cargo out for the whole operation. A check
124/// that drops the locks immediately is only advisory and races a build that
125/// starts one instruction later.
126pub(crate) fn cargo_locks(dir: &Path) -> Result<Option<Vec<fs::File>>> {
127    // Cargo puts the lock at `<build>/<profile>/.cargo-build-lock` or, with
128    // `--target`, at `<build>/<triple>/<profile>/.cargo-build-lock`. Walk a
129    // bounded depth and take every lock, not just direct children.
130    let entries = WalkDir::new(dir)
131        .min_depth(1)
132        .max_depth(3)
133        .into_iter()
134        .collect::<Result<Vec<_>, _>>()?;
135    let mut locks = Vec::new();
136    for entry in entries {
137        if entry.file_name().to_string_lossy() != ".cargo-build-lock" {
138            continue;
139        }
140        // A symlink/non-regular lock is treated as held (fail closed).
141        if !entry.file_type().is_file() {
142            return Ok(None);
143        }
144        let file = fs::OpenOptions::new()
145            .read(true)
146            .write(true)
147            .open(entry.path())
148            .or_else(|_| fs::File::open(entry.path()));
149        let Ok(file) = file else {
150            return Ok(None);
151        };
152        if file.try_lock().is_err() {
153            return Ok(None);
154        }
155        locks.push(file);
156    }
157    Ok(Some(locks))
158}
159
160pub(crate) fn dir_locked(dir: &Path) -> bool {
161    match cargo_locks(dir) {
162        Ok(Some(locks)) => {
163            drop(locks);
164            false
165        }
166        _ => true,
167    }
168}
169
170/// Path of the per-job epicenter lock (outside the job dir, so `--rm` can
171/// delete the dir while the lock is held).
172pub(crate) fn job_lock_path(work_root: &Path, job_id: &str) -> PathBuf {
173    work_root.join(format!(".lock-{job_id}"))
174}
175
176/// What the caller wants.
177pub struct Options<'a> {
178    /// Job id / work-dir name; `None` mints one.
179    pub job_id: Option<&'a str>,
180    /// Directory the command runs in (default: the current directory).
181    pub cwd: Option<PathBuf>,
182    /// Store the units the job produced back into the vault.
183    pub harvest_back: bool,
184    /// Remove the work dir after the run — success or failure. Without it
185    /// the dir is kept for inspection.
186    pub remove_on_success: bool,
187    /// Continue in an existing job dir instead of refusing (restore is
188    /// idempotent; a dir a live build holds is still refused).
189    pub reuse: bool,
190    /// The child's `CARGO_TARGET_DIR`: `Some("job")` means `<work dir>/target`
191    /// (per-job finals — the fully parallel case); any other value is a path.
192    pub target_dir: Option<&'a str>,
193    /// The command, argv-style.
194    pub command: &'a [String],
195}
196
197/// Resolve `--target-dir`: the bare flag (`"job"`) picks `<work dir>/target`,
198/// so finals live and die with the job; a value is a path, resolved against
199/// the command's working directory when relative.
200fn resolve_target_dir(raw: &str, work_dir: &Path, cwd: Option<&Path>) -> PathBuf {
201    if raw == "job" {
202        return work_dir.join("target");
203    }
204    let path = PathBuf::from(raw);
205    if path.is_absolute() {
206        return path;
207    }
208    let base = cwd
209        .map(Path::to_path_buf)
210        .or_else(|| std::env::current_dir().ok())
211        .unwrap_or_default();
212    base.join(path)
213}
214
215/// The directory the child will run in, as an absolute path when possible.
216fn project_dir(opts: &Options<'_>) -> Option<PathBuf> {
217    match &opts.cwd {
218        Some(dir) if dir.is_absolute() => Some(dir.clone()),
219        Some(dir) => std::env::current_dir().ok().map(|cwd| cwd.join(dir)),
220        None => std::env::current_dir().ok(),
221    }
222}
223
224/// `+toolchain` and `cargo --version` of the child, when it is cargo.
225/// `(None, None)` for any other program (fail-open: the default generation).
226fn child_cargo(command: &[String]) -> (Option<String>, Option<String>) {
227    let Some(program) = command.first() else {
228        return (None, None);
229    };
230    let is_cargo = Path::new(program)
231        .file_stem()
232        .and_then(|stem| stem.to_str())
233        .map(|stem| stem.eq_ignore_ascii_case("cargo"))
234        .unwrap_or(false);
235    if !is_cargo {
236        return (None, None);
237    }
238    // rustup's `+toolchain` must be the first argument after the program.
239    let selector = command
240        .get(1)
241        .and_then(|arg| arg.strip_prefix('+'))
242        .filter(|value| !value.is_empty())
243        .map(str::to_string);
244    let mut probe = std::process::Command::new(program);
245    if let Some(selector) = &selector {
246        probe.arg(format!("+{selector}"));
247    }
248    let version = probe
249        .arg("--version")
250        .output()
251        .ok()
252        .filter(|output| output.status.success())
253        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
254        .filter(|text| text.starts_with("cargo "));
255    (selector, version)
256}
257
258/// Packages the project resolves to, from the nearest `Cargo.lock` (the
259/// workspace root). `None` means "cannot tell" — no lock yet, unreadable, or
260/// empty — and the caller keeps the whole-generation seed (fail-open).
261fn project_packages(cwd: &Path, command: &[String]) -> Option<HashSet<String>> {
262    let mut dir = manifest_arg(command)
263        .map(|manifest| {
264            let manifest = if manifest.is_absolute() {
265                manifest
266            } else {
267                cwd.join(manifest)
268            };
269            manifest
270                .parent()
271                .map(Path::to_path_buf)
272                .unwrap_or_else(|| cwd.to_path_buf())
273        })
274        .unwrap_or_else(|| cwd.to_path_buf());
275    loop {
276        let lock = dir.join("Cargo.lock");
277        if lock.is_file() {
278            return parse_lock(&lock);
279        }
280        dir = dir.parent()?.to_path_buf();
281    }
282}
283
284/// `--manifest-path <p>` or `--manifest-path=<p>` from the child's argv.
285fn manifest_arg(command: &[String]) -> Option<PathBuf> {
286    let mut args = command.iter();
287    while let Some(arg) = args.next() {
288        if let Some(value) = arg.strip_prefix("--manifest-path=") {
289            return Some(PathBuf::from(value));
290        }
291        if arg == "--manifest-path" {
292            return args.next().map(PathBuf::from);
293        }
294    }
295    None
296}
297
298/// Package names from a `Cargo.lock` (all formats: `[[package]] name = …`).
299fn parse_lock(path: &Path) -> Option<HashSet<String>> {
300    let lock: toml::Value = fs::read_to_string(path).ok()?.parse().ok()?;
301    let names: HashSet<String> = lock
302        .get("package")?
303        .as_array()?
304        .iter()
305        .filter_map(|package| package.get("name")?.as_str().map(str::to_owned))
306        .collect();
307    (!names.is_empty()).then_some(names)
308}
309
310/// Run the job; returns the command's exit code.
311pub fn run(store_root: &Path, work_root: &Path, opts: &Options<'_>) -> Result<i32> {
312    let Some(program) = opts.command.first() else {
313        bail!("exec needs a command — `epicenter exec -- <cmd>`");
314    };
315    let job_id = match opts.job_id {
316        Some(id) => sanitize_job_id(id)?,
317        None => default_job_id(),
318    };
319    // Private work root + per-job epicenter lock (outside the job dir, so
320    // --rm can remove the dir while the lock is held).
321    crate::util::ensure_windows_user_path(work_root, "work root")?;
322    crate::util::create_private_dir_all(work_root)?;
323    // Acquire the per-job lock BEFORE looking at/creating the dir: the bulk
324    // work-dir GC takes the same lock and must never see an unlocked empty dir.
325    let _job_lock = match crate::util::FileLock::try_acquire(&job_lock_path(work_root, &job_id))? {
326        Some(lock) => lock,
327        None => bail!(
328            "job {} is in use by another epicenter process — wait for it, or use another --id",
329            job_id
330        ),
331    };
332    let work_dir = work_root.join(&job_id);
333    let mut reused = false;
334    let mut reuse_locks: Option<Vec<fs::File>> = None;
335    if fs::symlink_metadata(&work_dir).is_ok() {
336        if !opts.reuse {
337            bail!(
338                "job dir {} already exists — pick another --id, pass --reuse to continue in it, or remove it",
339                work_dir.display()
340            );
341        }
342        let meta = fs::symlink_metadata(&work_dir)
343            .with_context(|| format!("inspecting {}", work_dir.display()))?;
344        if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
345            bail!("{} exists and is not a real directory", work_dir.display());
346        }
347        let Some(locks) = cargo_locks(&work_dir)? else {
348            bail!(
349                "job dir {} is in use by a live build — wait for it, or use another --id",
350                work_dir.display()
351            );
352        };
353        // Keep Cargo out while restoring into the reused view. The locks must
354        // be released before the child starts (otherwise its own Cargo would
355        // block), so this is a best-effort narrowing, not a full guarantee.
356        reuse_locks = Some(locks);
357        reused = true;
358    } else {
359        crate::util::create_private_dir_all(&work_dir)?;
360    }
361    let target_dir = opts
362        .target_dir
363        .map(|raw| resolve_target_dir(raw, &work_dir, opts.cwd.as_deref()));
364
365    eprintln!(
366        "{}",
367        util::heading(&format!("epicenter exec — job {job_id}"))
368    );
369    eprintln!(
370        "  {} {}{}",
371        util::dim(&format!("{:<11}", "work dir")),
372        work_dir.display(),
373        if reused {
374            util::dim("  (reused)")
375        } else {
376            String::new()
377        }
378    );
379
380    // An empty vault is a cold build (fail-open), but a corrupt/tampered
381    // vault is not: never run the command over unverified artifacts.
382    // The store is one global generation, so seed only the packages this
383    // project resolves to: restoring all of it puts every other project's
384    // units in the job dir (214 fingerprints for a one-dependency project).
385    // A missing or unreadable lock keeps the whole-generation seed.
386    let packages = project_dir(opts).and_then(|cwd| project_packages(&cwd, opts.command));
387    // The child may select another toolchain (`cargo +nightly`), and Cargo
388    // 1.99-nightly+ writes the new build-dir layout. Both decide which vault
389    // generation/layout can actually be reused.
390    let (selector, cargo_version) = child_cargo(opts.command);
391    let layout = cargo_version
392        .as_deref()
393        .map(|version| fingerprint::layout_for_cargo(version, opts.command));
394    let restore = vault::restore_with(
395        &work_dir,
396        store_root,
397        None,
398        packages.as_ref(),
399        layout,
400        selector.as_deref(),
401        false,
402    )?;
403    if restore.fatal_errors > 0 {
404        bail!(
405            "vault restore refused {} unverified file(s) — refusing to run the command over unverified state",
406            restore.fatal_errors
407        );
408    }
409    match &restore.note {
410        Some(_) if opts.harvest_back => eprintln!(
411            "  {} cold start — the vault has nothing for this generation yet; --harvest will store what this job builds",
412            util::info(GLYPH_INFO)
413        ),
414        Some(_) => eprintln!(
415            "  {} cold start — the vault has nothing for this generation yet; pass --harvest to seed it from this job",
416            util::info(GLYPH_INFO)
417        ),
418        None => eprintln!(
419            "  {} {} unit(s) · {} file(s) · {}",
420            util::dim(&format!("{:<11}", "vault")),
421            restore.units_linked,
422            restore.files_linked + restore.files_copied,
423            util::green(&human_bytes(restore.bytes))
424        ),
425    }
426    eprintln!(
427        "  {} CARGO_BUILD_BUILD_DIR (this job only; no config change)",
428        util::dim(&format!("{:<11}", "build-dir"))
429    );
430    if let (Some(layout), Some(version)) = (layout, cargo_version.as_deref()) {
431        eprintln!(
432            "  {} {} · {}",
433            util::dim(&format!("{:<11}", "layout")),
434            layout.label(),
435            util::dim(version)
436        );
437    }
438    if let Some(target) = &target_dir {
439        eprintln!(
440            "  {} {}{}",
441            util::dim(&format!("{:<11}", "target dir")),
442            target.display(),
443            util::dim("  (CARGO_TARGET_DIR — finals live and die with this job)")
444        );
445    }
446    drop(reuse_locks);
447    eprintln!("{}", util::dim("  ── command ──"));
448    let _ = std::io::stderr().flush();
449
450    let started = Instant::now();
451    let mut child = crate::util::command_for(program)?;
452    child.args(&opts.command[1..]);
453    child.env("CARGO_BUILD_BUILD_DIR", &work_dir);
454    if let Some(target) = &target_dir {
455        child.env("CARGO_TARGET_DIR", target);
456    }
457    if let Some(dir) = &opts.cwd {
458        child.current_dir(dir);
459    }
460    let status = child
461        .status()
462        .with_context(|| format!("cannot run `{program}` — is it installed and on PATH?"))?;
463    let elapsed = started.elapsed();
464    let code = exit_code(&status);
465
466    let harvested = if opts.harvest_back {
467        Some(vault::harvest_with(
468            &work_dir,
469            store_root,
470            None,
471            false,
472            selector.as_deref(),
473            layout,
474        )?)
475    } else {
476        None
477    };
478    let mut removed = false;
479    let mut remove_error = None;
480    if opts.remove_on_success {
481        match util::force_remove_dir(&work_dir) {
482            Ok(()) => removed = true,
483            Err(err) => remove_error = Some(err),
484        }
485    }
486
487    eprintln!("{}", util::dim("  ── end ──"));
488    let mark = if code == 0 {
489        util::green(GLYPH_PASS)
490    } else {
491        util::fail(GLYPH_FAIL)
492    };
493    eprintln!("  {mark} exit {code} · {}", human_duration(elapsed));
494    if let Some(report) = &harvested {
495        if let Some(note) = &report.note {
496            // Harvest refused wholesale (e.g. the job predates the toolchain).
497            eprintln!("  {} {note}", util::warn(util::GLYPH_WARN));
498        } else {
499            let mut line = format!(
500                "{} newly stored · {} refreshed · {} already in the vault",
501                report.stored, report.refreshed, report.unchanged
502            );
503            let skipped = report.skipped_stale + report.skipped_incomplete;
504            if skipped > 0 {
505                line.push_str(&format!(" · {skipped} skipped"));
506            }
507            eprintln!("  {} {line}", util::dim(&format!("{:<11}", "harvest")));
508        }
509    }
510    if removed {
511        eprintln!(
512            "  {} {} (--rm)",
513            util::dim(&format!("{:<11}", "work dir")),
514            util::dim("removed")
515        );
516    } else if let Some(err) = &remove_error {
517        eprintln!(
518            "  {} {} — could not remove it: {err}",
519            util::dim(&format!("{:<11}", "work dir")),
520            work_dir.display()
521        );
522    } else {
523        eprintln!(
524            "  {} {} {}",
525            util::dim(&format!("{:<11}", "work dir")),
526            work_dir.display(),
527            util::dim("(kept — pass --rm to remove)")
528        );
529    }
530    Ok(code)
531}
532
533/// Signals use the shell convention: 128 + signal number.
534fn exit_code(status: &ExitStatus) -> i32 {
535    if let Some(code) = status.code() {
536        return code;
537    }
538    #[cfg(unix)]
539    {
540        use std::os::unix::process::ExitStatusExt;
541        if let Some(signal) = status.signal() {
542            return 128 + signal;
543        }
544    }
545    1
546}
547
548fn default_job_id() -> String {
549    let secs = SystemTime::now()
550        .duration_since(UNIX_EPOCH)
551        .map(|duration| duration.as_secs())
552        .unwrap_or(0);
553    format!("job-{}-{}", stamp_from_unix(secs), std::process::id())
554}
555
556/// `YYYYMMDD-HHMMSS` in UTC — sortable, readable, filesystem-safe.
557fn stamp_from_unix(secs: u64) -> String {
558    let days = secs / 86_400;
559    let rem = secs % 86_400;
560    let (year, month, day) = civil_from_days(days as i64);
561    format!(
562        "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
563        rem / 3_600,
564        (rem % 3_600) / 60,
565        rem % 60
566    )
567}
568
569/// Days since the Unix epoch to a civil date — the standard shift-and-correct
570/// integer algorithm, no lookup tables.
571fn civil_from_days(days: i64) -> (i64, u32, u32) {
572    let z = days + 719_468;
573    let era = z.div_euclid(146_097);
574    let doe = z.rem_euclid(146_097) as u64; // [0, 146096]
575    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
576    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
577    let mp = (5 * doy + 2) / 153; // [0, 11]
578    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
579    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
580    let year = yoe as i64 + era * 400 + if month <= 2 { 1 } else { 0 };
581    (year, month, day)
582}
583
584/// Job ids become directory names: plain characters only.
585fn sanitize_job_id(id: &str) -> Result<String> {
586    let trimmed = id.trim();
587    if trimmed.is_empty() {
588        bail!("--id must not be empty");
589    }
590    if trimmed == "." || trimmed == ".." || trimmed.starts_with('.') {
591        bail!("--id {id:?} is not a usable directory name");
592    }
593    if !trimmed
594        .chars()
595        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
596    {
597        bail!("--id {id:?}: use letters, digits, `-`, `_` or `.`");
598    }
599    if trimmed.ends_with(['.', ' ']) {
600        bail!("--id {id:?} must not end with '.' or ' ' (Windows strips it)");
601    }
602    if crate::util::is_windows_reserved(trimmed) {
603        bail!("--id {id:?} is a reserved Windows device name");
604    }
605    Ok(trimmed.to_string())
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use std::fs;
612
613    #[test]
614    fn utc_stamp_is_correct_and_sortable() {
615        assert_eq!(stamp_from_unix(0), "19700101-000000");
616        assert_eq!(stamp_from_unix(86_399), "19700101-235959");
617        assert_eq!(stamp_from_unix(86_400), "19700102-000000");
618        assert_eq!(stamp_from_unix(1_800_000_000), "20270115-080000");
619    }
620
621    #[test]
622    fn job_ids_are_path_safe() {
623        assert_eq!(sanitize_job_id(" ci-1234_ok ").unwrap(), "ci-1234_ok");
624        assert!(sanitize_job_id("").is_err());
625        assert!(sanitize_job_id("..").is_err());
626        assert!(sanitize_job_id("../x").is_err());
627        assert!(sanitize_job_id("a/b").is_err());
628        assert!(sanitize_job_id(".hidden").is_err());
629        assert!(sanitize_job_id("has space").is_err());
630        assert!(sanitize_job_id("CON").is_err());
631        assert!(sanitize_job_id("com1").is_err());
632        assert!(sanitize_job_id("trailing.").is_err());
633        assert!(sanitize_job_id("CON.txt").is_err());
634        assert!(sanitize_job_id("NUL.log").is_err());
635        assert!(sanitize_job_id("COM1.txt").is_err());
636    }
637
638    /// One synthetic unit in a build dir, harvested into a temp vault.
639    fn seeded_store() -> Result<(tempfile::TempDir, tempfile::TempDir)> {
640        let build = tempfile::tempdir()?;
641        let store = tempfile::tempdir()?;
642        let unit = build.path().join("debug/.fingerprint/x-1122334455667788");
643        fs::create_dir_all(&unit)?;
644        fs::write(unit.join("lib-x"), "aaaabbbbccccdddd")?;
645        fs::write(unit.join("dep-lib-x"), [0u8; 4])?;
646        fs::create_dir_all(build.path().join("debug/deps"))?;
647        fs::write(
648            build.path().join("debug/deps/libx-1122334455667788.rlib"),
649            [1u8; 8],
650        )?;
651        vault::harvest(build.path(), store.path(), None, false)?;
652        Ok((build, store))
653    }
654
655    #[cfg(unix)]
656    #[test]
657    fn exec_seeds_the_job_and_propagates_the_exit_code() -> Result<()> {
658        let (_build, store) = seeded_store()?;
659        let work = tempfile::tempdir()?;
660        // The synthetic store unit is for package `x`: name it in a lock so
661        // this test does not depend on whatever Cargo.lock exists above /tmp.
662        let project = tempfile::tempdir()?;
663        fs::write(
664            project.path().join("Cargo.lock"),
665            "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
666        )?;
667        let job_dir = work.path().join("jobtest");
668        let command = vec![
669            "sh".to_string(),
670            "-c".to_string(),
671            format!(
672                "test \"$CARGO_BUILD_BUILD_DIR\" = '{}' && exit 7",
673                job_dir.display()
674            ),
675        ];
676        let opts = Options {
677            job_id: Some("jobtest"),
678            cwd: Some(project.path().to_path_buf()),
679            harvest_back: false,
680            remove_on_success: false,
681            reuse: false,
682            target_dir: None,
683            command: &command,
684        };
685        let code = run(store.path(), work.path(), &opts)?;
686        assert_eq!(code, 7, "the command's exit code is the job's exit code");
687        assert!(
688            job_dir
689                .join("debug/.fingerprint/x-1122334455667788/lib-x")
690                .is_file(),
691            "the vault seeded the job"
692        );
693        Ok(())
694    }
695
696    #[test]
697    fn exec_seeds_only_the_packages_in_the_project_lock() -> Result<()> {
698        let (_build, store) = seeded_store()?;
699        let work = tempfile::tempdir()?;
700        let project = tempfile::tempdir()?;
701        let command: Vec<String> = vec!["true".into()];
702        let lock = project.path().join("Cargo.lock");
703
704        // The project's lock does not name the stored unit: do not seed it.
705        fs::write(
706            &lock,
707            "version = 4\n\n[[package]]\nname = \"unrelated\"\nversion = \"1.0.0\"\n",
708        )?;
709        let opts = Options {
710            job_id: Some("filtered"),
711            cwd: Some(project.path().to_path_buf()),
712            harvest_back: false,
713            remove_on_success: false,
714            reuse: false,
715            target_dir: None,
716            command: &command,
717        };
718        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
719        assert!(
720            !work
721                .path()
722                .join("filtered/debug/.fingerprint/x-1122334455667788")
723                .exists(),
724            "a unit outside the lock is not seeded"
725        );
726
727        // Naming the package in the lock seeds exactly that unit.
728        fs::write(
729            &lock,
730            "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
731        )?;
732        let opts = Options {
733            job_id: Some("seeded"),
734            ..opts
735        };
736        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
737        assert!(
738            work.path()
739                .join("seeded/debug/.fingerprint/x-1122334455667788/lib-x")
740                .is_file(),
741            "a unit inside the lock is seeded"
742        );
743        Ok(())
744    }
745
746    #[cfg(unix)]
747    #[test]
748    fn exec_rm_removes_the_job_dir_either_way() -> Result<()> {
749        let (_build, store) = seeded_store()?;
750        let work = tempfile::tempdir()?;
751
752        let ok: Vec<String> = vec!["true".into()];
753        let opts = Options {
754            job_id: Some("j-ok"),
755            cwd: None,
756            harvest_back: false,
757            remove_on_success: true,
758            reuse: false,
759            target_dir: None,
760            command: &ok,
761        };
762        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
763        assert!(!work.path().join("j-ok").exists(), "success -> removed");
764
765        // `--rm` is explicit: a failing build is removed too (the fixed
766        // rmeta-era failure mode used to leave dirs piling up here).
767        let bad: Vec<String> = vec!["false".into()];
768        let opts = Options {
769            job_id: Some("j-bad"),
770            cwd: None,
771            harvest_back: false,
772            remove_on_success: true,
773            reuse: false,
774            target_dir: None,
775            command: &bad,
776        };
777        assert_eq!(run(store.path(), work.path(), &opts)?, 1);
778        assert!(
779            !work.path().join("j-bad").exists(),
780            "--rm -> removed on failure too"
781        );
782
783        // Without --rm the dir stays for inspection.
784        let kept: Vec<String> = vec!["true".into()];
785        let opts = Options {
786            job_id: Some("j-keep"),
787            cwd: None,
788            harvest_back: false,
789            remove_on_success: false,
790            reuse: false,
791            target_dir: None,
792            command: &kept,
793        };
794        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
795        assert!(
796            work.path().join("j-keep").exists(),
797            "no --rm -> kept for inspection"
798        );
799        Ok(())
800    }
801
802    #[test]
803    fn work_tier_summarizes_job_dirs() -> Result<()> {
804        let home = tempfile::tempdir()?;
805        let build = home.path().join("build");
806        fs::create_dir_all(&build)?;
807        let debug = home.path().join("work/ci-1/debug");
808        fs::create_dir_all(&debug)?;
809        fs::write(debug.join("a.bin"), [0u8; 100])?;
810        fs::write(debug.join("b.bin"), [0u8; 50])?;
811
812        let tier = work_tier(&build)?;
813        assert_eq!(tier.dirs, 1);
814        assert_eq!(tier.files, 2);
815        assert_eq!(tier.bytes, 150);
816        assert!(tier.newest.is_some());
817        assert_eq!(tier.entries.len(), 1);
818        assert_eq!(tier.entries[0].name, "ci-1");
819        assert_eq!(tier.entries[0].bytes, 150);
820        assert_eq!(tier.entries[0].files, 2);
821        Ok(())
822    }
823
824    #[test]
825    fn exec_refuses_a_used_job_dir() -> Result<()> {
826        let (_build, store) = seeded_store()?;
827        let work = tempfile::tempdir()?;
828        fs::create_dir_all(work.path().join("taken"))?;
829        let command: Vec<String> = vec!["true".into()];
830        let opts = Options {
831            job_id: Some("taken"),
832            cwd: None,
833            harvest_back: false,
834            remove_on_success: false,
835            reuse: false,
836            target_dir: None,
837            command: &command,
838        };
839        let err = run(store.path(), work.path(), &opts).unwrap_err();
840        assert!(err.to_string().contains("already exists"), "{err}");
841        assert!(err.to_string().contains("--reuse"), "mentions the way out");
842        Ok(())
843    }
844
845    #[cfg(unix)]
846    #[test]
847    fn target_dir_resolution() {
848        let work = Path::new("/cache/work/ci-1");
849        let cwd = Path::new("/project");
850        assert_eq!(
851            resolve_target_dir("job", work, Some(cwd)),
852            PathBuf::from("/cache/work/ci-1/target")
853        );
854        assert_eq!(
855            resolve_target_dir("/abs/finals", work, Some(cwd)),
856            PathBuf::from("/abs/finals")
857        );
858        assert_eq!(
859            resolve_target_dir("rel/finals", work, Some(cwd)),
860            PathBuf::from("/project/rel/finals")
861        );
862    }
863
864    #[cfg(unix)]
865    #[test]
866    fn exec_reuse_continues_in_an_existing_dir() -> Result<()> {
867        let (_build, store) = seeded_store()?;
868        let work = tempfile::tempdir()?;
869        let ok: Vec<String> = vec!["true".into()];
870        let fresh = Options {
871            job_id: Some("j1"),
872            cwd: None,
873            harvest_back: false,
874            remove_on_success: false,
875            reuse: false,
876            target_dir: None,
877            command: &ok,
878        };
879        assert_eq!(run(store.path(), work.path(), &fresh)?, 0);
880
881        let err = run(store.path(), work.path(), &fresh).unwrap_err();
882        assert!(err.to_string().contains("already exists"), "{err}");
883
884        let again = Options {
885            reuse: true,
886            ..fresh
887        };
888        assert_eq!(run(store.path(), work.path(), &again)?, 0);
889        Ok(())
890    }
891
892    #[test]
893    fn exec_reuse_refuses_a_locked_dir() -> Result<()> {
894        let (_build, store) = seeded_store()?;
895        let work = tempfile::tempdir()?;
896        let debug = work.path().join("busy/debug");
897        fs::create_dir_all(&debug)?;
898        let lock = debug.join(".cargo-build-lock");
899        fs::write(&lock, b"")?;
900        let held = fs::OpenOptions::new().write(true).open(&lock)?;
901        held.try_lock().context("test holds the lock")?;
902
903        let cmd: Vec<String> = vec!["true".into()];
904        let opts = Options {
905            job_id: Some("busy"),
906            cwd: None,
907            harvest_back: false,
908            remove_on_success: false,
909            reuse: true,
910            target_dir: None,
911            command: &cmd,
912        };
913        let err = run(store.path(), work.path(), &opts).unwrap_err();
914        assert!(err.to_string().contains("in use"), "{err}");
915        Ok(())
916    }
917
918    #[test]
919    fn nested_target_lock_is_detected() -> Result<()> {
920        let home = tempfile::tempdir()?;
921        let job = home.path().join("job/debug");
922        fs::create_dir_all(&job)?;
923        let lock = job.join(".cargo-build-lock");
924        fs::write(&lock, b"")?;
925        let held = fs::OpenOptions::new().write(true).open(&lock)?;
926        held.try_lock().context("test holds the lock")?;
927        assert!(dir_locked(&home.path().join("job")), "direct profile lock");
928
929        let nested = home.path().join("cross/x86_64-unknown-linux-gnu/debug");
930        fs::create_dir_all(&nested)?;
931        let lock = nested.join(".cargo-build-lock");
932        fs::write(&lock, b"")?;
933        let held = fs::OpenOptions::new().write(true).open(&lock)?;
934        held.try_lock().context("test holds the nested lock")?;
935        assert!(
936            dir_locked(&home.path().join("cross")),
937            "target-triple lock must be found"
938        );
939        Ok(())
940    }
941
942    #[cfg(unix)]
943    #[test]
944    fn exec_exports_a_per_job_target_dir() -> Result<()> {
945        let (_build, store) = seeded_store()?;
946        let work = tempfile::tempdir()?;
947        let job_dir = work.path().join("t1");
948        let command = vec![
949            "sh".to_string(),
950            "-c".to_string(),
951            format!(
952                "test \"$CARGO_TARGET_DIR\" = '{}'",
953                job_dir.join("target").display()
954            ),
955        ];
956        let opts = Options {
957            job_id: Some("t1"),
958            cwd: None,
959            harvest_back: false,
960            remove_on_success: false,
961            reuse: false,
962            target_dir: Some("job"),
963            command: &command,
964        };
965        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
966        Ok(())
967    }
968}