1use 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
28pub fn work_root(build_root: &Path) -> PathBuf {
31 build_root.parent().unwrap_or(build_root).join("work")
32}
33
34#[derive(Debug, Clone)]
36pub struct WorkDirInfo {
37 pub name: String,
38 pub files: u64,
39 pub bytes: u64,
40 pub newest: Option<SystemTime>,
42}
43
44#[derive(Debug, Default)]
46pub struct WorkTier {
47 pub dirs: usize,
48 pub files: u64,
49 pub bytes: u64,
50 pub newest: Option<SystemTime>,
52 pub entries: Vec<WorkDirInfo>,
54}
55
56pub 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
104pub(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
124fn 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
142pub struct Options<'a> {
144 pub job_id: Option<&'a str>,
146 pub cwd: Option<PathBuf>,
148 pub harvest_back: bool,
150 pub remove_on_success: bool,
153 pub reuse: bool,
156 pub target_dir: Option<&'a str>,
159 pub command: &'a [String],
161}
162
163fn 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
181pub 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 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 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
343fn 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
363fn 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
386fn 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
399fn 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; 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 };
411 (year, month, day)
412}
413
414fn 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 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 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 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}