//! Arguments for `mcptest run`.

use clap::Args;

use crate::cli::parsers::{
    parse_cache_filter, parse_coverage_threshold, parse_sample_rate, parse_shard_spec,
};

/// Arguments for `mcptest run`.
///
/// Most options live on the global block. The few flags below only make
/// sense for an active run, so they sit on the subcommand. Test path
/// overrides land with .
#[derive(Debug, Args, Default)]
pub struct RunArgs {
    /// Path to the suite YAML to run.
    ///
    /// Exists so the repro lines the agent reporter prints
    /// (`mcptest run <suite> --filter "case"`) are executable verbatim.
    /// When omitted, the global `--config` flag (or `./mcptest.yaml`)
    /// picks the suite; when both are given, the positional path wins.
    #[arg(value_name = "SUITE", conflicts_with = "stdin")]
    pub suite: Option<std::path::PathBuf>,

    /// Read the suite YAML from standard input instead of a file.
    ///
    /// The piped document takes the same loading path as a file (schema
    /// validation, env files, interpolation), so an agent can hand a
    /// generated suite straight to the engine without managing a temp
    /// file. Mutually exclusive with a suite path.
    #[arg(long = "stdin")]
    pub stdin: bool,

    /// Pin this run's id instead of generating one.
    ///
    /// Exists for tooling (the `mcptest mcp-server` run_tool_test verb)
    /// that must know the id before the run starts so it can persist the
    /// inline suite next to the run artifacts. Normal runs omit it.
    #[arg(long = "run-id", value_name = "ID")]
    pub run_id: Option<String>,

    /// Re-record every snapshot encountered during this run.
    ///
    /// On a normal run the snapshot matcher writes on first encounter and
    /// diffs on later runs. With this flag the matcher overwrites the
    /// saved file every time, and the runner prints a one-line summary
    /// of how many snapshots changed so the operator can review the
    /// diff with `git diff`.
    ///
    /// Refused when the `CI` environment variable is `true` unless
    /// [`Self::allow_update_in_ci`] is also set; rewriting fixtures in CI
    /// is almost always a misconfigured pipeline.
    #[arg(long = "update-snapshots", short = 'u')]
    pub update_snapshots: bool,

    /// Re-record every cassette in scope from its live target and
    /// overwrite the files in place.
    ///
    /// The snapshot twin of `--update-snapshots`: server cassettes are
    /// refreshed from the live target named in each cassette's `meta`
    /// block before the run replays them, and agent cassettes take the
    /// `--record` path so the live provider is dispatched and the file
    /// rewritten. Honors `--filter` and `--tag` exactly like the run
    /// itself, and prints a one-line re-recorded count.
    ///
    /// Refused when the `CI` environment variable is `true` unless
    /// [`Self::allow_update_in_ci`] is also set, mirroring the
    /// `--update-snapshots` guard.
    #[arg(long = "update-cassettes")]
    pub update_cassettes: bool,

    /// Permit `--update-snapshots` / `--update-cassettes` even when
    /// `CI=true` is set.
    ///
    /// Reserved for the rare maintenance job that runs in CI on purpose
    /// (a scheduled job that refreshes baselines and opens a PR). The
    /// flag has no effect when neither update flag is set.
    #[arg(long = "allow-update-in-ci")]
    pub allow_update_in_ci: bool,

    /// Run the suite across multiple models in one invocation (a model
    /// matrix).
    ///
    /// Every agent test fans across this comma-separated list, producing one
    /// cell per model, and the run defaults to the `matrix` reporter so the
    /// output is a test-by-model comparison grid (pass an explicit
    /// `--reporter` to override). Bare ids are provider-auto-detected
    /// (`gpt-4o`, `claude-3-5-sonnet`); this overrides any `models:` the
    /// agents declared in YAML. Tool, resource, and prompt tests are
    /// model-independent and run once.
    #[arg(long = "models", value_delimiter = ',')]
    pub models: Vec<String>,

    /// Disable the LLM-judge verdict cache for this run.
    ///
    /// Overrides `evals.cache.verdicts: true` in YAML. Useful for spot
    /// checks where a stale cached verdict could mask drift; ADR 0008
    /// calls out the cache as opt-in for exactly this reason.
    #[arg(long = "no-verdict-cache")]
    pub no_verdict_cache: bool,

    /// Record coverage during the run.
    ///
    /// Toggles construction of a `CoverageTracker` inside the runner. The
    /// resulting `CoverageReport` is folded into the JSON, pretty,
    /// markdown, and HTML reporters via their `with_coverage` builders so
    /// a CI viewer can drill into which tools, error paths, and
    /// transports the suite actually exercised. Off by default to keep
    /// the runner overhead invisible for users who do not want it.
    #[arg(long)]
    pub coverage: bool,

    /// Quality gate thresholds for [`Self::coverage`].
    ///
    /// Accepts the `tools=80,args=60,error_paths=50,transports=100` syntax.
    /// When any threshold is unmet the runner exits with code 6 (the
    /// existing quality-gate exit). Only honoured when `--coverage` is
    /// also set; passing this flag without `--coverage` returns a clear
    /// error so the operator does not silently miss the gate.
    #[arg(long = "coverage-threshold", value_parser = parse_coverage_threshold, value_name = "SPEC")]
    pub coverage_threshold: Option<mcptest_core::coverage::Thresholds>,

    /// Opt this run into result caching (WOR-1398). Off by default: a bare
    /// `mcptest run` always dispatches every `tools/call` live. With this
    /// flag set, a cacheable tool result is served from a per-suite store
    /// (`<suite_dir>/.mcptest/cache`) on a hit and written on a miss, so a
    /// stable read does not pay for the server on every run. The
    /// `--no-cache*` flags refine read/write; the key folds the tool name,
    /// arguments, matcher signature, and runner version, so re-tuning an
    /// assertion or upgrading the binary never serves a stale hit. A server
    /// upgrade is not auto-detected: clear the cache (`mcptest cache clear`)
    /// after changing the target.
    #[arg(long = "cache")]
    pub cache: bool,

    /// Bypass the cache for this run.
    ///
    /// Equivalent to passing both `--no-cache-read` and `--no-cache-write`.
    /// The runner skips every cache lookup and never updates the store.
    /// Useful for the operator's "trust nothing" CI dry run. Only meaningful
    /// together with `--cache`.
    #[arg(long = "no-cache", conflicts_with_all = ["no_cache_read", "no_cache_write"])]
    pub no_cache: bool,

    /// Write fresh entries but ignore existing ones.
    ///
    /// Equivalent to "refresh the cache on this run". Every test still
    /// executes; the new result lands in the store but the old one is
    /// not consulted first. Pairs well with a scheduled job that wants
    /// to refresh after a server upgrade.
    #[arg(long = "no-cache-read")]
    pub no_cache_read: bool,

    /// Read existing entries but do not update the cache on this run.
    ///
    /// Equivalent to "freeze the cache for this run". Hits replay as
    /// usual; misses execute the test but the result is discarded. Pairs
    /// well with a smoke run against a hosted server where the operator
    /// does not want one transient outage to poison the cache.
    #[arg(long = "no-cache-write")]
    pub no_cache_write: bool,

    /// Run only tests matching this cache-set selector.
    ///
    /// `NEW` is the only value v1.0 ships; future variants like
    /// `STALE` (entries past TTL) or `FAILED` (entries that previously
    /// failed) will land additively. The runner consumes this filter
    /// when  wires it; today the value is parsed and validated
    /// so the CLI surface is stable.
    ///
    /// Distinct from the global `--filter EXPR` flag, which selects
    /// tests by name or tag.
    #[arg(long = "cache-filter", value_name = "SET", value_parser = parse_cache_filter)]
    pub cache_filter: Option<CacheFilter>,

    /// Record every agent test against the resolved provider and
    /// write a JSON cassette next to the YAML.
    ///
    /// Cassettes land under `<suite_dir>/cassettes/<agent>.json` and
    /// are reused on subsequent runs so CI does not need a live API
    /// key. With this flag set the live provider is dispatched even
    /// when a cassette already exists, and the file on disk is
    /// overwritten. Without the flag, the runner replays an existing
    /// cassette when it matches the YAML inputs and falls through to
    /// a live dispatch otherwise.
    #[arg(long = "record")]
    pub record: bool,

    /// Permit the deterministic stub provider when an agent's real
    /// provider cannot be resolved (a recognized model family with no API
    /// key in the environment, or an unrecognized family).
    ///
    /// Exists for offline smoke runs: the suite's wiring (servers, test
    /// selection, reporters) is exercised with no credential and no spend,
    /// and every affected cell is marked `stub:` in the report so the
    /// output cannot be mistaken for a real model run. Without this flag a
    /// non-resolvable provider is a hard error naming the env var to set
    /// (or the unsupported model), per WOR-1312.
    #[arg(long = "allow-stub-provider")]
    pub allow_stub_provider: bool,

    /// Skip matrix cells already recorded and passed in a prior
    /// `--record` run.
    ///
    /// A wide model matrix can die partway (a provider rate-limits after
    /// most cells recorded). With this flag the runner reads the run
    /// journal next to the cassettes and skips every (agent, model) cell
    /// it already recorded and passed, so a resumed run only re-runs the
    /// missing or failed cells instead of re-spending on all of them.
    /// Only meaningful together with `--record`: without `--record` the
    /// non-record path already replays existing cassettes, so this flag
    /// no-ops there. The journal is ignored (a fresh run) when the suite
    /// changed, so a prompt or expectation edit never reuses stale cells.
    #[arg(long = "resume")]
    pub resume: bool,

    /// Stop the runner after the first failing test.
    /// `--bail` is sugar for `--maxfail 1`. Subsequent tests are
    /// reported as `skipped` so the report still shows the suite shape.
    /// Conflicts with `--random`/`--last-failed`: "which test bailed" is
    /// only meaningful in a stable order (WOR-1507).
    #[arg(long = "bail", short = 'x', conflicts_with_all = ["random", "last_failed"])]
    pub bail: bool,

    /// Stop the runner after the Nth failing test. `1` is
    /// equivalent to `--bail`. Omit (or set to `0`) to disable the cap.
    #[arg(long = "maxfail", value_name = "N")]
    pub maxfail: Option<u32>,

    /// Run only a deterministic fraction of the discovered tests, in
    /// `0.0..=1.0` (WOR-1386). `1.0` (the default) runs every test; a smaller
    /// value hashes each test name to decide inclusion, so the same suite always
    /// samples the same subset across reruns. Sampled-out tests occupy a `Skip`
    /// row. Useful for a fast partial signal on a large suite.
    #[arg(long = "sample-rate", value_name = "RATE", value_parser = parse_sample_rate)]
    pub sample_rate: Option<f64>,

    /// Run the selected suite up to N times and decide a sequential
    /// reliability verdict (WOR-1400). Each pass of the suite is one
    /// trial: a fully passing run counts as a success, a run with any
    /// failure as a miss. A Sequential Probability Ratio Test updates
    /// after every trial and stops early as soon as the evidence is
    /// decisive, so a clearly reliable or clearly broken suite spends
    /// far fewer than N runs; only a borderline suite uses the whole
    /// budget and reports `inconclusive`. `1` (or omitted) runs the
    /// suite once with the normal binary verdict.
    #[arg(long = "repeat", value_name = "N")]
    pub repeat: Option<u32>,

    /// Confidence percent for the `--repeat` verdict, clamped to
    /// `[50, 99]` (default 95). Higher confidence demands more evidence
    /// before the test accepts or rejects, so a borderline suite tends
    /// toward `inconclusive`. Ignored unless `--repeat` is set.
    #[arg(long = "repeat-confidence", value_name = "PCT", default_value = "95")]
    pub repeat_confidence: u8,

    /// Count a `--repeat` `inconclusive` verdict as a failure for the
    /// exit code. The repeat-path twin of
    /// `run_options.inconclusive.treat_as: fail`: without it an
    /// undecided verdict stays neutral (exit 0), with it the run exits
    /// 1. Ignored unless `--repeat` is set.
    #[arg(long = "repeat-inconclusive-fail")]
    pub repeat_inconclusive_fail: bool,

    /// Print discovered tests without running them. Honors
    /// `--filter` so the printed list is exactly what `mcptest run`
    /// would execute. Exits `0` after printing.
    #[arg(long = "collect-only", visible_alias = "list-tests")]
    pub collect_only: bool,

    /// Explain what the run would do without executing anything.
    ///
    /// Resolves the suite, applies `--filter`, `--tag`, and the YAML
    /// `skip:`/`only:` markers, then prints each test that would run
    /// with one line per assertion plus its budgets, and each skipped
    /// test with its reason. No server is spawned, no model is
    /// dispatched, no cache is touched. Exits `0`.
    #[arg(long = "explain")]
    pub explain: bool,

    /// Treat an empty test selection as success.
    ///
    /// Without this flag, a run that discovers zero tests exits `7`
    /// so a misconfigured shard or filter does not silently report
    /// "all good." With the flag set, a zero-test run exits `0` with
    /// a clear message. Useful for CI matrices where one worker
    /// legitimately receives no work.
    #[arg(long = "pass-with-no-tests")]
    pub pass_with_no_tests: bool,

    /// Run a deterministic slice of the discovered tests.
    ///
    /// Format is `INDEX/TOTAL`, one-based. `--shard 1/3` runs the
    /// first third, `--shard 2/3` the second, and so on. The
    /// partition is stable: tests are sorted by name and bucketed
    /// round-robin so each worker gets a roughly equal slice and the
    /// same name always lands in the same shard. Combine with
    /// `--pass-with-no-tests` so a worker that draws an empty slice
    /// does not fail the CI matrix.
    #[arg(long = "shard", value_name = "INDEX/TOTAL", value_parser = parse_shard_spec)]
    pub shard: Option<ShardSpec>,

    /// Print the resolved suite as YAML and exit.
    ///
    /// Loads the YAML, applies env files and `--var` overrides, then
    /// prints the parsed suite (with provider API keys redacted) to
    /// stdout. No server connection, no model dispatch, no test
    /// execution. Useful for debugging variable interpolation or
    /// confirming a suite parses the way the operator expects.
    #[arg(long = "print-config")]
    pub print_config: bool,

    /// Run only tests that failed on the previous invocation.
    ///
    /// Reads the failure list from `.mcptest/last-run.json` (written
    /// after every run). When the file is missing or the previous
    /// run had zero failures the selection is empty, so pair with
    /// `--pass-with-no-tests` if that should not exit non-zero.
    /// Conflicts with `--failed-first`.
    #[arg(
        long = "last-failed",
        visible_alias = "lf",
        conflicts_with = "failed_first"
    )]
    pub last_failed: bool,

    /// Reorder the test list so the previous run's failures execute
    /// first. Unlike `--last-failed`, every discovered test
    /// still runs; only the ordering changes. Useful when iterating
    /// on a flaky case: the failing test surfaces at the top of the
    /// report and `--bail` short-circuits faster.
    #[arg(long = "failed-first", visible_alias = "ff")]
    pub failed_first: bool,

    /// Shuffle the test order before running.
    ///
    /// Useful for catching hidden ordering dependencies between
    /// tests. Each run prints the seed so you can reproduce a
    /// shuffle that surfaced a flake with `--seed N`. Conflicts
    /// with `--failed-first` because both control ordering.
    #[arg(long = "random", conflicts_with = "failed_first")]
    pub random: bool,

    /// Pin the shuffle seed for `--random`. When
    /// `--random` is set without a seed, the runner picks a random
    /// `u64` and logs it. Set this flag to reproduce a previous
    /// shuffle exactly. Implies `--random`.
    #[arg(long = "seed", value_name = "N")]
    pub seed: Option<u64>,

    /// Run only tests tagged with this name. Repeatable;
    /// multiple `--tag` flags select tests that match any of the
    /// given tags. Tags come from the YAML `tags: [...]` block on
    /// tool, resource, prompt, and agent tests.
    #[arg(long = "tag", value_name = "NAME", action = clap::ArgAction::Append)]
    pub tags: Vec<String>,

    /// Skip tests tagged with this name. Repeatable;
    /// tests carrying any of the listed tags are filtered out.
    /// Applied after `--tag` so a test that matches both is dropped.
    #[arg(long = "skip-tag", value_name = "NAME", action = clap::ArgAction::Append)]
    pub skip_tags: Vec<String>,

    /// Run only tests whose YAML or cassettes changed in the git
    /// working tree. Shells out to
    /// `git diff --name-only <base>...HEAD` (default base
    /// `origin/main`, override with `--changed-base`). Falls through
    /// to "no tests selected" outside a git repo, which pairs with
    /// `--pass-with-no-tests` for environments where the flag is set
    /// unconditionally.
    #[arg(long = "changed")]
    pub changed: bool,

    /// Git base ref for `--changed` (default `origin/main`).
    #[arg(
        long = "changed-base",
        value_name = "REF",
        default_value = "origin/main"
    )]
    pub changed_base: String,

    /// mTLS client certificate PEM path. Overrides
    /// `http.tls.mtls.client_cert_path` from the YAML when set.
    #[arg(long = "client-cert", value_name = "PATH")]
    pub client_cert: Option<std::path::PathBuf>,

    /// mTLS client private key PEM path. Overrides
    /// `http.tls.mtls.client_key_path`. Mutually exclusive with
    /// `--client-key-env`; the existing config validator enforces that.
    #[arg(long = "client-key", value_name = "PATH")]
    pub client_key: Option<std::path::PathBuf>,

    /// Env var holding the mTLS client private key PEM.
    /// Overrides `http.tls.mtls.client_key_env`. Names a var; the key
    /// itself is never passed on the command line.
    #[arg(long = "client-key-env", value_name = "VAR")]
    pub client_key_env: Option<String>,

    /// CA bundle PEM path for server verification. Overrides
    /// `http.tls.mtls.ca_bundle_path`.
    #[arg(long = "ca-bundle", value_name = "PATH")]
    pub ca_bundle: Option<std::path::PathBuf>,

    /// AWS region for SigV4 signing. Overrides
    /// `auth.aws_sigv4.region`.
    #[arg(long = "aws-sigv4-region", value_name = "REGION")]
    pub aws_sigv4_region: Option<String>,

    /// AWS service name for SigV4 signing. Overrides
    /// `auth.aws_sigv4.service` (for example `execute-api`, `lambda`).
    #[arg(long = "service", value_name = "NAME")]
    pub service: Option<String>,

    /// AWS named profile for SigV4. Selects the
    /// named-profile credential source: the access key, secret key, region,
    /// and optional session token resolve from `~/.aws/credentials` and
    /// `~/.aws/config` (or the `AWS_SHARED_CREDENTIALS_FILE` /
    /// `AWS_CONFIG_FILE` overrides) at sign time.
    #[arg(long = "profile", value_name = "NAME")]
    pub profile: Option<String>,

    /// Web Bot Auth Ed25519 signing key PEM path. Overrides
    /// `auth.web_bot_auth.key_path`.
    #[arg(long = "webbot-key", value_name = "PATH")]
    pub webbot_key: Option<std::path::PathBuf>,

    /// Env var holding the Web Bot Auth signing key PEM.
    /// Overrides `auth.web_bot_auth.key_env`. Names a var; the key is
    /// never passed on the command line.
    #[arg(long = "webbot-key-env", value_name = "VAR")]
    pub webbot_key_env: Option<String>,

    /// Web Bot Auth `Signature-Agent` URL. Overrides
    /// `auth.web_bot_auth.signature_agent`.
    #[arg(long = "webbot-agent", value_name = "URL")]
    pub webbot_agent: Option<String>,

    /// Web Bot Auth signature algorithm. Overrides
    /// `auth.web_bot_auth.algorithm`. `ed25519` is supported today;
    /// `rsa-pss` parses but is rejected downstream as deferred.
    #[arg(long = "webbot-algorithm", value_name = "ALG")]
    pub webbot_algorithm: Option<String>,

    /// Treat lint warnings as fatal (WOR-1294).
    ///
    /// The run path lints the resolved suite for assertions that can
    /// never fail (a `contains` with an empty operand, a regex that
    /// matches the empty string) and prints each finding to stderr.
    /// Without this flag the warnings are advisory and the run
    /// proceeds; with it, any finding exits with code 2 before any
    /// server is spawned, so CI can refuse a suite that asserts
    /// nothing.
    #[arg(long = "deny-lints")]
    pub deny_lints: bool,

    /// Extra arguments appended to every stdio server's command
    ///. Any argument after `--` lands here. Use to inject
    /// `--debug` or a `--config <path>` into the server subprocess
    /// without editing the YAML. Ignored for HTTP / SSE servers.
    /// `last` (instead of `trailing_var_arg`) keeps the positional
    /// suite path from being swallowed into this list.
    #[arg(last = true, value_name = "SERVER_ARG")]
    pub server_passthrough: Vec<String>,

    /// Select a single entry from the suite's `target_versions:`
    /// list. Useful when CI fans the suite over each declared
    /// protocol revision and wants to keep one job per matrix cell.
    /// When the suite declares no list, this still works as a
    /// one-off "run against this revision only" knob. Validated
    /// against `crate::mcptest_core::target_versions::effective`;
    /// the selector resolves the override down to one
    /// `ProtocolVersion` the runner walks.
    #[arg(long, value_name = "VERSION")]
    pub target_version: Option<String>,

    /// Path to a timing baseline file the run is compared against.
    /// When set, each test's elapsed milliseconds is checked against
    /// the baseline's recorded p90; tests that exceed `p90 * (1 +
    /// tolerance_pct / 100)` print a regression warning and the
    /// process exits non-zero. The baseline format is the same one
    /// `mcptest baseline update` writes.
    #[arg(long, value_name = "PATH")]
    pub check_baseline: Option<std::path::PathBuf>,

    /// Per-test tolerance, in percent, applied to the baseline p90
    /// before flagging a regression. `--tolerance-pct 50` lets a
    /// test take up to 1.5x its baseline p90 before failing.
    /// Defaults to 0 (any overrun is a regression). Ignored unless
    /// `--check-baseline` is set.
    #[arg(long, value_name = "N", default_value = "0")]
    pub tolerance_pct: f64,
}

/// `--shard INDEX/TOTAL` for `mcptest run`.
///
/// Both fields are one-based and validated at parse time so the
/// runner never has to defend against `0/0` or `4/3`. The partition
/// is stable across runs: tests are sorted by name and bucketed by
/// `index_in_sorted_list % total + 1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShardSpec {
    /// One-based shard index. `1 <= index <= total`.
    pub index: u32,
    /// Total number of shards in the partition. Always `>= 1`.
    pub total: u32,
}

/// `--filter` cache-set selector for `mcptest run`.
///
/// One variant ships in v1.0; the enum is open so future selectors
/// land without breaking the flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheFilter {
    /// Tests not currently in the cache.
    New,
}

/// Map `RunArgs` to the snapshot matcher's
/// [`snapshot::UpdateSnapshotsMode`](mcptest_core::matchers::snapshot::UpdateSnapshotsMode).
///
/// Pulled out as a free function so it stays unit-testable without
/// constructing a full `Cli`. Once the runner lands the call
/// site will hand the returned mode to every snapshot matcher invocation.
pub fn update_snapshots_mode_for(
    args: &RunArgs,
) -> mcptest_core::matchers::snapshot::UpdateSnapshotsMode {
    use mcptest_core::matchers::snapshot::UpdateSnapshotsMode;
    if args.update_snapshots {
        UpdateSnapshotsMode::Always
    } else {
        UpdateSnapshotsMode::Never
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::{Cli, Command};
    use clap::Parser;

    #[test]
    fn run_parses_long_update_snapshots_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--update-snapshots"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.update_snapshots),
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn run_parses_short_update_snapshots_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "-u"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.update_snapshots),
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn run_parses_update_cassettes_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--update-cassettes"]).expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert!(args.update_cassettes);
                assert!(!args.update_snapshots, "flags stay independent");
            }
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn run_parses_allow_update_in_ci_flag() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            "--update-snapshots",
            "--allow-update-in-ci",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert!(args.update_snapshots);
                assert!(args.allow_update_in_ci);
            }
            other => panic!("unexpected variant: {other:?}"),
        }
    }

    #[test]
    fn update_snapshots_mode_maps_to_always_when_flag_set() {
        use mcptest_core::matchers::snapshot::UpdateSnapshotsMode;
        let args = RunArgs {
            update_snapshots: true,
            ..RunArgs::default()
        };
        assert_eq!(
            update_snapshots_mode_for(&args),
            UpdateSnapshotsMode::Always
        );
    }

    #[test]
    fn update_snapshots_mode_defaults_to_never() {
        use mcptest_core::matchers::snapshot::UpdateSnapshotsMode;
        let args = RunArgs::default();
        assert_eq!(update_snapshots_mode_for(&args), UpdateSnapshotsMode::Never);
    }

    #[test]
    fn run_parses_cache_opt_in_flag_and_defaults_off() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--cache"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.cache),
            other => panic!("expected Run, got {other:?}"),
        }
        let cli = Cli::try_parse_from(["mcptest", "run"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(!args.cache),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_accepts_no_cache_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--no-cache"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.no_cache),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_no_cache_conflicts_with_split_flags() {
        let err = Cli::try_parse_from(["mcptest", "run", "--no-cache", "--no-cache-read"])
            .expect_err("conflict");
        assert!(err.to_string().contains("cannot be used"));
    }

    #[test]
    fn run_accepts_split_no_cache_flags() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--no-cache-read", "--no-cache-write"])
            .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert!(args.no_cache_read);
                assert!(args.no_cache_write);
                assert!(!args.no_cache);
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_cache_filter_new_parses_to_cache_filter_new() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--cache-filter", "NEW"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(args.cache_filter, Some(CacheFilter::New)),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_cache_filter_rejects_unknown_value() {
        let err = Cli::try_parse_from(["mcptest", "run", "--cache-filter", "STALE"])
            .expect_err("invalid");
        assert!(err.to_string().contains("STALE"));
    }

    #[test]
    fn run_parses_pass_with_no_tests_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--pass-with-no-tests"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.pass_with_no_tests),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_repeat_and_defaults_confidence_to_95() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--repeat", "20"]).expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(args.repeat, Some(20));
                assert_eq!(args.repeat_confidence, 95);
                assert!(!args.repeat_inconclusive_fail);
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_repeat_confidence_and_inconclusive_fail() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            "--repeat",
            "30",
            "--repeat-confidence",
            "99",
            "--repeat-inconclusive-fail",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(args.repeat, Some(30));
                assert_eq!(args.repeat_confidence, 99);
                assert!(args.repeat_inconclusive_fail);
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_repeat_defaults_to_none() {
        let cli = Cli::try_parse_from(["mcptest", "run"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(args.repeat, None),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_print_config_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--print-config"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.print_config),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_shard_parses_one_based_index() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--shard", "2/3"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(args.shard, Some(ShardSpec { index: 2, total: 3 })),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_shard_rejects_zero_index() {
        let err = Cli::try_parse_from(["mcptest", "run", "--shard", "0/3"]).expect_err("invalid");
        assert!(err.to_string().contains("one-based"));
    }

    #[test]
    fn run_shard_rejects_index_past_total() {
        let err = Cli::try_parse_from(["mcptest", "run", "--shard", "4/3"]).expect_err("invalid");
        assert!(err.to_string().contains("between"));
    }

    #[test]
    fn run_parses_last_failed_alias() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--lf"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.last_failed),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_failed_first_alias() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--ff"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.failed_first),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_random_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--random"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.random),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_seed_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--seed", "42"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(args.seed, Some(42)),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_rejects_random_with_failed_first() {
        let err = Cli::try_parse_from(["mcptest", "run", "--random", "--failed-first"])
            .expect_err("conflict");
        assert!(err.to_string().contains("cannot be used"));
    }

    #[test]
    fn run_collects_server_passthrough_after_double_dash() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            "--",
            "--debug",
            "--config",
            "/tmp/extra.yaml",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(
                args.server_passthrough,
                vec!["--debug", "--config", "/tmp/extra.yaml"]
            ),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_accepts_a_positional_suite_path() {
        let cli = Cli::try_parse_from(["mcptest", "run", "tests/create_issue.yml"]).expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(
                    args.suite.as_deref(),
                    Some(std::path::Path::new("tests/create_issue.yml"))
                );
                assert!(args.server_passthrough.is_empty());
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_positional_suite_composes_with_global_filter() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            ".mcptest/inline/run-1.yml",
            "--filter",
            "status check",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert!(args.suite.is_some());
            }
            other => panic!("expected Run, got {other:?}"),
        }
        assert_eq!(cli.global.filter.as_deref(), Some("status check"));
    }

    #[test]
    fn run_parses_stdin_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--stdin"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.stdin),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_rejects_stdin_with_a_positional_suite_path() {
        let err =
            Cli::try_parse_from(["mcptest", "run", "--stdin", "suite.yml"]).expect_err("conflict");
        assert!(err.to_string().contains("cannot be used"));
    }

    #[test]
    fn run_parses_a_pinned_run_id() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--run-id", "run-abc123"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert_eq!(args.run_id.as_deref(), Some("run-abc123")),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_suite_path_and_passthrough_coexist_across_double_dash() {
        let cli =
            Cli::try_parse_from(["mcptest", "run", "suite.yml", "--", "--debug"]).expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(
                    args.suite.as_deref(),
                    Some(std::path::Path::new("suite.yml"))
                );
                assert_eq!(args.server_passthrough, vec!["--debug"]);
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_allow_stub_provider_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--allow-stub-provider"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.allow_stub_provider),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn allow_stub_provider_defaults_off() {
        let cli = Cli::try_parse_from(["mcptest", "run"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(!args.allow_stub_provider),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_resume_flag() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--record", "--resume"]).expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert!(args.record);
                assert!(args.resume);
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_resume_defaults_off() {
        let cli = Cli::try_parse_from(["mcptest", "run"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(!args.resume),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_rejects_last_failed_with_failed_first() {
        let err = Cli::try_parse_from(["mcptest", "run", "--last-failed", "--failed-first"])
            .expect_err("conflict");
        assert!(err.to_string().contains("cannot be used"));
    }

    #[test]
    fn run_parses_deny_lints_flag_and_defaults_off() {
        let cli = Cli::try_parse_from(["mcptest", "run", "--deny-lints"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(args.deny_lints),
            other => panic!("expected Run, got {other:?}"),
        }
        let cli = Cli::try_parse_from(["mcptest", "run"]).expect("parse");
        match cli.command {
            Command::Run(args) => assert!(!args.deny_lints),
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_mtls_auth_flags() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            "--client-cert",
            "/tmp/c.pem",
            "--client-key",
            "/tmp/c.key",
            "--ca-bundle",
            "/tmp/ca.pem",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(
                    args.client_cert.as_deref(),
                    Some(std::path::Path::new("/tmp/c.pem"))
                );
                assert_eq!(
                    args.client_key.as_deref(),
                    Some(std::path::Path::new("/tmp/c.key"))
                );
                assert_eq!(
                    args.ca_bundle.as_deref(),
                    Some(std::path::Path::new("/tmp/ca.pem"))
                );
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }

    #[test]
    fn run_parses_sigv4_and_webbot_auth_flags() {
        let cli = Cli::try_parse_from([
            "mcptest",
            "run",
            "--aws-sigv4-region",
            "us-east-1",
            "--service",
            "execute-api",
            "--profile",
            "prod",
            "--webbot-key-env",
            "BOT_KEY",
            "--webbot-agent",
            "https://mcptest.sh",
            "--webbot-algorithm",
            "ed25519",
        ])
        .expect("parse");
        match cli.command {
            Command::Run(args) => {
                assert_eq!(args.aws_sigv4_region.as_deref(), Some("us-east-1"));
                assert_eq!(args.service.as_deref(), Some("execute-api"));
                assert_eq!(args.profile.as_deref(), Some("prod"));
                assert_eq!(args.webbot_key_env.as_deref(), Some("BOT_KEY"));
                assert_eq!(args.webbot_agent.as_deref(), Some("https://mcptest.sh"));
                assert_eq!(args.webbot_algorithm.as_deref(), Some("ed25519"));
            }
            other => panic!("expected Run, got {other:?}"),
        }
    }
}
