1use 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
31pub fn work_root(build_root: &Path) -> PathBuf {
34 build_root.parent().unwrap_or(build_root).join("work")
35}
36
37#[derive(Debug, Clone)]
39pub struct WorkDirInfo {
40 pub name: String,
41 pub files: u64,
42 pub bytes: u64,
43 pub newest: Option<SystemTime>,
45}
46
47#[derive(Debug, Default)]
49pub struct WorkTier {
50 pub dirs: usize,
51 pub files: u64,
52 pub bytes: u64,
53 pub newest: Option<SystemTime>,
55 pub entries: Vec<WorkDirInfo>,
57}
58
59pub 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
117pub(crate) fn cargo_locks(dir: &Path) -> Result<Option<Vec<fs::File>>> {
127 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 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
170pub(crate) fn job_lock_path(work_root: &Path, job_id: &str) -> PathBuf {
173 work_root.join(format!(".lock-{job_id}"))
174}
175
176pub struct Options<'a> {
178 pub job_id: Option<&'a str>,
180 pub cwd: Option<PathBuf>,
182 pub harvest_back: bool,
184 pub remove_on_success: bool,
187 pub reuse: bool,
190 pub target_dir: Option<&'a str>,
193 pub command: &'a [String],
195}
196
197fn 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
215fn 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
224fn 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 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
258fn 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
284fn 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
298fn 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
310pub 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 crate::util::ensure_windows_user_path(work_root, "work root")?;
322 crate::util::create_private_dir_all(work_root)?;
323 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 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 let packages = project_dir(opts).and_then(|cwd| project_packages(&cwd, opts.command));
387 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 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
533fn 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
556fn 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
569fn 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; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u32; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = yoe as i64 + era * 400 + if month <= 2 { 1 } else { 0 };
581 (year, month, day)
582}
583
584fn 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 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 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 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 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 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 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}