wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What the retention policy must never get wrong.

use super::*;

fn write(path: &Path, bytes: usize) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, "x".repeat(bytes)).unwrap();
}

/// Age is set explicitly: a sweep orders by mtime, and two files written in
/// the same millisecond would make "oldest first" a coin toss.
fn age(path: &Path, seconds_ago: u64) {
    let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds_ago);
    std::fs::File::options()
        .write(true)
        .open(path)
        .unwrap()
        .set_modified(when)
        .unwrap();
}

/// TEST8990: a sweep removes the oldest entries and stops at the limit.
#[test]
fn test8990_the_oldest_go_first_and_it_stops_when_it_is_under() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let ops = stream_dir(root, Stream::Ops).join("2026-08-22");
    for (n, name) in ["a", "b", "c", "d"].iter().enumerate() {
        let path = ops.join(format!("{name}.jsonl"));
        write(&path, 30 * 1024 * 1024);
        // a is oldest, d is newest.
        age(&path, (4 - n as u64) * 3600);
    }

    // 120 MB held, 64 MB allowed (the floor): the two oldest must go, and the
    // two newest must stay.
    let report = sweep(root, MIN_MAX_BYTES, &[], false);
    assert_eq!(report.removed.len(), 2, "{report:?}");
    assert!(report.removed[0].path.ends_with("a.jsonl"));
    assert!(report.removed[1].path.ends_with("b.jsonl"));
    assert!(!ops.join("a.jsonl").exists());
    assert!(ops.join("c.jsonl").exists(), "a sweep must stop at the limit");
    assert!(ops.join("d.jsonl").exists());
    assert!(!report.still_over);
}

/// TEST8991: what is being written now is never swept.
///
/// The whole point of the file is that a page can be navigated away from and
/// come back to a complete log. A sweep that deleted the log of the operation
/// currently running would take the output out from under the page watching
/// it, and it would happen exactly when the directory is fullest — which is
/// exactly when a long build is running.
#[test]
fn test8991_a_running_operations_log_is_protected() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let ops = stream_dir(root, Stream::Ops).join("2026-08-22");
    let running = ops.join("running.jsonl");
    write(&running, 90 * 1024 * 1024);
    age(&running, 99_999); // by age, the first thing a sweep would take
    let other = ops.join("other.jsonl");
    write(&other, 30 * 1024 * 1024);
    age(&other, 1);

    let report = sweep(root, MIN_MAX_BYTES, &[running.clone()], false);
    assert!(running.exists(), "the protected entry must survive");
    assert_eq!(report.protected, 1);
    assert!(!other.exists(), "everything else is still fair game");
    assert!(
        report.still_over,
        "it must SAY it could not get under the limit rather than pretending it did"
    );
}

/// TEST8992: a test session is removed whole, never half.
///
/// Half a session is a directory that still reads as a session — it has a
/// runner.log — but whose failures file is gone, so `--rerun-failed` reruns
/// nothing and reports that nothing failed.
#[test]
fn test8992_a_session_is_one_entry() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let session = stream_dir(root, Stream::Test).join("2026-08-22_10-00-00");
    write(&session.join("runner.log"), 40 * 1024 * 1024);
    write(&session.join("suite-one.log"), 40 * 1024 * 1024);
    age(&session.join("runner.log"), 7200);
    age(&session.join("suite-one.log"), 7200);
    let newer = stream_dir(root, Stream::Daemon).join("wsb-now.log");
    write(&newer, 10 * 1024 * 1024);
    age(&newer, 1);

    let found = entries(root);
    assert_eq!(found.len(), 2, "one session and one daemon log: {found:?}");

    let report = sweep(root, MIN_MAX_BYTES, &[], false);
    assert_eq!(report.removed.len(), 1);
    assert_eq!(report.removed[0].group, Stream::Test.dir_name());
    assert!(!session.exists(), "the session goes whole");
    assert!(newer.exists());
}

/// TEST8993: a dry run decides everything and removes nothing.
#[test]
fn test8993_a_dry_run_touches_nothing() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let ops = stream_dir(root, Stream::Ops).join("2026-08-22");
    let old = ops.join("old.jsonl");
    write(&old, 100 * 1024 * 1024);
    age(&old, 7200);

    let report = sweep(root, MIN_MAX_BYTES, &[], true);
    assert_eq!(report.removed.len(), 1, "it must still SAY what it would take");
    assert!(old.exists(), "a dry run removes nothing");
}

/// TEST8994: the limit cannot be set below the floor.
///
/// A limit smaller than one run's output sweeps away the log a page is
/// showing as fast as it is written, which reads as the workbench losing
/// output rather than as a limit set too small.
#[test]
fn test8994_the_limit_has_a_floor() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let path = stream_dir(root, Stream::Ops)
        .join("2026-08-22")
        .join("one.jsonl");
    write(&path, 32 * 1024 * 1024);

    // Asked for one byte; the floor is what is honoured, so the 32 MB log
    // stays.
    let report = sweep(root, 1, &[], false);
    assert!(report.removed.is_empty(), "{report:?}");
    assert!(path.exists());
}

/// TEST8995: an operation's log is named so that a listing reads in order.
#[test]
fn test8995_op_log_paths_are_dated_and_ordered() {
    let root = Path::new("/w/.wsb/disposable/logs");
    let id = uuid::Uuid::parse_str("3f9a2c7d-0000-4000-8000-000000000000").unwrap();
    let at: DateTime<Utc> = "2026-08-22T14:30:12Z".parse().unwrap();
    let path = op_log_path(root, "TestRun", &id, at);

    let name = path.file_name().unwrap().to_string_lossy().to_string();
    assert!(name.ends_with("-testrun-3f9a2c7d.jsonl"), "{name}");
    assert_eq!(
        path.parent().unwrap().parent().unwrap(),
        root.join("ops"),
        "one directory per day, under the stream"
    );
    // The date directory and the time prefix are LOCAL: an operator reads
    // "the build I ran after lunch", not a UTC offset from it.
    let local = at.with_timezone(&chrono::Local);
    assert_eq!(
        path.parent().unwrap().file_name().unwrap().to_string_lossy(),
        local.format("%Y-%m-%d").to_string()
    );
    assert!(name.starts_with(&local.format("%H%M%S").to_string()));
}

/// TEST8996: every stream is reported, including the empty ones.
#[test]
fn test8996_usage_names_every_stream() {
    let dir = tempfile::tempdir().unwrap();
    write(
        &stream_dir(dir.path(), Stream::Ops)
            .join("2026-08-22")
            .join("one.jsonl"),
        2048,
    );

    // And a file somebody parked at the root: counted in the total, kept out
    // of what a sweep may touch.
    std::fs::write(dir.path().join("a-screenshot.png"), "x".repeat(512)).unwrap();

    let usage = usage(dir.path());
    assert_eq!(usage.managed_bytes, 2048);
    assert_eq!(usage.unmanaged_bytes, 512);
    assert_eq!(usage.total_bytes, 2560, "the total is what `du` would say");
    assert_eq!(usage.entries, 1);
    for stream in Stream::ALL {
        assert!(
            usage.by_group.contains_key(stream.dir_name()),
            "{} is missing from {:?}",
            stream.dir_name(),
            usage.by_group
        );
    }
}

/// TEST11402: a sweep that cannot reach the bytes says so.
///
/// The logs root accumulated 174 MB in 239 dated directories written by an
/// EARLIER layout, before the streams existed. They sit at the root rather
/// than under `test/`, so `entries` never lists them: the sweep walked a
/// directory holding 174 MB, found nothing to remove, and reported success.
/// The settings page showed the 174 MB, the limit was 2 GB, and no limit an
/// operator could set would ever have reclaimed a byte of it.
///
/// "Over the limit and nothing I may touch" and "under the limit" are not the
/// same answer, and reporting the second one for the first is how a directory
/// grows unbounded with a limiter running. So the sweep reports `still_over`
/// when the ROOT is over its limit and what holds it is unmanaged — the same
/// signal it already raises when what is left is protected.
#[test]
fn test11402_unmanaged_bytes_over_the_limit_are_reported_not_hidden() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();

    // The old layout: a dated session directory at the ROOT, not under any
    // stream. This is exactly what the 239 directories were.
    write(&root.join("2026-07-12_17-44-53").join("runner.log"), 90 * 1024 * 1024);
    // And one file the sweep CAN reach, so the run is not vacuous.
    let reachable = stream_dir(root, Stream::Ops)
        .join("2026-08-22")
        .join("a.jsonl");
    write(&reachable, 10 * 1024 * 1024);
    age(&reachable, 3600);

    // 100 MB held against the 64 MiB floor. The sweep can only reach 10 MB of
    // it, so removing everything it may touch still leaves the root over.
    let report = sweep(root, MIN_MAX_BYTES, &[], false);

    assert!(
        report.still_over,
        "the root holds {} against a {} limit and the sweep reported it settled: {report:?}",
        human_bytes(usage(root).total_bytes),
        human_bytes(MIN_MAX_BYTES)
    );
    assert_eq!(
        report.unreachable_bytes,
        90 * 1024 * 1024,
        "the bytes no sweep can reach are named, so an operator knows a smaller \
         limit will not help: {report:?}"
    );
    // What it COULD reach, it still took: an unreachable remainder is not a
    // reason to stop sweeping the part that is managed.
    assert!(!reachable.exists(), "the reachable log was not swept: {report:?}");
}

/// Finding a finished operation's log again, which is the whole point of
/// writing one down.
mod find_op_log {
    use super::super::*;
    use uuid::Uuid;

    /// A logs root holding one finished operation's log.
    fn logged(id: Uuid, kind: &str) -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::TempDir::new().expect("temp dir");
        let root = root(dir.path());
        let writer = op_log::OpLogWriter::create(
            &root,
            id,
            kind,
            "running the declared suite",
            &["wsb".to_string(), "test".to_string()],
            chrono::Utc::now(),
        )
        .expect("a log is created");
        let path = writer.path().to_path_buf();
        writer.finish(op_log::Status::Completed, None);
        (dir, path)
    }

    #[test]
    fn test10244_a_finished_log_is_found_by_its_id() {
        let id = Uuid::new_v4();
        let (dir, path) = logged(id, "test_run");
        // Nothing in memory knows this operation — which is the state a daemon
        // restart leaves behind, and the state in which the output used to be
        // unreachable.
        assert_eq!(
            find_op_log(&root(dir.path()), &id),
            Some(path),
            "the log is found from the id alone"
        );
    }

    #[test]
    fn test10245_an_unknown_id_finds_nothing() {
        let (dir, _) = logged(Uuid::new_v4(), "test_run");
        assert_eq!(
            find_op_log(&root(dir.path()), &Uuid::new_v4()),
            None,
            "an id that was never logged has no file"
        );
    }

    #[test]
    fn test10246_a_short_id_collision_resolves_to_the_right_log() {
        // The NAME carries eight characters of the id. Two operations can
        // share those eight and still be different operations; the header
        // carries the whole id, and that is what decides.
        let first = Uuid::parse_str("aabbccdd-0000-4000-8000-000000000001").expect("uuid");
        let second = Uuid::parse_str("aabbccdd-0000-4000-8000-000000000002").expect("uuid");
        let dir = tempfile::TempDir::new().expect("temp dir");
        let logs = root(dir.path());
        let mut paths = Vec::new();
        for (id, kind) in [(first, "build"), (second, "test-run")] {
            let writer = op_log::OpLogWriter::create(
                &logs,
                id,
                kind,
                "label",
                &["wsb".to_string()],
                chrono::Utc::now(),
            )
            .expect("a log is created");
            paths.push(writer.path().to_path_buf());
            writer.finish(op_log::Status::Completed, None);
        }
        assert!(
            paths[0] != paths[1],
            "the two logs are different files: {paths:?}"
        );
        assert_eq!(find_op_log(&logs, &first), Some(paths[0].clone()));
        assert_eq!(find_op_log(&logs, &second), Some(paths[1].clone()));
    }

    #[test]
    fn test10247_a_log_root_that_was_never_used_finds_nothing() {
        let dir = tempfile::TempDir::new().expect("temp dir");
        // A workspace that has never run an operation has no ops directory.
        // That is a condition to answer, not to report.
        assert_eq!(find_op_log(&root(dir.path()), &Uuid::new_v4()), None);
    }
}