mbx 1.4.0

A build cache for Rust projects
Documentation
use super::*;

fn write_line(path: &Path, line: &str) {
    use std::io::Write as _;
    let mut file = File::options()
        .create(true)
        .append(true)
        .open(path)
        .unwrap();
    file.write_all(line.as_bytes()).unwrap();
}

#[test]
fn a_stream_records_a_build_from_start_to_totals() {
    let store = tempfile::tempdir().unwrap();
    let writer = EventWriter::new(store.path());
    writer.started(Path::new("/workspace"), &["build".into()]);
    writer.action(
        ActionOutcome::Hit,
        Some("serde".into()),
        11,
        ActionDetail {
            avoided_compiler_ns: 1_000,
            output_files: 2,
            output_bytes: 64,
            reflinked_output_bytes: 64,
            copied_output_bytes: 0,
        },
    );
    writer.action(
        ActionOutcome::Bypass {
            reason: "incremental".into(),
        },
        Some("mbx".into()),
        22,
        ActionDetail::default(),
    );
    writer.finished(serde_json::json!({ "hits": 1, "misses": 0 }));

    let paths = session_paths(store.path(), writer.id());
    let contents = std::fs::read_to_string(&paths.events).unwrap();
    let events = parse_events(&contents);
    assert_eq!(events.len(), 4);
    assert!(matches!(
        &events[0],
        SessionEvent::SessionStarted { command, .. } if command == &["build".to_string()]
    ));
    assert!(matches!(
        &events[1],
        SessionEvent::Action {
            outcome: ActionOutcome::Hit,
            crate_name: Some(name),
            duration_ns: 11,
            detail,
            ..
        } if name == "serde" && detail.output_bytes == 64
    ));
    assert_eq!(events[2].outcome_label(), Some("incremental"));
    assert!(matches!(
        &events[3],
        SessionEvent::SessionFinished { stats, .. } if stat(stats, "hits") == 1
    ));
}

#[test]
fn a_command_that_records_nothing_leaves_no_stream() {
    let store = tempfile::tempdir().unwrap();
    let writer = EventWriter::new(store.path());
    let paths = session_paths(store.path(), writer.id());
    drop(writer);

    assert!(!paths.events.exists());
    assert!(session_ids(store.path()).is_empty());
}

#[test]
fn a_tail_reads_only_what_was_appended() {
    let store = tempfile::tempdir().unwrap();
    let writer = EventWriter::new(store.path());
    writer.started(Path::new("/workspace"), &["build".into()]);
    let id = writer.id().to_string();

    let mut tail = SessionTail::new(store.path(), id);
    assert_eq!(tail.read().len(), 1);
    // Nothing new: a second read of an unchanged stream yields nothing rather
    // than the whole file again.
    assert!(tail.read().is_empty());

    writer.action(ActionOutcome::Miss, None, 5, ActionDetail::default());
    let appended = tail.read();
    assert_eq!(appended.len(), 1);
    assert_eq!(appended[0].outcome_label(), Some("miss"));
}

#[test]
fn a_tail_holds_back_a_line_that_has_no_newline_yet() {
    let store = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(store.path().join(SESSIONS_DIR)).unwrap();
    let paths = session_paths(store.path(), "1-1-aaaa");
    let complete = r#"{"type":"action","v":1,"ts_ms":1,"outcome":{"kind":"miss"},"duration_ns":1}"#;

    write_line(&paths.events, &format!("{complete}\n"));
    // A row the writer is still in the middle of appending.
    write_line(&paths.events, r#"{"type":"action","v":1,"ts_"#);

    let mut tail = SessionTail::new(store.path(), "1-1-aaaa".into());
    assert_eq!(tail.read().len(), 1);

    // The rest of the partial line arrives.
    write_line(
        &paths.events,
        "ms\":2,\"outcome\":{\"kind\":\"hit\"},\"duration_ns\":2}\n",
    );
    let appended = tail.read();
    assert_eq!(appended.len(), 1);
    assert_eq!(appended[0].outcome_label(), Some("hit"));
}

#[test]
fn a_reader_skips_a_line_it_cannot_parse() {
    let store = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(store.path().join(SESSIONS_DIR)).unwrap();
    let paths = session_paths(store.path(), "1-1-aaaa");
    write_line(&paths.events, "not json at all\n");
    write_line(
        &paths.events,
        "{\"type\":\"action\",\"v\":1,\"ts_ms\":1,\"outcome\":{\"kind\":\"hit\"},\"duration_ns\":1}\n",
    );
    // A record type this build does not know about.
    write_line(&paths.events, "{\"type\":\"from_the_future\",\"v\":9}\n");

    let mut tail = SessionTail::new(store.path(), "1-1-aaaa".into());
    let events = tail.read();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].outcome_label(), Some("hit"));
}

#[test]
fn an_event_keeps_its_meaning_when_a_newer_field_is_added() {
    let line = r#"{"type":"action","v":1,"ts_ms":1,"outcome":{"kind":"hit"},"duration_ns":7,"crate_name":"serde","invented_later":{"a":1}}"#;
    let events = parse_events(line);
    assert_eq!(events.len(), 1);
    assert!(matches!(
        &events[0],
        SessionEvent::Action {
            crate_name: Some(name),
            duration_ns: 7,
            ..
        } if name == "serde"
    ));
}

#[test]
fn a_live_stream_is_told_apart_from_a_finished_and_an_abandoned_one() {
    let store = tempfile::tempdir().unwrap();

    let writer = EventWriter::new(store.path());
    writer.started(Path::new("/workspace"), &["build".into()]);
    let mut live = SessionTail::new(store.path(), writer.id().to_string());
    live.read();
    assert_eq!(live.state(), SessionState::Live);

    // The build ends with its totals.
    writer.finished(serde_json::json!({ "hits": 0 }));
    let mut finished = SessionTail::new(store.path(), writer.id().to_string());
    finished.read();
    assert_eq!(finished.state(), SessionState::Finished);

    // The build dies: the lock goes with the process, and no totals were ever
    // written.
    let abandoned = EventWriter::new(store.path());
    abandoned.started(Path::new("/workspace"), &["build".into()]);
    let id = abandoned.id().to_string();
    drop(abandoned);
    let mut tail = SessionTail::new(store.path(), id);
    tail.read();
    assert_eq!(tail.state(), SessionState::Abandoned);
}

#[test]
fn a_stream_stops_growing_at_its_cap_but_still_reports_its_totals() {
    let store = tempfile::tempdir().unwrap();
    // A cap of one byte: the first row reaches it, so every later row is
    // dropped, which is the same path a 16 MiB build takes.
    let writer = EventWriter::with_cap(store.path(), 1);
    writer.started(Path::new("/workspace"), &["build".into()]);
    for _ in 0..5 {
        writer.action(
            ActionOutcome::Miss,
            Some("serde".into()),
            1,
            ActionDetail::default(),
        );
    }
    writer.finished(serde_json::json!({ "hits": 3 }));

    let paths = session_paths(store.path(), writer.id());
    let events = parse_events(&std::fs::read_to_string(&paths.events).unwrap());
    // One truncation notice however many rows were dropped, and the totals.
    assert_eq!(
        events
            .iter()
            .filter(|event| matches!(event, SessionEvent::Truncated { .. }))
            .count(),
        1
    );
    assert!(
        !events
            .iter()
            .any(|event| matches!(event, SessionEvent::Action { .. }))
    );
    assert!(matches!(
        events.last(),
        Some(SessionEvent::SessionFinished { stats, .. }) if stat(stats, "hits") == 3
    ));
}

#[test]
fn tails_open_newest_first_and_no_more_than_asked() {
    let store = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(store.path().join(SESSIONS_DIR)).unwrap();
    for id in ["100-1-aaaa", "200-1-bbbb", "300-1-cccc"] {
        write_line(&session_paths(store.path(), id).events, "\n");
    }

    let tails = open_tails(store.path(), 2);
    let ids: Vec<&str> = tails.iter().map(SessionTail::id).collect();
    assert_eq!(ids, ["300-1-cccc", "200-1-bbbb"]);
}