fairlead 0.20.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 crate::setup_prove::{Proof, Record};

fn config(text: &str) -> Config {
    toml::from_str(text).unwrap()
}

fn suites(files: Vec<Vec<&str>>) -> Suites {
    let files: Vec<Vec<String>> = files
        .into_iter()
        .map(|f| f.into_iter().map(String::from).collect())
        .collect();
    Suites {
        tests: files.iter().map(Vec::len).sum(),
        files,
        unmatched: Vec::new(),
        ambiguous: Vec::new(),
        error: None,
    }
}

const TWO: &str = r#"
[[tests.runners]]
id = "unit"
match = ["src/**"]
command = ["npx", "vitest", "run", "{files}"]

[[tests.runners]]
id = "e2e"
match = ["e2e/**"]
command = ["npx", "playwright", "test", "{files}"]
"#;

#[test]
fn only_the_track_decides_whether_ci_is_required_and_where_it_prints() {
    for id in ["binary", "config", "runners", "hooks", "git-hook", "agents"] {
        assert!(required(id, Track::New), "{id}");
    }
    for id in ["proved", "smoke", "gate", "measure", "tests"] {
        assert!(!required(id, Track::Ongoing), "{id}");
    }
    assert!(required("ci", Track::Ongoing));
    assert!(!required("ci", Track::Mvp));
    let list = || vec![ok("hooks", ""), ok("ci", ""), ok("gate", "")];
    let ids = |items: Vec<Item>| items.iter().map(|i| i.id).collect::<Vec<_>>();
    assert_eq!(ids(in_order(list(), Track::Mvp)), ["hooks", "ci", "gate"]);
    assert_eq!(ids(in_order(list(), Track::New)), ["hooks", "gate", "ci"]);
}

#[test]
fn open_counts_every_open_item_and_only_a_required_one_blocks() {
    let mut items = vec![
        ok("binary", ""),
        open("gate", "", "fairlead done"),
        later("ci", ""),
        open("hooks", "", "fairlead hooks install"),
    ];
    assert_eq!(open_of(&items), (2, Some("gate")));
    assert!(!blocked(&items));
    items[3].required = true;
    assert!(blocked(&items));
    assert_eq!(open_of(&items[..1]), (0, None));
}

#[test]
fn runners_are_open_for_a_test_with_no_runner_or_two_or_a_command_naming_no_file() {
    let c = config(TWO);
    let fine = suites(vec![vec!["src/a.test.ts"], vec!["e2e/b.spec.ts"]]);
    let item = runners(&c, &fine);
    assert_eq!(item.state, State::Ok);
    assert_eq!(
        item.text,
        "2 runners, 2 test files, each with exactly one runner"
    );
    let unmatched = Suites {
        unmatched: vec!["lib/x.test.ts".into()],
        ..suites(vec![vec![], vec![]])
    };
    let item = runners(&c, &unmatched);
    assert_eq!(item.text, "1 test file with no runner, first lib/x.test.ts");
    assert!(item.fix.unwrap().contains("`lib/x.test.ts`"));
    let twice = Suites {
        ambiguous: vec![("src/a.spec.ts".into(), vec!["unit".into(), "e2e".into()])],
        ..suites(vec![vec!["src/a.spec.ts"], vec![]])
    };
    let item = runners(&c, &twice);
    assert_eq!(
        item.text,
        "1 test file with more than one runner, first src/a.spec.ts (unit, e2e)"
    );
    let whole = config(
        "[[tests.runners]]\nid = \"whole\"\nmatch = [\"e2e/**\"]\ncommand = [\"npx\", \"playwright\", \"test\"]\n",
    );
    let item = runners(&whole, &suites(vec![vec!["e2e/b.spec.ts"]]));
    assert_eq!(item.state, State::Open);
    assert_eq!(
        item.fix.as_deref(),
        Some("put `{files}` where the test files go in fairlead.toml")
    );
    let none = runners(&Config::default(), &suites(vec![]));
    assert_eq!(none.state, State::Ok);
    let unrun = Suites {
        tests: 3,
        ..suites(vec![])
    };
    assert_eq!(runners(&Config::default(), &unrun).state, State::Open);
}

#[test]
fn proved_reads_the_record_as_open_later_or_ok() {
    let c = config(TWO);
    let s = suites(vec![vec!["src/a.test.ts"], vec!["e2e/b.spec.ts"]]);
    let proof = |runner: &str, passed: Option<bool>, why: Option<&str>| Proof {
        runner: runner.into(),
        file: "f".into(),
        passed,
        seconds: 0.4,
        why: why.map(String::from),
    };
    let none = proved(&c, &s, &Record::default());
    assert_eq!(none.state, State::Open);
    assert_eq!(
        none.text,
        "2 of 2 runners haven't run a test file here: unit, e2e"
    );
    let later = Record {
        proved: vec![
            proof("unit", Some(true), None),
            proof("e2e", None, Some("need \"browser\" not met")),
        ],
    };
    let item = proved(&c, &s, &later);
    assert_eq!(item.state, State::Later);
    assert_eq!(item.text, "e2e can't run here: need \"browser\" not met");
    let failed = Record {
        proved: vec![
            proof("unit", Some(false), Some("1 test failed (exit 1)")),
            proof("e2e", None, Some("need \"browser\" not met")),
        ],
    };
    let item = proved(&c, &s, &failed);
    assert_eq!(item.state, State::Open);
    assert_eq!(item.text, "unit failed on f: 1 test failed (exit 1)");
    let passed = Record {
        proved: vec![
            proof("unit", Some(true), None),
            proof("e2e", Some(true), None),
        ],
    };
    let item = proved(&c, &s, &passed);
    assert_eq!(item.state, State::Ok);
    assert_eq!(
        item.text,
        "2 runners ran one test file: unit 0.4 s, e2e 0.4 s"
    );
    let empty = proved(&c, &suites(vec![vec![], vec![]]), &passed);
    assert_eq!(empty.state, State::Later);
}

#[test]
fn smoke_is_wanted_past_50_test_files_or_behind_a_need() {
    let c = config(TWO);
    let many: Vec<String> = (0..51).map(|i| format!("src/{i}.test.ts")).collect();
    let big = suites(vec![many.iter().map(String::as_str).collect(), vec![]]);
    let item = smoke(&c, &big);
    assert_eq!(item.state, State::Open);
    assert_eq!(item.text, "unit: 51 test files, and it names no `smoke`");
    let small = suites(vec![vec!["src/a.test.ts"], vec!["e2e/b.spec.ts"]]);
    assert_eq!(smoke(&c, &small).state, State::Ok);
    let needy = config(&format!(
        "{TWO}\n[[needs]]\nid = \"browser\"\nenv = \"BROWSER\"\ntests = [\"e2e/**\"]\n"
    ));
    let item = smoke(&needy, &small);
    assert_eq!(
        item.text,
        "e2e: need \"browser\" covers it, and it names no `smoke`"
    );
    let named = config(&format!(
        "{}smoke = [\"e2e/b.spec.ts\"]\n\n[[needs]]\nid = \"browser\"\nenv = \"BROWSER\"\ntests = [\"e2e/**\"]\n",
        TWO
    ));
    assert_eq!(smoke(&named, &small).state, State::Ok);
    let stale = config(&format!("{TWO}smoke = [\"e2e/gone.spec.ts\"]\n"));
    let item = smoke(&stale, &small);
    assert_eq!(item.state, State::Open);
    assert_eq!(
        item.text,
        "e2e: its `smoke` entry `e2e/gone.spec.ts` names no test file it matches"
    );
}

#[test]
fn the_gate_wants_a_pass_in_30_days_and_the_scorecard_10_runs_with_reach() {
    let event = |at: &str, decision: &str, reach: bool| {
        let mut e = serde_json::json!({"at": at, "stage": "done", "decision": decision});
        if reach {
            e["reach"] = serde_json::json!({"all": false, "reached": 1, "suite": 2, "ran": 1});
        }
        e
    };
    let today = days("2026-03-01").unwrap();
    let fresh = [event("2026-02-20T10:00:00.000Z", "pass", true)];
    assert_eq!(
        gate(&fresh, today).text,
        "`fairlead done` last passed 9 days ago"
    );
    let stale = [event("2026-01-01T10:00:00.000Z", "pass", true)];
    assert_eq!(gate(&stale, today).state, State::Open);
    let failed = [event("2026-02-28T10:00:00.000Z", "fail", true)];
    let item = gate(&failed, today);
    assert_eq!(
        (item.state, item.fix.as_deref()),
        (State::Open, Some("fairlead done"))
    );
    let item = measure(&fresh);
    assert_eq!(item.state, State::Later);
    assert_eq!(item.text, "the scorecard needs 9 more gate runs");
    let ten: Vec<Value> = (0..10)
        .map(|_| event("2026-02-20T10:00:00.000Z", "pass", true))
        .collect();
    assert_eq!(measure(&ten).state, State::Ok);
    let bare: Vec<Value> = (0..10)
        .map(|_| event("2026-02-20T10:00:00.000Z", "pass", false))
        .collect();
    assert_eq!(measure(&bare).state, State::Later);
}

#[test]
fn the_binary_is_open_below_the_floor_and_the_fix_is_how_the_repository_installs_it() {
    let dir = std::env::temp_dir().join(format!("fl-setup-binary-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let floor = config("fairlead = \"99.0\"\n");
    let item = binary(&dir, Some(&floor));
    assert_eq!(item.state, State::Open);
    assert!(
        item.text.starts_with("the config needs 99.0 or later"),
        "{}",
        item.text
    );
    assert_eq!(item.fix.as_deref(), Some(INSTALLER));
    std::fs::write(
        dir.join("package.json"),
        r#"{"devDependencies": {"fairlead": "^99.0.0"}}"#,
    )
    .unwrap();
    std::fs::write(dir.join("pnpm-lock.yaml"), "").unwrap();
    let item = binary(&dir, None);
    assert!(
        item.text.starts_with("package.json asks for ^99.0.0"),
        "{}",
        item.text
    );
    assert_eq!(item.fix.as_deref(), Some("pnpm install"));
    let _ = std::fs::remove_dir_all(&dir);
}