use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::proc::{self, Quiet as _};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Owner {
pub run: String,
pub node: String,
pub seat: String,
pub pid: u32,
pub worktree: String,
pub head: String,
}
impl Owner {
#[must_use]
pub fn here(run: &str, node: &str, seat: &str, worktree: &Path, head: &str) -> Owner {
Owner {
run: run.to_owned(),
node: node.to_owned(),
seat: seat.to_owned(),
pid: std::process::id(),
worktree: worktree.display().to_string(),
head: head.to_owned(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LeaseFile {
cache_dir: String,
owner: Owner,
acquired_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Free,
Active(Owner),
Stale(Owner),
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Busy {
Active(Owner),
Unknown,
Contended,
}
impl Busy {
#[must_use]
pub fn describe(&self) -> String {
match self {
Busy::Active(o) => format!(
"held by run {} node {} seat {} (pid {})",
o.run, o.node, o.seat, o.pid
),
Busy::Unknown => {
"an unreadable lease is present; refusing to guess who holds it".to_owned()
}
Busy::Contended => "lost a race for the lease; retrying".to_owned(),
}
}
}
#[derive(Debug)]
pub struct Guard {
path: PathBuf,
released: bool,
}
impl Guard {
fn new(path: PathBuf) -> Guard {
Guard {
path,
released: false,
}
}
pub fn release(mut self) {
self.do_release();
}
fn do_release(&mut self) {
if !self.released {
let _ = std::fs::remove_file(&self.path);
self.released = true;
}
}
}
impl Drop for Guard {
fn drop(&mut self) {
self.do_release();
}
}
fn leases_dir(home: &Path) -> PathBuf {
home.join("cache-leases")
}
fn slug(cache_dir: &Path) -> String {
let norm = normalize(cache_dir);
let mut readable: String = norm
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
readable.truncate(80);
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
norm.hash(&mut hasher);
format!("{readable}-{:08x}", hasher.finish() as u32)
}
fn normalize(p: &Path) -> String {
std::fs::canonicalize(p)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| p.display().to_string())
.replace('\\', "/")
.to_ascii_lowercase()
}
fn lease_path(home: &Path, cache_dir: &Path) -> PathBuf {
leases_dir(home).join(format!("{}.json", slug(cache_dir)))
}
fn identity_path(home: &Path, cache_dir: &Path) -> PathBuf {
leases_dir(home).join(format!("{}.identity.json", slug(cache_dir)))
}
fn catalog_path(home: &Path, cache_dir: &Path) -> PathBuf {
leases_dir(home).join(format!("{}.catalog.json", slug(cache_dir)))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CatalogRecord {
cache_dir: String,
last_owner: Owner,
last_used_at: Timestamp,
}
fn record_catalog(home: &Path, cache_dir: &Path, owner: &Owner) {
let path = catalog_path(home, cache_dir);
let record = CatalogRecord {
cache_dir: cache_dir.display().to_string(),
last_owner: owner.clone(),
last_used_at: Timestamp::now(),
};
let Ok(body) = serde_json::to_string_pretty(&record) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &body).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
fn read_catalog(path: &Path) -> Option<CatalogRecord> {
let body = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&body).ok()
}
fn read_lease(path: &Path) -> Option<LeaseFile> {
let body = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&body).ok()
}
fn peek_cache_dir(path: &Path) -> Option<String> {
let body = std::fs::read_to_string(path).ok()?;
let value: serde_json::Value = serde_json::from_str(&body).ok()?;
value
.get("cache_dir")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
fn classify(path: &Path) -> Status {
classify_with(path, proc::pid_alive)
}
fn classify_with<F: Fn(u32) -> bool>(path: &Path, alive: F) -> Status {
if !path.exists() {
return Status::Free;
}
let Some(lease) = read_lease(path) else {
return Status::Unknown;
};
let this_process = std::process::id();
if lease.owner.pid == this_process || alive(lease.owner.pid) {
Status::Active(lease.owner)
} else {
Status::Stale(lease.owner)
}
}
fn write_new(path: &Path, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
let lease = LeaseFile {
cache_dir: cache_dir.display().to_string(),
owner: owner.clone(),
acquired_at: Timestamp::now(),
};
let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
f.write_all(body.as_bytes())?;
Ok(())
}
pub enum AcquireOutcome {
Acquired(Guard),
Busy(Busy),
}
pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
try_acquire_with(home, cache_dir, owner, proc::pid_alive)
}
fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
home: &Path,
cache_dir: &Path,
owner: &Owner,
alive: F,
) -> Result<AcquireOutcome> {
let dir = leases_dir(home);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(format!("{}.json", slug(cache_dir)));
for _ in 0..2 {
match write_new(&path, cache_dir, owner) {
Ok(()) => {
record_catalog(home, cache_dir, owner);
return Ok(AcquireOutcome::Acquired(Guard::new(path)));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
}
match classify_with(&path, alive) {
Status::Free => {} Status::Stale(_) => {
let _ = std::fs::remove_file(&path);
}
Status::Active(o) => return Ok(AcquireOutcome::Busy(Busy::Active(o))),
Status::Unknown => return Ok(AcquireOutcome::Busy(Busy::Unknown)),
}
}
Ok(AcquireOutcome::Busy(Busy::Contended))
}
#[must_use]
pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
let path = lease_path(home, cache_dir);
matches!(classify(&path), Status::Active(_) | Status::Unknown)
}
pub async fn wait_for(
home: &Path,
cache_dir: &Path,
owner: &Owner,
budget: Duration,
poll: Duration,
) -> Result<Guard> {
let start = std::time::Instant::now();
loop {
match try_acquire(home, cache_dir, owner)? {
AcquireOutcome::Acquired(g) => return Ok(g),
AcquireOutcome::Busy(busy) => {
let elapsed = start.elapsed();
if elapsed >= budget {
bail!(
"timed out after {}s waiting for the build cache at {} ({})",
budget.as_secs(),
cache_dir.display(),
busy.describe()
);
}
tokio::time::sleep(poll.min(budget - elapsed)).await;
}
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub cache_dir: String,
pub status: EntryStatus,
}
#[derive(Debug, Clone)]
pub enum EntryStatus {
Active(Owner),
Stale(Owner),
Unknown,
Idle(Owner),
}
#[must_use]
pub fn inventory(home: &Path) -> Vec<Entry> {
inventory_with(home, proc::pid_alive)
}
fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
let dir = leases_dir(home);
let Ok(rd) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".json")
|| name.ends_with(".identity.json")
|| name.ends_with(".catalog.json")
{
continue;
}
let status = match classify_with(&path, alive) {
Status::Free => continue,
Status::Active(o) => EntryStatus::Active(o),
Status::Stale(o) => EntryStatus::Stale(o),
Status::Unknown => EntryStatus::Unknown,
};
let cache_dir = peek_cache_dir(&path)
.unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
out.push(Entry { cache_dir, status });
}
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(stem) = name.strip_suffix(".catalog.json") else {
continue;
};
if dir.join(format!("{stem}.json")).exists() {
continue;
}
if let Some(record) = read_catalog(&path) {
out.push(Entry {
cache_dir: record.cache_dir,
status: EntryStatus::Idle(record.last_owner),
});
}
}
}
out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
out
}
pub fn maintenance_prune(
home: &Path,
cache_dir: &Path,
limit: u64,
) -> Result<Option<crate::disk::Prune>> {
let owner = Owner {
run: "maintenance".to_owned(),
node: "prune".to_owned(),
seat: "janitor".to_owned(),
pid: std::process::id(),
worktree: String::new(),
head: String::new(),
};
match try_acquire(home, cache_dir, &owner)? {
AcquireOutcome::Busy(_) => Ok(None),
AcquireOutcome::Acquired(guard) => {
let result = crate::disk::prune_dir(cache_dir, limit)?;
guard.release();
Ok(Some(result))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
pub worktree: String,
pub head: String,
}
impl Identity {
#[must_use]
pub fn new(worktree: &Path, head: &str) -> Identity {
Identity {
worktree: worktree.display().to_string(),
head: head.to_owned(),
}
}
}
#[must_use]
pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
let path = identity_path(home, cache_dir);
let Ok(body) = std::fs::read_to_string(path) else {
return true;
};
match serde_json::from_str::<Identity>(&body) {
Ok(recorded) => &recorded != current,
Err(_) => true,
}
}
pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
let path = identity_path(home, cache_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
let _ = std::fs::remove_file(identity_path(home, cache_dir));
}
#[must_use]
pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
return Vec::new();
};
value
.get("packages")
.and_then(|p| p.as_array())
.map(|packages| {
packages
.iter()
.filter_map(|p| p.get("name").and_then(|n| n.as_str()))
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn refresh_stale_packages(worktree: &Path, cache_dir: &Path) -> Result<Vec<String>> {
let meta = std::process::Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(worktree)
.quiet()
.output()
.context("run `cargo metadata`")?;
if !meta.status.success() {
bail!(
"cargo metadata failed: {}",
String::from_utf8_lossy(&meta.stderr)
);
}
let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
let mut failed = Vec::new();
for name in &names {
let out = std::process::Command::new("cargo")
.arg("clean")
.arg("-p")
.arg(name)
.arg("--target-dir")
.arg(cache_dir)
.current_dir(worktree)
.quiet()
.output()
.with_context(|| format!("cargo clean -p {name}"))?;
if !out.status.success() {
failed.push(format!(
"{name}: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
}
if !failed.is_empty() {
bail!(
"cargo clean -p failed for {} package(s): {}",
failed.len(),
failed.join("; ")
);
}
Ok(names)
}
pub fn ensure_fresh(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
if needs_refresh(home, cache_dir, identity) {
let cleaned = refresh_stale_packages(&PathBuf::from(&identity.worktree), cache_dir)?;
tracing::info!(
?cleaned,
cache = %cache_dir.display(),
"build cache: source identity changed; cleaned the workspace's own packages before reuse"
);
}
record_identity(home, cache_dir, identity)
}
#[cfg(test)]
mod tests {
use super::*;
fn owner(pid: u32) -> Owner {
Owner {
run: "r1".to_owned(),
node: "gate".to_owned(),
seat: "gate".to_owned(),
pid,
worktree: "/w".to_owned(),
head: "deadbeef".to_owned(),
}
}
#[test]
fn an_uncontended_lease_is_acquired_and_freed_on_release() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let this = std::process::id();
match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
AcquireOutcome::Acquired(g) => {
assert!(in_use(home.path(), &cache), "held while the guard lives");
g.release();
}
AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
}
assert!(!in_use(home.path(), &cache), "freed after release");
}
#[test]
fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let this = std::process::id();
let _first =
try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
let mut second_owner = owner(this);
second_owner.run = "r2".to_owned();
match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
}
assert!(in_use(home.path(), &cache));
}
impl std::fmt::Debug for AcquireOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
}
}
}
#[test]
fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let dead_owner = owner(999_999);
let path = lease_path(home.path(), &cache);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
write_new(&path, &cache, &dead_owner).expect("seed a stale lease");
assert_eq!(
classify_with(&path, |_| false),
Status::Stale(dead_owner.clone())
);
match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
.expect("acquire")
{
AcquireOutcome::Acquired(_) => {}
other => panic!("stale lease should have been reclaimed: {other:?}"),
}
}
#[test]
fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let path = lease_path(home.path(), &cache);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"not json").unwrap();
assert_eq!(classify(&path), Status::Unknown);
assert!(in_use(home.path(), &cache), "unknown counts as in use");
match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
AcquireOutcome::Busy(Busy::Unknown) => {}
other => panic!("expected Busy::Unknown, got {other:?}"),
}
}
#[tokio::test]
async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
.expect("acquire")
.pipe();
let mut waiter = owner(std::process::id());
waiter.run = "r2".to_owned();
let started = std::time::Instant::now();
let err = wait_for(
home.path(),
&cache,
&waiter,
Duration::from_millis(150),
Duration::from_millis(20),
)
.await
.expect_err("still held, must time out");
assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
assert!(
err.to_string().contains("r1"),
"names the current holder: {err}"
);
}
#[tokio::test]
async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let guard =
match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
AcquireOutcome::Acquired(g) => g,
AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
};
let home_path = home.path().to_path_buf();
let cache_path = cache.clone();
let mut waiter = owner(std::process::id());
waiter.run = "r2".to_owned();
let wait = tokio::spawn(async move {
wait_for(
&home_path,
&cache_path,
&waiter,
Duration::from_secs(5),
Duration::from_millis(10),
)
.await
});
tokio::time::sleep(Duration::from_millis(50)).await;
guard.release();
let acquired = wait.await.expect("task").expect("acquire after release");
acquired.release();
}
#[test]
fn inventory_reports_active_stale_and_unknown_but_not_free() {
let home = tempfile::TempDir::new().expect("temp");
let active_cache = home.path().join("active");
let stale_cache = home.path().join("stale");
let unknown_cache = home.path().join("unknown");
let _held =
try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
let stale_path = lease_path(home.path(), &stale_cache);
std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
write_new(&stale_path, &stale_cache, &owner(999_999)).unwrap();
let unknown_path = lease_path(home.path(), &unknown_cache);
std::fs::write(&unknown_path, b"garbage").unwrap();
let entries = inventory_with(home.path(), |pid| pid == std::process::id());
assert_eq!(entries.len(), 3, "{entries:?}");
let by_dir = |dir: &Path| {
entries
.iter()
.find(|e| e.cache_dir == dir.display().to_string())
.unwrap_or_else(|| panic!("no entry for {}", dir.display()))
};
assert!(matches!(
by_dir(&active_cache).status,
EntryStatus::Active(_)
));
assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
let unknown = entries
.iter()
.find(|e| matches!(e.status, EntryStatus::Unknown))
.unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
assert!(
unknown
.cache_dir
.contains(&unknown_path.display().to_string()),
"{unknown:?}"
);
}
#[test]
fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let this = std::process::id();
match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
AcquireOutcome::Acquired(g) => g.release(),
AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
}
assert!(!in_use(home.path(), &cache));
let entries = inventory_with(home.path(), |pid| pid == this);
let entry = entries
.iter()
.find(|e| e.cache_dir == cache.display().to_string())
.unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
match &entry.status {
EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
other => panic!("expected Idle, got {other:?}"),
}
}
#[test]
fn reacquiring_a_released_cache_reports_active_not_idle() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let this = std::process::id();
match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
AcquireOutcome::Acquired(g) => g.release(),
AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
}
let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
let entries = inventory_with(home.path(), |pid| pid == this);
assert_eq!(
entries.len(),
1,
"the catalog row must not duplicate the live lease: {entries:?}"
);
assert!(matches!(entries[0].status, EntryStatus::Active(_)));
}
#[test]
fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
assert!(
result.is_none(),
"must not prune while a live owner holds it"
);
assert!(cache.join("big").exists(), "nothing was deleted");
}
#[test]
fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
let pruned = maintenance_prune(home.path(), &cache, 1)
.expect("no io error")
.expect("cache was free");
assert!(pruned.freed > 0);
assert!(
!in_use(home.path(), &cache),
"the maintenance lease was released"
);
}
#[test]
fn identity_drift_is_detected_once_and_then_settles() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let a = Identity {
worktree: "/w/a".to_owned(),
head: "aaaa".to_owned(),
};
let b = Identity {
worktree: "/w/b".to_owned(),
head: "bbbb".to_owned(),
};
assert!(
needs_refresh(home.path(), &cache, &a),
"nothing recorded yet"
);
record_identity(home.path(), &cache, &a).expect("record");
assert!(
!needs_refresh(home.path(), &cache, &a),
"same identity, no refresh needed"
);
assert!(needs_refresh(home.path(), &cache, &b), "different source");
record_identity(home.path(), &cache, &b).expect("record");
assert!(!needs_refresh(home.path(), &cache, &b));
}
#[test]
fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
let home = tempfile::TempDir::new().expect("temp");
let cache = home.path().join("cache");
let a = Identity {
worktree: "/w/a".to_owned(),
head: "aaaa".to_owned(),
};
record_identity(home.path(), &cache, &a).expect("record");
assert!(!needs_refresh(home.path(), &cache, &a));
invalidate_identity(home.path(), &cache);
assert!(
needs_refresh(home.path(), &cache, &a),
"invalidation must not be skippable by asking about the same identity again"
);
invalidate_identity(home.path(), &home.path().join("never-recorded"));
}
#[test]
fn workspace_package_names_are_read_from_cargo_metadata_json() {
let fixture = r#"{
"packages": [
{"name": "magi", "version": "0.1.0"},
{"name": "magi-cli", "version": "0.1.0"}
],
"workspace_members": []
}"#;
let mut names = parse_workspace_package_names(fixture);
names.sort();
assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
assert_eq!(
parse_workspace_package_names("not json"),
Vec::<String>::new()
);
assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
}
#[test]
fn slugs_are_stable_and_filesystem_safe() {
let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
assert_eq!(a, b, "same input, same slug");
assert!(
a.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"filesystem-safe: {a}"
);
}
#[test]
fn busy_active_describes_the_holder() {
let b = Busy::Active(owner(123));
let s = b.describe();
assert!(
s.contains("r1") && s.contains("gate") && s.contains("123"),
"{s}"
);
}
trait Pipe: Sized {
fn pipe(self) -> Guard;
}
impl Pipe for AcquireOutcome {
fn pipe(self) -> Guard {
match self {
AcquireOutcome::Acquired(g) => g,
AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
}
}
}
}