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(crate) fn remove_job_lock(work_root: &Path, job_id: &str) {
179 let dir = work_root.join(job_id);
180 let path = job_lock_path(work_root, job_id);
181 if dir.exists() || !path.exists() {
182 return;
183 }
184 let Ok(Some(_lock)) = crate::util::FileLock::try_acquire(&path) else {
185 return;
186 };
187 if !dir.exists() {
188 let _ = fs::remove_file(&path);
189 }
190}
191
192pub(crate) fn sweep_job_locks(work_root: &Path) {
195 let Ok(entries) = fs::read_dir(work_root) else {
196 return;
197 };
198 for entry in entries.flatten() {
199 let name = entry.file_name().to_string_lossy().into_owned();
200 let Some(job_id) = name.strip_prefix(".lock-") else {
201 continue;
202 };
203 if !job_id.is_empty() {
204 remove_job_lock(work_root, job_id);
205 }
206 }
207}
208
209pub struct Options<'a> {
211 pub job_id: Option<&'a str>,
213 pub cwd: Option<PathBuf>,
215 pub harvest_back: bool,
217 pub remove_on_success: bool,
220 pub reuse: bool,
223 pub target_dir: Option<&'a str>,
226 pub command: &'a [String],
228}
229
230fn resolve_target_dir(raw: &str, work_dir: &Path, cwd: Option<&Path>) -> PathBuf {
234 if raw == "job" {
235 return work_dir.join("target");
236 }
237 let path = PathBuf::from(raw);
238 if path.is_absolute() {
239 return path;
240 }
241 let base = cwd
242 .map(Path::to_path_buf)
243 .or_else(|| std::env::current_dir().ok())
244 .unwrap_or_default();
245 base.join(path)
246}
247
248fn project_dir(opts: &Options<'_>) -> Option<PathBuf> {
250 match &opts.cwd {
251 Some(dir) if dir.is_absolute() => Some(dir.clone()),
252 Some(dir) => std::env::current_dir().ok().map(|cwd| cwd.join(dir)),
253 None => std::env::current_dir().ok(),
254 }
255}
256
257fn child_cargo(command: &[String]) -> (Option<String>, Option<String>) {
260 let Some(program) = command.first() else {
261 return (None, None);
262 };
263 let is_cargo = Path::new(program)
264 .file_stem()
265 .and_then(|stem| stem.to_str())
266 .map(|stem| stem.eq_ignore_ascii_case("cargo"))
267 .unwrap_or(false);
268 if !is_cargo {
269 return (None, None);
270 }
271 let selector = command
273 .get(1)
274 .and_then(|arg| arg.strip_prefix('+'))
275 .filter(|value| !value.is_empty())
276 .map(str::to_string);
277 let mut probe = std::process::Command::new(program);
278 if let Some(selector) = &selector {
279 probe.arg(format!("+{selector}"));
280 }
281 let version = probe
282 .arg("--version")
283 .output()
284 .ok()
285 .filter(|output| output.status.success())
286 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
287 .filter(|text| text.starts_with("cargo "));
288 (selector, version)
289}
290
291fn project_packages(cwd: &Path, command: &[String]) -> Option<HashSet<String>> {
295 let mut dir = manifest_arg(command)
296 .map(|manifest| {
297 let manifest = if manifest.is_absolute() {
298 manifest
299 } else {
300 cwd.join(manifest)
301 };
302 manifest
303 .parent()
304 .map(Path::to_path_buf)
305 .unwrap_or_else(|| cwd.to_path_buf())
306 })
307 .unwrap_or_else(|| cwd.to_path_buf());
308 loop {
309 let lock = dir.join("Cargo.lock");
310 if lock.is_file() {
311 return parse_lock(&lock);
312 }
313 dir = dir.parent()?.to_path_buf();
314 }
315}
316
317fn manifest_arg(command: &[String]) -> Option<PathBuf> {
319 let mut args = command.iter();
320 while let Some(arg) = args.next() {
321 if let Some(value) = arg.strip_prefix("--manifest-path=") {
322 return Some(PathBuf::from(value));
323 }
324 if arg == "--manifest-path" {
325 return args.next().map(PathBuf::from);
326 }
327 }
328 None
329}
330
331fn parse_lock(path: &Path) -> Option<HashSet<String>> {
333 let lock: toml::Value = fs::read_to_string(path).ok()?.parse().ok()?;
334 let names: HashSet<String> = lock
335 .get("package")?
336 .as_array()?
337 .iter()
338 .filter_map(|package| package.get("name")?.as_str().map(str::to_owned))
339 .collect();
340 (!names.is_empty()).then_some(names)
341}
342
343pub fn run(store_root: &Path, work_root: &Path, opts: &Options<'_>) -> Result<i32> {
345 let Some(program) = opts.command.first() else {
346 bail!("exec needs a command — `epicenter exec -- <cmd>`");
347 };
348 let job_id = match opts.job_id {
349 Some(id) => sanitize_job_id(id)?,
350 None => default_job_id(),
351 };
352 crate::util::ensure_windows_user_path(work_root, "work root")?;
355 crate::util::create_private_dir_all(work_root)?;
356 let job_lock = match crate::util::FileLock::try_acquire(&job_lock_path(work_root, &job_id))? {
359 Some(lock) => lock,
360 None => bail!(
361 "job {} is in use by another epicenter process — wait for it, or use another --id",
362 job_id
363 ),
364 };
365 let work_dir = work_root.join(&job_id);
366 let mut reused = false;
367 let mut reuse_locks: Option<Vec<fs::File>> = None;
368 if fs::symlink_metadata(&work_dir).is_ok() {
369 if !opts.reuse {
370 bail!(
371 "job dir {} already exists — pick another --id, pass --reuse to continue in it, or remove it",
372 work_dir.display()
373 );
374 }
375 let meta = fs::symlink_metadata(&work_dir)
376 .with_context(|| format!("inspecting {}", work_dir.display()))?;
377 if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
378 bail!("{} exists and is not a real directory", work_dir.display());
379 }
380 let Some(locks) = cargo_locks(&work_dir)? else {
381 bail!(
382 "job dir {} is in use by a live build — wait for it, or use another --id",
383 work_dir.display()
384 );
385 };
386 reuse_locks = Some(locks);
390 reused = true;
391 } else {
392 crate::util::create_private_dir_all(&work_dir)?;
393 }
394 let target_dir = opts
395 .target_dir
396 .map(|raw| resolve_target_dir(raw, &work_dir, opts.cwd.as_deref()));
397
398 eprintln!(
399 "{}",
400 util::heading(&format!("epicenter exec — job {job_id}"))
401 );
402 eprintln!(
403 " {} {}{}",
404 util::dim(&format!("{:<11}", "work dir")),
405 work_dir.display(),
406 if reused {
407 util::dim(" (reused)")
408 } else {
409 String::new()
410 }
411 );
412
413 let packages = project_dir(opts).and_then(|cwd| project_packages(&cwd, opts.command));
420 let (selector, cargo_version) = child_cargo(opts.command);
424 let layout = cargo_version
425 .as_deref()
426 .map(|version| fingerprint::layout_for_cargo(version, opts.command));
427 let restore = vault::restore_with(
428 &work_dir,
429 store_root,
430 None,
431 packages.as_ref(),
432 layout,
433 selector.as_deref(),
434 false,
435 )?;
436 if restore.fatal_errors > 0 {
437 bail!(
438 "vault restore refused {} unverified file(s) — refusing to run the command over unverified state",
439 restore.fatal_errors
440 );
441 }
442 match &restore.note {
443 Some(_) if opts.harvest_back => eprintln!(
444 " {} cold start — the vault has nothing for this generation yet; --harvest will store what this job builds",
445 util::info(GLYPH_INFO)
446 ),
447 Some(_) => eprintln!(
448 " {} cold start — the vault has nothing for this generation yet; pass --harvest to seed it from this job",
449 util::info(GLYPH_INFO)
450 ),
451 None => eprintln!(
452 " {} {} unit(s) · {} file(s) · {}",
453 util::dim(&format!("{:<11}", "vault")),
454 restore.units_linked,
455 restore.files_linked + restore.files_copied,
456 util::green(&human_bytes(restore.bytes))
457 ),
458 }
459 eprintln!(
460 " {} CARGO_BUILD_BUILD_DIR (this job only; no config change)",
461 util::dim(&format!("{:<11}", "build-dir"))
462 );
463 if let (Some(layout), Some(version)) = (layout, cargo_version.as_deref()) {
464 eprintln!(
465 " {} {} · {}",
466 util::dim(&format!("{:<11}", "layout")),
467 layout.label(),
468 util::dim(version)
469 );
470 }
471 if let Some(target) = &target_dir {
472 eprintln!(
473 " {} {}{}",
474 util::dim(&format!("{:<11}", "target dir")),
475 target.display(),
476 util::dim(" (CARGO_TARGET_DIR — finals live and die with this job)")
477 );
478 }
479 drop(reuse_locks);
480 eprintln!("{}", util::dim(" ── command ──"));
481 let _ = std::io::stderr().flush();
482
483 let started = Instant::now();
484 let mut child = crate::util::command_for(program)?;
485 child.args(&opts.command[1..]);
486 child.env("CARGO_BUILD_BUILD_DIR", &work_dir);
487 if let Some(target) = &target_dir {
488 child.env("CARGO_TARGET_DIR", target);
489 }
490 if let Some(dir) = &opts.cwd {
491 child.current_dir(dir);
492 }
493 let status = child
494 .status()
495 .with_context(|| format!("cannot run `{program}` — is it installed and on PATH?"))?;
496 let elapsed = started.elapsed();
497 let code = exit_code(&status);
498
499 let harvested = if opts.harvest_back {
500 Some(vault::harvest_with(
501 &work_dir,
502 store_root,
503 None,
504 false,
505 selector.as_deref(),
506 layout,
507 )?)
508 } else {
509 None
510 };
511 let mut removed = false;
512 let mut remove_error = None;
513 if opts.remove_on_success {
514 match util::force_remove_dir(&work_dir) {
515 Ok(()) => removed = true,
516 Err(err) => remove_error = Some(err),
517 }
518 }
519
520 eprintln!("{}", util::dim(" ── end ──"));
521 let mark = if code == 0 {
522 util::green(GLYPH_PASS)
523 } else {
524 util::fail(GLYPH_FAIL)
525 };
526 eprintln!(" {mark} exit {code} · {}", human_duration(elapsed));
527 if let Some(report) = &harvested {
528 if let Some(note) = &report.note {
529 eprintln!(" {} {note}", util::warn(util::GLYPH_WARN));
531 } else {
532 let mut line = format!(
533 "{} newly stored · {} refreshed · {} already in the vault",
534 report.stored, report.refreshed, report.unchanged
535 );
536 let skipped = report.skipped_stale + report.skipped_incomplete;
537 if skipped > 0 {
538 line.push_str(&format!(" · {skipped} skipped"));
539 }
540 eprintln!(" {} {line}", util::dim(&format!("{:<11}", "harvest")));
541 }
542 }
543 if removed {
544 eprintln!(
545 " {} {} (--rm)",
546 util::dim(&format!("{:<11}", "work dir")),
547 util::dim("removed")
548 );
549 } else if let Some(err) = &remove_error {
550 eprintln!(
551 " {} {} — could not remove it: {err}",
552 util::dim(&format!("{:<11}", "work dir")),
553 work_dir.display()
554 );
555 } else {
556 eprintln!(
557 " {} {} {}",
558 util::dim(&format!("{:<11}", "work dir")),
559 work_dir.display(),
560 util::dim("(kept — pass --rm to remove)")
561 );
562 }
563 drop(job_lock);
566 if removed {
567 remove_job_lock(work_root, &job_id);
568 sweep_job_locks(work_root);
569 }
570 Ok(code)
571}
572
573fn exit_code(status: &ExitStatus) -> i32 {
575 if let Some(code) = status.code() {
576 return code;
577 }
578 #[cfg(unix)]
579 {
580 use std::os::unix::process::ExitStatusExt;
581 if let Some(signal) = status.signal() {
582 return 128 + signal;
583 }
584 }
585 1
586}
587
588fn default_job_id() -> String {
589 let secs = SystemTime::now()
590 .duration_since(UNIX_EPOCH)
591 .map(|duration| duration.as_secs())
592 .unwrap_or(0);
593 format!("job-{}-{}", stamp_from_unix(secs), std::process::id())
594}
595
596fn stamp_from_unix(secs: u64) -> String {
598 let days = secs / 86_400;
599 let rem = secs % 86_400;
600 let (year, month, day) = civil_from_days(days as i64);
601 format!(
602 "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
603 rem / 3_600,
604 (rem % 3_600) / 60,
605 rem % 60
606 )
607}
608
609fn civil_from_days(days: i64) -> (i64, u32, u32) {
612 let z = days + 719_468;
613 let era = z.div_euclid(146_097);
614 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 };
621 (year, month, day)
622}
623
624fn sanitize_job_id(id: &str) -> Result<String> {
626 let trimmed = id.trim();
627 if trimmed.is_empty() {
628 bail!("--id must not be empty");
629 }
630 if trimmed == "." || trimmed == ".." || trimmed.starts_with('.') {
631 bail!("--id {id:?} is not a usable directory name");
632 }
633 if !trimmed
634 .chars()
635 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
636 {
637 bail!("--id {id:?}: use letters, digits, `-`, `_` or `.`");
638 }
639 if trimmed.ends_with(['.', ' ']) {
640 bail!("--id {id:?} must not end with '.' or ' ' (Windows strips it)");
641 }
642 if crate::util::is_windows_reserved(trimmed) {
643 bail!("--id {id:?} is a reserved Windows device name");
644 }
645 Ok(trimmed.to_string())
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::fs;
652
653 #[test]
654 fn utc_stamp_is_correct_and_sortable() {
655 assert_eq!(stamp_from_unix(0), "19700101-000000");
656 assert_eq!(stamp_from_unix(86_399), "19700101-235959");
657 assert_eq!(stamp_from_unix(86_400), "19700102-000000");
658 assert_eq!(stamp_from_unix(1_800_000_000), "20270115-080000");
659 }
660
661 #[test]
662 fn job_ids_are_path_safe() {
663 assert_eq!(sanitize_job_id(" ci-1234_ok ").unwrap(), "ci-1234_ok");
664 assert!(sanitize_job_id("").is_err());
665 assert!(sanitize_job_id("..").is_err());
666 assert!(sanitize_job_id("../x").is_err());
667 assert!(sanitize_job_id("a/b").is_err());
668 assert!(sanitize_job_id(".hidden").is_err());
669 assert!(sanitize_job_id("has space").is_err());
670 assert!(sanitize_job_id("CON").is_err());
671 assert!(sanitize_job_id("com1").is_err());
672 assert!(sanitize_job_id("trailing.").is_err());
673 assert!(sanitize_job_id("CON.txt").is_err());
674 assert!(sanitize_job_id("NUL.log").is_err());
675 assert!(sanitize_job_id("COM1.txt").is_err());
676 }
677
678 fn seeded_store() -> Result<(tempfile::TempDir, tempfile::TempDir)> {
680 let build = tempfile::tempdir()?;
681 let store = tempfile::tempdir()?;
682 let unit = build.path().join("debug/.fingerprint/x-1122334455667788");
683 fs::create_dir_all(&unit)?;
684 fs::write(unit.join("lib-x"), "aaaabbbbccccdddd")?;
685 fs::write(unit.join("dep-lib-x"), [0u8; 4])?;
686 fs::create_dir_all(build.path().join("debug/deps"))?;
687 fs::write(
688 build.path().join("debug/deps/libx-1122334455667788.rlib"),
689 [1u8; 8],
690 )?;
691 vault::harvest(build.path(), store.path(), None, false)?;
692 Ok((build, store))
693 }
694
695 #[cfg(unix)]
696 #[test]
697 fn exec_seeds_the_job_and_propagates_the_exit_code() -> Result<()> {
698 let (_build, store) = seeded_store()?;
699 let work = tempfile::tempdir()?;
700 let project = tempfile::tempdir()?;
703 fs::write(
704 project.path().join("Cargo.lock"),
705 "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
706 )?;
707 let job_dir = work.path().join("jobtest");
708 let command = vec![
709 "sh".to_string(),
710 "-c".to_string(),
711 format!(
712 "test \"$CARGO_BUILD_BUILD_DIR\" = '{}' && exit 7",
713 job_dir.display()
714 ),
715 ];
716 let opts = Options {
717 job_id: Some("jobtest"),
718 cwd: Some(project.path().to_path_buf()),
719 harvest_back: false,
720 remove_on_success: false,
721 reuse: false,
722 target_dir: None,
723 command: &command,
724 };
725 let code = run(store.path(), work.path(), &opts)?;
726 assert_eq!(code, 7, "the command's exit code is the job's exit code");
727 assert!(
728 job_dir
729 .join("debug/.fingerprint/x-1122334455667788/lib-x")
730 .is_file(),
731 "the vault seeded the job"
732 );
733 Ok(())
734 }
735
736 #[test]
737 fn exec_seeds_only_the_packages_in_the_project_lock() -> Result<()> {
738 let (_build, store) = seeded_store()?;
739 let work = tempfile::tempdir()?;
740 let project = tempfile::tempdir()?;
741 let command: Vec<String> = vec!["true".into()];
742 let lock = project.path().join("Cargo.lock");
743
744 fs::write(
746 &lock,
747 "version = 4\n\n[[package]]\nname = \"unrelated\"\nversion = \"1.0.0\"\n",
748 )?;
749 let opts = Options {
750 job_id: Some("filtered"),
751 cwd: Some(project.path().to_path_buf()),
752 harvest_back: false,
753 remove_on_success: false,
754 reuse: false,
755 target_dir: None,
756 command: &command,
757 };
758 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
759 assert!(
760 !work
761 .path()
762 .join("filtered/debug/.fingerprint/x-1122334455667788")
763 .exists(),
764 "a unit outside the lock is not seeded"
765 );
766
767 fs::write(
769 &lock,
770 "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
771 )?;
772 let opts = Options {
773 job_id: Some("seeded"),
774 ..opts
775 };
776 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
777 assert!(
778 work.path()
779 .join("seeded/debug/.fingerprint/x-1122334455667788/lib-x")
780 .is_file(),
781 "a unit inside the lock is seeded"
782 );
783 Ok(())
784 }
785
786 #[cfg(unix)]
787 #[test]
788 fn exec_rm_removes_the_job_dir_either_way() -> Result<()> {
789 let (_build, store) = seeded_store()?;
790 let work = tempfile::tempdir()?;
791 fs::write(work.path().join(".lock-orphan"), b"")?;
793
794 let ok: Vec<String> = vec!["true".into()];
795 let opts = Options {
796 job_id: Some("j-ok"),
797 cwd: None,
798 harvest_back: false,
799 remove_on_success: true,
800 reuse: false,
801 target_dir: None,
802 command: &ok,
803 };
804 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
805 assert!(!work.path().join("j-ok").exists(), "success -> removed");
806 assert!(
807 !work.path().join(".lock-j-ok").exists(),
808 "success -> its job lock removed"
809 );
810 assert!(
811 !work.path().join(".lock-orphan").exists(),
812 "orphan lock debris swept"
813 );
814
815 let bad: Vec<String> = vec!["false".into()];
818 let opts = Options {
819 job_id: Some("j-bad"),
820 cwd: None,
821 harvest_back: false,
822 remove_on_success: true,
823 reuse: false,
824 target_dir: None,
825 command: &bad,
826 };
827 assert_eq!(run(store.path(), work.path(), &opts)?, 1);
828 assert!(
829 !work.path().join("j-bad").exists(),
830 "--rm -> removed on failure too"
831 );
832 assert!(
833 !work.path().join(".lock-j-bad").exists(),
834 "--rm -> its job lock removed on failure too"
835 );
836
837 let kept: Vec<String> = vec!["true".into()];
839 let opts = Options {
840 job_id: Some("j-keep"),
841 cwd: None,
842 harvest_back: false,
843 remove_on_success: false,
844 reuse: false,
845 target_dir: None,
846 command: &kept,
847 };
848 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
849 assert!(
850 work.path().join("j-keep").exists(),
851 "no --rm -> kept for inspection"
852 );
853 assert!(
854 work.path().join(".lock-j-keep").exists(),
855 "a kept job keeps its lock for the next --reuse"
856 );
857 Ok(())
858 }
859
860 #[test]
861 fn work_tier_summarizes_job_dirs() -> Result<()> {
862 let home = tempfile::tempdir()?;
863 let build = home.path().join("build");
864 fs::create_dir_all(&build)?;
865 let debug = home.path().join("work/ci-1/debug");
866 fs::create_dir_all(&debug)?;
867 fs::write(debug.join("a.bin"), [0u8; 100])?;
868 fs::write(debug.join("b.bin"), [0u8; 50])?;
869
870 let tier = work_tier(&build)?;
871 assert_eq!(tier.dirs, 1);
872 assert_eq!(tier.files, 2);
873 assert_eq!(tier.bytes, 150);
874 assert!(tier.newest.is_some());
875 assert_eq!(tier.entries.len(), 1);
876 assert_eq!(tier.entries[0].name, "ci-1");
877 assert_eq!(tier.entries[0].bytes, 150);
878 assert_eq!(tier.entries[0].files, 2);
879 Ok(())
880 }
881
882 #[test]
883 fn exec_refuses_a_used_job_dir() -> Result<()> {
884 let (_build, store) = seeded_store()?;
885 let work = tempfile::tempdir()?;
886 fs::create_dir_all(work.path().join("taken"))?;
887 let command: Vec<String> = vec!["true".into()];
888 let opts = Options {
889 job_id: Some("taken"),
890 cwd: None,
891 harvest_back: false,
892 remove_on_success: false,
893 reuse: false,
894 target_dir: None,
895 command: &command,
896 };
897 let err = run(store.path(), work.path(), &opts).unwrap_err();
898 assert!(err.to_string().contains("already exists"), "{err}");
899 assert!(err.to_string().contains("--reuse"), "mentions the way out");
900 Ok(())
901 }
902
903 #[cfg(unix)]
904 #[test]
905 fn target_dir_resolution() {
906 let work = Path::new("/cache/work/ci-1");
907 let cwd = Path::new("/project");
908 assert_eq!(
909 resolve_target_dir("job", work, Some(cwd)),
910 PathBuf::from("/cache/work/ci-1/target")
911 );
912 assert_eq!(
913 resolve_target_dir("/abs/finals", work, Some(cwd)),
914 PathBuf::from("/abs/finals")
915 );
916 assert_eq!(
917 resolve_target_dir("rel/finals", work, Some(cwd)),
918 PathBuf::from("/project/rel/finals")
919 );
920 }
921
922 #[cfg(unix)]
923 #[test]
924 fn exec_reuse_continues_in_an_existing_dir() -> Result<()> {
925 let (_build, store) = seeded_store()?;
926 let work = tempfile::tempdir()?;
927 let ok: Vec<String> = vec!["true".into()];
928 let fresh = Options {
929 job_id: Some("j1"),
930 cwd: None,
931 harvest_back: false,
932 remove_on_success: false,
933 reuse: false,
934 target_dir: None,
935 command: &ok,
936 };
937 assert_eq!(run(store.path(), work.path(), &fresh)?, 0);
938
939 let err = run(store.path(), work.path(), &fresh).unwrap_err();
940 assert!(err.to_string().contains("already exists"), "{err}");
941
942 let again = Options {
943 reuse: true,
944 ..fresh
945 };
946 assert_eq!(run(store.path(), work.path(), &again)?, 0);
947 Ok(())
948 }
949
950 #[test]
951 fn exec_reuse_refuses_a_locked_dir() -> Result<()> {
952 let (_build, store) = seeded_store()?;
953 let work = tempfile::tempdir()?;
954 let debug = work.path().join("busy/debug");
955 fs::create_dir_all(&debug)?;
956 let lock = debug.join(".cargo-build-lock");
957 fs::write(&lock, b"")?;
958 let held = fs::OpenOptions::new().write(true).open(&lock)?;
959 held.try_lock().context("test holds the lock")?;
960
961 let cmd: Vec<String> = vec!["true".into()];
962 let opts = Options {
963 job_id: Some("busy"),
964 cwd: None,
965 harvest_back: false,
966 remove_on_success: false,
967 reuse: true,
968 target_dir: None,
969 command: &cmd,
970 };
971 let err = run(store.path(), work.path(), &opts).unwrap_err();
972 assert!(err.to_string().contains("in use"), "{err}");
973 Ok(())
974 }
975
976 #[test]
977 fn nested_target_lock_is_detected() -> Result<()> {
978 let home = tempfile::tempdir()?;
979 let job = home.path().join("job/debug");
980 fs::create_dir_all(&job)?;
981 let lock = job.join(".cargo-build-lock");
982 fs::write(&lock, b"")?;
983 let held = fs::OpenOptions::new().write(true).open(&lock)?;
984 held.try_lock().context("test holds the lock")?;
985 assert!(dir_locked(&home.path().join("job")), "direct profile lock");
986
987 let nested = home.path().join("cross/x86_64-unknown-linux-gnu/debug");
988 fs::create_dir_all(&nested)?;
989 let lock = nested.join(".cargo-build-lock");
990 fs::write(&lock, b"")?;
991 let held = fs::OpenOptions::new().write(true).open(&lock)?;
992 held.try_lock().context("test holds the nested lock")?;
993 assert!(
994 dir_locked(&home.path().join("cross")),
995 "target-triple lock must be found"
996 );
997 Ok(())
998 }
999
1000 #[cfg(unix)]
1001 #[test]
1002 fn exec_exports_a_per_job_target_dir() -> Result<()> {
1003 let (_build, store) = seeded_store()?;
1004 let work = tempfile::tempdir()?;
1005 let job_dir = work.path().join("t1");
1006 let command = vec![
1007 "sh".to_string(),
1008 "-c".to_string(),
1009 format!(
1010 "test \"$CARGO_TARGET_DIR\" = '{}'",
1011 job_dir.join("target").display()
1012 ),
1013 ];
1014 let opts = Options {
1015 job_id: Some("t1"),
1016 cwd: None,
1017 harvest_back: false,
1018 remove_on_success: false,
1019 reuse: false,
1020 target_dir: Some("job"),
1021 command: &command,
1022 };
1023 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
1024 Ok(())
1025 }
1026}