mod run_descriptor_tests {
use super::super::*;
pub(super) static REGISTRY_GUARD: Mutex<()> = Mutex::new(());
pub(super) struct IsolatedRegistry {
_dir: tempfile::TempDir,
_guard: std::sync::MutexGuard<'static, ()>,
previous: Option<std::ffi::OsString>,
}
impl IsolatedRegistry {
pub(super) fn new() -> Self {
let guard = REGISTRY_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let dir = tempfile::tempdir().expect("state dir");
let previous = std::env::var_os("XDG_STATE_HOME");
std::env::set_var("XDG_STATE_HOME", dir.path());
Self { _dir: dir, _guard: guard, previous }
}
}
impl Drop for IsolatedRegistry {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
}
}
pub(super) struct TestWorkspace {
_dir: tempfile::TempDir,
pub(super) path: PathBuf,
}
pub(super) fn workspace() -> TestWorkspace {
let dir = tempfile::tempdir().expect("workspace");
let path = dir.path().canonicalize().expect("canonical workspace");
TestWorkspace { _dir: dir, path }
}
pub(super) fn descriptor(id: &str, workspace: &Path, started_at: &str) -> RunDescriptor {
RunDescriptor {
id: id.to_string(),
pid: std::process::id(),
status: RunStatus::Running,
workspace: workspace.to_path_buf(),
plan: workspace.join("plan.rhei.md"),
state_machine: None,
control_url: Some("http://127.0.0.1:54321".to_string()),
started_at: started_at.to_string(),
headless: true,
parallel: 2,
log: Some(workspace.join("runtime/run.log")),
events: workspace.join("runtime/events.jsonl"),
exit_code: None,
}
}
pub(super) fn publish_ended(id: &str, workspace: &Path, started_at: &str) {
let mut ended = descriptor(id, workspace, started_at);
ended.status = RunStatus::Finished;
ended.exit_code = Some(0);
publish_run_descriptor(&ended);
if let Some(entry) = run_registry_path(id) {
assert!(entry.is_file(), "the entry must exist for the test to mean anything");
}
}
#[test]
fn a_descriptor_round_trips_through_its_published_file() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let original = descriptor("aa11bb", &workspace.path, "2026-08-22T14:03:22Z");
publish_run_descriptor(&original);
let read = read_descriptor(&run_descriptor_path(&workspace.path)).expect("published");
assert_eq!(read.id, "aa11bb");
assert_eq!(read.pid, original.pid);
assert_eq!(read.control_url.as_deref(), Some("http://127.0.0.1:54321"));
assert!(read.headless);
assert_eq!(read.exit_code, None);
}
#[test]
fn publishing_also_writes_the_registry_pointer() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
publish_run_descriptor(&descriptor("cc22dd", &workspace.path, "2026-08-22T14:03:22Z"));
let pointer = run_registry_dir().expect("registry dir").join("cc22dd.json");
assert!(pointer.is_file(), "a bare id must be resolvable from anywhere");
}
#[test]
fn publishing_records_absolute_paths_whatever_it_was_given() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
fs::write(workspace.path.join("plan.rhei.md"), "# Rhei: X\n").expect("plan");
let mut relative = descriptor("re11at", &workspace.path, "2026-08-22T14:03:22Z");
relative.plan = PathBuf::from("./plan.rhei.md");
relative.events = PathBuf::from("runtime/events.jsonl");
publish_run_descriptor(&relative);
let read = read_descriptor(&run_descriptor_path(&workspace.path)).expect("published");
assert!(read.plan.is_absolute(), "plan was recorded as {}", read.plan.display());
assert!(read.events.is_absolute(), "events was recorded as {}", read.events.display());
assert!(read.workspace.is_absolute());
}
#[test]
fn finalizing_stamps_the_exit_code_and_keeps_the_registry_entry() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
publish_run_descriptor(&descriptor("ee33ff", &workspace.path, "2026-08-22T14:03:22Z"));
finalize_run_descriptor(3);
let read = read_descriptor(&run_descriptor_path(&workspace.path)).expect("published");
assert_eq!(read.status, RunStatus::Failed);
assert_eq!(read.exit_code, Some(3));
assert_eq!(read.control_url, None);
let entry = run_registry_dir().expect("registry dir").join("ee33ff.json");
let kept = read_descriptor(&entry).expect("the entry outlives the run");
assert_eq!(kept.status, RunStatus::Failed);
assert_eq!(kept.exit_code, Some(3), "the entry carries the answer, not a dangling pointer");
}
#[test]
fn a_zero_exit_finalizes_as_finished() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
publish_run_descriptor(&descriptor("aabbcc", &workspace.path, "2026-08-22T14:03:22Z"));
finalize_run_descriptor(0);
let read = read_descriptor(&run_descriptor_path(&workspace.path)).expect("published");
assert_eq!(read.status, RunStatus::Finished);
assert_eq!(read.exit_code, Some(0));
}
#[test]
fn finalizing_leaves_an_entry_that_belongs_to_another_run_alone() {
let _registry = IsolatedRegistry::new();
let mine = workspace();
let theirs = workspace();
publish_run_descriptor(&descriptor("c01115", &mine.path, "2026-08-22T14:03:22Z"));
let mut other = descriptor("c01115", &theirs.path, "2026-08-22T15:00:00Z");
other.pid = std::process::id() + 1;
let entry = run_registry_path("c01115").expect("registry path");
write_descriptor(&entry, &other).expect("their entry");
finalize_run_descriptor(7);
let kept = read_descriptor(&entry).expect("entry");
assert_eq!(kept.workspace, theirs.path, "the other run's entry was rewritten");
assert_eq!(kept.exit_code, None);
}
#[test]
fn liveness_follows_the_run_lock_not_the_recorded_status() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let running = descriptor("dead01", &workspace.path, "2026-08-22T14:03:22Z");
publish_run_descriptor(&running);
drop(try_acquire_run_lock(&workspace.path).expect("lock"));
assert_eq!(
running.liveness(),
Liveness::Ended,
"nothing holds the lock, so the run is gone whatever its status says"
);
let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
assert_eq!(running.liveness(), Liveness::Live, "a held run lock is what makes a run live");
}
#[test]
fn a_terminal_status_is_never_reported_live() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
let mut finished = descriptor("done01", &workspace.path, "2026-08-22T14:03:22Z");
finished.status = RunStatus::Finished;
publish_run_descriptor(&finished);
assert_eq!(finished.liveness(), Liveness::Ended);
}
#[test]
fn a_superseded_entry_is_gone_even_while_another_run_holds_the_workspace() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let ghost = descriptor("ghost1", &workspace.path, "2026-08-22T10:00:00Z");
publish_run_descriptor(&ghost);
let successor = descriptor("live22", &workspace.path, "2026-08-22T11:00:00Z");
publish_run_descriptor(&successor);
let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
assert_eq!(ghost.liveness(), Liveness::Gone, "the workspace no longer names the ghost");
assert_eq!(
successor.liveness(),
Liveness::Live,
"the run the workspace names is the live one"
);
}
#[test]
fn a_workspace_that_is_gone_makes_its_entry_prunable() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let run = descriptor("rmrf01", &workspace.path, "2026-08-22T10:00:00Z");
publish_run_descriptor(&run);
fs::remove_dir_all(&workspace.path).expect("delete the workspace");
assert_eq!(run.liveness(), Liveness::Gone);
}
#[cfg(unix)]
#[test]
fn an_unreadable_run_lock_is_unknown_rather_than_dead() {
use std::os::unix::fs::PermissionsExt;
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let run = descriptor("locked1", &workspace.path, "2026-08-22T10:00:00Z");
publish_run_descriptor(&run);
drop(try_acquire_run_lock(&workspace.path).expect("lock"));
let rhei_dir = workspace.path.join(".rhei");
fs::set_permissions(&rhei_dir, fs::Permissions::from_mode(0o000)).expect("chmod 000");
let verdict = run.liveness();
fs::set_permissions(&rhei_dir, fs::Permissions::from_mode(0o755)).expect("chmod 755");
assert!(
matches!(verdict, Liveness::Unknown(_)),
"an unreadable lock says nothing about the run, got {verdict:?}"
);
assert_eq!(run.liveness(), Liveness::Ended, "and it is readable again afterwards");
}
#[test]
fn a_missing_lock_file_is_unknown_rather_than_dead() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let run = descriptor("nolock", &workspace.path, "2026-08-22T10:00:00Z");
publish_run_descriptor(&run);
assert!(matches!(run.liveness(), Liveness::Unknown(_)));
}
#[test]
fn probing_liveness_creates_nothing_in_the_workspace() {
let _registry = IsolatedRegistry::new();
let workspace = workspace();
let run = descriptor("nowrite", &workspace.path, "2026-08-22T10:00:00Z");
publish_run_descriptor(&run);
let _ = run.liveness();
assert!(!workspace.path.join(".rhei").exists(), "a probe must not write to disk");
}
#[test]
fn exit_code_is_serialized_even_while_it_is_unknown() {
let workspace = workspace();
let running = descriptor("nullex", &workspace.path, "2026-08-22T14:03:22Z");
let rendered: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&running).expect("render"))
.expect("valid JSON");
assert!(rendered.get("exit_code").expect("present").is_null(), "got: {rendered}");
let mut foreground = running.clone();
foreground.log = None;
let rendered: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&foreground).expect("render"))
.expect("valid JSON");
assert!(rendered.get("log").is_none(), "an absent console is absent: {rendered}");
}
#[test]
fn an_undecided_watch_reports_only_once_its_grace_has_run_out() {
let mut watch = UndecidedWatch::default();
assert!(!watch.exhausted("lock unreadable"), "the first probe starts the grace");
assert!(!watch.exhausted("lock unreadable"), "and the next one is still inside it");
watch.since = Some(Instant::now() - UNDECIDED_GRACE - Duration::from_millis(1));
assert!(watch.exhausted("still unreadable"));
assert_eq!(watch.reason(), "still unreadable", "it reports the last reason it saw");
assert!(!watch.exhausted("still unreadable"), "and rearms rather than firing every poll");
watch.decided();
assert!(watch.reason().is_empty(), "a decided probe retires the grace");
assert!(!watch.exhausted("unreadable again"), "which starts a fresh one");
}
#[cfg(unix)]
#[test]
fn stops_wait_keeps_waiting_while_liveness_is_undecided() {
use std::os::unix::fs::PermissionsExt;
let workspace = workspace();
let run = descriptor("waitng", &workspace.path, "2026-08-22T10:00:00Z");
write_test_descriptor(&run);
drop(try_acquire_run_lock(&workspace.path).expect("lock"));
let lock = workspace.path.join(".rhei").join("run.lock");
fs::set_permissions(&lock, fs::Permissions::from_mode(0o000)).expect("chmod 000");
assert!(matches!(run.liveness(), Liveness::Unknown(_)), "the case under test");
let (done, waited) = std::sync::mpsc::channel();
let waiting = run.clone();
let waiter = std::thread::spawn(move || {
let outcome = await_run_end(&waiting);
let _ = done.send(());
outcome
});
assert!(
waited.recv_timeout(Duration::from_secs(1)).is_err(),
"it returned on an undecided probe instead of waiting"
);
let mut ended = run.clone();
ended.status = RunStatus::Finished;
ended.exit_code = Some(130);
write_test_descriptor(&ended);
let outcome = waiter.join().expect("the waiter returns once the run records its end");
fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).expect("chmod 644");
assert!(outcome.is_ok(), "it saw the recorded end: {outcome:?}");
}
#[cfg(unix)]
fn write_test_descriptor(descriptor: &RunDescriptor) {
let path = run_descriptor_path(&descriptor.workspace);
fs::create_dir_all(path.parent().expect("runtime directory")).expect("runtime directory");
fs::write(&path, serde_json::to_string_pretty(descriptor).expect("render"))
.expect("workspace descriptor");
}
}