//! `mcptest run --print-config` rendering.
//!
//! Split out of [`super::run_live`] so that module stays under the
//! production-module-size guard. Renders the resolved suite (servers,
//! providers, budget, selected tests) to stdout without connecting to any
//! server.

use anyhow::{Context, Result};

use super::run_select::{candidate_names, compute_selection, Selection};
use crate::cli::args::RunArgs;
use crate::server_override::ServerOverride;

/// Render the resolved suite to stdout for `--print-config`.
///
/// Loads the YAML, applies the same validation `mcptest run` would run, then
/// prints a human-readable summary covering servers, providers, the resolved
/// test list (after `--filter`, `--shard`, and `--last-failed`), and the
/// budget. Provider API key env vars are listed by name, never resolved
/// values, so the output stays safe to paste into a bug report.
pub(crate) fn print_resolved_config(
    config_path: &std::path::Path,
    filter: Option<&str>,
    args: &RunArgs,
    proxy: &mcptest_core::network::ProxyConfig,
    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 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)?;

    println!("mcptest run --print-config");
    println!("config: {}", config_path.display());
    println!();

    print_servers(&suite);
    print_providers(&suite);
    print_budget(&suite);

    println!("proxy: {}", proxy.summary());
    println!();

    print_selection(&suite, &selection);
    Ok(0)
}

fn print_servers(suite: &mcptest_core::suite::ResolvedSuite) {
    println!("servers ({}):", suite.servers.len());
    for (name, spec) in &suite.servers {
        println!("  - {name} ({})", server_kind_label(spec));
    }
    if suite.servers.is_empty() {
        println!("  (none)");
    }
    println!();
}

fn print_providers(suite: &mcptest_core::suite::ResolvedSuite) {
    println!("providers ({}):", suite.providers.len());
    for (name, prov) in &suite.providers {
        let key_label = prov
            .api_key_env
            .as_deref()
            .map(|env| format!("key_env={env}"))
            .unwrap_or_else(|| "no auth".to_string());
        println!(
            "  - {name} (openai-compat, base={}, {})",
            prov.base_url, key_label
        );
    }
    if suite.providers.is_empty() {
        println!("  (none)");
    }
    println!();
}

fn print_budget(suite: &mcptest_core::suite::ResolvedSuite) {
    println!("budget:");
    println!(
        "  per_test:  {}",
        format_cents(suite.budget.per_test_usd_cents)
    );
    println!(
        "  per_suite: {}",
        format_cents(suite.budget.per_suite_usd_cents)
    );
    println!();
}

fn print_selection(suite: &mcptest_core::suite::ResolvedSuite, selection: &Selection) {
    println!(
        "tests selected ({} of {} candidate, after --filter / --shard / --last-failed / skip / only):",
        selection.selected.len(),
        candidate_names(suite).len()
    );
    for name in &selection.selected {
        println!("  - {name}");
    }
    if selection.selected.is_empty() {
        println!("  (none)");
    }
    // WOR-1267: surface the authoring markers so an operator debugging
    // "why did my test not run" sees the parked tests and the focus
    // state without opening the YAML.
    if selection.only_active {
        println!();
        println!(
            "only: ACTIVE ({} focused test(s); everything else is implicitly skipped)",
            selection.selected.len()
        );
    }
    if !selection.skipped.is_empty() {
        println!();
        println!("tests skipped ({}):", selection.skipped.len());
        for (name, reason) in &selection.skipped {
            println!("  - {name}: {reason}");
        }
    }
}

/// Render `mcptest run --explain` (WOR-1267): the dry-run view of what the
/// run would do. Loads and resolves the suite exactly like the run (so
/// `--filter`, `--tag`, `skip:`, and `only:` all apply), then prints one
/// block per test that would run (action, one line per assertion, budgets)
/// and one line per skipped test with its reason. Connects to nothing,
/// spawns nothing, touches no cache. Exits `0`.
pub(crate) fn print_explain(
    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 quiet {
        return Ok(0);
    }
    println!("mcptest run --explain: {}", config_path.display());
    println!("(dry run: no server spawned, no test executed)");
    if selection.only_active {
        println!(
            "note: only: true is active; every test outside the only set is implicitly skipped"
        );
    }
    if suite.budget.per_test_usd_cents.is_some() || suite.budget.per_suite_usd_cents.is_some() {
        println!(
            "suite budget: per_test={}, per_suite={}",
            format_cents(suite.budget.per_test_usd_cents),
            format_cents(suite.budget.per_suite_usd_cents)
        );
    }
    println!();
    for name in &selection.selected {
        explain_one(&suite, name);
    }
    for (name, reason) in &selection.skipped {
        println!("SKIP {name} ({reason})");
    }
    println!();
    println!(
        "{} test(s) would run, {} skipped",
        selection.selected.len(),
        selection.skipped.len()
    );
    Ok(0)
}

/// Print the explain block for one selected case name: the tool /
/// resource / prompt test, the agent matrix cell, or the check block the
/// name belongs to. Names come from the same selection pass the run
/// uses, so the lookup never misses; an unknown name (which would
/// indicate a selection bug) prints a plain `RUN` line rather than
/// failing the dry run.
fn explain_one(suite: &mcptest_core::suite::ResolvedSuite, name: &str) {
    if let Some(t) = suite.tests.iter().find(|t| t.name == name) {
        explain_typed_test(t);
        return;
    }
    for agent in &suite.agents {
        for model_ref in &agent.models {
            let case = if agent.models.len() == 1 {
                agent.name.clone()
            } else {
                format!("{} [{}]", agent.name, model_ref.display_label())
            };
            if case == name {
                explain_agent_cell(&case, agent, model_ref);
                return;
            }
        }
    }
    if suite.calibration.iter().any(|c| c.name == name) {
        println!("RUN {name}");
        println!("    calibration check");
        return;
    }
    if suite.tool_quality.iter().any(|c| c.name == name) {
        println!("RUN {name}");
        println!("    tool_quality check");
        return;
    }
    println!("RUN {name}");
}

/// Explain block for a tool / resource / prompt test: the action line,
/// one line per assertion (assert-set members included), and the
/// combined-score threshold when one is set.
fn explain_typed_test(t: &mcptest_core::suite::TypedTest) {
    use mcptest_core::suite::Action;
    println!("RUN {}", t.name);
    match &t.action {
        Action::ToolCall { tool, args } => {
            println!(
                "    tool {tool} on server {} args {}",
                t.server,
                brief_json(args)
            );
        }
        Action::ResourceRead { uri } => println!("    resource {uri} on server {}", t.server),
        Action::PromptGet { name, .. } => println!("    prompt {name} on server {}", t.server),
    }
    for a in &t.expectations {
        println!("    {}", assertion_line(a));
    }
    for set in &t.assert_sets {
        let label = set.name.as_deref().unwrap_or("(unnamed)");
        println!(
            "    assert-set {label} (threshold {}, {} assertions):",
            set.threshold,
            set.assertions.len()
        );
        for a in &set.assertions {
            println!("      {}", assertion_line(a));
        }
    }
    if let Some(threshold) = t.threshold {
        println!("    threshold: {threshold}");
    }
}

/// Explain block for one (agent, model) matrix cell: the dispatch line,
/// one line per assertion, the judged eval metrics, and the perf budgets
/// (turn, token, time, and tool-call caps).
fn explain_agent_cell(
    case: &str,
    agent: &mcptest_core::suite::AgentTest,
    model_ref: &mcptest_core::suite::ModelRef,
) {
    println!("RUN {case}");
    println!(
        "    agent against servers [{}] with model {}",
        agent.servers.join(", "),
        model_ref.display_label()
    );
    for a in &agent.expectations {
        println!("    {}", assertion_line(a));
    }
    for metric in &agent.eval {
        println!("    eval {}", eval_metric_label(metric));
    }
    if let Some(threshold) = agent.threshold {
        println!("    threshold: {threshold}");
    }
    let mut budgets = vec![
        format!("max_turns={}", agent.max_turns),
        format!("max_tokens={}", agent.max_tokens),
        format!("runs={}", agent.runs),
    ];
    if let Some(tb) = agent.token_budget {
        budgets.push(format!("token_budget={tb}"));
    }
    if let Some(mc) = agent.max_tool_calls {
        budgets.push(format!("max_tool_calls={mc}"));
    }
    if let Some(d) = agent.time_budget {
        budgets.push(format!("time_budget={}s", d.as_secs()));
    }
    println!("    budgets: {}", budgets.join(", "));
}

/// One explain line for an assertion: target, matcher name, and a brief
/// parameter summary derived from the typed model (never re-serialized
/// raw YAML). The weight is shown only when it differs from the 1.0
/// default so the common case stays short.
fn assertion_line(a: &mcptest_core::suite::Assertion) -> String {
    let target: &str = if a.target.is_empty() {
        "(response)"
    } else {
        &a.target
    };
    let mut line = format!("expect {target} {}", matcher_summary(&a.matcher));
    if (a.weight - 1.0).abs() > f64::EPSILON {
        line.push_str(&format!(" (weight {})", a.weight));
    }
    line
}

/// Matcher name plus a brief parameter summary for the explain view.
/// Composition wrappers print their arity rather than recursing fully so
/// one assertion always stays one line.
fn matcher_summary(m: &mcptest_core::suite::Matcher) -> String {
    use mcptest_core::suite::Matcher as M;
    match m {
        M::Exact(v) => format!("exact {}", brief_json(v)),
        M::Contains(v) => format!("contains {}", brief_json(v)),
        M::Regex(s) => format!("regex {s:?}"),
        M::Subset(v) => format!("subset {}", brief_json(v)),
        M::Schema(_) => "schema (inline JSON Schema)".to_string(),
        M::Snapshot(k) => format!("snapshot {k:?}"),
        M::LlmJudge(v) => format!("llm-judge {}", brief_json(v)),
        M::LlmJury(v) => format!("llm-jury {}", brief_json(v)),
        M::ContainsAll(v) => format!("contains-all {}", brief_json(v)),
        M::ContainsAny(v) => format!("contains-any {}", brief_json(v)),
        M::IContains(s) => format!("icontains {s:?}"),
        M::StartsWith(s) => format!("starts-with {s:?}"),
        M::IsJson(v) => payload_or_bare("is-json", v),
        M::IsValidToolsCall(v) => payload_or_bare("is-valid-tools-call", v),
        M::IsXml(v) => payload_or_bare("is-xml", v),
        M::IsSql(_) => "is-sql".to_string(),
        M::Similar(v) => format!("similar {}", brief_json(v)),
        M::Citation(v) => format!("citation {}", brief_json(v)),
        M::Levenshtein(v) => format!("levenshtein {}", brief_json(v)),
        M::Cel(expr) => format!("cel {expr:?}"),
        M::Not(inner) => format!("not {}", matcher_summary(inner)),
        M::OneOf(list) => format!("oneOf ({} matchers)", list.len()),
        M::AnyOf(list) => format!("anyOf ({} matchers)", list.len()),
        M::AllOf(list) => format!("allOf ({} matchers)", list.len()),
    }
}

/// Matchers whose payload may be `null` (a bare shape check) print just
/// the name in that case, and the payload summary otherwise.
fn payload_or_bare(name: &str, v: &serde_json::Value) -> String {
    if v.is_null() {
        name.to_string()
    } else {
        format!("{name} {}", brief_json(v))
    }
}

/// Compact one-line JSON for explain summaries, truncated so a large
/// inline payload cannot flood the dry-run view.
fn brief_json(v: &serde_json::Value) -> String {
    const MAX: usize = 60;
    let s = serde_json::to_string(v).unwrap_or_else(|_| "<unprintable>".to_string());
    if s.chars().count() <= MAX {
        return s;
    }
    let mut out: String = s.chars().take(MAX).collect();
    out.push_str("...");
    out
}

/// Short label for a judged eval metric in the explain view: the metric
/// name plus its gating threshold (and the strict marker when set).
fn eval_metric_label(metric: &mcptest_core::suite::EvalMetric) -> String {
    use mcptest_core::suite::EvalMetric as E;
    let gate = |name: &str, threshold: &f64, strict: &bool| {
        let strict_suffix = if *strict { ", strict" } else { "" };
        format!("{name} (threshold {threshold}{strict_suffix})")
    };
    match metric {
        E::Rubric(_) => "rubric".to_string(),
        E::TaskCompletion { threshold, strict } => gate("task_completion", threshold, strict),
        E::McpUse { threshold, strict } => gate("mcp_use", threshold, strict),
        E::ArgumentCorrectness { threshold, strict } => {
            gate("argument_correctness", threshold, strict)
        }
        E::PlanQuality { threshold, strict } => gate("plan_quality", threshold, strict),
        E::MultiTurnMcpUse { threshold, strict } => gate("multi_turn_mcp_use", threshold, strict),
    }
}

fn server_kind_label(spec: &mcptest_core::suite::ServerSpec) -> &'static str {
    use mcptest_core::suite::ServerSpec;
    match spec {
        ServerSpec::Stdio { .. } => "stdio",
        ServerSpec::StreamableHttp { .. } => "http",
        ServerSpec::LegacySse { .. } => "sse",
        ServerSpec::Cassette { .. } => "cassette",
    }
}

fn format_cents(cents: Option<u64>) -> String {
    match cents {
        Some(c) => format!("${:.2}", (c as f64) / 100.0),
        None => "unlimited".to_string(),
    }
}

/// Emit the finished run report. `--reporter` picks the format, `--output`
/// picks the sink: a file path, or stdout when omitted or `-`. This mirrors
/// `mcptest report`, so every format reaches both a file and stdout.
///
/// The redacted view is shared across both sinks (a token-shaped value never
/// reaches disk or the terminal). The default `pretty` prints one line per test
/// plus a summary to stdout; `minimal` prints the terse one-line summary
/// (`ran N tests: ...`); `quiet` suppresses stdout but a file sink still
/// receives the canonical JSON artifact. When `--output` writes a file, a
/// one-line summary still goes to stderr so the operator sees the result.
/// GitHub Actions annotations compose on top per `--annotations`.
/// `suite_path` feeds the agent reporter's repro fallback for tests that
/// carry no `file`, so every printed repro line is executable.
///
/// `redactor` is threaded in by the run handler rather than built here so the
/// plaintext of every secret resolved during interpolation (WOR-1454) is
/// scrubbed from both the terminal and the file sink, not just the
/// shape-based heuristics a fresh redactor would catch.
#[allow(clippy::too_many_arguments)]
pub(super) fn emit_report(
    report: &mcptest_core::report::Report,
    output: Option<&std::path::Path>,
    reporter: Option<crate::cli::Reporter>,
    annotations: crate::cli::global::AnnotationsMode,
    color: crate::cli::global::ColorWhen,
    quiet: bool,
    agent_budget: Option<usize>,
    suite_path: Option<&std::path::Path>,
    coverage: Option<&mcptest_core::coverage::CoverageReport>,
    redactor: &mcptest_core::redaction::Redactor,
) -> Result<()> {
    use crate::cli::reporters::{annotations_enabled, RenderFormat};
    use crate::cli::Reporter;
    use mcptest_core::report::reporters::emit_annotations;

    let view = report.redacted(redactor);

    let format = reporter.unwrap_or(Reporter::Pretty);
    let is_quiet = quiet || matches!(format, Reporter::Quiet);
    // A real file path, or `None` for stdout (no `--output`, or `--output -`).
    let file_sink = output.filter(|p| p.as_os_str() != "-");
    // Color is for the stdout console only; a file sink stays plain.
    let stdout_color = if file_sink.is_none() {
        color.resolve(std::io::IsTerminal::is_terminal(&std::io::stdout()))
    } else {
        false
    };

    if let Some(path) = file_sink {
        let mut file = std::fs::File::create(path)
            .with_context(|| format!("could not create run report at {}", path.display()))?;
        // `quiet` has no renderable format, so fall back to the canonical
        // JSON artifact that `mcptest report` can re-render.
        let fmt = format.render_format().unwrap_or(RenderFormat::Json);
        run_reporter(fmt, false, agent_budget, suite_path, coverage).render(&view, &mut file)?;
        if !is_quiet {
            eprintln!(
                "ran {} tests: {} passed, {} failed, {} skipped ({}ms) -> {}",
                view.summary.total,
                view.summary.passed,
                view.summary.failed,
                view.summary.skipped,
                view.summary.duration_ms,
                path.display()
            );
        }
    } else if !is_quiet {
        match format {
            // `minimal`: the terse one-line summary to stdout, per-test FAIL
            // lines to stderr. The legacy default, kept for compact CI logs.
            Reporter::Minimal => print_run_summary(&view, false, stdout_color),
            // Default `pretty` lists each test plus a summary; the machine
            // formats render their document. Both go to stdout.
            other => {
                let fmt = other.render_format().unwrap_or(RenderFormat::Json);
                let stdout = std::io::stdout();
                run_reporter(fmt, stdout_color, agent_budget, suite_path, coverage)
                    .render(&view, &mut stdout.lock())?;
            }
        }
    }

    if annotations_enabled(annotations) {
        emit_annotations(&view, &mut std::io::stderr())?;
    }
    Ok(())
}

/// Build the reporter for a live run. Delegates to the shared
/// [`crate::cli::reporters::build_reporter`] except for the agent format,
/// which additionally needs the suite path so file-less tests still get an
/// executable repro line (the shared builder also serves `mcptest report`,
/// where no suite path exists).
fn run_reporter(
    fmt: crate::cli::reporters::RenderFormat,
    color: bool,
    agent_budget: Option<usize>,
    suite_path: Option<&std::path::Path>,
    coverage: Option<&mcptest_core::coverage::CoverageReport>,
) -> Box<dyn mcptest_core::report::Reporter> {
    use crate::cli::reporters::{build_reporter, RenderFormat};
    use mcptest_core::report::reporters::{
        AgentReporter, HtmlReporter, JsonReporter, MarkdownReporter, PrettyReporter,
        DEFAULT_TOKEN_BUDGET,
    };

    if let (RenderFormat::Agent, Some(path)) = (fmt, suite_path) {
        return Box::new(
            AgentReporter::default()
                .with_token_budget(agent_budget.unwrap_or(DEFAULT_TOKEN_BUDGET))
                .with_suite_path(path.display().to_string()),
        );
    }
    // WOR-1386: only four reporters render a coverage section; attach it via
    // their `with_coverage` builder. Other formats fall through to the shared
    // builder, which has no coverage slot.
    if let Some(cov) = coverage {
        match fmt {
            RenderFormat::Pretty => {
                return Box::new(
                    PrettyReporter::default()
                        .with_color(color)
                        .with_coverage(cov.clone()),
                )
            }
            RenderFormat::Json => {
                return Box::new(JsonReporter::default().with_coverage(cov.clone()))
            }
            RenderFormat::Md => {
                return Box::new(MarkdownReporter::default().with_coverage(cov.clone()))
            }
            RenderFormat::Html => {
                return Box::new(HtmlReporter::default().with_coverage(cov.clone()))
            }
            _ => {}
        }
    }
    build_reporter(fmt, false, false, color, agent_budget)
}

/// Stamp the run's identity onto the report: the pinned `--run-id` when
/// given (tooling that persisted artifacts up front needs the ids to
/// match), a fresh UUIDv7 otherwise. The engine leaves `run_id` empty, so
/// this runs once, after the report is fully assembled.
pub(super) fn finalize_run_identity(
    report: &mut mcptest_core::report::Report,
    pinned: Option<&str>,
) {
    report.run_id = match pinned {
        Some(id) => id.to_string(),
        None => mcptest_core::report::run_store::new_run_id(),
    };
}

/// Persist the redacted report and per-test failure outputs under
/// `.mcptest/runs/<run_id>` (the store the MCP server resources read),
/// pruning to the retention cap. Failures are warnings: the exit code
/// reflects the run, not the bookkeeping, same as the last-run state file.
///
/// `redactor` is threaded in by the run handler so the persisted artifact is
/// scrubbed against the plaintext of every secret resolved during
/// interpolation (WOR-1454), not only the shape-based heuristics.
pub(super) fn persist_run_artifacts(
    report: &mcptest_core::report::Report,
    redactor: &mcptest_core::redaction::Redactor,
) {
    let Ok(cwd) = std::env::current_dir() else {
        // No working directory means nowhere to anchor `.mcptest`; the
        // run result already reached the reporters, so just skip.
        return;
    };
    let store = mcptest_core::report::run_store::RunStore::for_workspace(cwd);
    if let Err(err) = store.persist(report, redactor) {
        eprintln!("mcptest run: could not persist run artifacts: {err}");
    }
}

/// Render a `mcptest-config::Error` from the run-once schema gate to
/// stderr in the same shape as `mcptest validate`. The summary
/// line goes first, then each per-issue line carries the JSON Pointer
/// path (or "(root)" when the validator could not localize) plus any
/// "did you mean" hint the loader produced. Lives here with the other
/// run-output rendering so `run_live` stays under the module-size guard.
pub(crate) fn print_config_error(path: &std::path::Path, err: &mcptest_config::Error) {
    use mcptest_config::Error as ConfigError;
    eprintln!(
        "mcptest run: config validation failed for {}",
        path.display()
    );
    match err {
        ConfigError::SchemaValidation { summary, errors } => {
            eprintln!("  {summary}");
            for issue in errors {
                let location = if issue.path.is_empty() {
                    "(root)".to_string()
                } else {
                    issue.path.clone()
                };
                let hint = issue
                    .hint
                    .as_deref()
                    .map(|h| format!(" ({h})"))
                    .unwrap_or_default();
                eprintln!("  [{}] {}{}", location, issue.message, hint);
            }
        }
        other => eprintln!("  {other}"),
    }
    // WOR-1268: link the troubleshooting entry as the last line of the
    // block so a failing terminal points straight at the fix.
    eprintln!(
        "  see: {}",
        mcptest_core::error_anchors::CONFIG_VALIDATION_FAILED
    );
}

/// Print the one-line run summary plus a `FAIL` line per failing test.
/// Suppressed under `--quiet`. Lives here with [`emit_report`] so
/// [`super::run_live`] stays under the production-module-size guard.
pub(super) fn print_run_summary(report: &mcptest_core::report::Report, quiet: bool, color: bool) {
    use mcptest_core::report::TestStatus;
    use mcptest_core::style::Styler;
    if quiet {
        return;
    }
    let sty = Styler::new(color);
    let passed = sty.pass(&format!("{} passed", report.summary.passed));
    let failed = if report.summary.failed > 0 {
        sty.fail(&format!("{} failed", report.summary.failed))
    } else {
        format!("{} failed", report.summary.failed)
    };
    let skipped = sty.dim(&format!("{} skipped", report.summary.skipped));
    println!(
        "ran {} tests: {passed}, {failed}, {skipped} ({}ms)",
        report.summary.total, report.summary.duration_ms
    );
    for t in &report.tests {
        if let TestStatus::Fail = t.status {
            if let Some(failure) = &t.failure {
                eprintln!("  {} {}: {}", sty.fail("FAIL"), t.name, failure.message);
            }
        }
    }
}

/// Build a well-formed report for an interrupted run (WOR-1434): one
/// `Inconclusive` row per selected spec carrying an "interrupted" note, with
/// the summary derived from the rows. Lives here with the other report
/// emitters so the run handler stays within the module-size budget.
pub(crate) fn interrupted_report(
    specs: &[mcptest_core::runner::TestSpec],
) -> mcptest_core::report::Report {
    use mcptest_core::report::{Report, RunSummary, TestFailure, TestResult, TestStatus};
    let tests: Vec<TestResult> = specs
        .iter()
        .map(|s| TestResult {
            name: s.name.clone(),
            status: TestStatus::Inconclusive,
            duration_ms: 0,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: Some(TestFailure {
                message: "interrupted by SIGINT before the runner reported a verdict".to_string(),
                diff: None,
                network_diagnostic: None,
            }),
            file: s.file.clone(),
            line: s.line,
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        })
        .collect();
    Report {
        schema_version: "0.1".to_string(),
        mcptest_version: mcptest_core::version::full().to_string(),
        generated_at: String::new(),
        run_id: String::new(),
        summary: RunSummary::from_tests(&tests),
        tests,
        cost: None,
        metadata: None,
        matrix: None,
        protocol_violations: Vec::new(),
    }
}
