fairlead 0.18.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
use super::*;
use fairlead_replay::dataset::Job;

fn config() -> Config {
    toml::from_str(
        r#"
[[tests.runners]]
id = "unit"
match = ["test/**"]
command = ["sh", "{files}"]

[[checks]]
id = "lint"
command = ["sh"]
"#,
    )
    .unwrap()
}

fn done_event(steps: &[(&str, bool, f64)], plan: f64) -> String {
    let steps: Vec<Value> = steps
        .iter()
        .map(|(id, passed, seconds)| serde_json::json!({"id": id, "passed": passed, "seconds": seconds}))
        .collect();
    serde_json::json!({"at": "2026-11-05T10:00:00Z", "stage": "done", "steps": steps, "plan_seconds": plan})
        .to_string()
}

fn kinds(s: &Speed) -> Vec<(&str, String)> {
    s.lines
        .iter()
        .map(|l| (l.kind, l.subject.clone()))
        .collect()
}

#[test]
fn a_step_that_never_fails_one_that_always_fails_and_one_faster_than_its_plan_are_named() {
    let mut log = String::new();
    for i in 0..NEVER_FAILED {
        log.push_str(&done_event(
            &[
                ("unit", true, 0.4),
                ("lint", true, 20.0 + i as f64),
                ("guard", false, 1.0),
            ],
            2.0,
        ));
        log.push('\n');
    }
    log.push_str("{\"at\": \"2026-10-01T00:00:00Z\", \"stage\": \"done\", \"steps\": [{\"id\": \"unit\", \"passed\": false, \"seconds\": 9}]}\n");
    let mut inputs = Inputs::default();
    inputs.add_events(&log, "2026-11-01");
    let s = coach(&inputs, &config(), None);
    assert_eq!(s.plan_seconds, Some(2.0));
    let k = kinds(&s);
    assert_eq!(k[0], ("slowest", "lint (done)".to_string()));
    assert!(k.contains(&("never-failed", "lint (done)".into())), "{k:?}");
    assert!(
        k.contains(&("never-failed", "unit (done)".into())),
        "an event before --since doesn't count: {k:?}"
    );
    assert!(
        k.contains(&("always-fails", "guard (done)".into())),
        "{k:?}"
    );
    assert!(k.contains(&("run-whole", "unit (done)".into())), "{k:?}");
    assert!(
        !k.contains(&("never-failed", "guard (done)".into())),
        "guard isn't a runner or check"
    );
    let whole = s.lines.iter().find(|l| l.kind == "run-whole").unwrap();
    assert_eq!(
        whole.numbers,
        "its longest run took 0.4 s against 2.0 s to plan"
    );
    assert!(whole.suggestion.contains("all_from = \"draft\""));
    let later = s
        .lines
        .iter()
        .find(|l| l.kind == "never-failed" && l.subject.starts_with("lint"))
        .unwrap();
    assert!(
        later
            .suggestion
            .contains("`[[checks]] id = \"lint\", from = \"merge\"`"),
        "{}",
        later.suggestion
    );
}

#[test]
fn without_a_plan_time_no_run_whole_line_and_the_replay_plan_time_stands_in() {
    let mut inputs = Inputs::default();
    inputs.add_results(&serde_json::json!({"invocations": [
        {"id": "unit", "passed": true, "seconds": 0.5},
        {"id": "unit", "passed": true, "seconds": 0.6},
        {"id": "unit", "passed": true, "seconds": 0.7}]}));
    let none = coach(&inputs, &config(), None);
    assert!(none.lines.iter().all(|l| l.kind != "run-whole"));
    assert!(render(&none).contains("no plan time recorded yet"));
    let replayed = coach(&inputs, &config(), Some(1.5));
    let whole = replayed
        .lines
        .iter()
        .find(|l| l.kind == "run-whole")
        .unwrap();
    assert_eq!(whole.subject, "unit (ci run)");
    assert!(replayed.lines[0].suggestion.contains("--shards 2"));
}

fn row(run_id: u64, attempt: u32, pr: u64, conclusion: &str, job_seconds: Option<u64>) -> Row {
    Row {
        repo: "o/r".into(),
        run_id,
        attempt,
        event: "pull_request".into(),
        workflow: "ci".into(),
        pr: Some(pr),
        head_sha: "h".into(),
        base_sha: None,
        created_at: "2026-11-05T10:00:00Z".into(),
        conclusion: conclusion.into(),
        seconds: None,
        jobs: match conclusion {
            "cancelled" => Vec::new(),
            c => vec![Job {
                name: "test".into(),
                conclusion: c.into(),
                failed_steps: Vec::new(),
                annotations: Vec::new(),
                annotations_capped: false,
                log: Vec::new(),
                image: None,
                seconds: job_seconds,
            }],
        },
    }
}

#[test]
fn a_dataset_gives_reruns_cancellations_and_the_pull_requests_behind_them() {
    let mut inputs = Inputs::default();
    inputs.add_rows(vec![
        row(1, 1, 7, "failure", Some(60)),
        row(1, 2, 7, "success", Some(50)),
        row(2, 1, 7, "cancelled", None),
        row(3, 1, 8, "success", Some(40)),
        row(4, 1, 8, "cancelled", None),
    ]);
    let s = coach(&inputs, &config(), None);
    let line = |kind: &str| {
        s.lines
            .iter()
            .find(|l| l.kind == kind)
            .unwrap()
            .numbers
            .clone()
    };
    assert_eq!(
        line("reruns"),
        "1 reruns over 4 runs; 1 passed only on a rerun of the same commit (25%)"
    );
    assert_eq!(line("cancelled"), "2 of 4 runs cancelled (50%)");
    assert_eq!(
        line("pulls"),
        "#7: 2 runs, 1 reruns, 1 cancelled; #8: 2 runs, 0 reruns, 1 cancelled"
    );
    let job = s.steps.iter().find(|st| st.source == Source::Job).unwrap();
    assert_eq!((job.runs, job.failed, job.max), (3, 1, 60.0));
}

#[test]
fn nothing_recorded_says_where_numbers_come_from() {
    let s = coach(&Inputs::default(), &config(), None);
    assert!(render(&s).contains("nothing recorded yet"));
}