rhei-cli 0.2.0

Command-line driver for the Rhei agent runtime.
Documentation
// The machine-wide registry: what a sweep keeps, what it prunes, and how a
// reference resolves.
// §FS-rhei-run-headless.2 §FS-rhei-run-headless.3 §FS-rhei-run-headless.6

mod run_registry_tests {
    use super::run_descriptor_tests::{descriptor, publish_ended, workspace, IsolatedRegistry};
    use super::super::*;

    fn ids(runs: &[RunDescriptor]) -> Vec<&str> {
        runs.iter().map(|run| run.id.as_str()).collect()
    }

    #[test]
    fn live_runs_are_listed_newest_first() {
        let _registry = IsolatedRegistry::new();
        let older = workspace();
        let newer = workspace();
        let _held_older = try_acquire_run_lock(&older.path).expect("lock").expect("available");
        let _held_newer = try_acquire_run_lock(&newer.path).expect("lock").expect("available");
        publish_run_descriptor(&descriptor("old111", &older.path, "2026-08-22T10:00:00Z"));
        publish_run_descriptor(&descriptor("new222", &newer.path, "2026-08-22T18:00:00Z"));

        assert_eq!(ids(&sweep_run_registry().live), vec!["new222", "old111"]);
    }

    /// The entry is what makes the id resolvable after the fact. Deleting it
    /// broke `rhei attach <id>` at exactly the moment the answer existed.
    // §FS-rhei-run-headless.2 §FS-rhei-run-headless.5.3
    #[test]
    fn an_ended_run_keeps_its_entry_and_stays_out_of_the_live_list() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_ended("gone01", &workspace.path, "2026-08-22T14:03:22Z");

        let sweep = sweep_run_registry();
        assert!(sweep.live.is_empty(), "an ended run is not live");
        assert_eq!(ids(&sweep.ended), vec!["gone01"]);
        assert!(run_registry_path("gone01").expect("path").is_file(), "the entry stays");
        assert_eq!(resolve_run(Some("gone01")).expect("resolves").id, "gone01");
    }

    /// The only verdict that prunes: the workspace no longer names the run.
    #[test]
    fn a_superseded_entry_is_pruned_as_the_list_is_read() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_run_descriptor(&descriptor("ghost1", &workspace.path, "2026-08-22T10:00:00Z"));
        publish_run_descriptor(&descriptor("live22", &workspace.path, "2026-08-22T11:00:00Z"));
        let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");

        assert_eq!(ids(&sweep_run_registry().live), vec!["live22"]);
        assert!(!run_registry_path("ghost1").expect("path").exists(), "superseded entries go");
    }

    #[test]
    fn a_deleted_workspace_prunes_its_entry() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_run_descriptor(&descriptor("rmrf02", &workspace.path, "2026-08-22T10:00:00Z"));
        fs::remove_dir_all(&workspace.path).expect("delete the workspace");

        assert!(sweep_run_registry().live.is_empty());
        assert!(!run_registry_path("rmrf02").expect("path").exists());
    }

    /// An older binary reading a newer one's registry must not destroy it.
    // §FS-rhei-run-headless.3
    #[test]
    fn an_unparseable_entry_is_kept_and_reported() {
        let _registry = IsolatedRegistry::new();
        let dir = run_registry_dir().expect("registry dir");
        fs::create_dir_all(&dir).expect("registry dir");
        let entry = dir.join("future.json");
        fs::write(&entry, "{\"schema\": \"from a newer rhei\"}\n").expect("entry");

        let sweep = sweep_run_registry();
        assert!(entry.is_file(), "an entry this build cannot read is not an entry to delete");
        assert_eq!(sweep.undecided.len(), 1);
        assert!(sweep.undecided[0].reason.contains("could not be read"));
        assert!(sweep.undecided[0].summary_line().contains("future.json"));
    }

    /// Completion calls the sweep. A transient `EACCES` must not unregister a
    /// live run. §FS-rhei-run-headless.3
    #[cfg(unix)]
    #[test]
    fn an_unreadable_workspace_keeps_its_entry_and_is_reported() {
        use std::os::unix::fs::PermissionsExt;
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_run_descriptor(&descriptor("chmod0", &workspace.path, "2026-08-22T10:00:00Z"));
        let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
        let rhei_dir = workspace.path.join(".rhei");
        fs::set_permissions(&rhei_dir, fs::Permissions::from_mode(0o000)).expect("chmod 000");

        let sweep = sweep_run_registry();
        fs::set_permissions(&rhei_dir, fs::Permissions::from_mode(0o755)).expect("chmod 755");

        assert!(sweep.live.is_empty(), "it could not be confirmed live");
        assert_eq!(sweep.undecided.len(), 1, "but it is listed, with a reason");
        assert!(run_registry_path("chmod0").expect("path").is_file(), "and it is kept");
        assert_eq!(ids(&sweep_run_registry().live), vec!["chmod0"], "readable again, live again");
    }

    /// Ended entries accumulate. If a prefix search saw them beside live runs,
    /// a two-character prefix that resolves today would start reporting
    /// "matches 4 runs" tomorrow. §FS-rhei-run-headless.3
    #[test]
    fn a_live_run_wins_a_prefix_that_ended_runs_also_match() {
        let _registry = IsolatedRegistry::new();
        let live = workspace();
        let first = workspace();
        let second = workspace();
        let third = workspace();
        publish_ended("ab0001", &first.path, "2026-08-22T10:00:00Z");
        publish_ended("ab0002", &second.path, "2026-08-22T10:30:00Z");
        publish_ended("ab0003", &third.path, "2026-08-22T11:00:00Z");
        publish_run_descriptor(&descriptor("ab9999", &live.path, "2026-08-22T12:00:00Z"));
        let _held = try_acquire_run_lock(&live.path).expect("lock").expect("available");

        assert_eq!(resolve_run(Some("ab")).expect("the live run wins").id, "ab9999");
        // An exact id still reaches an ended run, which is the point of keeping
        // the entry at all.
        assert_eq!(resolve_run(Some("ab0002")).expect("exact").id, "ab0002");
    }

    #[test]
    fn an_exact_id_resolves() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
        publish_run_descriptor(&descriptor("abc123", &workspace.path, "2026-08-22T14:03:22Z"));
        assert_eq!(resolve_run(Some("abc123")).expect("resolved").id, "abc123");
    }

    #[test]
    fn a_unique_prefix_resolves_and_an_ambiguous_one_lists_the_candidates() {
        let _registry = IsolatedRegistry::new();
        let first = workspace();
        let second = workspace();
        let _held_first = try_acquire_run_lock(&first.path).expect("lock").expect("available");
        let _held_second = try_acquire_run_lock(&second.path).expect("lock").expect("available");
        publish_run_descriptor(&descriptor("ab0001", &first.path, "2026-08-22T10:00:00Z"));
        publish_run_descriptor(&descriptor("ab0002", &second.path, "2026-08-22T11:00:00Z"));

        assert_eq!(resolve_run(Some("ab0001")).expect("exact").id, "ab0001");
        assert!(resolve_run(Some("ab00012")).is_err(), "a longer non-id must not match");

        let err = resolve_run(Some("ab")).expect_err("ambiguous");
        let message = err.to_string();
        assert!(message.contains("matches 2 runs"), "got: {message}");
        assert!(message.contains("ab0001") && message.contains("ab0002"));
    }

    #[test]
    fn an_ambiguous_prefix_among_ended_runs_lists_them_too() {
        let _registry = IsolatedRegistry::new();
        let first = workspace();
        let second = workspace();
        publish_ended("cd0001", &first.path, "2026-08-22T10:00:00Z");
        publish_ended("cd0002", &second.path, "2026-08-22T11:00:00Z");

        let err = resolve_run(Some("cd")).expect_err("ambiguous");
        assert!(err.to_string().contains("matches 2 runs that have ended"), "got: {err}");
    }

    /// The message covers ended runs too, because they resolve too: pointing
    /// only at the live listing points away from half the answers.
    // §FS-rhei-run-headless.3
    #[test]
    fn an_unknown_reference_points_at_the_run_list() {
        let _registry = IsolatedRegistry::new();
        let err = resolve_run(Some("nosuch")).expect_err("unknown");
        assert!(err.to_string().contains("no run matches 'nosuch'"));
        let help = err.help().expect("help").to_string();
        assert!(help.contains("rhei runs"), "got: {help}");
        assert!(help.contains("ended"), "an ended run resolves as well: {help}");
    }

    #[test]
    fn a_path_resolves_to_that_workspaces_descriptor() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_run_descriptor(&descriptor("path01", &workspace.path, "2026-08-22T14:03:22Z"));
        let resolved = resolve_run(Some(&workspace.path.display().to_string())).expect("by path");
        assert_eq!(resolved.id, "path01");
    }

    #[test]
    fn a_workspace_with_no_recorded_run_says_how_to_start_one() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        let err = resolve_run(Some(&workspace.path.display().to_string()))
            .expect_err("no run recorded");
        assert!(err.to_string().contains("no run has been recorded"));
        assert!(err.help().expect("help").to_string().contains("--headless"));
    }

    /// Retention is not accumulation: a machine that runs a plan a minute must
    /// not grow an unbounded registry. §FS-rhei-run-headless.2
    #[test]
    fn the_registry_keeps_only_the_newest_ended_runs() {
        let _registry = IsolatedRegistry::new();
        let root = workspace();
        let total = RETAINED_ENDED_RUNS + 5;
        for index in 0..total {
            let each = root.path.join(format!("ws{index:03}"));
            fs::create_dir_all(&each).expect("workspace");
            // Minute-by-minute start times, so "newest first" is well defined.
            publish_ended(
                &format!("e{index:05}"),
                &each,
                &format!("2026-08-22T{:02}:{:02}:00Z", index / 60, index % 60),
            );
        }

        let sweep = sweep_run_registry();
        assert_eq!(sweep.ended.len(), RETAINED_ENDED_RUNS);
        let newest = format!("e{:05}", total - 1);
        let oldest = "e00000";
        assert_eq!(sweep.ended[0].id, newest, "the newest survives");
        assert!(!run_registry_path(oldest).expect("path").exists(), "the oldest is dropped");
        assert!(run_registry_path(&newest).expect("path").is_file());
    }
    /// The tier that resolves first is *not known to have ended*, not *live*.
    /// An entry nobody could check is exactly the one an operator needs
    /// `attach` and `stop` to reach, and it stopped resolving at all the moment
    /// a lock file became unreadable.
    // §FS-rhei-run-headless.3
    #[cfg(unix)]
    #[test]
    fn an_undecided_entry_resolves_by_exact_id_and_by_prefix() {
        use std::os::unix::fs::PermissionsExt;
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        publish_run_descriptor(&descriptor("uk0001", &workspace.path, "2026-08-22T10:00:00Z"));
        let _held = try_acquire_run_lock(&workspace.path).expect("lock").expect("available");
        let lock = workspace.path.join(".rhei").join("run.lock");
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o000)).expect("chmod 000");

        let sweep = sweep_run_registry();
        assert!(sweep.live.is_empty(), "the case under test is an entry nobody could check");
        assert_eq!(sweep.undecided.len(), 1);

        let exact = resolve_run(Some("uk0001"));
        let prefix = resolve_run(Some("uk"));
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).expect("chmod 644");

        assert_eq!(exact.expect("an undecided entry resolves by its id").id, "uk0001");
        assert_eq!(prefix.expect("and by a unique prefix").id, "uk0001");
    }

    /// Live first, then undecided: a decided answer wins a tie against one that
    /// could not be checked. §FS-rhei-run-headless.3
    #[cfg(unix)]
    #[test]
    fn a_live_run_is_offered_before_an_undecided_one() {
        use std::os::unix::fs::PermissionsExt;
        let _registry = IsolatedRegistry::new();
        let live = workspace();
        let blind = workspace();
        publish_run_descriptor(&descriptor("lv0001", &live.path, "2026-08-22T10:00:00Z"));
        publish_run_descriptor(&descriptor("uk0002", &blind.path, "2026-08-22T11:00:00Z"));
        let _held_live = try_acquire_run_lock(&live.path).expect("lock").expect("available");
        let _held_blind = try_acquire_run_lock(&blind.path).expect("lock").expect("available");
        let lock = blind.path.join(".rhei").join("run.lock");
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o000)).expect("chmod 000");

        let sweep = sweep_run_registry();
        let order = sweep.not_known_to_have_ended();
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).expect("chmod 644");

        assert_eq!(
            order.iter().map(|run| run.id.as_str()).collect::<Vec<_>>(),
            vec!["lv0001", "uk0002"],
            "the decided answer comes first"
        );
    }

    /// A tab keypress must not unlink a file. Completion classifies exactly as
    /// the listing does and removes nothing; the listing, which the operator
    /// actually asked for, still prunes.
    // §FS-rhei-run-headless.3
    #[test]
    fn reading_the_registry_for_completion_prunes_nothing() {
        let _registry = IsolatedRegistry::new();
        let workspace = workspace();
        let gone = descriptor("gone09", &workspace.path, "2026-08-22T10:00:00Z");
        publish_run_descriptor(&gone);
        // Superseded: the workspace names a later run, so this entry is
        // provably prunable — the one verdict that deletes.
        publish_run_descriptor(&descriptor("next09", &workspace.path, "2026-08-22T11:00:00Z"));
        let entry = run_registry_path("gone09").expect("registry path");
        assert!(entry.is_file(), "the entry must exist for the test to mean anything");

        assert!(read_run_registry().live.is_empty(), "nothing holds the lock");
        assert!(entry.is_file(), "a read must not delete what a listing would");

        sweep_run_registry();
        assert!(!entry.exists(), "and the listing still prunes it");
    }

    /// A hundred retained entries share prefixes freely. Printing every match
    /// produced 203 lines of wrapped diagnostic, which is not an answer to
    /// "which one did you mean?".
    // §FS-rhei-run-headless.3
    #[test]
    fn an_ambiguous_prefix_lists_ten_candidates_and_counts_the_rest() {
        let _registry = IsolatedRegistry::new();
        let root = workspace();
        let total = LISTED_AMBIGUOUS_MATCHES + 5;
        for index in 0..total {
            let each = root.path.join(format!("ws{index:03}"));
            fs::create_dir_all(&each).expect("workspace");
            publish_ended(
                &format!("ef{index:04}"),
                &each,
                &format!("2026-08-22T10:{index:02}:00Z"),
            );
        }

        let err = resolve_run(Some("ef")).expect_err("ambiguous");
        let message = err.to_string();
        assert!(message.contains(&format!("matches {total} runs that have ended")), "{message}");
        let listed = message.lines().filter(|line| line.contains("finished")).count();
        assert_eq!(listed, LISTED_AMBIGUOUS_MATCHES, "capped at ten:\n{message}");
        assert!(message.contains("... and 5 more"), "and says how many it left out: {message}");
    }

    /// What `rhei attach <TAB>` offers, in the order resolution tries it: live
    /// first, then the entries nobody could check — which are exactly the runs
    /// an operator needs to reach when something is wrong — then the most
    /// recent ended ones, which `rhei attach` still answers for.
    // §FS-rhei-run-headless.3 §FS-rhei-run-headless.2
    #[cfg(unix)]
    #[test]
    fn completion_offers_live_then_unchecked_then_recently_ended_runs() {
        use std::os::unix::fs::PermissionsExt;
        let _registry = IsolatedRegistry::new();
        let live = workspace();
        let blind = workspace();
        let over = workspace();
        publish_ended("rr0003", &over.path, "2026-08-22T09:00:00Z");
        publish_run_descriptor(&descriptor("rr0001", &live.path, "2026-08-22T10:00:00Z"));
        publish_run_descriptor(&descriptor("rr0002", &blind.path, "2026-08-22T11:00:00Z"));
        let _held_live = try_acquire_run_lock(&live.path).expect("lock").expect("available");
        let _held_blind = try_acquire_run_lock(&blind.path).expect("lock").expect("available");
        let lock = blind.path.join(".rhei").join("run.lock");
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o000)).expect("chmod 000");

        let offered = complete_run_reference(std::ffi::OsStr::new("rr"));
        fs::set_permissions(&lock, fs::Permissions::from_mode(0o644)).expect("chmod 644");

        let ids: Vec<String> = offered
            .iter()
            .map(|candidate| candidate.get_value().to_string_lossy().into_owned())
            .collect();
        assert_eq!(ids, vec!["rr0001", "rr0002", "rr0003"]);
    }

}