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