//! Live runner dispatch for `mcptest run`.

use anyhow::{Context, Result};

use crate::cli::args::RunArgs;
use crate::cli::handlers::run_live_options::apply_run_options;
use crate::cli::handlers::run_live_paths::{
    compute_suite_fingerprint, load_resume_journal, run_journal_path,
};
use crate::cli::handlers::run_live_plans::{build_all_plans, suite_budget, BuildPlansCtx};
use crate::cli::handlers::run_select::{
    compute_selection, finalize_spec_order, handle_empty_selection, refuse_focused_suite_in_ci,
    warn_focused_selection,
};
use crate::cli::Reporter;
use crate::server_override::ServerOverride;

/// Load the YAML, connect every declared server, drive the runner
/// with the concrete `McpExecutor`, and emit a one-line summary.
/// Exit code mirrors the suite outcome: 0 when every test passes (or
/// the suite is empty), 1 when any test fails.
///
/// `override_` carries the `--server-*` flags. It is applied to the
/// parsed YAML before the typed suite parse so the redirect actually
/// changes the run target instead of being silently dropped.
// Each parameter is a distinct already-resolved input; a wrapper struct would
// be a speculative abstraction (see AGENTS.md), so the arg count exceeds the lint.
// WOR-1502 added the compositions+lane short-circuit and the failure-policy
// threading, nudging the linear setup one line over the length lint; splitting
// the orchestrator further would scatter that setup, so allow the length here.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(crate) fn run_live(
    config_path: &std::path::Path,
    options: mcptest_core::runner::RunnerOptions,
    parallel_flag: Option<usize>,
    quiet: bool,
    record: bool,
    resume: bool,
    filter: Option<&str>,
    args: &RunArgs,
    proxy: mcptest_core::network::ProxyConfig,
    override_: &ServerOverride,
    output: Option<&std::path::Path>,
    reporter: Option<Reporter>,
    annotations: crate::cli::global::AnnotationsMode,
    color: crate::cli::global::ColorWhen,
    wait_for_ready: Option<std::time::Duration>,
    agent_budget: Option<usize>,
) -> Result<i32> {
    let raw = std::fs::read_to_string(config_path)
        .with_context(|| format!("could not read {}", config_path.display()))?;
    let mut yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
        .with_context(|| format!("could not parse YAML at {}", config_path.display()))?;
    // Redirect the run target before the typed parse. The override
    // rewrites the sole `servers:` entry in place; parse then reads the
    // rewritten map, so `--server-url` and friends actually take effect.
    if override_.is_any() {
        override_.apply(&mut yaml)?;
    }
    // Interpolate `${...}` references (variables + env) before the typed
    // parse so a `${dir}` in a tool's `args:` or a server's `command:`
    // reaches the server resolved, not verbatim. The run path also installs
    // the secret-resolver registry (WOR-1453), so a `vault://`-style
    // reference resolves here; the resolved plaintext is returned so it can
    // be scrubbed from every output sink (WOR-1454).
    let (yaml, resolved_secrets) =
        crate::cli::handlers::interp::interpolate_run_config_for_run(yaml, config_path, quiet)?;
    // One redactor, seeded with the resolved plaintext, threaded into every
    // sink this handler controls (reporter output and the persisted run
    // artifact) so a resolved secret never reaches disk or the terminal.
    let redactor = mcptest_core::redaction::Redactor::new().with_secret_values(resolved_secrets);
    let mut suite = mcptest_core::suite::parse(&yaml)
        .map_err(|e| anyhow::anyhow!("typed suite parse failed: {e}"))?;
    // Resolve relative `cassette:` paths against the suite directory so the
    // suite replays the same from any working directory (matches imports).
    if let Some(dir) = config_path.parent() {
        mcptest_core::suite::rebase_cassette_paths(&mut suite.servers, dir);
    }
    // Fail fast on a `server:` reference that does not resolve, naming the
    // test and the declared servers, before any connection is attempted.
    mcptest_core::suite::validate_server_refs(&suite).map_err(|e| anyhow::anyhow!("{e}"))?;

    // WOR-1294: surface assertions that can never fail (vacuous matchers)
    // before any server is spawned.
    if let Some(exit) = gate_suite_lints(&suite, args) {
        return Ok(exit);
    }

    // WOR-1283 / WOR-1305 / WOR-1323: resolve the suite's `run_options:`
    // block in one pass: fold the parallel knob into the worker count (a
    // non-default restart policy forces serial), map the restart policy
    // onto the core enum, and read the inconclusive exit policy.
    let mut resolved = apply_run_options(&yaml, parallel_flag, options)?;
    apply_suite_default_timeout(&mut resolved, &suite);

    // WOR-1320: the LLM-judge verdict cache the agent `eval:` metrics consult.
    // `--no-verdict-cache` wins; else the `evals.cache.verdicts` opt-in (default
    // off, ADR 0008) decides, and a never-opted-in suite gets a no-op cache.
    let verdict_cache = crate::commands::eval::build_verdict_cache(&yaml, args.no_verdict_cache);

    // `--models a,b,c` runs the suite as a model matrix: override every agent's
    // `models:` with the CLI list so the existing matrix-cell machinery records
    // a cell per (agent, model) and the matrix reporter renders the grid.
    let reporter = super::run_live_matrix::apply_model_matrix(&mut suite, args, reporter);

    // `--wait-for-ready`: poll every URL server until it accepts connections
    // before the run connects. Stdio servers are spawned by us, so there is
    // nothing to wait for. A timeout or non-retryable probe error aborts the
    // run with the readiness exit code rather than letting connect fail.
    if let Some(budget) = wait_for_ready {
        if let Some(exit) =
            crate::cli::handlers::readiness::await_servers_ready(&suite, budget, quiet)
        {
            return Ok(exit);
        }
    }

    tracing::info!(
        target: "mcptest::cli",
        suite_path = %config_path.display(),
        server_count = suite.servers.len(),
        test_count = suite.tests.len(),
        agent_count = suite.agents.len(),
        "live run dispatching"
    );

    // WOR-1267: refuse a focused suite under CI before anything executes,
    // compositions included.
    if let Some(exit) = refuse_focused_suite_in_ci(&suite) {
        return Ok(exit);
    }

    // Compositions are a distinct test type the run command also executes.
    // Run them up front so a compositions-only suite is not reported as "0
    // tests discovered". The exit code folds into the run's overall result.
    let composition_exit = if suite.compositions.is_empty() {
        None
    } else {
        let rt = tokio::runtime::Runtime::new().context("could not build tokio runtime")?;
        let exit = rt.block_on(super::run_compositions::run_compositions(
            &suite, &proxy, args, quiet,
        ))?;
        Some(exit)
    };
    // Short-circuit only for a pure compositions suite. A suite that also
    // declares a lane (compliance/security/conformance/...) must fall through
    // so its lanes run; `composition_exit` still folds into the result below.
    let composition_only = composition_exit.is_some()
        && suite.tests.is_empty()
        && suite.agents.is_empty()
        && !super::run_live_lanes::suite_declares_a_lane(&suite);
    if composition_only {
        return Ok(composition_exit.unwrap_or(0));
    }

    let selection = compute_selection(&suite, config_path, filter, args)?;
    warn_focused_selection(&selection, quiet);
    // A lane-only suite still runs its lanes (WOR-1490); short-circuit only when no lane is declared.
    if selection.selected.is_empty()
        && selection.skipped.is_empty()
        && !super::run_live_lanes::suite_declares_a_lane(&suite)
    {
        let empty_exit = handle_empty_selection(
            selection.suite_empty,
            args,
            quiet,
            config_path,
            super::run_routing_hint::other_block_hint(&yaml),
        );
        // A suite that ran compositions but has no tool/agent tests already
        // produced a result above; surface the worse of the two codes.
        return Ok(empty_exit.max(composition_exit.unwrap_or(0)));
    }
    let (selected_set, selected_order) = selection_indices(&selection);

    // The record-mode resume journal lives next to the cassettes
    // so it travels with them. Compute its path and the suite fingerprint
    // once, then load (and validate) any prior journal before the cell
    // loop. The fingerprint hashes the resolved suite YAML so any prompt,
    // expectation, or model edit invalidates stale progress.
    let journal_path = run_journal_path(config_path);
    let fingerprint = compute_suite_fingerprint(&yaml);
    let journal = load_resume_journal(record, resume, &journal_path, &fingerprint, quiet);

    let rt = tokio::runtime::Runtime::new().context("could not build tokio runtime")?;
    let ctx = LiveRunCtx {
        suite: &suite,
        selected_set: &selected_set,
        selected_order: &selected_order,
        skipped: &selection.skipped,
        config_path,
        quiet,
        record,
        proxy: &proxy,
        args,
        journal_path: &journal_path,
        output,
        reporter,
        annotations,
        color,
        agent_budget,
        inconclusive_fails_run: resolved.inconclusive_fails_run,
        restart_policy: resolved.restart_policy,
        failure_policy: resolved.failure_policy,
        verdict_cache,
        redactor: &redactor,
    };
    let exit = rt.block_on(run_live_async(ctx, resolved.options, journal))?;
    // Fold in the composition result for a mixed suite (tests + compositions):
    // the run fails if either half failed.
    Ok(exit.max(composition_exit.unwrap_or(0)))
}

/// WOR-1294: lint the suite for assertions that can never fail (vacuous
/// matchers) and print each finding to stderr so machine-read stdout
/// reporters stay clean. Returns `Some(2)` when `--deny-lints` is set and
/// any finding exists, so CI can refuse a suite that asserts nothing;
/// `None` lets the run proceed. Split out of [`run_live`] for the
/// function-length lint.
fn gate_suite_lints(suite: &mcptest_core::suite::ResolvedSuite, args: &RunArgs) -> Option<i32> {
    let lints = mcptest_core::suite::lint::lint_suite(suite);
    for warning in &lints {
        eprintln!("lint: {warning}");
    }
    if args.deny_lints && !lints.is_empty() {
        eprintln!(
            "mcptest run: --deny-lints is set and {} lint warning(s) were found; refusing to run.",
            lints.len()
        );
        return Some(crate::cli::exit_codes::EXIT_INVALID_INVOCATION);
    }
    None
}

/// WOR-1487: a suite `performance.default_timeout_ms` sets the runner's default
/// per-test timeout (a test's own `timeout:` still wins). There is no `--timeout`
/// run flag, so this overrides the built-in default with no CLI precedence to
/// reconcile. Split out of [`run_live`] for the function-length lint.
fn apply_suite_default_timeout(
    resolved: &mut super::run_live_options::ResolvedRunOptions,
    suite: &mcptest_core::suite::ResolvedSuite,
) {
    if let Some(ms) = suite
        .performance
        .as_ref()
        .and_then(|p| p.default_timeout_ms)
    {
        resolved.options.default_timeout = std::time::Duration::from_millis(ms);
    }
}

/// Index the selection for the runner: the set of names to execute plus
/// the report ordering, where skipped tests order after the executed ones
/// so `--failed-first` and shuffle keep controlling the executed half of
/// the report (WOR-1267). Split out of [`run_live`] for the
/// function-length lint.
fn selection_indices(
    selection: &super::run_select::Selection,
) -> (
    std::collections::BTreeSet<String>,
    std::collections::HashMap<String, usize>,
) {
    let selected_set = selection.selected.iter().cloned().collect();
    let selected_order = selection
        .selected
        .iter()
        .chain(selection.skipped.iter().map(|(name, _)| name))
        .enumerate()
        .map(|(i, name)| (name.clone(), i))
        .collect();
    (selected_set, selected_order)
}

/// Already-resolved inputs the live async run needs. Bundled so
/// [`run_live_async`] has a single context parameter instead of a dozen
/// positional arguments. Borrowed where the run only reads.
struct LiveRunCtx<'a> {
    suite: &'a mcptest_core::suite::ResolvedSuite,
    selected_set: &'a std::collections::BTreeSet<String>,
    selected_order: &'a std::collections::HashMap<String, usize>,
    /// `(name, reason)` rows for tests parked by `skip:` or excluded by
    /// `only:` (WOR-1267). Each becomes a SKIP spec the engine reports
    /// without executing.
    skipped: &'a [(String, String)],
    config_path: &'a std::path::Path,
    quiet: bool,
    record: bool,
    proxy: &'a mcptest_core::network::ProxyConfig,
    args: &'a RunArgs,
    journal_path: &'a std::path::Path,
    /// `--output` sink for the report (`None` or `-` means stdout).
    output: Option<&'a std::path::Path>,
    /// `--reporter` format (`None` defaults to pretty).
    reporter: Option<Reporter>,
    /// `--annotations` mode for GitHub Actions inline annotations.
    annotations: crate::cli::global::AnnotationsMode,
    /// `--color` mode for the console output.
    color: crate::cli::global::ColorWhen,
    /// `--agent-budget` token ceiling for the `agent` reporter.
    agent_budget: Option<usize>,
    /// WOR-1305: when true (`run_options.inconclusive.treat_as: fail`),
    /// any inconclusive verdict in the report makes the run exit non-zero.
    inconclusive_fails_run: bool,
    /// WOR-1323: the suite's `run_options.restart_policy` mapped onto the
    /// core enum; non-default values arm the respawn wrapper.
    restart_policy: mcptest_core::runner::restart::RestartPolicy,
    /// WOR-1508: the opt-in failure-source policy that downgrades classified
    /// transient/external failures out of the exit code.
    failure_policy: mcptest_core::eval::FailurePolicy,
    /// WOR-1320: the LLM-judge verdict cache the agent `eval:` metrics
    /// consult, built from `evals.cache.verdicts` and `--no-verdict-cache`.
    /// Owned here so one cache (and one hit/miss tally) spans the whole run.
    verdict_cache: mcptest_core::eval::VerdictCache,
    /// WOR-1454: redactor seeded with the plaintext of every secret resolved
    /// during interpolation. Threaded into the reporter and the persisted
    /// run artifact so a resolved value never reaches a sink unscrubbed.
    redactor: &'a mcptest_core::redaction::Redactor,
}

/// The async heart of [`run_live`]: connect servers, plan tool and agent
/// tests, drive the runner, write the resume journal, and gate on the
/// tool-selection floor. Split out of `run_live` so each function stays under
/// the function-length lint; behavior is unchanged.
// This is the run's top-level coordinator: each step (plan build, drive,
// section attach, gate) is already an extracted helper, so what remains is the
// irreducible sequence of calls that wire them together. Holding it one line
// under the threshold (it sits right at the boundary) would mean a speculative
// context struct the contributor guide warns against, so the length is allowed.
#[allow(clippy::too_many_lines)]
// WOR-1519: the run-level span every per-request `mcp.request` span nests under,
// so a `--log-format json` run (or a future OTLP export) shows one trace per run.
#[tracing::instrument(name = "mcptest.run", skip_all)]
async fn run_live_async(
    ctx: LiveRunCtx<'_>,
    options: mcptest_core::runner::RunnerOptions,
    mut journal: Option<mcptest_core::runner::resume::RunJournal>,
) -> Result<i32> {
    use mcptest_core::connector::ServerPool;
    use std::sync::Arc;

    let LiveRunCtx {
        suite,
        selected_set,
        selected_order,
        skipped,
        config_path,
        quiet,
        record,
        proxy,
        args,
        journal_path,
        output,
        reporter,
        annotations,
        color,
        agent_budget,
        inconclusive_fails_run,
        restart_policy,
        failure_policy,
        mut verdict_cache,
        redactor,
    } = ctx;
    let proxy = proxy.clone();

    {
        if args.update_cassettes {
            refresh_server_cassettes(suite, selected_set, &proxy, quiet).await?;
        }

        let pool = Arc::new(ServerPool::new());
        super::run_live_connect::connect_referenced_servers(suite, &proxy, args, &pool).await?;

        // Fetch the `tools/list` catalog for every selected
        // tool_quality check now, while the pool is live (the pool is shut
        // down inside the run-with-hooks drive). The scoring and assertions
        // run after the report comes back.
        let tool_quality_catalogs =
            super::run_live_checks::fetch_tool_quality_catalogs(suite, selected_set, &pool).await?;

        // WOR-1386: `--coverage` sources its report now, while the pool is live.
        // WOR-1488: a declared `coverage:` suite block enables instrumentation
        // too, so a suite owns its coverage gate without the flag.
        let coverage_enabled = args.coverage || suite.coverage.is_some();
        let coverage = super::run_live_coverage::prepare(coverage_enabled, suite, &pool).await;

        // Build the tool plans plus the agent-matrix cells. Extracted to keep
        // this function under the function-length lint; it returns the specs,
        // plans, and the post-run accumulators.
        let budget = suite_budget(suite);
        let mut bundle = build_all_plans(BuildPlansCtx {
            suite,
            selected_set,
            config_path,
            record,
            quiet,
            proxy: &proxy,
            journal: journal.as_ref(),
            pool: &pool,
            budget: &budget,
            allow_stub_provider: args.allow_stub_provider,
            verdict_cache: &mut verdict_cache,
        })
        .await?;

        finalize_spec_order(&mut bundle.specs, skipped, selected_order);

        // Bracket the run with the suite-level hooks, drive the
        // runner (with the WOR-1323 respawn wrapper for non-default restart
        // policies), and gate. The drive plumbing lives in `run_live_drive`
        // so this module stays under the production-module-size guard.
        //
        // WOR-1434: race the drive against Ctrl-C. A SIGINT drops the drive
        // future, which cancels the in-flight executor tasks and tears the
        // server pool down through `Drop`, then we emit whatever partial
        // result exists (the spec list, every test marked interrupted) and
        // return [`EXIT_INTERRUPTED`] so a deliberate Ctrl-C is never read as
        // a test regression.
        let drive =
            super::run_live_drive_loop::drive_with_restart(super::run_live_drive_loop::DriveCtx {
                pool: &pool,
                suite,
                plans: bundle.plans,
                specs: &bundle.specs,
                options,
                last_real_provider: bundle.last_real_provider,
                last_real_model: bundle.last_real_model,
                config_path,
                args,
                proxy: &proxy,
                restart_policy,
                coverage: coverage
                    .as_ref()
                    .map(super::run_live_coverage::Coverage::handle),
            });
        let mut report = match super::run_live_drive_loop::drive_or_interrupt(drive).await? {
            super::run_live_drive_loop::DriveResult::Completed(report) => report,
            super::run_live_drive_loop::DriveResult::Interrupted => {
                return super::run_live_drive_loop::emit_interrupted_run(
                    super::run_live_drive_loop::InterruptCtx {
                        specs: &bundle.specs,
                        config_path,
                        quiet,
                        output,
                        reporter,
                        annotations,
                        color,
                        agent_budget,
                        redactor,
                    },
                );
            }
        };
        // WOR-1513: snapshot the real per-test durations BEFORE the synthetic
        // check/lane rows (each stamped duration_ms = 1) are appended below, so
        // the performance lane's p95 reflects actual tool-call latency.
        let perf_durations: Vec<u64> = report.tests.iter().map(|t| t.duration_ms).collect();
        // Run the computed-metric check blocks (calibration and tool_quality),
        // then fold every post-run section (agent gates, run metadata, the
        // matrix grid, agent wall-clock/cost) onto the report.
        super::run_live_checks::attach_run_sections(
            &mut report,
            super::run_live_checks::RunSectionsCtx {
                suite,
                selected: selected_set,
                config_path,
                tool_quality_catalogs: &tool_quality_catalogs,
                args,
                matrix_cells: &bundle.matrix_cells,
                sections: bundle.sections,
            },
        )?;
        // WOR-1481: run the post-drive capability lanes; their verdicts fold
        // into the report and the single exit code inside `report_and_gate`.
        let mut lane_verdicts =
            super::run_live_lanes::run_post_drive_lanes(&super::run_live_lanes::LaneCtx {
                suite,
                config_path,
                quiet,
            });
        // WOR-1487: the performance lane reads the run's per-test durations, so
        // it folds in here (after the drive) rather than through `LaneCtx`.
        if let Some(verdict) = super::run_live_lanes::run_performance_lane(&perf_durations, suite) {
            lane_verdicts.push(verdict);
        }
        // WOR-1488: the suite's `coverage:` thresholds gate the run; the
        // `--coverage-threshold` flag overrides them when both are present.
        let coverage_threshold = args
            .coverage_threshold
            .as_ref()
            .or_else(|| suite.coverage.as_ref().map(|c| &c.thresholds));
        super::run_live_gate::report_and_gate(
            &mut report,
            &bundle.recorded_cells,
            &bundle.floor_outcomes,
            &bundle.matrix_cells,
            suite,
            journal.as_mut(),
            journal_path,
            config_path,
            quiet,
            output,
            reporter,
            annotations,
            color,
            args.check_baseline.as_deref(),
            args.tolerance_pct,
            agent_budget,
            args.run_id.as_deref(),
            inconclusive_fails_run,
            coverage.map(super::run_live_coverage::into_report),
            coverage_threshold,
            redactor,
            lane_verdicts,
            failure_policy,
        )
    }
}

/// WOR-1265: `--update-cassettes` pre-pass. Re-records every in-scope server
/// cassette from its live target and prints the one-line re-recorded summary.
/// Runs before the connect pass so the run that follows replays the refreshed
/// recordings; the selection set scopes the refresh exactly like the run.
async fn refresh_server_cassettes(
    suite: &mcptest_core::suite::ResolvedSuite,
    selected_set: &std::collections::BTreeSet<String>,
    proxy: &mcptest_core::network::ProxyConfig,
    quiet: bool,
) -> Result<()> {
    let outcomes = mcptest_core::cassette::refresh_selected_cassettes(suite, selected_set, proxy)
        .await
        .map_err(|e| anyhow::anyhow!("run --update-cassettes: {e}"))?;
    if !quiet {
        let changed = outcomes.iter().filter(|o| o.changed).count();
        println!(
            "Re-recorded {} server cassette(s) ({changed} changed)",
            outcomes.len()
        );
    }
    Ok(())
}

/// `mcptest run --collect-only` implementation. Parses the YAML,
/// expands the agent matrix, applies `--filter`, `--shard`, and
/// `--last-failed`/`--failed-first`, and prints one test name per
/// line. Exits without connecting to any MCP server or dispatching
/// any model. , , .
pub(crate) fn print_discovered_tests(
    config_path: &std::path::Path,
    filter: Option<&str>,
    quiet: bool,
    args: &RunArgs,
    override_: &ServerOverride,
) -> Result<i32> {
    let raw = std::fs::read_to_string(config_path)
        .with_context(|| format!("could not read {}", config_path.display()))?;
    let mut yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
        .with_context(|| format!("could not parse YAML at {}", config_path.display()))?;
    if override_.is_any() {
        override_.apply(&mut yaml)?;
    }
    let yaml =
        crate::cli::handlers::interp::interpolate_run_config_reported(yaml, config_path, quiet)?;
    let suite = mcptest_core::suite::parse(&yaml)
        .map_err(|e| anyhow::anyhow!("typed suite parse failed: {e}"))?;

    let selection = compute_selection(&suite, config_path, filter, args)?;
    if selection.selected.is_empty() && selection.skipped.is_empty() {
        return Ok(handle_empty_selection(
            selection.suite_empty,
            args,
            quiet,
            config_path,
            super::run_routing_hint::other_block_hint(&yaml),
        ));
    }
    if !quiet {
        for name in &selection.selected {
            println!("{name}");
        }
        // WOR-1267: parked tests stay visible so the printed list still
        // covers the whole suite shape, with the reason inline.
        for (name, reason) in &selection.skipped {
            println!("{name} (skipped: {reason})");
        }
        println!();
        if selection.skipped.is_empty() {
            println!("{} test(s) discovered", selection.selected.len());
        } else {
            println!(
                "{} test(s) discovered ({} skipped)",
                selection.selected.len() + selection.skipped.len(),
                selection.skipped.len()
            );
        }
    }
    Ok(0)
}

#[cfg(test)]
mod tests {
    use crate::cli::handlers::run_live_options::{
        apply_run_options, core_restart_policy, fold_exit_code,
    };
    use mcptest_core::runner::restart::RestartPolicy as Core;
    use mcptest_core::runner::Parallelism;

    fn yaml(src: &str) -> serde_yaml::Value {
        serde_yaml::from_str(src).expect("test yaml parses")
    }

    #[test]
    fn per_test_policy_maps_to_the_core_per_test_policy() {
        use mcptest_config::run_options::RestartPolicy as Config;
        assert_eq!(core_restart_policy(Config::PerTest), Core::PerTest);
        assert_eq!(core_restart_policy(Config::PerFile), Core::PerFile);
        assert_eq!(core_restart_policy(Config::PerRun), Core::PerRun);
    }

    #[test]
    fn per_test_policy_forces_serial_execution() {
        let y = yaml("run_options:\n  restart_policy: per_test\n  parallel: 4\n");
        let resolved = apply_run_options(&y, None, Default::default()).expect("resolves");
        assert_eq!(resolved.options.parallel, Parallelism::Fixed(1));
        assert_eq!(resolved.restart_policy, Core::PerTest);
    }

    #[test]
    fn per_run_policy_keeps_the_folded_parallelism() {
        let y = yaml("run_options:\n  parallel: 4\n");
        let resolved = apply_run_options(&y, None, Default::default()).expect("resolves");
        assert_eq!(resolved.options.parallel, Parallelism::Fixed(4));
        assert_eq!(resolved.restart_policy, Core::PerRun);
    }

    #[test]
    fn inconclusive_verdict_fails_the_run_when_treated_as_fail() {
        assert_eq!(fold_exit_code(0, false, 1, true), 1);
    }

    #[test]
    fn inconclusive_verdict_stays_neutral_under_the_default_policy() {
        assert_eq!(fold_exit_code(0, false, 1, false), 0);
    }

    #[test]
    fn clean_run_exits_zero_even_with_the_fail_policy_armed() {
        assert_eq!(fold_exit_code(0, false, 0, true), 0);
    }

    #[test]
    fn failures_and_gate_breaches_exit_nonzero_regardless_of_inconclusive_policy() {
        assert_eq!(fold_exit_code(2, false, 0, false), 1);
        assert_eq!(fold_exit_code(0, true, 0, false), 1);
    }

    /// WOR-1434: the partial report a SIGINT produces has one inconclusive row
    /// per selected spec, a derived (consistent) summary, and an "interrupted"
    /// note on every row.
    #[test]
    fn interrupted_report_marks_every_spec_inconclusive_with_a_consistent_summary() {
        use mcptest_core::report::TestStatus;
        use mcptest_core::runner::TestSpec;
        let specs = vec![TestSpec::new("a"), TestSpec::new("b"), TestSpec::new("c")];
        let report = super::super::run_live_print::interrupted_report(&specs);
        assert_eq!(report.summary.total, 3);
        assert_eq!(report.summary.inconclusive, 3);
        assert_eq!(report.summary.passed, 0);
        assert_eq!(report.summary.failed, 0);
        assert!(report.summary.is_consistent(&report.tests));
        assert!(report
            .tests
            .iter()
            .all(|t| t.status == TestStatus::Inconclusive));
        let note = report.tests[0]
            .failure
            .as_ref()
            .expect("interrupted row carries a note");
        assert!(note.message.contains("interrupted by SIGINT"));
    }
}
