wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What a log file has to survive to be worth writing.

use super::*;
use crate::output::Record;

fn line(seq: u64, text: &str) -> Line {
    Line {
        seq,
        ts: "2026-08-22T14:30:12Z".to_string(),
        record: Record::Stdout {
            text: text.to_string(),
        },
    }
}

fn written(dir: &Path, lines: &[&str], finish: Option<Status>) -> PathBuf {
    let writer = OpLogWriter::create(
        dir,
        uuid::Uuid::new_v4(),
        "test_run",
        "the declared suite",
        &["wsb".to_string(), "test".to_string()],
        chrono::Utc::now(),
    )
    .expect("the log opens");
    for (seq, text) in lines.iter().enumerate() {
        writer.write_line(&line(seq as u64, text));
    }
    if let Some(status) = finish {
        writer.finish(status, None);
    }
    writer.path().to_path_buf()
}

/// TEST8981: what was written is what is read back, in order.
#[test]
fn test8981_a_log_round_trips() {
    let dir = tempfile::tempdir().unwrap();
    let path = written(
        dir.path(),
        &["first", "second", "third"],
        Some(Status::Completed),
    );

    let log = read(&path).expect("the log reads back");
    assert_eq!(log.lines.len(), 3);
    assert_eq!(log.header.label, "the declared suite");
    assert_eq!(log.header.argv, vec!["wsb", "test"]);
    assert_eq!(log.unreadable, 0);
    let footer = log.footer.expect("a finished operation has a footer");
    assert_eq!(footer.status, Status::Completed);
    assert_eq!(footer.lines, 3);
    // The records themselves, not a rendering of them: the run view a page
    // folds from a file must be the one it folds from the live stream.
    match &log.lines[1].record {
        Record::Stdout { text } => assert_eq!(text, "second"),
        other => panic!("the record changed shape on the way to disk: {other:?}"),
    }
    assert_eq!(log.lines[1].ts, "2026-08-22T14:30:12Z");
}

/// TEST8982: an operation that never finished says so.
///
/// The file of a run whose daemon was killed has no footer. Read as a finished
/// run it would claim an outcome nothing ever decided — and the page would
/// show a build as completed that in fact died.
#[test]
fn test8982_an_unfinished_log_has_no_footer() {
    let dir = tempfile::tempdir().unwrap();
    let path = written(dir.path(), &["started", "working"], None);

    let log = read(&path).expect("an unfinished log still reads");
    assert_eq!(log.lines.len(), 2);
    assert!(
        log.footer.is_none(),
        "an operation that did not finish must not read as one that did"
    );

    let (header, footer) = read_envelope(&path).expect("the envelope reads");
    assert_eq!(header.kind, "test_run");
    assert!(footer.is_none());
}

/// TEST8983: the envelope is read without reading the whole file.
///
/// Startup reads every recent log's envelope to rebuild history. If that meant
/// parsing every line, a directory holding a few large build logs would put
/// hundreds of megabytes of JSON in front of the first page draw.
#[test]
fn test8983_the_envelope_is_read_from_the_ends() {
    let dir = tempfile::tempdir().unwrap();
    let long: Vec<String> = (0..5000).map(|i| format!("line {i} {}", "x".repeat(200))).collect();
    let refs: Vec<&str> = long.iter().map(String::as_str).collect();
    let path = written(dir.path(), &refs, Some(Status::Failed));

    let size = std::fs::metadata(&path).unwrap().len();
    assert!(size > 1_000_000, "the fixture must be big enough to matter");

    let (header, footer) = read_envelope(&path).expect("the envelope reads");
    assert_eq!(header.label, "the declared suite");
    let footer = footer.expect("a finished operation has a footer");
    assert_eq!(footer.status, Status::Failed);
    assert_eq!(footer.lines, 5000);
}

/// TEST8984: a line that is not readable is counted, not silently dropped.
#[test]
fn test8984_unreadable_lines_are_counted() {
    let dir = tempfile::tempdir().unwrap();
    let path = written(dir.path(), &["good"], None);
    // A truncated final line: what a daemon killed mid-write leaves behind.
    {
        use std::io::Write;
        let mut file = std::fs::File::options().append(true).open(&path).unwrap();
        writeln!(file, "{{\"seq\":1,\"type\":\"std").unwrap();
    }

    let log = read(&path).expect("a damaged log still reads");
    assert_eq!(log.lines.len(), 1);
    assert_eq!(
        log.unreadable, 1,
        "a line that could not be read must be counted, not hidden"
    );
}

/// TEST8985: recent logs come back newest first, across dated directories.
#[test]
fn test8985_recent_logs_are_newest_first() {
    let dir = tempfile::tempdir().unwrap();
    let ops = super::super::stream_dir(dir.path(), super::super::Stream::Ops);
    let days: [(&str, &[&str]); 2] = [
        ("2026-08-21", &["090000", "170000"]),
        ("2026-08-22", &["080000"]),
    ];
    for (day, times) in days {
        for time in times {
            let path = ops.join(day).join(format!("{time}-test-run-aaaaaaaa.jsonl"));
            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
            std::fs::write(&path, "{}\n").unwrap();
        }
    }

    let found = recent_logs(dir.path(), 10);
    let names: Vec<String> = found
        .iter()
        .map(|p| {
            format!(
                "{}/{}",
                p.parent().unwrap().file_name().unwrap().to_string_lossy(),
                p.file_name().unwrap().to_string_lossy()
            )
        })
        .collect();
    assert_eq!(
        names,
        vec![
            "2026-08-22/080000-test-run-aaaaaaaa.jsonl",
            "2026-08-21/170000-test-run-aaaaaaaa.jsonl",
            "2026-08-21/090000-test-run-aaaaaaaa.jsonl",
        ]
    );

    // And the limit is a limit on what is returned, taken from the newest end.
    let two = recent_logs(dir.path(), 2);
    assert_eq!(two.len(), 2);
    assert!(two[0].ends_with("2026-08-22/080000-test-run-aaaaaaaa.jsonl"));
}