1use std::path::{Path, PathBuf};
48use std::time::Duration;
49
50use anyhow::{Context as _, Result, bail};
51use jiff::Timestamp;
52use serde::{Deserialize, Serialize};
53
54use crate::proc::{self, Quiet as _};
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Owner {
59 pub run: String,
61 pub node: String,
63 pub seat: String,
66 pub pid: u32,
70 pub worktree: String,
73 pub head: String,
75}
76
77impl Owner {
78 #[must_use]
80 pub fn here(run: &str, node: &str, seat: &str, worktree: &Path, head: &str) -> Owner {
81 Owner {
82 run: run.to_owned(),
83 node: node.to_owned(),
84 seat: seat.to_owned(),
85 pid: std::process::id(),
86 worktree: worktree.display().to_string(),
87 head: head.to_owned(),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94struct LeaseFile {
95 cache_dir: String,
99 owner: Owner,
100 acquired_at: Timestamp,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Status {
106 Free,
108 Active(Owner),
110 Stale(Owner),
112 Unknown,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Busy {
120 Active(Owner),
122 Unknown,
124 Contended,
126}
127
128impl Busy {
129 #[must_use]
131 pub fn describe(&self) -> String {
132 match self {
133 Busy::Active(o) => format!(
134 "held by run {} node {} seat {} (pid {})",
135 o.run, o.node, o.seat, o.pid
136 ),
137 Busy::Unknown => {
138 "an unreadable lease is present; refusing to guess who holds it".to_owned()
139 }
140 Busy::Contended => "lost a race for the lease; retrying".to_owned(),
141 }
142 }
143}
144
145#[derive(Debug)]
149pub struct Guard {
150 path: PathBuf,
151 released: bool,
152}
153
154impl Guard {
155 fn new(path: PathBuf) -> Guard {
156 Guard {
157 path,
158 released: false,
159 }
160 }
161
162 pub fn release(mut self) {
166 self.do_release();
167 }
168
169 fn do_release(&mut self) {
170 if !self.released {
171 let _ = std::fs::remove_file(&self.path);
172 self.released = true;
173 }
174 }
175}
176
177impl Drop for Guard {
178 fn drop(&mut self) {
179 self.do_release();
180 }
181}
182
183fn leases_dir(home: &Path) -> PathBuf {
187 home.join("cache-leases")
188}
189
190fn slug(cache_dir: &Path) -> String {
195 let norm = normalize(cache_dir);
196 let mut readable: String = norm
197 .chars()
198 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
199 .collect();
200 readable.truncate(80);
201 use std::hash::{Hash, Hasher};
202 let mut hasher = std::collections::hash_map::DefaultHasher::new();
203 norm.hash(&mut hasher);
204 format!("{readable}-{:08x}", hasher.finish() as u32)
205}
206
207fn normalize(p: &Path) -> String {
213 std::fs::canonicalize(p)
214 .map(|p| p.display().to_string())
215 .unwrap_or_else(|_| p.display().to_string())
216 .replace('\\', "/")
217 .to_ascii_lowercase()
218}
219
220fn lease_path(home: &Path, cache_dir: &Path) -> PathBuf {
221 leases_dir(home).join(format!("{}.json", slug(cache_dir)))
222}
223
224fn identity_path(home: &Path, cache_dir: &Path) -> PathBuf {
225 leases_dir(home).join(format!("{}.identity.json", slug(cache_dir)))
226}
227
228fn catalog_path(home: &Path, cache_dir: &Path) -> PathBuf {
229 leases_dir(home).join(format!("{}.catalog.json", slug(cache_dir)))
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
249struct CatalogRecord {
250 cache_dir: String,
251 last_owner: Owner,
252 last_used_at: Timestamp,
253}
254
255fn record_catalog(home: &Path, cache_dir: &Path, owner: &Owner) {
261 let path = catalog_path(home, cache_dir);
262 let record = CatalogRecord {
263 cache_dir: cache_dir.display().to_string(),
264 last_owner: owner.clone(),
265 last_used_at: Timestamp::now(),
266 };
267 let Ok(body) = serde_json::to_string_pretty(&record) else {
268 return;
269 };
270 let tmp = path.with_extension("json.tmp");
271 if std::fs::write(&tmp, &body).is_ok() {
272 let _ = std::fs::rename(&tmp, &path);
273 }
274}
275
276fn read_catalog(path: &Path) -> Option<CatalogRecord> {
281 let body = std::fs::read_to_string(path).ok()?;
282 serde_json::from_str(&body).ok()
283}
284
285fn read_lease(path: &Path) -> Option<LeaseFile> {
288 let body = std::fs::read_to_string(path).ok()?;
289 serde_json::from_str(&body).ok()
290}
291
292fn peek_cache_dir(path: &Path) -> Option<String> {
297 let body = std::fs::read_to_string(path).ok()?;
298 let value: serde_json::Value = serde_json::from_str(&body).ok()?;
299 value
300 .get("cache_dir")
301 .and_then(|v| v.as_str())
302 .map(str::to_owned)
303}
304
305fn classify(path: &Path) -> Status {
307 classify_with(path, proc::pid_alive)
308}
309
310fn classify_with<F: Fn(u32) -> bool>(path: &Path, alive: F) -> Status {
319 if !path.exists() {
320 return Status::Free;
321 }
322 let Some(lease) = read_lease(path) else {
323 return Status::Unknown;
324 };
325 let this_process = std::process::id();
326 if lease.owner.pid == this_process || alive(lease.owner.pid) {
327 Status::Active(lease.owner)
328 } else {
329 Status::Stale(lease.owner)
330 }
331}
332
333fn write_new(path: &Path, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
338 use std::io::Write as _;
339 let mut f = std::fs::OpenOptions::new()
340 .write(true)
341 .create_new(true)
342 .open(path)?;
343 let lease = LeaseFile {
344 cache_dir: cache_dir.display().to_string(),
345 owner: owner.clone(),
346 acquired_at: Timestamp::now(),
347 };
348 let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
349 f.write_all(body.as_bytes())?;
350 Ok(())
351}
352
353pub enum AcquireOutcome {
355 Acquired(Guard),
357 Busy(Busy),
359}
360
361pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
365 try_acquire_with(home, cache_dir, owner, proc::pid_alive)
366}
367
368fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
371 home: &Path,
372 cache_dir: &Path,
373 owner: &Owner,
374 alive: F,
375) -> Result<AcquireOutcome> {
376 let dir = leases_dir(home);
377 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
378 let path = dir.join(format!("{}.json", slug(cache_dir)));
379
380 for _ in 0..2 {
386 match write_new(&path, cache_dir, owner) {
387 Ok(()) => {
388 record_catalog(home, cache_dir, owner);
389 return Ok(AcquireOutcome::Acquired(Guard::new(path)));
390 }
391 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
392 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
393 }
394 match classify_with(&path, alive) {
395 Status::Free => {} Status::Stale(_) => {
397 let _ = std::fs::remove_file(&path);
398 }
399 Status::Active(o) => return Ok(AcquireOutcome::Busy(Busy::Active(o))),
400 Status::Unknown => return Ok(AcquireOutcome::Busy(Busy::Unknown)),
401 }
402 }
403 Ok(AcquireOutcome::Busy(Busy::Contended))
404}
405
406#[must_use]
411pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
412 let path = lease_path(home, cache_dir);
413 matches!(classify(&path), Status::Active(_) | Status::Unknown)
414}
415
416pub async fn wait_for(
425 home: &Path,
426 cache_dir: &Path,
427 owner: &Owner,
428 budget: Duration,
429 poll: Duration,
430) -> Result<Guard> {
431 let start = std::time::Instant::now();
432 loop {
433 match try_acquire(home, cache_dir, owner)? {
434 AcquireOutcome::Acquired(g) => return Ok(g),
435 AcquireOutcome::Busy(busy) => {
436 let elapsed = start.elapsed();
437 if elapsed >= budget {
438 bail!(
439 "timed out after {}s waiting for the build cache at {} ({})",
440 budget.as_secs(),
441 cache_dir.display(),
442 busy.describe()
443 );
444 }
445 tokio::time::sleep(poll.min(budget - elapsed)).await;
446 }
447 }
448 }
449}
450
451#[derive(Debug, Clone)]
457pub struct Entry {
458 pub cache_dir: String,
460 pub status: EntryStatus,
462}
463
464#[derive(Debug, Clone)]
466pub enum EntryStatus {
467 Active(Owner),
469 Stale(Owner),
471 Unknown,
473 Idle(Owner),
479}
480
481#[must_use]
489pub fn inventory(home: &Path) -> Vec<Entry> {
490 inventory_with(home, proc::pid_alive)
491}
492
493fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
496 let dir = leases_dir(home);
497 let Ok(rd) = std::fs::read_dir(&dir) else {
498 return Vec::new();
499 };
500 let mut out = Vec::new();
501 for entry in rd.flatten() {
502 let path = entry.path();
503 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
504 continue;
505 };
506 if !name.ends_with(".json")
507 || name.ends_with(".identity.json")
508 || name.ends_with(".catalog.json")
509 {
510 continue;
511 }
512 let status = match classify_with(&path, alive) {
513 Status::Free => continue,
514 Status::Active(o) => EntryStatus::Active(o),
515 Status::Stale(o) => EntryStatus::Stale(o),
516 Status::Unknown => EntryStatus::Unknown,
517 };
518 let cache_dir = peek_cache_dir(&path)
524 .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
525 out.push(Entry { cache_dir, status });
526 }
527
528 if let Ok(rd) = std::fs::read_dir(&dir) {
534 for entry in rd.flatten() {
535 let path = entry.path();
536 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
537 continue;
538 };
539 let Some(stem) = name.strip_suffix(".catalog.json") else {
540 continue;
541 };
542 if dir.join(format!("{stem}.json")).exists() {
543 continue;
544 }
545 if let Some(record) = read_catalog(&path) {
546 out.push(Entry {
547 cache_dir: record.cache_dir,
548 status: EntryStatus::Idle(record.last_owner),
549 });
550 }
551 }
552 }
553
554 out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
555 out
556}
557
558pub fn maintenance_prune(
567 home: &Path,
568 cache_dir: &Path,
569 limit: u64,
570) -> Result<Option<crate::disk::Prune>> {
571 let owner = Owner {
572 run: "maintenance".to_owned(),
573 node: "prune".to_owned(),
574 seat: "janitor".to_owned(),
575 pid: std::process::id(),
576 worktree: String::new(),
577 head: String::new(),
578 };
579 match try_acquire(home, cache_dir, &owner)? {
580 AcquireOutcome::Busy(_) => Ok(None),
581 AcquireOutcome::Acquired(guard) => {
582 let result = crate::disk::prune_dir(cache_dir, limit)?;
583 guard.release();
584 Ok(Some(result))
585 }
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct Identity {
594 pub worktree: String,
596 pub head: String,
598}
599
600impl Identity {
601 #[must_use]
603 pub fn new(worktree: &Path, head: &str) -> Identity {
604 Identity {
605 worktree: worktree.display().to_string(),
606 head: head.to_owned(),
607 }
608 }
609}
610
611#[must_use]
616pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
617 let path = identity_path(home, cache_dir);
618 let Ok(body) = std::fs::read_to_string(path) else {
619 return true;
620 };
621 match serde_json::from_str::<Identity>(&body) {
622 Ok(recorded) => &recorded != current,
623 Err(_) => true,
624 }
625}
626
627pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
629 let path = identity_path(home, cache_dir);
630 if let Some(parent) = path.parent() {
631 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
632 }
633 let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
634 let tmp = path.with_extension("json.tmp");
635 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
636 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
637 Ok(())
638}
639
640pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
654 let _ = std::fs::remove_file(identity_path(home, cache_dir));
655}
656
657#[must_use]
663pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
664 let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
665 return Vec::new();
666 };
667 value
668 .get("packages")
669 .and_then(|p| p.as_array())
670 .map(|packages| {
671 packages
672 .iter()
673 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
674 .map(str::to_owned)
675 .collect()
676 })
677 .unwrap_or_default()
678}
679
680fn refresh_stale_packages(worktree: &Path, cache_dir: &Path) -> Result<Vec<String>> {
687 let meta = std::process::Command::new("cargo")
688 .args(["metadata", "--no-deps", "--format-version", "1"])
689 .current_dir(worktree)
690 .quiet()
691 .output()
692 .context("run `cargo metadata`")?;
693 if !meta.status.success() {
694 bail!(
695 "cargo metadata failed: {}",
696 String::from_utf8_lossy(&meta.stderr)
697 );
698 }
699 let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
700 let mut failed = Vec::new();
701 for name in &names {
702 let out = std::process::Command::new("cargo")
703 .arg("clean")
704 .arg("-p")
705 .arg(name)
706 .arg("--target-dir")
707 .arg(cache_dir)
708 .current_dir(worktree)
709 .quiet()
710 .output()
711 .with_context(|| format!("cargo clean -p {name}"))?;
712 if !out.status.success() {
713 failed.push(format!(
722 "{name}: {}",
723 String::from_utf8_lossy(&out.stderr).trim()
724 ));
725 }
726 }
727 if !failed.is_empty() {
728 bail!(
729 "cargo clean -p failed for {} package(s): {}",
730 failed.len(),
731 failed.join("; ")
732 );
733 }
734 Ok(names)
735}
736
737pub fn ensure_fresh(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
748 if needs_refresh(home, cache_dir, identity) {
749 let cleaned = refresh_stale_packages(&PathBuf::from(&identity.worktree), cache_dir)?;
750 tracing::info!(
751 ?cleaned,
752 cache = %cache_dir.display(),
753 "build cache: source identity changed; cleaned the workspace's own packages before reuse"
754 );
755 }
756 record_identity(home, cache_dir, identity)
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 fn owner(pid: u32) -> Owner {
764 Owner {
765 run: "r1".to_owned(),
766 node: "gate".to_owned(),
767 seat: "gate".to_owned(),
768 pid,
769 worktree: "/w".to_owned(),
770 head: "deadbeef".to_owned(),
771 }
772 }
773
774 #[test]
775 fn an_uncontended_lease_is_acquired_and_freed_on_release() {
776 let home = tempfile::TempDir::new().expect("temp");
777 let cache = home.path().join("cache");
778 let this = std::process::id();
779 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
780 AcquireOutcome::Acquired(g) => {
781 assert!(in_use(home.path(), &cache), "held while the guard lives");
782 g.release();
783 }
784 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
785 }
786 assert!(!in_use(home.path(), &cache), "freed after release");
787 }
788
789 #[test]
790 fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
791 let home = tempfile::TempDir::new().expect("temp");
792 let cache = home.path().join("cache");
793 let this = std::process::id();
794 let _first =
798 try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
799 let mut second_owner = owner(this);
800 second_owner.run = "r2".to_owned();
801 match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
802 AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
803 other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
804 }
805 assert!(in_use(home.path(), &cache));
806 }
807
808 impl std::fmt::Debug for AcquireOutcome {
809 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 match self {
811 AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
812 AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
813 }
814 }
815 }
816
817 #[test]
818 fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
819 let home = tempfile::TempDir::new().expect("temp");
820 let cache = home.path().join("cache");
821 let dead_owner = owner(999_999);
827 let path = lease_path(home.path(), &cache);
828 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
829 write_new(&path, &cache, &dead_owner).expect("seed a stale lease");
830 assert_eq!(
831 classify_with(&path, |_| false),
832 Status::Stale(dead_owner.clone())
833 );
834
835 match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
836 .expect("acquire")
837 {
838 AcquireOutcome::Acquired(_) => {}
839 other => panic!("stale lease should have been reclaimed: {other:?}"),
840 }
841 }
842
843 #[test]
844 fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
845 let home = tempfile::TempDir::new().expect("temp");
846 let cache = home.path().join("cache");
847 let path = lease_path(home.path(), &cache);
848 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
849 std::fs::write(&path, b"not json").unwrap();
850 assert_eq!(classify(&path), Status::Unknown);
851 assert!(in_use(home.path(), &cache), "unknown counts as in use");
852 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
853 AcquireOutcome::Busy(Busy::Unknown) => {}
854 other => panic!("expected Busy::Unknown, got {other:?}"),
855 }
856 }
857
858 #[tokio::test]
859 async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
860 let home = tempfile::TempDir::new().expect("temp");
861 let cache = home.path().join("cache");
862 let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
863 .expect("acquire")
864 .pipe();
865 let mut waiter = owner(std::process::id());
866 waiter.run = "r2".to_owned();
867 let started = std::time::Instant::now();
868 let err = wait_for(
869 home.path(),
870 &cache,
871 &waiter,
872 Duration::from_millis(150),
873 Duration::from_millis(20),
874 )
875 .await
876 .expect_err("still held, must time out");
877 assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
878 assert!(
879 err.to_string().contains("r1"),
880 "names the current holder: {err}"
881 );
882 }
883
884 #[tokio::test]
885 async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
886 let home = tempfile::TempDir::new().expect("temp");
887 let cache = home.path().join("cache");
888 let guard =
889 match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
890 AcquireOutcome::Acquired(g) => g,
891 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
892 };
893 let home_path = home.path().to_path_buf();
894 let cache_path = cache.clone();
895 let mut waiter = owner(std::process::id());
896 waiter.run = "r2".to_owned();
897 let wait = tokio::spawn(async move {
898 wait_for(
899 &home_path,
900 &cache_path,
901 &waiter,
902 Duration::from_secs(5),
903 Duration::from_millis(10),
904 )
905 .await
906 });
907 tokio::time::sleep(Duration::from_millis(50)).await;
908 guard.release();
909 let acquired = wait.await.expect("task").expect("acquire after release");
910 acquired.release();
911 }
912
913 #[test]
914 fn inventory_reports_active_stale_and_unknown_but_not_free() {
915 let home = tempfile::TempDir::new().expect("temp");
916 let active_cache = home.path().join("active");
917 let stale_cache = home.path().join("stale");
918 let unknown_cache = home.path().join("unknown");
919
920 let _held =
921 try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
922 let stale_path = lease_path(home.path(), &stale_cache);
923 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
924 write_new(&stale_path, &stale_cache, &owner(999_999)).unwrap();
925 let unknown_path = lease_path(home.path(), &unknown_cache);
926 std::fs::write(&unknown_path, b"garbage").unwrap();
927
928 let entries = inventory_with(home.path(), |pid| pid == std::process::id());
933 assert_eq!(entries.len(), 3, "{entries:?}");
934 let by_dir = |dir: &Path| {
935 entries
936 .iter()
937 .find(|e| e.cache_dir == dir.display().to_string())
938 .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
939 };
940 assert!(matches!(
941 by_dir(&active_cache).status,
942 EntryStatus::Active(_)
943 ));
944 assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
945 let unknown = entries
950 .iter()
951 .find(|e| matches!(e.status, EntryStatus::Unknown))
952 .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
953 assert!(
954 unknown
955 .cache_dir
956 .contains(&unknown_path.display().to_string()),
957 "{unknown:?}"
958 );
959 }
960
961 #[test]
962 fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
963 let home = tempfile::TempDir::new().expect("temp");
964 let cache = home.path().join("cache");
965 let this = std::process::id();
966
967 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
968 AcquireOutcome::Acquired(g) => g.release(),
969 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
970 }
971
972 assert!(!in_use(home.path(), &cache));
977 let entries = inventory_with(home.path(), |pid| pid == this);
978 let entry = entries
979 .iter()
980 .find(|e| e.cache_dir == cache.display().to_string())
981 .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
982 match &entry.status {
983 EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
984 other => panic!("expected Idle, got {other:?}"),
985 }
986 }
987
988 #[test]
989 fn reacquiring_a_released_cache_reports_active_not_idle() {
990 let home = tempfile::TempDir::new().expect("temp");
991 let cache = home.path().join("cache");
992 let this = std::process::id();
993 match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
994 AcquireOutcome::Acquired(g) => g.release(),
995 AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
996 }
997 let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
998 let entries = inventory_with(home.path(), |pid| pid == this);
999 assert_eq!(
1000 entries.len(),
1001 1,
1002 "the catalog row must not duplicate the live lease: {entries:?}"
1003 );
1004 assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1005 }
1006
1007 #[test]
1008 fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1009 let home = tempfile::TempDir::new().expect("temp");
1010 let cache = home.path().join("cache");
1011 std::fs::create_dir_all(&cache).unwrap();
1012 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1013 let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1014
1015 let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1016 assert!(
1017 result.is_none(),
1018 "must not prune while a live owner holds it"
1019 );
1020 assert!(cache.join("big").exists(), "nothing was deleted");
1021 }
1022
1023 #[test]
1024 fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1025 let home = tempfile::TempDir::new().expect("temp");
1026 let cache = home.path().join("cache");
1027 std::fs::create_dir_all(&cache).unwrap();
1028 std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1029
1030 let pruned = maintenance_prune(home.path(), &cache, 1)
1031 .expect("no io error")
1032 .expect("cache was free");
1033 assert!(pruned.freed > 0);
1034 assert!(
1035 !in_use(home.path(), &cache),
1036 "the maintenance lease was released"
1037 );
1038 }
1039
1040 #[test]
1041 fn identity_drift_is_detected_once_and_then_settles() {
1042 let home = tempfile::TempDir::new().expect("temp");
1043 let cache = home.path().join("cache");
1044 let a = Identity {
1045 worktree: "/w/a".to_owned(),
1046 head: "aaaa".to_owned(),
1047 };
1048 let b = Identity {
1049 worktree: "/w/b".to_owned(),
1050 head: "bbbb".to_owned(),
1051 };
1052 assert!(
1053 needs_refresh(home.path(), &cache, &a),
1054 "nothing recorded yet"
1055 );
1056 record_identity(home.path(), &cache, &a).expect("record");
1057 assert!(
1058 !needs_refresh(home.path(), &cache, &a),
1059 "same identity, no refresh needed"
1060 );
1061 assert!(needs_refresh(home.path(), &cache, &b), "different source");
1062 record_identity(home.path(), &cache, &b).expect("record");
1063 assert!(!needs_refresh(home.path(), &cache, &b));
1064 }
1065
1066 #[test]
1067 fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1068 let home = tempfile::TempDir::new().expect("temp");
1069 let cache = home.path().join("cache");
1070 let a = Identity {
1071 worktree: "/w/a".to_owned(),
1072 head: "aaaa".to_owned(),
1073 };
1074 record_identity(home.path(), &cache, &a).expect("record");
1075 assert!(!needs_refresh(home.path(), &cache, &a));
1076
1077 invalidate_identity(home.path(), &cache);
1082 assert!(
1083 needs_refresh(home.path(), &cache, &a),
1084 "invalidation must not be skippable by asking about the same identity again"
1085 );
1086
1087 invalidate_identity(home.path(), &home.path().join("never-recorded"));
1090 }
1091
1092 #[test]
1093 fn workspace_package_names_are_read_from_cargo_metadata_json() {
1094 let fixture = r#"{
1095 "packages": [
1096 {"name": "magi", "version": "0.1.0"},
1097 {"name": "magi-cli", "version": "0.1.0"}
1098 ],
1099 "workspace_members": []
1100 }"#;
1101 let mut names = parse_workspace_package_names(fixture);
1102 names.sort();
1103 assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1104 assert_eq!(
1105 parse_workspace_package_names("not json"),
1106 Vec::<String>::new()
1107 );
1108 assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1109 }
1110
1111 #[test]
1112 fn slugs_are_stable_and_filesystem_safe() {
1113 let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1114 let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1115 assert_eq!(a, b, "same input, same slug");
1116 assert!(
1117 a.chars()
1118 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1119 "filesystem-safe: {a}"
1120 );
1121 }
1122
1123 #[test]
1124 fn busy_active_describes_the_holder() {
1125 let b = Busy::Active(owner(123));
1126 let s = b.describe();
1127 assert!(
1128 s.contains("r1") && s.contains("gate") && s.contains("123"),
1129 "{s}"
1130 );
1131 }
1132
1133 trait Pipe: Sized {
1134 fn pipe(self) -> Guard;
1135 }
1136 impl Pipe for AcquireOutcome {
1137 fn pipe(self) -> Guard {
1138 match self {
1139 AcquireOutcome::Acquired(g) => g,
1140 AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1141 }
1142 }
1143 }
1144}