//! Run-aggregator spine for `mcptest run` (WOR-1481).
//!
//! A run already executes tool and agent tests and folds their gates into one
//! exit code. Several capabilities (compliance, security, conformance,
//! scenarios, model-compat, performance) today live behind their own
//! subcommands, so a developer who wants the full picture runs the suite once
//! and then re-runs each tool by hand. This module introduces a `Lane`
//! abstraction so a single `mcptest run` can drive those extra capabilities
//! alongside the test/agent run, each lane contributing report rows and folding
//! into the same single exit code.
//!
//! The compliance lane (WOR-1482) runs the suite's `compliance:` block, the
//! security lane (WOR-1483) runs its `security: { scan: true }` opt-in, the
//! conformance lane (WOR-1484) runs its `conformance:` block, the scenarios
//! lane (WOR-1485) runs its `scenarios:` block, and the model-compat lane
//! (WOR-1486) diffs its `model_compat:` baseline against the candidate, each
//! folding its outcomes into report rows. The performance lane (WOR-1487) reads
//! the finished report's per-test durations, so it folds in via
//! [`run_performance_lane`] rather than the [`Lane`] trait; it is advisory. A
//! run with none of those blocks stays byte-identical to before.

use std::path::Path;

use mcptest_core::checks::CheckOutcome;
use mcptest_core::report::Report;
use mcptest_core::suite::ResolvedSuite;

use super::run_live_checks::append_check_outcomes;

/// Whether a lane's failures should fail the run (`Gating`) or only be reported
/// (`Advisory`).
///
/// A gating lane folds into the exit code exactly like a failing test, so CI
/// breaks on it. An advisory lane still appends its rows to the report (so the
/// developer sees the finding) but never changes the exit code, which lets a
/// new capability ride along in informational mode before it is trusted to gate
/// a pipeline.
///
/// `Gating` is constructed by the compliance, security, conformance, scenarios,
/// and model-compat lanes. `Advisory` is constructed by the performance lane
/// (WOR-1487): the spine fix in WOR-1491 excludes an advisory lane's failing
/// rows from the exit-code tally, so the row renders but never gates CI.
pub(super) enum LaneGating {
    /// Failures fold into the process exit code (exit 1 like a failing test).
    Gating,
    /// Failures are reported but never change the exit code. The spine excludes
    /// these from the exit-code tally (WOR-1491).
    Advisory,
}

/// One lane's contribution to the run: the report rows it produced, whether any
/// of them failed, how it gates, and a short label for the per-lane summary.
///
/// Bundling the rows with the `any_failed`/`gating` decision keeps the
/// fold step ([`fold_lane_verdicts`]) a pure transform: it appends the rows and
/// decides the gate from this one value, with no second pass over the report.
///
/// The compliance (WOR-1482), scenarios (WOR-1485), and model-compat
/// (WOR-1486) lanes construct these today; the remaining lane engines
/// (WOR-1483/1484/1487) build them as they land.
pub(super) struct LaneVerdict {
    /// The PASS/FAIL rows this lane added, folded onto the report as-is.
    pub rows: Vec<CheckOutcome>,
    /// True when at least one of `rows` failed; precomputed so the fold does not
    /// re-derive it from the report after appending.
    pub any_failed: bool,
    /// Whether `any_failed` should fail the run or only be reported.
    pub gating: LaneGating,
    /// Short human label (for example `"compliance"`) for the stderr summary.
    pub label: String,
}

/// Read-only inputs every lane needs to run after the drive completes.
///
/// A lane reads the resolved suite (its servers and declared capabilities), the
/// config path (to resolve relative inputs the way the check blocks do), and
/// the quiet flag (so a lane can stay silent under `--quiet`). It is a borrow
/// bundle so [`run_post_drive_lanes`] passes one value to every lane instead of
/// threading three arguments through each call.
///
/// `suite` and `config_path` are read by the compliance lane (WOR-1482) and the
/// scenarios lane (WOR-1485); the remaining lane engines (WOR-1483..1487)
/// consume the rest as they land.
pub(super) struct LaneCtx<'a> {
    /// The resolved suite the run executed, the source of each lane's targets.
    pub suite: &'a ResolvedSuite,
    /// The suite file path, so a lane resolves relative inputs against the
    /// suite directory like the existing check blocks do.
    pub config_path: &'a std::path::Path,
    /// The `--quiet` flag, so a lane suppresses its own chatter when set. No
    /// lane reads it yet (the scenarios lane is silent and the run-level quiet
    /// flag already governs the fold summary), so a narrow allow keeps the build
    /// clean until a chatty lane lands.
    #[allow(dead_code)]
    pub quiet: bool,
}

/// A capability lane the run drives after the test/agent drive completes.
///
/// Each lane owns one capability (compliance, security, and so on) and decides
/// whether it has anything to contribute to this run. Returning an empty `Vec`
/// means "not applicable to this suite" (a no-op); a non-empty `Vec` contributes
/// one or more verdicts (a gating verdict plus an optional advisory verdict, see
/// WOR-1518); and `Err(_)` is a lane-level failure the spine turns into a failing
/// gating row rather than dropping (WOR-1513).
pub(super) trait Lane {
    /// Run this lane against the post-drive context. An empty `Vec` is a no-op.
    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>>;

    /// Short label for this lane, matching the `LaneVerdict.label` it produces.
    /// Used to name the synthetic failing row when the lane errors (WOR-1513),
    /// so a lane that could not run gates the build instead of vanishing.
    fn label(&self) -> &'static str;
}

// WOR-1518: a lane returns a Vec of verdicts so it can keep its gating rows and
// its advisory rows in separate verdicts. Advisory findings render as failing
// rows in an `Advisory` verdict (so `--json` consumers see `passed: false`) that
// the spine keeps out of the exit code, instead of the old passing-row dodge.

/// Compliance lane (WOR-1482): runs the suite's `compliance:` block through
/// the same engine `mcptest compliance run --from-suite` uses, folding each
/// check into a gating row so one `mcptest run` covers compliance too.
struct ComplianceLane;

impl Lane for ComplianceLane {
    fn label(&self) -> &'static str {
        "compliance"
    }

    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>> {
        if ctx.suite.compliance_tests.is_empty() {
            return Ok(vec![]);
        }
        // `run_suite_live` builds its own tokio runtime and `block_on`s it,
        // but the lanes run inside the run's ambient runtime. Entering a
        // runtime from within another panics, so the call goes on a fresh OS
        // thread that has no ambient runtime.
        let path = ctx.config_path.to_path_buf();
        let results =
            std::thread::spawn(move || crate::commands::compliance_suite::run_suite_live(&path))
                .join()
                .map_err(|_| anyhow::anyhow!("compliance lane worker thread panicked"))??;

        // `run_suite_live` runs the compliance checks AND the suite's ride-along
        // tool/resource/prompt tests, so `mcptest compliance run` and a full run
        // stay consistent. The lane folds ONLY the compliance checks: the
        // ride-along tests are already run and counted by this `mcptest run`, so
        // folding them again would double-count them. Each compliance check's
        // rule id is `rule_id_from_name(check.name)`, the same derivation the
        // engine uses, so this set matches the compliance result rows exactly.
        let compliance_ids: std::collections::HashSet<&str> = ctx
            .suite
            .compliance_tests
            .iter()
            .map(|c| crate::commands::compliance_suite::rule_id_from_name(&c.name))
            .collect();
        let rows: Vec<CheckOutcome> = results
            .iter()
            .filter(|r| compliance_ids.contains(r.rule_id.as_str()))
            .map(|r| CheckOutcome {
                name: format!("compliance/{}", r.rule_id),
                passed: r.passed,
                failure: if r.passed {
                    None
                } else {
                    Some(
                        r.message
                            .clone()
                            .unwrap_or_else(|| "compliance check failed".into()),
                    )
                },
            })
            .collect();
        let any_failed = rows.iter().any(|r| !r.passed);
        // Compliance checks issue fixed protocol calls and score deterministic
        // pass/fail outcomes, so their verdict gates the run like a failing
        // test.
        Ok(vec![LaneVerdict {
            rows,
            any_failed,
            gating: LaneGating::Gating,
            label: "compliance".into(),
        }])
    }
}

/// Security lane (WOR-1483): when the suite opts in with `security: { scan:
/// true }`, runs the deterministic security scan over the suite's server tool
/// surface and folds each finding into a row. The lane body lives in
/// [`super::run_live_lane_security`] so this module stays under the
/// production-size cap. It only ever runs the deterministic lanes; the advisory
/// LLM judge stays behind `mcptest security --model` (ADR 0041).
struct SecurityFullLane;

impl Lane for SecurityFullLane {
    fn label(&self) -> &'static str {
        "security"
    }

    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>> {
        super::run_live_lane_security::run(ctx)
    }
}

/// Conformance lane (WOR-1484): when the suite declares a `conformance:` block,
/// connects the suite's server, captures the MCP handshake evidence, runs the
/// SEP conformance probes, and folds each probe outcome into a row. A failed
/// MUST gates the run; a failed SHOULD or MAY is advisory. The lane body lives
/// in [`super::run_live_lane_conformance`] so this module stays under the
/// production-size cap.
struct ConformanceLane;

impl Lane for ConformanceLane {
    fn label(&self) -> &'static str {
        "conformance"
    }

    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>> {
        super::run_live_lane_conformance::run(ctx)
    }
}

/// Scenarios lane: runs the suite's declared multi-step scenarios and folds
/// each scenario outcome into a gating row (WOR-1485).
struct ScenariosLane;

impl Lane for ScenariosLane {
    fn label(&self) -> &'static str {
        "scenarios"
    }

    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>> {
        if ctx.suite.scenarios.is_empty() {
            return Ok(vec![]);
        }
        // Scenario `cassette:` paths resolve against the suite directory, the
        // same base the `mcptest scenario` command uses.
        let base_dir = ctx.config_path.parent().unwrap_or_else(|| Path::new("."));
        let mut rows = Vec::with_capacity(ctx.suite.scenarios.len());
        for scenario in &ctx.suite.scenarios {
            // A scenario-engine error (missing cassette, load failure) is a real
            // failure, so propagate it rather than degrading to a skip.
            let outcome = super::scenario::evaluate(scenario, base_dir)?;
            let passed = outcome.passed();
            rows.push(CheckOutcome {
                name: format!("scenario/{}", scenario.name),
                passed,
                failure: if passed {
                    None
                } else {
                    Some(outcome.failures.join("; "))
                },
            });
        }
        let any_failed = rows.iter().any(|r| !r.passed);
        // Scenarios replay recorded cassettes through a deterministic engine, so
        // their verdict is reproducible and gates the run like a failing test.
        Ok(vec![LaneVerdict {
            rows,
            any_failed,
            gating: LaneGating::Gating,
            label: "scenarios".into(),
        }])
    }
}

/// Model-compat lane (WOR-1486): diffs the suite's `model_compat:` baseline
/// against its candidate snapshot with the offline diff engine and folds the
/// verdict in. A clean diff is one passing row, a breaking change is a gating
/// failing row, and non-breaking drift is reported but advisory (never gates).
struct ModelCompatLane;

impl Lane for ModelCompatLane {
    fn label(&self) -> &'static str {
        "model-compat"
    }

    fn run(&self, ctx: &LaneCtx<'_>) -> anyhow::Result<Vec<LaneVerdict>> {
        let Some(block) = &ctx.suite.model_compat else {
            return Ok(vec![]);
        };
        // The two snapshot paths resolve against the suite directory, the same
        // base the check blocks use for their relative inputs.
        let base_dir = ctx.config_path.parent().unwrap_or_else(|| Path::new("."));
        let baseline_path = base_dir.join(&block.baseline);
        let candidate_path = base_dir.join(&block.candidate);

        // `diff_baselines` is offline (no server, no tokio runtime), so the load
        // and diff run inline with no worker thread.
        let baseline = crate::commands::model_compat::read_baseline(&baseline_path)?;
        let candidate = crate::commands::model_compat::read_baseline(&candidate_path)?;
        let diff = mcptest_core::model_compat::diff_baselines(&baseline, &candidate)
            .map_err(|e| anyhow::anyhow!("model-compat diff failed: {e}"))?;

        // Classify the diff the way the `model-compat diff` subcommand does: any
        // FAIL is a breaking change (a gating failing row), else any DRIFT is
        // non-breaking drift (an advisory failing row, kept out of the exit code
        // but reported as `passed: false` so a `--json` consumer sees it),
        // else the diff is clean (one passing gating row). WOR-1518.
        let s = &diff.summary;
        if s.fail > 0 {
            Ok(vec![LaneVerdict {
                rows: vec![CheckOutcome {
                    name: format!("model-compat/{} FAIL", s.fail),
                    passed: false,
                    failure: Some(format!("{} breaking change(s), {} drift", s.fail, s.drift)),
                }],
                any_failed: true,
                gating: LaneGating::Gating,
                label: "model-compat".into(),
            }])
        } else if s.drift > 0 {
            Ok(vec![LaneVerdict {
                rows: vec![CheckOutcome {
                    name: format!("model-compat/{} DRIFT", s.drift),
                    passed: false,
                    failure: Some(format!("{} non-breaking drift change(s)", s.drift)),
                }],
                any_failed: true,
                gating: LaneGating::Advisory,
                label: "model-compat (advisory)".into(),
            }])
        } else {
            Ok(vec![LaneVerdict {
                rows: vec![CheckOutcome {
                    name: "model-compat/clean".to_string(),
                    passed: true,
                    failure: None,
                }],
                any_failed: false,
                gating: LaneGating::Gating,
                label: "model-compat".into(),
            }])
        }
    }
}

/// Performance lane (WOR-1487): an advisory p95-latency budget over the run's
/// per-test durations. Returns `None` when the suite declares no
/// `performance.p95_latency_ms`, or when the run produced no timed tests.
///
/// Unlike the trait-based lanes, this one needs the run's per-test durations,
/// which [`LaneCtx`] does not carry, so the run folds it in after the drive.
/// WOR-1513: `durations` is the sample of REAL test latencies captured before
/// the synthetic check/lane rows (each stamped `duration_ms = 1`) are appended,
/// so the p95 reflects actual tool-call latency and not a pile of 1ms markers.
/// The row is advisory: a p95 over budget renders as a failing row that the
/// spine keeps out of the exit code (WOR-1491), matching the schema's soft p95
/// budget that the reporter only highlights.
pub(super) fn run_performance_lane(
    durations: &[u64],
    suite: &ResolvedSuite,
) -> Option<LaneVerdict> {
    let budget_ms = suite.performance.as_ref()?.p95_latency_ms?;
    if durations.is_empty() {
        return None;
    }
    Some(performance_verdict(durations, budget_ms))
}

/// Build the advisory performance verdict from a per-test duration sample and a
/// p95 budget. A breach is a failing (advisory) row; within budget is a pass.
fn performance_verdict(durations: &[u64], budget_ms: u64) -> LaneVerdict {
    let p95 = percentile_ms(durations, 95);
    let exceeded = p95 > budget_ms;
    let name = if exceeded {
        format!("performance/p95 latency {p95}ms exceeds budget {budget_ms}ms (advisory)")
    } else {
        format!("performance/p95 latency {p95}ms within budget {budget_ms}ms")
    };
    LaneVerdict {
        rows: vec![CheckOutcome {
            name,
            passed: !exceeded,
            failure: None,
        }],
        any_failed: exceeded,
        gating: LaneGating::Advisory,
        label: "performance".into(),
    }
}

/// Nearest-rank percentile (milliseconds) of a duration sample. Empty input is
/// zero. `pct` is the percentile in `0..=100`.
fn percentile_ms(durations: &[u64], pct: u8) -> u64 {
    if durations.is_empty() {
        return 0;
    }
    let mut sorted = durations.to_vec();
    sorted.sort_unstable();
    let n = sorted.len();
    // Nearest-rank: rank = ceil(pct/100 * n), one-based; index = rank - 1.
    let rank = (pct as usize * n).div_ceil(100);
    let idx = rank.saturating_sub(1).min(n - 1);
    sorted[idx]
}

/// The lanes the run drives after the test/agent drive completes, in a fixed
/// order so the report rows and the per-lane summaries are deterministic.
///
/// All five trait-based lanes (compliance, security, conformance, scenarios,
/// model-compat) have real engines. The performance lane (WOR-1487) is not in
/// this list because it reads the finished report; the run calls
/// [`run_performance_lane`] separately and folds its verdict in alongside these.
pub(super) fn all_post_run_lanes() -> Vec<Box<dyn Lane>> {
    vec![
        Box::new(ComplianceLane),
        Box::new(SecurityFullLane),
        Box::new(ConformanceLane),
        Box::new(ScenariosLane),
        Box::new(ModelCompatLane),
    ]
}

/// Whether the suite declares any lane that would produce a verdict, even with
/// no tool or agent tests.
///
/// The run path short-circuits on an empty test selection; this lets a
/// capability-only suite (for example just a `compliance:` or `conformance:`
/// block) still reach the post-drive lanes, so a single `mcptest run` audits it.
/// Keep this in sync with each lane's own emptiness check.
pub(super) fn suite_declares_a_lane(suite: &ResolvedSuite) -> bool {
    !suite.scenarios.is_empty()
        || !suite.compliance_tests.is_empty()
        || suite.security.as_ref().is_some_and(|s| s.scan)
        || suite.conformance.is_some()
        || suite.model_compat.is_some()
}

/// Run every post-drive lane over `ctx`, dropping the no-ops (`None`) and
/// collecting the verdicts that contributed something.
///
/// WOR-1513: a lane error no longer degrades to "contributed nothing". Every
/// lane here gates, so an error becomes a failing gating row (printed to stderr
/// and folded into the report and exit code) via [`lane_error_verdict`]. A gate
/// that could not run must fail the build, not pass it green.
pub(super) fn run_post_drive_lanes(ctx: &LaneCtx<'_>) -> Vec<LaneVerdict> {
    let mut verdicts = Vec::new();
    for lane in all_post_run_lanes() {
        match lane.run(ctx) {
            Ok(lane_verdicts) => verdicts.extend(lane_verdicts),
            Err(e) => {
                // WOR-1513: a lane that errored did not run its check. Every
                // lane in this set gates, so surface the error as a failing
                // gating row (on stderr and in the report) instead of dropping
                // it, which would let CI pass green on a gate that never ran.
                eprintln!("{}: lane failed to run: {e}", lane.label());
                verdicts.push(lane_error_verdict(lane.label(), &e));
            }
        }
    }
    verdicts
}

/// Build a failing gating verdict for a lane whose engine errored (WOR-1513).
/// The lane could not produce a real verdict, and every post-drive lane gates,
/// so this row fails the run rather than letting the gate silently vanish.
fn lane_error_verdict(label: &'static str, err: &anyhow::Error) -> LaneVerdict {
    LaneVerdict {
        rows: vec![CheckOutcome {
            name: format!("{label}/lane-error"),
            passed: false,
            failure: Some(format!("lane failed to run: {err}")),
        }],
        any_failed: true,
        gating: LaneGating::Gating,
        label: label.to_string(),
    }
}

/// The outcome of folding lane verdicts onto the report.
///
/// `gating_failed` drives the exit code like a failing test. `advisory_failed`
/// counts the failing rows from advisory lanes: those rows render in the report
/// but must not drive the exit code, so the caller subtracts them from the
/// failed tally (WOR-1491).
pub(super) struct LaneFold {
    /// True when a gating lane had a failure, so the run fails.
    pub gating_failed: bool,
    /// How many advisory-lane rows failed. Kept out of the exit-code tally.
    pub advisory_failed: usize,
}

/// Fold every lane verdict onto the report and report how the lanes affect the
/// gate.
///
/// Each verdict's rows are appended through the existing
/// [`append_check_outcomes`] so they re-tally into the summary like the agent
/// gate rows do. When not quiet, a one-line per-lane summary goes to stderr
/// (stdout stays clean for machine-read reporters). A gating lane's failure sets
/// `gating_failed`; an advisory lane's failing rows still append but are counted
/// in `advisory_failed` so the caller can keep them out of the exit code.
pub(super) fn fold_lane_verdicts(
    report: &mut Report,
    verdicts: Vec<LaneVerdict>,
    quiet: bool,
) -> LaneFold {
    let mut gating_failed = false;
    let mut advisory_failed = 0;
    for verdict in verdicts {
        if !quiet {
            if verdict.any_failed {
                let failed = verdict.rows.iter().filter(|r| !r.passed).count();
                eprintln!("{}: {failed} check(s) failed", verdict.label);
            } else {
                eprintln!("{}: PASS", verdict.label);
            }
        }
        if verdict.any_failed {
            match verdict.gating {
                LaneGating::Gating => gating_failed = true,
                LaneGating::Advisory => {
                    advisory_failed += verdict.rows.iter().filter(|r| !r.passed).count();
                }
            }
        }
        append_check_outcomes(report, verdict.rows);
    }
    LaneFold {
        gating_failed,
        advisory_failed,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::report::{RunSummary, TestStatus};

    fn empty_report() -> Report {
        Report {
            schema_version: "0.1".into(),
            mcptest_version: "test".into(),
            generated_at: String::new(),
            run_id: String::new(),
            summary: RunSummary::from_tests(&[]),
            tests: Vec::new(),
            cost: None,
            metadata: None,
            matrix: None,
            protocol_violations: Vec::new(),
        }
    }

    fn failing_outcome(name: &str) -> CheckOutcome {
        CheckOutcome {
            name: name.into(),
            passed: false,
            failure: Some("nope".into()),
        }
    }

    fn minimal_suite() -> ResolvedSuite {
        let yaml: serde_yaml::Value =
            serde_yaml::from_str("servers:\n  s: { command: [\"echo\"] }\n").unwrap();
        mcptest_core::suite::parse(&yaml).unwrap()
    }

    #[test]
    fn no_op_lanes_do_not_change_the_gate() {
        let suite = minimal_suite();
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        let verdicts = run_post_drive_lanes(&ctx);
        assert!(verdicts.is_empty());

        let mut report = empty_report();
        let fold = fold_lane_verdicts(&mut report, verdicts, true);
        assert!(!fold.gating_failed);
        assert_eq!(fold.advisory_failed, 0);
        assert!(report.tests.is_empty());
        assert_eq!(report.summary.failed, 0);
    }

    #[test]
    fn gating_failure_fails_the_run_and_appends_its_row() {
        let mut report = empty_report();
        let verdicts = vec![LaneVerdict {
            rows: vec![failing_outcome("compliance/rule-1")],
            any_failed: true,
            gating: LaneGating::Gating,
            label: "compliance".into(),
        }];
        let fold = fold_lane_verdicts(&mut report, verdicts, true);
        assert!(fold.gating_failed);
        assert_eq!(fold.advisory_failed, 0);
        assert_eq!(report.tests.len(), 1);
        assert!(matches!(report.tests[0].status, TestStatus::Fail));
        assert_eq!(report.summary.failed, 1);
    }

    #[test]
    fn advisory_failure_does_not_fail_the_run_but_still_appends_its_row() {
        let mut report = empty_report();
        let verdicts = vec![LaneVerdict {
            rows: vec![failing_outcome("perf/p95")],
            any_failed: true,
            gating: LaneGating::Advisory,
            label: "performance".into(),
        }];
        let fold = fold_lane_verdicts(&mut report, verdicts, true);
        // The advisory row renders as a failed test, but it is counted in
        // `advisory_failed`, so the caller keeps it out of the exit-code tally:
        // `report.summary.failed - advisory_failed == 0`, and the run stays green.
        assert!(!fold.gating_failed);
        assert_eq!(fold.advisory_failed, 1);
        assert_eq!(report.tests.len(), 1);
        assert!(matches!(report.tests[0].status, TestStatus::Fail));
        assert_eq!(report.summary.failed, 1);
        assert_eq!(report.summary.failed - fold.advisory_failed, 0);
    }

    #[test]
    fn scenarios_lane_no_ops_when_the_suite_has_none() {
        let suite = minimal_suite();
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        assert!(ScenariosLane.run(&ctx).unwrap().is_empty());
    }

    #[test]
    fn compliance_lane_no_ops_when_the_suite_has_no_compliance_block() {
        // A suite with no `compliance:` block must short-circuit before the
        // lane spawns its worker thread or connects any server, so a run with
        // only tool tests stays byte-identical. The happy path (a real
        // compliance block folding into rows) needs a live mock server, so it
        // lives in the cli_run_compliance_lane integration test.
        let suite = minimal_suite();
        assert!(suite.compliance_tests.is_empty());
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        assert!(ComplianceLane.run(&ctx).unwrap().is_empty());
    }

    #[test]
    fn model_compat_lane_no_ops_when_the_suite_has_no_block() {
        // A suite with no `model_compat:` block must short-circuit before the
        // lane touches the filesystem, so a run with only tool tests stays
        // byte-identical. The happy path (loading two snapshots and folding the
        // diff verdict) needs fixture files, so it lives in the
        // cli_run_model_compat_lane integration test.
        let suite = minimal_suite();
        assert!(suite.model_compat.is_none());
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        assert!(ModelCompatLane.run(&ctx).unwrap().is_empty());
    }

    /// A one-call cassette in `add_widget`/`inc` shape, matching the bundled
    /// scenario-world tape format so the engine replays it offline.
    const ONE_CALL_CASSETTE: &str = r#"{
      "schema_version": "v1",
      "agent_name": "s",
      "model": "stub",
      "user_prompt": "go",
      "recorded_at": "2026-06-21T00:00:00Z",
      "trace": {
        "tool_calls": [{ "name": "add_widget", "server": "inv", "args": {}, "hop_index": 0 }],
        "tool_results": [{ "call_index": 0, "server": "inv", "is_error": false, "result": {} }],
        "final_response": "done",
        "conversation": {
          "tokens": { "prompt": 1, "completion": 1, "total": 2 },
          "duration_ms": 1, "message_count": 1, "stop_reason": "completed", "turns": []
        }
      }
    }"#;

    /// Scenarios-only suite (`expect_state: widgets: 4`) the engine replays
    /// against the one-call cassette and passes. The cassette adds one widget
    /// (3 -> 4), so this gates green.
    const PASSING_SCENARIO: &str = r#"scenarios:
  - name: add one widget
    cassette: cassettes/c.json
    seed:
      widgets: 3
    transitions:
      - tool: add_widget
        effect:
          widgets: { inc: 1 }
    expect_state:
      widgets: 4
"#;

    #[test]
    fn scenarios_lane_folds_a_passing_scenario_into_a_passing_gating_row() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("cassettes")).unwrap();
        std::fs::write(tmp.path().join("cassettes/c.json"), ONE_CALL_CASSETTE).unwrap();
        let yaml: serde_yaml::Value = serde_yaml::from_str(PASSING_SCENARIO).unwrap();
        let suite = mcptest_core::suite::parse(&yaml).unwrap();
        let config_path = tmp.path().join("suite.yml");
        let ctx = LaneCtx {
            suite: &suite,
            config_path: &config_path,
            quiet: true,
        };

        let verdicts = ScenariosLane.run(&ctx).unwrap();
        assert_eq!(
            verdicts.len(),
            1,
            "a declared scenario contributes one verdict"
        );
        let verdict = &verdicts[0];
        assert_eq!(verdict.label, "scenarios");
        assert!(!verdict.any_failed);
        assert_eq!(verdict.rows.len(), 1);
        assert_eq!(verdict.rows[0].name, "scenario/add one widget");
        assert!(verdict.rows[0].passed);
        assert!(matches!(verdict.gating, LaneGating::Gating));
    }

    #[test]
    fn percentile_is_nearest_rank_and_handles_the_empty_sample() {
        assert_eq!(percentile_ms(&[], 95), 0);
        assert_eq!(percentile_ms(&[42], 95), 42);
        // p95 of ten 10..=100 values (nearest-rank) is the tenth, 100.
        let sample: Vec<u64> = (1..=10).map(|n| n * 10).collect();
        assert_eq!(percentile_ms(&sample, 95), 100);
        // Order independence: the same multiset unsorted yields the same p95.
        assert_eq!(percentile_ms(&[30, 10, 1000, 20], 95), 1000);
    }

    #[test]
    fn a_p95_within_budget_is_a_passing_advisory_row() {
        let verdict = performance_verdict(&[10, 20, 30], 100);
        assert_eq!(verdict.label, "performance");
        assert!(!verdict.any_failed);
        assert_eq!(verdict.rows.len(), 1);
        assert!(verdict.rows[0].passed);
        assert!(verdict.rows[0].name.contains("within budget"));
        assert!(matches!(verdict.gating, LaneGating::Advisory));
    }

    #[test]
    fn a_p95_over_budget_is_a_failing_advisory_row_that_does_not_gate() {
        let verdict = performance_verdict(&[10, 20, 30, 1000], 100);
        assert!(verdict.any_failed);
        assert!(!verdict.rows[0].passed);
        assert!(verdict.rows[0].name.contains("exceeds budget"));
        // Advisory: the fold keeps a failing advisory row out of the exit code.
        assert!(matches!(verdict.gating, LaneGating::Advisory));
        let mut report = empty_report();
        let fold = fold_lane_verdicts(&mut report, vec![verdict], true);
        assert!(!fold.gating_failed);
        assert_eq!(fold.advisory_failed, 1);
    }

    #[test]
    fn the_performance_lane_is_none_without_a_p95_budget() {
        // A suite with no `performance:` block contributes no performance lane.
        assert!(run_performance_lane(&[10, 20], &minimal_suite()).is_none());
    }

    #[test]
    fn the_performance_lane_scores_the_passed_duration_sample() {
        // WOR-1513: the lane scores the duration slice it is handed (the real
        // test latencies captured before synthetic 1ms check rows), not the
        // whole report. p95 of [100,100,100] = 100ms exceeds the 50ms budget.
        let yaml: serde_yaml::Value = serde_yaml::from_str(
            "servers:\n  s: { command: [\"echo\"] }\nperformance:\n  p95_latency_ms: 50\n",
        )
        .unwrap();
        let suite = mcptest_core::suite::parse(&yaml).unwrap();
        let verdict = run_performance_lane(&[100, 100, 100], &suite).expect("budget present");
        assert!(verdict.any_failed);
        assert!(matches!(verdict.gating, LaneGating::Advisory));
        assert!(verdict.rows[0].name.contains("exceeds budget"));
        // No real timed tests (empty sample) contributes nothing.
        assert!(run_performance_lane(&[], &suite).is_none());
    }

    #[test]
    fn a_lane_engine_error_fails_the_run_as_a_gating_row() {
        // WOR-1513: a lane that errors must gate, not silently vanish.
        let verdict = lane_error_verdict("conformance", &anyhow::anyhow!("server unreachable"));
        assert!(verdict.any_failed);
        assert!(matches!(verdict.gating, LaneGating::Gating));
        assert!(verdict.rows[0].name.contains("conformance/lane-error"));
        let mut report = empty_report();
        let fold = fold_lane_verdicts(&mut report, vec![verdict], true);
        assert!(fold.gating_failed);
        assert_eq!(fold.advisory_failed, 0);
    }
}
