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::fs;
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::process::ExitStatus;
20use std::time::{Instant, SystemTime, UNIX_EPOCH};
21
22use walkdir::WalkDir;
23
24use anyhow::{Context, Result, bail};
25
26use crate::util::{self, GLYPH_FAIL, GLYPH_INFO, GLYPH_PASS, human_bytes, human_duration};
27use crate::vault;
28
29/// `{cargo-cache-home}/epicenter/work` — the home of per-job working dirs,
30/// a sibling of `build/` and `store/`.
31pub fn work_root(build_root: &Path) -> PathBuf {
32    build_root.parent().unwrap_or(build_root).join("work")
33}
34
35/// One job dir as the read-only summary sees it.
36#[derive(Debug, Clone)]
37pub struct WorkDirInfo {
38    pub name: String,
39    pub files: u64,
40    pub bytes: u64,
41    /// Newest file mtime — the dir's last activity.
42    pub newest: Option<SystemTime>,
43}
44
45/// What the `work/` tier currently holds (read-only summary).
46#[derive(Debug, Default)]
47pub struct WorkTier {
48    pub dirs: usize,
49    pub files: u64,
50    pub bytes: u64,
51    /// Newest file mtime across all job dirs — the tier's last activity.
52    pub newest: Option<SystemTime>,
53    /// Per-dir detail, oldest activity first (the most collectible on top).
54    pub entries: Vec<WorkDirInfo>,
55}
56
57/// Summarize the job dirs under `work/`.
58pub fn work_tier(build_root: &Path) -> Result<WorkTier> {
59    let mut tier = WorkTier::default();
60    let Ok(entries) = std::fs::read_dir(work_root(build_root)) else {
61        return Ok(tier);
62    };
63    for entry in entries.flatten() {
64        if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
65            continue;
66        }
67        let name = entry.file_name().to_string_lossy().into_owned();
68        if name.starts_with('.') {
69            continue;
70        }
71        let mut info = WorkDirInfo {
72            name,
73            files: 0,
74            bytes: 0,
75            newest: None,
76        };
77        for file in WalkDir::new(entry.path())
78            .min_depth(1)
79            .into_iter()
80            .flatten()
81        {
82            if !file.file_type().is_file() {
83                continue;
84            }
85            if let Ok(meta) = file.metadata() {
86                info.files += 1;
87                info.bytes += meta.len();
88                if let Ok(modified) = meta.modified() {
89                    if info.newest.map_or(true, |current| modified > current) {
90                        info.newest = Some(modified);
91                    }
92                }
93            }
94        }
95        tier.dirs += 1;
96        tier.files += info.files;
97        tier.bytes += info.bytes;
98        if let Some(modified) = info.newest {
99            if tier.newest.map_or(true, |current| modified > current) {
100                tier.newest = Some(modified);
101            }
102        }
103        tier.entries.push(info);
104    }
105    tier.entries.sort_by_key(|info| info.newest);
106    Ok(tier)
107}
108
109/// Does a live build hold this build dir? Cargo takes an exclusive advisory
110/// lock on `<build-dir>/<profile>/.cargo-build-lock` for the duration of a
111/// build; the lock file itself survives idle builds, so only lock contention
112/// means "in use". Shared by `exec` (reuse refusal) and `evict` (view guards).
113pub(crate) fn dir_locked(dir: &Path) -> bool {
114    // Cargo puts the lock at `<build>/<profile>/.cargo-build-lock` or, with
115    // `--target`, at `<build>/<triple>/<profile>/.cargo-build-lock`. Walk a
116    // bounded depth and test every lock, not just direct children.
117    let Ok(entries) = WalkDir::new(dir)
118        .min_depth(1)
119        .max_depth(3)
120        .into_iter()
121        .collect::<Result<Vec<_>, _>>()
122    else {
123        return true;
124    };
125    for entry in entries {
126        if entry.file_name().to_string_lossy() != ".cargo-build-lock" {
127            continue;
128        }
129        // A symlink/non-regular lock is treated as held (fail closed).
130        if !entry.file_type().is_file() || lock_held(entry.path()) {
131            return true;
132        }
133    }
134    false
135}
136
137/// Path of the per-job epicenter lock (outside the job dir, so `--rm` can
138/// delete the dir while the lock is held).
139pub(crate) fn job_lock_path(work_root: &Path, job_id: &str) -> PathBuf {
140    work_root.join(format!(".lock-{job_id}"))
141}
142
143/// A lock we cannot take is a lock somebody else holds.
144fn lock_held(lock: &Path) -> bool {
145    let file = std::fs::OpenOptions::new()
146        .write(true)
147        .open(lock)
148        .or_else(|_| std::fs::File::open(lock));
149    let Ok(file) = file else {
150        return true;
151    };
152    match file.try_lock() {
153        Ok(()) => {
154            let _ = file.unlock();
155            false
156        }
157        Err(_) => true,
158    }
159}
160
161/// What the caller wants.
162pub struct Options<'a> {
163    /// Job id / work-dir name; `None` mints one.
164    pub job_id: Option<&'a str>,
165    /// Directory the command runs in (default: the current directory).
166    pub cwd: Option<PathBuf>,
167    /// Store the units the job produced back into the vault.
168    pub harvest_back: bool,
169    /// Remove the work dir after the run — success or failure. Without it
170    /// the dir is kept for inspection.
171    pub remove_on_success: bool,
172    /// Continue in an existing job dir instead of refusing (restore is
173    /// idempotent; a dir a live build holds is still refused).
174    pub reuse: bool,
175    /// The child's `CARGO_TARGET_DIR`: `Some("job")` means `<work dir>/target`
176    /// (per-job finals — the fully parallel case); any other value is a path.
177    pub target_dir: Option<&'a str>,
178    /// The command, argv-style.
179    pub command: &'a [String],
180}
181
182/// Resolve `--target-dir`: the bare flag (`"job"`) picks `<work dir>/target`,
183/// so finals live and die with the job; a value is a path, resolved against
184/// the command's working directory when relative.
185fn resolve_target_dir(raw: &str, work_dir: &Path, cwd: Option<&Path>) -> PathBuf {
186    if raw == "job" {
187        return work_dir.join("target");
188    }
189    let path = PathBuf::from(raw);
190    if path.is_absolute() {
191        return path;
192    }
193    let base = cwd
194        .map(Path::to_path_buf)
195        .or_else(|| std::env::current_dir().ok())
196        .unwrap_or_default();
197    base.join(path)
198}
199
200/// Run the job; returns the command's exit code.
201pub fn run(store_root: &Path, work_root: &Path, opts: &Options<'_>) -> Result<i32> {
202    let Some(program) = opts.command.first() else {
203        bail!("exec needs a command — `epicenter exec -- <cmd>`");
204    };
205    let job_id = match opts.job_id {
206        Some(id) => sanitize_job_id(id)?,
207        None => default_job_id(),
208    };
209    // Private work root + per-job epicenter lock (outside the job dir, so
210    // --rm can remove the dir while the lock is held).
211    crate::util::create_private_dir_all(work_root)?;
212    // Acquire the per-job lock BEFORE looking at/creating the dir: the bulk
213    // work-dir GC takes the same lock and must never see an unlocked empty dir.
214    let _job_lock = match crate::util::FileLock::try_acquire(&job_lock_path(work_root, &job_id))? {
215        Some(lock) => lock,
216        None => bail!(
217            "job {} is in use by another epicenter process — wait for it, or use another --id",
218            job_id
219        ),
220    };
221    let work_dir = work_root.join(&job_id);
222    let mut reused = false;
223    if fs::symlink_metadata(&work_dir).is_ok() {
224        if !opts.reuse {
225            bail!(
226                "job dir {} already exists — pick another --id, pass --reuse to continue in it, or remove it",
227                work_dir.display()
228            );
229        }
230        let meta = fs::symlink_metadata(&work_dir)
231            .with_context(|| format!("inspecting {}", work_dir.display()))?;
232        if !meta.is_dir() || meta.file_type().is_symlink() {
233            bail!("{} exists and is not a real directory", work_dir.display());
234        }
235        if dir_locked(&work_dir) {
236            bail!(
237                "job dir {} is in use by a live build — wait for it, or use another --id",
238                work_dir.display()
239            );
240        }
241        reused = true;
242    } else {
243        crate::util::create_private_dir_all(&work_dir)?;
244    }
245    let target_dir = opts
246        .target_dir
247        .map(|raw| resolve_target_dir(raw, &work_dir, opts.cwd.as_deref()));
248
249    eprintln!(
250        "{}",
251        util::heading(&format!("epicenter exec — job {job_id}"))
252    );
253    eprintln!(
254        "  {} {}{}",
255        util::dim(&format!("{:<11}", "work dir")),
256        work_dir.display(),
257        if reused {
258            util::dim("  (reused)")
259        } else {
260            String::new()
261        }
262    );
263
264    // An empty vault is a cold build (fail-open), but a corrupt/tampered
265    // vault is not: never run the command over unverified artifacts.
266    let restore = vault::restore(&work_dir, store_root, None, false)?;
267    if restore.fatal_errors > 0 {
268        bail!(
269            "vault restore refused {} unverified file(s) — refusing to run the command over unverified state",
270            restore.fatal_errors
271        );
272    }
273    match &restore.note {
274        Some(_) if opts.harvest_back => eprintln!(
275            "  {} cold start — the vault has nothing for this generation yet; --harvest will store what this job builds",
276            util::info(GLYPH_INFO)
277        ),
278        Some(_) => eprintln!(
279            "  {} cold start — the vault has nothing for this generation yet; pass --harvest to seed it from this job",
280            util::info(GLYPH_INFO)
281        ),
282        None => eprintln!(
283            "  {} {} unit(s) · {} file(s) · {}",
284            util::dim(&format!("{:<11}", "vault")),
285            restore.units_linked,
286            restore.files_linked + restore.files_copied,
287            util::green(&human_bytes(restore.bytes))
288        ),
289    }
290    eprintln!(
291        "  {} CARGO_BUILD_BUILD_DIR (this job only; no config change)",
292        util::dim(&format!("{:<11}", "build-dir"))
293    );
294    if let Some(target) = &target_dir {
295        eprintln!(
296            "  {} {}{}",
297            util::dim(&format!("{:<11}", "target dir")),
298            target.display(),
299            util::dim("  (CARGO_TARGET_DIR — finals live and die with this job)")
300        );
301    }
302    eprintln!("{}", util::dim("  ── command ──"));
303    let _ = std::io::stderr().flush();
304
305    let started = Instant::now();
306    let mut child = crate::util::command_for(program)?;
307    child.args(&opts.command[1..]);
308    child.env("CARGO_BUILD_BUILD_DIR", &work_dir);
309    if let Some(target) = &target_dir {
310        child.env("CARGO_TARGET_DIR", target);
311    }
312    if let Some(dir) = &opts.cwd {
313        child.current_dir(dir);
314    }
315    let status = child
316        .status()
317        .with_context(|| format!("cannot run `{program}` — is it installed and on PATH?"))?;
318    let elapsed = started.elapsed();
319    let code = exit_code(&status);
320
321    let harvested = if opts.harvest_back {
322        Some(vault::harvest(&work_dir, store_root, None, false)?)
323    } else {
324        None
325    };
326    let mut removed = false;
327    let mut remove_error = None;
328    if opts.remove_on_success {
329        match util::force_remove_dir(&work_dir) {
330            Ok(()) => removed = true,
331            Err(err) => remove_error = Some(err),
332        }
333    }
334
335    eprintln!("{}", util::dim("  ── end ──"));
336    let mark = if code == 0 {
337        util::green(GLYPH_PASS)
338    } else {
339        util::fail(GLYPH_FAIL)
340    };
341    eprintln!("  {mark} exit {code} · {}", human_duration(elapsed));
342    if let Some(report) = &harvested {
343        if let Some(note) = &report.note {
344            // Harvest refused wholesale (e.g. the job predates the toolchain).
345            eprintln!("  {} {note}", util::warn(util::GLYPH_WARN));
346        } else {
347            let mut line = format!(
348                "{} newly stored · {} refreshed · {} already in the vault",
349                report.stored, report.refreshed, report.unchanged
350            );
351            let skipped = report.skipped_stale + report.skipped_incomplete;
352            if skipped > 0 {
353                line.push_str(&format!(" · {skipped} skipped"));
354            }
355            eprintln!("  {} {line}", util::dim(&format!("{:<11}", "harvest")));
356        }
357    }
358    if removed {
359        eprintln!(
360            "  {} {} (--rm)",
361            util::dim(&format!("{:<11}", "work dir")),
362            util::dim("removed")
363        );
364    } else if let Some(err) = &remove_error {
365        eprintln!(
366            "  {} {} — could not remove it: {err}",
367            util::dim(&format!("{:<11}", "work dir")),
368            work_dir.display()
369        );
370    } else {
371        eprintln!(
372            "  {} {} {}",
373            util::dim(&format!("{:<11}", "work dir")),
374            work_dir.display(),
375            util::dim("(kept — pass --rm to remove)")
376        );
377    }
378    Ok(code)
379}
380
381/// Signals use the shell convention: 128 + signal number.
382fn exit_code(status: &ExitStatus) -> i32 {
383    if let Some(code) = status.code() {
384        return code;
385    }
386    #[cfg(unix)]
387    {
388        use std::os::unix::process::ExitStatusExt;
389        if let Some(signal) = status.signal() {
390            return 128 + signal;
391        }
392    }
393    1
394}
395
396fn default_job_id() -> String {
397    let secs = SystemTime::now()
398        .duration_since(UNIX_EPOCH)
399        .map(|duration| duration.as_secs())
400        .unwrap_or(0);
401    format!("job-{}-{}", stamp_from_unix(secs), std::process::id())
402}
403
404/// `YYYYMMDD-HHMMSS` in UTC — sortable, readable, filesystem-safe.
405fn stamp_from_unix(secs: u64) -> String {
406    let days = secs / 86_400;
407    let rem = secs % 86_400;
408    let (year, month, day) = civil_from_days(days as i64);
409    format!(
410        "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
411        rem / 3_600,
412        (rem % 3_600) / 60,
413        rem % 60
414    )
415}
416
417/// Days since the Unix epoch to a civil date — the standard shift-and-correct
418/// integer algorithm, no lookup tables.
419fn civil_from_days(days: i64) -> (i64, u32, u32) {
420    let z = days + 719_468;
421    let era = z.div_euclid(146_097);
422    let doe = z.rem_euclid(146_097) as u64; // [0, 146096]
423    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
424    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
425    let mp = (5 * doy + 2) / 153; // [0, 11]
426    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
427    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
428    let year = yoe as i64 + era * 400 + if month <= 2 { 1 } else { 0 };
429    (year, month, day)
430}
431
432/// Job ids become directory names: plain characters only.
433fn sanitize_job_id(id: &str) -> Result<String> {
434    let trimmed = id.trim();
435    if trimmed.is_empty() {
436        bail!("--id must not be empty");
437    }
438    if trimmed == "." || trimmed == ".." || trimmed.starts_with('.') {
439        bail!("--id {id:?} is not a usable directory name");
440    }
441    if !trimmed
442        .chars()
443        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
444    {
445        bail!("--id {id:?}: use letters, digits, `-`, `_` or `.`");
446    }
447    if trimmed.ends_with('.') {
448        bail!("--id {id:?} must not end with '.' (Windows strips it)");
449    }
450    let upper = trimmed.to_ascii_uppercase();
451    let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
452        || (upper.len() == 4
453            && (upper.starts_with("COM") || upper.starts_with("LPT"))
454            && upper.as_bytes()[3].is_ascii_digit());
455    if reserved {
456        bail!("--id {id:?} is a reserved Windows device name");
457    }
458    Ok(trimmed.to_string())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::fs;
465
466    #[test]
467    fn utc_stamp_is_correct_and_sortable() {
468        assert_eq!(stamp_from_unix(0), "19700101-000000");
469        assert_eq!(stamp_from_unix(86_399), "19700101-235959");
470        assert_eq!(stamp_from_unix(86_400), "19700102-000000");
471        assert_eq!(stamp_from_unix(1_800_000_000), "20270115-080000");
472    }
473
474    #[test]
475    fn job_ids_are_path_safe() {
476        assert_eq!(sanitize_job_id(" ci-1234_ok ").unwrap(), "ci-1234_ok");
477        assert!(sanitize_job_id("").is_err());
478        assert!(sanitize_job_id("..").is_err());
479        assert!(sanitize_job_id("../x").is_err());
480        assert!(sanitize_job_id("a/b").is_err());
481        assert!(sanitize_job_id(".hidden").is_err());
482        assert!(sanitize_job_id("has space").is_err());
483        assert!(sanitize_job_id("CON").is_err());
484        assert!(sanitize_job_id("com1").is_err());
485        assert!(sanitize_job_id("trailing.").is_err());
486    }
487
488    /// One synthetic unit in a build dir, harvested into a temp vault.
489    fn seeded_store() -> Result<(tempfile::TempDir, tempfile::TempDir)> {
490        let build = tempfile::tempdir()?;
491        let store = tempfile::tempdir()?;
492        let unit = build.path().join("debug/.fingerprint/x-1122334455667788");
493        fs::create_dir_all(&unit)?;
494        fs::write(unit.join("lib-x"), "aaaabbbbccccdddd")?;
495        fs::write(unit.join("dep-lib-x"), [0u8; 4])?;
496        fs::create_dir_all(build.path().join("debug/deps"))?;
497        fs::write(
498            build.path().join("debug/deps/libx-1122334455667788.rlib"),
499            [1u8; 8],
500        )?;
501        vault::harvest(build.path(), store.path(), None, false)?;
502        Ok((build, store))
503    }
504
505    #[cfg(unix)]
506    #[test]
507    fn exec_seeds_the_job_and_propagates_the_exit_code() -> Result<()> {
508        let (_build, store) = seeded_store()?;
509        let work = tempfile::tempdir()?;
510        let job_dir = work.path().join("jobtest");
511        let command = vec![
512            "sh".to_string(),
513            "-c".to_string(),
514            format!(
515                "test \"$CARGO_BUILD_BUILD_DIR\" = '{}' && exit 7",
516                job_dir.display()
517            ),
518        ];
519        let opts = Options {
520            job_id: Some("jobtest"),
521            cwd: None,
522            harvest_back: false,
523            remove_on_success: false,
524            reuse: false,
525            target_dir: None,
526            command: &command,
527        };
528        let code = run(store.path(), work.path(), &opts)?;
529        assert_eq!(code, 7, "the command's exit code is the job's exit code");
530        assert!(
531            job_dir
532                .join("debug/.fingerprint/x-1122334455667788/lib-x")
533                .is_file(),
534            "the vault seeded the job"
535        );
536        Ok(())
537    }
538
539    #[cfg(unix)]
540    #[test]
541    fn exec_rm_removes_the_job_dir_either_way() -> Result<()> {
542        let (_build, store) = seeded_store()?;
543        let work = tempfile::tempdir()?;
544
545        let ok: Vec<String> = vec!["true".into()];
546        let opts = Options {
547            job_id: Some("j-ok"),
548            cwd: None,
549            harvest_back: false,
550            remove_on_success: true,
551            reuse: false,
552            target_dir: None,
553            command: &ok,
554        };
555        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
556        assert!(!work.path().join("j-ok").exists(), "success -> removed");
557
558        // `--rm` is explicit: a failing build is removed too (the fixed
559        // rmeta-era failure mode used to leave dirs piling up here).
560        let bad: Vec<String> = vec!["false".into()];
561        let opts = Options {
562            job_id: Some("j-bad"),
563            cwd: None,
564            harvest_back: false,
565            remove_on_success: true,
566            reuse: false,
567            target_dir: None,
568            command: &bad,
569        };
570        assert_eq!(run(store.path(), work.path(), &opts)?, 1);
571        assert!(
572            !work.path().join("j-bad").exists(),
573            "--rm -> removed on failure too"
574        );
575
576        // Without --rm the dir stays for inspection.
577        let kept: Vec<String> = vec!["true".into()];
578        let opts = Options {
579            job_id: Some("j-keep"),
580            cwd: None,
581            harvest_back: false,
582            remove_on_success: false,
583            reuse: false,
584            target_dir: None,
585            command: &kept,
586        };
587        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
588        assert!(
589            work.path().join("j-keep").exists(),
590            "no --rm -> kept for inspection"
591        );
592        Ok(())
593    }
594
595    #[test]
596    fn work_tier_summarizes_job_dirs() -> Result<()> {
597        let home = tempfile::tempdir()?;
598        let build = home.path().join("build");
599        fs::create_dir_all(&build)?;
600        let debug = home.path().join("work/ci-1/debug");
601        fs::create_dir_all(&debug)?;
602        fs::write(debug.join("a.bin"), [0u8; 100])?;
603        fs::write(debug.join("b.bin"), [0u8; 50])?;
604
605        let tier = work_tier(&build)?;
606        assert_eq!(tier.dirs, 1);
607        assert_eq!(tier.files, 2);
608        assert_eq!(tier.bytes, 150);
609        assert!(tier.newest.is_some());
610        assert_eq!(tier.entries.len(), 1);
611        assert_eq!(tier.entries[0].name, "ci-1");
612        assert_eq!(tier.entries[0].bytes, 150);
613        assert_eq!(tier.entries[0].files, 2);
614        Ok(())
615    }
616
617    #[test]
618    fn exec_refuses_a_used_job_dir() -> Result<()> {
619        let (_build, store) = seeded_store()?;
620        let work = tempfile::tempdir()?;
621        fs::create_dir_all(work.path().join("taken"))?;
622        let command: Vec<String> = vec!["true".into()];
623        let opts = Options {
624            job_id: Some("taken"),
625            cwd: None,
626            harvest_back: false,
627            remove_on_success: false,
628            reuse: false,
629            target_dir: None,
630            command: &command,
631        };
632        let err = run(store.path(), work.path(), &opts).unwrap_err();
633        assert!(err.to_string().contains("already exists"), "{err}");
634        assert!(err.to_string().contains("--reuse"), "mentions the way out");
635        Ok(())
636    }
637
638    #[test]
639    fn target_dir_resolution() {
640        let work = Path::new("/cache/work/ci-1");
641        let cwd = Path::new("/project");
642        assert_eq!(
643            resolve_target_dir("job", work, Some(cwd)),
644            PathBuf::from("/cache/work/ci-1/target")
645        );
646        assert_eq!(
647            resolve_target_dir("/abs/finals", work, Some(cwd)),
648            PathBuf::from("/abs/finals")
649        );
650        assert_eq!(
651            resolve_target_dir("rel/finals", work, Some(cwd)),
652            PathBuf::from("/project/rel/finals")
653        );
654    }
655
656    #[cfg(unix)]
657    #[test]
658    fn exec_reuse_continues_in_an_existing_dir() -> Result<()> {
659        let (_build, store) = seeded_store()?;
660        let work = tempfile::tempdir()?;
661        let ok: Vec<String> = vec!["true".into()];
662        let fresh = Options {
663            job_id: Some("j1"),
664            cwd: None,
665            harvest_back: false,
666            remove_on_success: false,
667            reuse: false,
668            target_dir: None,
669            command: &ok,
670        };
671        assert_eq!(run(store.path(), work.path(), &fresh)?, 0);
672
673        let err = run(store.path(), work.path(), &fresh).unwrap_err();
674        assert!(err.to_string().contains("already exists"), "{err}");
675
676        let again = Options {
677            reuse: true,
678            ..fresh
679        };
680        assert_eq!(run(store.path(), work.path(), &again)?, 0);
681        Ok(())
682    }
683
684    #[test]
685    fn exec_reuse_refuses_a_locked_dir() -> Result<()> {
686        let (_build, store) = seeded_store()?;
687        let work = tempfile::tempdir()?;
688        let debug = work.path().join("busy/debug");
689        fs::create_dir_all(&debug)?;
690        let lock = debug.join(".cargo-build-lock");
691        fs::write(&lock, b"")?;
692        let held = fs::OpenOptions::new().write(true).open(&lock)?;
693        held.try_lock().context("test holds the lock")?;
694
695        let cmd: Vec<String> = vec!["true".into()];
696        let opts = Options {
697            job_id: Some("busy"),
698            cwd: None,
699            harvest_back: false,
700            remove_on_success: false,
701            reuse: true,
702            target_dir: None,
703            command: &cmd,
704        };
705        let err = run(store.path(), work.path(), &opts).unwrap_err();
706        assert!(err.to_string().contains("in use"), "{err}");
707        Ok(())
708    }
709
710    #[test]
711    fn nested_target_lock_is_detected() -> Result<()> {
712        let home = tempfile::tempdir()?;
713        let job = home.path().join("job/debug");
714        fs::create_dir_all(&job)?;
715        let lock = job.join(".cargo-build-lock");
716        fs::write(&lock, b"")?;
717        let held = fs::OpenOptions::new().write(true).open(&lock)?;
718        held.try_lock().context("test holds the lock")?;
719        assert!(dir_locked(&home.path().join("job")), "direct profile lock");
720
721        let nested = home.path().join("cross/x86_64-unknown-linux-gnu/debug");
722        fs::create_dir_all(&nested)?;
723        let lock = nested.join(".cargo-build-lock");
724        fs::write(&lock, b"")?;
725        let held = fs::OpenOptions::new().write(true).open(&lock)?;
726        held.try_lock().context("test holds the nested lock")?;
727        assert!(
728            dir_locked(&home.path().join("cross")),
729            "target-triple lock must be found"
730        );
731        Ok(())
732    }
733
734    #[cfg(unix)]
735    #[test]
736    fn exec_exports_a_per_job_target_dir() -> Result<()> {
737        let (_build, store) = seeded_store()?;
738        let work = tempfile::tempdir()?;
739        let job_dir = work.path().join("t1");
740        let command = vec![
741            "sh".to_string(),
742            "-c".to_string(),
743            format!(
744                "test \"$CARGO_TARGET_DIR\" = '{}'",
745                job_dir.join("target").display()
746            ),
747        ];
748        let opts = Options {
749            job_id: Some("t1"),
750            cwd: None,
751            harvest_back: false,
752            remove_on_success: false,
753            reuse: false,
754            target_dir: Some("job"),
755            command: &command,
756        };
757        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
758        Ok(())
759    }
760}