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