use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use std::path::{Path, PathBuf};
use std::process;

use tarn::assert::types::RunResult;
use tarn::bench;
use tarn::config::{self, TarnConfig};
use tarn::cookie;
use tarn::env;
use tarn::error::TarnError;
use tarn::format;
use tarn::model::{HttpTransportConfig, HttpVersionPreference, TestFile};

#[cfg(test)]
use tarn::model::Defaults;
use tarn::parser;
use tarn::report::json::JsonOutputMode;
use tarn::report::progress::{HumanProgress, NdjsonProgress, ProgressMode, ProgressReporter};
use tarn::report::{self, OutputFormat, OutputTarget, RenderOptions};
use tarn::runner;
use tarn::selector::{self, Selector};

#[derive(Parser)]
#[command(name = "tarn", version, about = "CLI-first API testing tool")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Run test files
    Run {
        /// Test file or directory to run
        path: Option<String>,

        /// Output format target(s): human, json, junit, tap, html, curl, curl-all, compact, llm, or FORMAT=PATH.
        /// When omitted, tarn picks `human` on a TTY and `llm` when stdout is piped (so an LLM never sees boxed headers by default).
        #[arg(long, value_delimiter = ',')]
        format: Vec<String>,

        /// JSON report mode: verbose (default) or compact
        #[arg(long = "json-mode", default_value = "verbose")]
        json_mode: String,

        /// Filter by tag (comma-separated, AND logic)
        #[arg(long)]
        tag: Option<String>,

        /// Select a single file, test, or step (repeatable).
        /// Form: FILE[::TEST[::STEP]]. STEP may be a name or a 0-based index.
        /// Multiple --select flags union; combined with --tag they AND.
        #[arg(long = "select", value_name = "FILE[::TEST[::STEP]]")]
        select: Vec<String>,

        /// Restrict the run to a single named test. Ergonomic shorthand for
        /// `--select FILE::TEST`; internally composed against every file in
        /// the discovery set. Combine with `--step-filter` to narrow further.
        #[arg(long = "test-filter", value_name = "NAME")]
        test_filter: Option<String>,

        /// Restrict the run to a single step within the `--test-filter` test.
        /// Accepts a 0-based numeric index or an exact step name. When used
        /// without `--test-filter` it applies to the flat-steps form of the
        /// file. Internally composes into the selector grammar.
        #[arg(long = "step-filter", value_name = "INDEX|NAME")]
        step_filter: Option<String>,

        /// Override environment variables (key=value)
        #[arg(long = "var", value_name = "KEY=VALUE")]
        vars: Vec<String>,

        /// Environment name (loads tarn.env.{name}.yaml)
        #[arg(long = "env")]
        env_name: Option<String>,

        /// Print full request/response for every step
        #[arg(short, long)]
        verbose: bool,

        /// Include response body, response headers, and captures in the
        /// report for every step (not just failed ones). Works with
        /// `json`, `html`, `compact`, and `llm` formats. Use
        /// `--max-body` to cap body size.
        #[arg(long = "verbose-responses")]
        verbose_responses: bool,

        /// Maximum response body size (bytes) embedded in the report
        /// when `--verbose-responses` or a per-step `debug: true`
        /// causes the body to be recorded. Larger bodies are truncated
        /// with a `"...<truncated: N bytes>"` marker.
        #[arg(long = "max-body", value_name = "BYTES", default_value_t = tarn::runner::DEFAULT_MAX_BODY_BYTES)]
        max_body: usize,

        /// Show only failed tests and steps in the output.
        /// `--only-fails` is accepted as an alias.
        #[arg(long = "only-failed", alias = "only-fails")]
        only_failed: bool,

        /// Disable streaming progress output (print the final report in one batch)
        #[arg(long = "no-progress")]
        no_progress: bool,

        /// Stream NDJSON events to stdout for machine-readable progress.
        /// Mutually exclusive with any --format target that writes to stdout.
        #[arg(long = "ndjson", conflicts_with = "no_progress")]
        ndjson: bool,

        /// Show interpolated requests without sending them
        #[arg(long)]
        dry_run: bool,

        /// Watch for changes and rerun on file save
        #[arg(short, long)]
        watch: bool,

        /// Run test files in parallel. Only safe when tests don't share
        /// mutable state (DB rows, global counters, filesystem fixtures).
        /// Mark state-sharing files with `serial_only: true` or bucket
        /// them with a shared `group:` name; opt in via
        /// `parallel_opt_in: true` in `tarn.config.yaml` to silence the
        /// startup warning once the suite has been audited.
        #[arg(long)]
        parallel: bool,

        /// Number of parallel workers (default: number of CPUs)
        #[arg(short, long)]
        jobs: Option<usize>,

        /// Suppress the `--parallel` isolation warning. Use in CI once
        /// the suite has been verified safe for concurrent execution.
        /// Prefer setting `parallel_opt_in: true` in the project
        /// config; this flag is an escape hatch for one-off runs.
        #[arg(long = "no-parallel-warning")]
        no_parallel_warning: bool,

        /// Load and persist cookie jars to a JSON file
        #[arg(long = "cookie-jar")]
        cookie_jar: Option<String>,

        /// Reset the default cookie jar between named tests in each file.
        /// Overrides the file's declared `cookies:` mode (except `off`).
        #[arg(long = "cookie-jar-per-test")]
        cookie_jar_per_test: bool,

        /// Explicit proxy URL for HTTP/HTTPS requests
        #[arg(long)]
        proxy: Option<String>,

        /// Hosts that should bypass the configured proxy
        #[arg(long = "no-proxy")]
        no_proxy: Option<String>,

        /// Additional PEM CA bundle to trust
        #[arg(long)]
        cacert: Option<String>,

        /// Client certificate PEM file
        #[arg(long)]
        cert: Option<String>,

        /// Client private key PEM file
        #[arg(long)]
        key: Option<String>,

        /// Disable TLS certificate and hostname verification
        #[arg(long)]
        insecure: bool,

        /// Force HTTP/1.1
        #[arg(long = "http1.1", conflicts_with = "http2")]
        http1_1: bool,

        /// Force HTTP/2
        #[arg(long, conflicts_with = "http1_1")]
        http2: bool,

        /// Additional header name to redact in reports (repeatable,
        /// case-insensitive). Merges with — never narrows — the built-in
        /// defaults and any `redaction:` block from config or test files.
        #[arg(long = "redact-header", value_name = "NAME")]
        redact_header: Vec<String>,

        /// Include normally-ignored directories (`.git`, `.worktrees`,
        /// `node_modules`, `.venv`/`venv`, `dist`, `build`, `target`, `tmp`,
        /// `.tarn`) during test discovery. Use with care — it commonly
        /// doubles the run by picking up stale worktree copies.
        #[arg(long = "no-default-excludes")]
        no_default_excludes: bool,

        /// Override the path for the always-on JSON artifact. Defaults to
        /// `<project-root>/.tarn/last-run.json`. Every run overwrites the
        /// file so machines can always find the most recent result
        /// without rerunning in `--format json`.
        #[arg(long = "report-json", value_name = "PATH")]
        report_json: Option<PathBuf>,

        /// Skip writing the always-on JSON artifact. Use for transient
        /// runs where persisting the result on disk is undesirable.
        #[arg(long = "no-last-run-json")]
        no_last_run_json: bool,

        /// Skip writing per-step fixtures under `.tarn/fixtures/`.
        /// Fixtures are what `tarn.getFixture` / `tarn.evaluateJsonpath`
        /// / `tarn.explainFailure` in tarn-lsp consume, so disabling
        /// them means LLM clients cannot answer "what did this step
        /// last return?" until the next run without `--no-fixtures`.
        #[arg(long = "no-fixtures")]
        no_fixtures: bool,

        /// Override the rolling-history retention cap for per-step
        /// fixtures. Defaults to 5. Setting this to 1 keeps only the
        /// most recent fixture (plus the always-on
        /// `latest-passed.json`). Higher values trade disk for
        /// deeper history — helpful for debugging flaky steps.
        #[arg(long = "fixture-retention", value_name = "N", default_value_t = tarn::fixtures::DEFAULT_RETENTION)]
        fixture_retention: usize,

        /// Emit a compact root-cause-first JSON payload (`AgentReport`)
        /// to stdout for automation / MCP clients, suppressing the
        /// normal stdout formatter. Composes with other `--format`
        /// targets that write to files; mutually exclusive with
        /// `--ndjson`, `--watch`, and any explicit `--format` target
        /// that writes a non-JSON payload to stdout. Stderr still
        /// carries the usual `run id:` / `run artifacts:` announcements
        /// so humans watching the run see progress (NAZ-412).
        #[arg(long = "agent")]
        agent: bool,
    },

    /// Validate test files without running
    Validate {
        /// Test file or directory to validate
        path: Option<String>,

        /// Output format: human (default) or json
        #[arg(long, default_value = "human")]
        format: String,

        /// Include normally-ignored directories during discovery (see `run --no-default-excludes`).
        #[arg(long = "no-default-excludes")]
        no_default_excludes: bool,
    },

    /// Lint test files for reliability smells and correctness errors (NAZ-406).
    ///
    /// Separate from `tarn validate`: validate answers "does this parse
    /// and match the schema?"; lint answers "is this a test that won't
    /// fall over next month?". Catches things like positional captures
    /// on shared list endpoints (TL001), polling with weak stop
    /// conditions (TL003), and mutations without a status assertion
    /// (TL004). See `tarn/src/lint/` for the full rule set.
    ///
    /// Exit codes: 0 when no findings reach the severity threshold,
    /// 1 when findings exist at or above the threshold, 2 on I/O or
    /// parse error.
    Lint {
        /// Test file or directory to lint
        path: Option<String>,

        /// Output format: human (default) or json
        #[arg(long, default_value = "human")]
        format: String,

        /// Minimum severity to report: error, warning (default), or info
        #[arg(long, default_value = "warning")]
        severity: String,

        /// Suppress TL008 (hard-coded absolute URLs). Useful when tests
        /// deliberately hit third-party services by absolute URL.
        #[arg(long = "lint-allow-absolute-urls")]
        lint_allow_absolute_urls: bool,

        /// Include normally-ignored directories during discovery (see `run --no-default-excludes`).
        #[arg(long = "no-default-excludes")]
        no_default_excludes: bool,
    },

    /// Format Tarn test files into canonical YAML
    Fmt {
        /// Test file or directory to format
        path: Option<String>,

        /// Check whether files are already formatted without writing changes
        #[arg(long)]
        check: bool,
    },

    /// List all tests (dry run)
    List {
        /// Filter by tag
        #[arg(long)]
        tag: Option<String>,

        /// Restrict listing to a single file (skips workspace discovery)
        #[arg(long)]
        file: Option<PathBuf>,

        /// Output format: human (default) or json
        #[arg(long, default_value = "human")]
        format: String,

        /// Include normally-ignored directories during discovery (see `run --no-default-excludes`).
        #[arg(long = "no-default-excludes")]
        no_default_excludes: bool,
    },

    /// List project-defined named environments
    Env {
        /// Output environment metadata as JSON
        #[arg(long)]
        json: bool,
    },

    /// Convert common-case .hurl files into Tarn YAML
    ImportHurl {
        /// Source .hurl file
        path: String,

        /// Output path for the generated .tarn.yaml file
        #[arg(short, long)]
        output: Option<String>,
    },

    /// Initialize a new Tarn project
    Init,

    /// Benchmark a test step with concurrent requests
    Bench {
        /// Test file to benchmark
        path: String,

        /// Total number of requests
        #[arg(short = 'n', long, default_value = "100")]
        requests: u64,

        /// Number of concurrent workers
        #[arg(short, long, default_value = "10")]
        concurrency: u64,

        /// Step index to benchmark (0-based)
        #[arg(long, default_value = "0")]
        step: usize,

        /// Ramp-up duration (e.g., "5s", "500ms")
        #[arg(long)]
        ramp_up: Option<String>,

        /// Override environment variables
        #[arg(long = "var", value_name = "KEY=VALUE")]
        vars: Vec<String>,

        /// Environment name
        #[arg(long = "env")]
        env_name: Option<String>,

        /// Output format: human, json
        #[arg(long, default_value = "human")]
        format: String,

        /// Additional benchmark exports: json=PATH or csv=PATH
        #[arg(long, value_delimiter = ',')]
        export: Vec<String>,

        /// Fail if throughput drops below this requests/second threshold
        #[arg(long = "fail-under-rps")]
        fail_under_rps: Option<f64>,

        /// Fail if the benchmark error rate rises above this percentage
        #[arg(long = "fail-above-error-rate")]
        fail_above_error_rate: Option<f64>,

        /// Fail if p95 latency rises above this many milliseconds
        #[arg(long = "fail-above-p95-ms")]
        fail_above_p95_ms: Option<u64>,

        /// Fail if p99 latency rises above this many milliseconds
        #[arg(long = "fail-above-p99-ms")]
        fail_above_p99_ms: Option<u64>,

        /// Explicit proxy URL for HTTP/HTTPS requests
        #[arg(long)]
        proxy: Option<String>,

        /// Hosts that should bypass the configured proxy
        #[arg(long = "no-proxy")]
        no_proxy: Option<String>,

        /// Additional PEM CA bundle to trust
        #[arg(long)]
        cacert: Option<String>,

        /// Client certificate PEM file
        #[arg(long)]
        cert: Option<String>,

        /// Client private key PEM file
        #[arg(long)]
        key: Option<String>,

        /// Disable TLS certificate and hostname verification
        #[arg(long)]
        insecure: bool,

        /// Force HTTP/1.1
        #[arg(long = "http1.1", conflicts_with = "http2")]
        http1_1: bool,

        /// Force HTTP/2
        #[arg(long, conflicts_with = "http1_1")]
        http2: bool,
    },

    /// Update tarn to the latest version
    Update {
        /// Check for updates without installing
        #[arg(long)]
        check: bool,
    },

    /// Generate shell completions
    Completions {
        /// Shell to generate completions for
        #[arg(value_enum)]
        shell: Shell,
    },

    /// Re-render a prior JSON report in the LLM-friendly format.
    /// Accepts a file path or `-` to read from stdin. Primarily for
    /// `tarn run --format json > run.json && tarn summary run.json`
    /// workflows where a large report is streamed once and summarized
    /// lazily on demand.
    Summary {
        /// Path to a prior JSON report produced by `tarn run --format json`
        /// (or `.tarn/last-run.json`). Use `-` to read from stdin.
        path: String,

        /// Output format (compact or llm). Defaults to llm.
        #[arg(long, default_value = "llm")]
        format: String,
    },

    /// Group a prior run's failures by root cause and suppress cascade noise.
    ///
    /// Reads the `failures.json` artifact emitted by `tarn run`. With no
    /// arguments it defaults to `.tarn/failures.json` (the latest run);
    /// `--run <run_id>` reads the per-run archive under
    /// `.tarn/runs/<run_id>/failures.json`.
    Failures {
        /// Run id whose archive should be loaded (see `.tarn/runs/`).
        /// Without this flag the workspace-level latest-run pointer is used.
        #[arg(long)]
        run: Option<String>,

        /// Output format: `human` (default) or `json`.
        #[arg(long, default_value = "human")]
        format: String,

        /// Expand cascade lists instead of summarizing them as `└─ cascades: N skipped`.
        #[arg(long = "include-cascades")]
        include_cascades: bool,

        /// Disable ANSI color output.
        #[arg(long = "no-color")]
        no_color: bool,
    },

    /// Rerun the failing subset of a prior run (NAZ-403).
    ///
    /// Reads `failures.json` from a previous `tarn run` and executes only
    /// the `(file, test)` pairs that failed. Setup/teardown failures
    /// escalate to a whole-file rerun. Use `--run <id>` to target a
    /// specific historical archive under `.tarn/runs/<id>/`; omit it to
    /// use the latest-run pointer at `.tarn/failures.json`.
    ///
    /// The rerun produces a fresh run artifact set (its own `run_id`,
    /// `report.json`, `summary.json`, `failures.json`, `state.json`, and
    /// `last-run.json` pointer). Source provenance is stamped onto the
    /// new report via `rerun_source` so automation can chain reruns.
    ///
    /// Source lookup is anchored at the current workspace root. If you
    /// `cd`-ed away from the project that produced the archive, pass the
    /// project root via the shell (`cd <dir>` or `tarn -C <dir>` once
    /// that flag exists) before invoking `tarn rerun`; the command does
    /// not chase `state.json.working_directory` from the source run.
    Rerun {
        /// Read the failing subset from the source run. Required — `tarn rerun`
        /// on its own would be indistinguishable from a full run, so we refuse
        /// the command without an explicit opt-in.
        #[arg(long)]
        failed: bool,

        /// Run id whose `failures.json` should seed the rerun. Without
        /// this flag the workspace-level pointer at `.tarn/failures.json`
        /// is used (equivalent to "rerun the latest run's failures").
        #[arg(long)]
        run: Option<String>,

        /// Output format target(s) — same grammar as `tarn run`.
        #[arg(long, value_delimiter = ',')]
        format: Vec<String>,

        /// JSON report mode: verbose (default) or compact.
        #[arg(long = "json-mode", default_value = "verbose")]
        json_mode: String,

        /// Filter by tag (comma-separated, AND logic).
        #[arg(long)]
        tag: Option<String>,

        /// Additional selector entries unioned with the rerun selection.
        #[arg(long = "select", value_name = "FILE[::TEST[::STEP]]")]
        select: Vec<String>,

        /// Further narrow the rerun to a single named test.
        #[arg(long = "test-filter", value_name = "NAME")]
        test_filter: Option<String>,

        /// Further narrow the rerun to a single step.
        #[arg(long = "step-filter", value_name = "INDEX|NAME")]
        step_filter: Option<String>,

        /// Override environment variables (key=value).
        #[arg(long = "var", value_name = "KEY=VALUE")]
        vars: Vec<String>,

        /// Environment name (loads tarn.env.{name}.yaml).
        #[arg(long = "env")]
        env_name: Option<String>,

        /// Print full request/response for every step.
        #[arg(short, long)]
        verbose: bool,

        /// Include response body, response headers, and captures in the
        /// report for every step.
        #[arg(long = "verbose-responses")]
        verbose_responses: bool,

        /// Maximum response body size (bytes) embedded in the report.
        #[arg(long = "max-body", value_name = "BYTES", default_value_t = tarn::runner::DEFAULT_MAX_BODY_BYTES)]
        max_body: usize,

        /// Show only failed tests and steps in the output.
        #[arg(long = "only-failed", alias = "only-fails")]
        only_failed: bool,

        /// Disable streaming progress output.
        #[arg(long = "no-progress")]
        no_progress: bool,

        /// Stream NDJSON events to stdout for machine-readable progress.
        #[arg(long = "ndjson", conflicts_with = "no_progress")]
        ndjson: bool,

        /// Show interpolated requests without sending them.
        #[arg(long)]
        dry_run: bool,

        /// Run test files in parallel.
        #[arg(long)]
        parallel: bool,

        /// Number of parallel workers (default: number of CPUs).
        #[arg(short, long)]
        jobs: Option<usize>,

        /// Suppress the `--parallel` isolation warning.
        #[arg(long = "no-parallel-warning")]
        no_parallel_warning: bool,

        /// Load and persist cookie jars to a JSON file.
        #[arg(long = "cookie-jar")]
        cookie_jar: Option<String>,

        /// Reset the default cookie jar between named tests in each file.
        #[arg(long = "cookie-jar-per-test")]
        cookie_jar_per_test: bool,

        /// Explicit proxy URL for HTTP/HTTPS requests.
        #[arg(long)]
        proxy: Option<String>,

        /// Hosts that should bypass the configured proxy.
        #[arg(long = "no-proxy")]
        no_proxy: Option<String>,

        /// Additional PEM CA bundle to trust.
        #[arg(long)]
        cacert: Option<String>,

        /// Client certificate PEM file.
        #[arg(long)]
        cert: Option<String>,

        /// Client private key PEM file.
        #[arg(long)]
        key: Option<String>,

        /// Disable TLS certificate and hostname verification.
        #[arg(long)]
        insecure: bool,

        /// Force HTTP/1.1.
        #[arg(long = "http1.1", conflicts_with = "http2")]
        http1_1: bool,

        /// Force HTTP/2.
        #[arg(long, conflicts_with = "http1_1")]
        http2: bool,

        /// Additional header name to redact in reports.
        #[arg(long = "redact-header", value_name = "NAME")]
        redact_header: Vec<String>,

        /// Override the path for the always-on JSON artifact.
        #[arg(long = "report-json", value_name = "PATH")]
        report_json: Option<PathBuf>,

        /// Skip writing the always-on JSON artifact.
        #[arg(long = "no-last-run-json")]
        no_last_run_json: bool,

        /// Skip writing per-step fixtures.
        #[arg(long = "no-fixtures")]
        no_fixtures: bool,

        /// Override the rolling-history retention cap for per-step fixtures.
        #[arg(long = "fixture-retention", value_name = "N", default_value_t = tarn::fixtures::DEFAULT_RETENTION)]
        fixture_retention: usize,
    },

    /// Inspect a prior run's report at run, file, test, or step level (NAZ-405).
    ///
    /// Loads `<workspace>/.tarn/runs/<run_id>/report.json`. `run_id`
    /// supports the aliases `last` / `latest` / `@latest` (most recent
    /// archive) and `prev` (the one before latest). Use `target` to
    /// drill down: `FILE`, `FILE::TEST`, or `FILE::TEST::STEP`. The
    /// step view is the canonical "open a failing step without parsing
    /// the whole JSON" path.
    ///
    /// Exit codes: 0 on success, 2 on unknown run id / missing file /
    /// parse error. Pass `--fail-on-failure` to make the command
    /// return 1 when the inspected entity is failing (useful in CI
    /// smoke tests that want a non-zero code without grepping output).
    Inspect {
        /// Run id (or alias: `last`, `latest`, `@latest`, `prev`).
        run_id: String,

        /// Optional drill-down target: `FILE`, `FILE::TEST`, or
        /// `FILE::TEST::STEP`. Omit for a run-level summary.
        target: Option<String>,

        /// Output format: `human` (default) or `json`.
        #[arg(long, default_value = "human")]
        format: String,

        /// Filter the run-level list to failed files/tests that carry
        /// a failure in this category (e.g. `assertion_failed`).
        /// Ignored on file / test / step targets.
        #[arg(long = "filter-category", value_name = "CATEGORY")]
        filter_category: Option<String>,

        /// Exit 1 when the inspected entity (run / file / test / step)
        /// is failing. Disabled by default so `tarn inspect` stays
        /// idempotent on success/failure alike.
        #[arg(long = "fail-on-failure")]
        fail_on_failure: bool,
    },

    /// Concise re-render of a prior run's persisted artifacts (NAZ-404).
    ///
    /// Loads `summary.json` + `failures.json` from `.tarn/` (or from
    /// `.tarn/runs/<run_id>/` when `--run` is given) and prints a
    /// one-screen verdict plus, when there are failures, at most 10
    /// root-cause groups. The goal is to replace external helper
    /// scripts like `parse-results.py` with a stable, built-in
    /// reporter that humans and agents can both consume.
    ///
    /// Use `--format json` for a documented JSON envelope
    /// (`schema_version` 1). Exit codes: 0 on a clean run, 1 when the
    /// loaded artifact has failures, 2 on missing / malformed
    /// artifacts or an unknown `--run <id>`.
    Report {
        /// Run id (or alias: `last`, `latest`, `@latest`, `prev`).
        /// Without this flag the workspace-level pointer at
        /// `.tarn/summary.json` + `.tarn/failures.json` is read.
        #[arg(long)]
        run: Option<String>,

        /// Output format: `concise` (default) or `json`.
        #[arg(long, default_value = "concise")]
        format: String,

        /// Disable ANSI color output. Honored automatically when
        /// stdout is not a TTY.
        #[arg(long = "no-color")]
        no_color: bool,
    },

    /// Compare two runs' summary + failure sets (NAZ-405).
    ///
    /// Loads `summary.json` + `failures.json` for both `run_a` and
    /// `run_b`. Aliases `last` / `prev` are supported. Emits a totals
    /// delta plus a failure-set delta bucketed into `new`, `fixed`,
    /// and `persistent` using the same fingerprinting that
    /// `tarn failures` uses, so cascade noise collapses instead of
    /// double-counting.
    ///
    /// Exit codes: 0 on success, 2 on unknown run id / parse error.
    /// The command never returns a "regressed" code — inspect the
    /// JSON or grep the human output for `new:` to decide.
    Diff {
        /// Baseline run id (or alias: `last`, `prev`).
        run_a: String,
        /// Comparison run id (or alias: `last`, `prev`).
        run_b: String,

        /// Output format: `human` (default) or `json`.
        #[arg(long, default_value = "human")]
        format: String,

        /// Only consider failures whose root cause file matches.
        #[arg(long, value_name = "PATH")]
        file: Option<String>,

        /// Only consider failures whose root cause test matches.
        #[arg(long, value_name = "NAME")]
        test: Option<String>,

        /// Only consider failures in this category
        /// (e.g. `assertion_failed`).
        #[arg(long = "filter-category", value_name = "CATEGORY")]
        filter_category: Option<String>,
    },

    /// Pack a minimum remediation bundle for agents (NAZ-414).
    ///
    /// Reads the persisted `summary.json` + `failures.json` (and the
    /// full `report.json` when present) for the targeted run and emits
    /// a compact, deterministic payload carrying: failed YAML snippets,
    /// request / response excerpts for the failing steps, captures
    /// produced / consumed / blocked, rerun guidance, and artifact
    /// paths for deeper inspection. An agent can take the packed
    /// context and decide the next edit without reopening the full
    /// report in common cases.
    ///
    /// Without `--run`, the workspace-level pointers under `.tarn/`
    /// are read (the latest run). Use `--run <id>` to target any
    /// historical archive; `last` / `latest` / `@latest` / `prev` are
    /// supported. `--file` / `--test` are repeatable and compose as
    /// AND with each other (and with `--failed`). Output defaults to
    /// JSON; pass `--format markdown` for a human-friendly version of
    /// the same data. `--max-chars` caps the serialized output size;
    /// when the budget is exceeded, the lowest-priority sections are
    /// trimmed first and a truncation note is appended.
    ///
    /// Exit codes: 0 on success, 2 on unknown run id / missing
    /// artifacts / parse error.
    PackContext {
        /// Pack only failing entries. Defaults to true when no other
        /// filter is set; pass `--failed=false` to include every
        /// failure regardless of the narrow selection.
        #[arg(long, default_value_t = true)]
        failed: bool,

        /// Run id (or alias: `last`, `latest`, `@latest`, `prev`).
        /// Without this flag the workspace-level pointers under
        /// `.tarn/` are used.
        #[arg(long)]
        run: Option<String>,

        /// Restrict to failures in the given file (repeatable).
        #[arg(long, value_name = "PATH")]
        file: Vec<String>,

        /// Restrict to failures in the given test name (repeatable).
        #[arg(long, value_name = "NAME")]
        test: Vec<String>,

        /// Output format: `json` (default) or `markdown`.
        #[arg(long, default_value = "json")]
        format: String,

        /// Upper bound on the serialized output length, in characters.
        /// When exceeded, lower-priority sections are trimmed first
        /// and a truncation note is appended.
        #[arg(long = "max-chars", default_value_t = tarn::report::pack_context::DEFAULT_MAX_CHARS)]
        max_chars: usize,
    },

    /// Map a change to the Tarn targets it most likely affects (NAZ-410).
    ///
    /// Answers "what should I run next?" without executing any tests.
    /// Accepts four input flavors: `--diff` (reads `git diff --name-only
    /// HEAD` in the current repo), `--files PATH[,…]`, `--endpoints
    /// METHOD:PATH[,…]`, and `--openapi-ops ID[,…]`. At least one must
    /// be provided. Output is ranked by confidence and score; JSON mode
    /// is stable under `schema_version: 1`.
    ///
    /// Exit codes: 0 on success, 2 on missing inputs / I/O / parse /
    /// git errors.
    Impact {
        /// Read changed files from `git diff --name-only HEAD` under CWD.
        #[arg(long)]
        diff: bool,

        /// Explicit changed file list (comma-separated).
        #[arg(long, value_delimiter = ',')]
        files: Vec<String>,

        /// Endpoints touched by the change, `METHOD:PATH` (repeatable
        /// / comma-separated). Example: `GET:/users/:id,POST:/users`.
        #[arg(long, value_delimiter = ',')]
        endpoints: Vec<String>,

        /// OpenAPI operation ids touched by the change.
        #[arg(long = "openapi-ops", value_delimiter = ',')]
        openapi_ops: Vec<String>,

        /// Output format: `human` (default) or `json`.
        #[arg(long, default_value = "human")]
        format: String,

        /// Minimum confidence to include in the output: `low` (default),
        /// `medium`, or `high`. Filtering happens after scoring so the
        /// reported `matches` array is already filtered.
        #[arg(long = "min-confidence", default_value = "low")]
        min_confidence: String,

        /// Restrict test discovery to the given path (file or directory).
        /// Defaults to the current directory.
        #[arg(long)]
        path: Option<String>,

        /// Disable the default excludes (`.git`, `node_modules`, `target`, …).
        #[arg(long = "no-default-excludes")]
        no_default_excludes: bool,
    },

    /// Scaffold a minimal `.tarn.yaml` skeleton (NAZ-411).
    ///
    /// Generates a valid-but-intentionally-incomplete test file from
    /// one of four inputs: an OpenAPI operation id, a raw `curl`
    /// command saved in a file, an explicit method+URL pair, or a
    /// previously-recorded fixture (`.tarn/fixtures/...`). The
    /// output carries `# TODO:` markers on every inferred-but-
    /// unverified field so agents can iterate without re-reading the
    /// file from scratch.
    ///
    /// Exactly one of `--from-openapi`, `--from-curl`, `--method`, or
    /// `--from-recorded` must be provided. Exit codes: 0 on success,
    /// 2 on bad inputs / I/O errors.
    Scaffold {
        /// Path to an OpenAPI 3.x spec (JSON or YAML). Requires
        /// `--op-id`.
        #[arg(long = "from-openapi", value_name = "FILE")]
        from_openapi: Option<String>,

        /// OpenAPI `operationId` to scaffold. Required when
        /// `--from-openapi` is set.
        #[arg(long = "op-id", value_name = "ID")]
        op_id: Option<String>,

        /// Path to a file containing a single `curl` invocation
        /// (multi-line with `\` continuations is fine).
        #[arg(long = "from-curl", value_name = "FILE")]
        from_curl: Option<String>,

        /// Explicit HTTP method. Must be paired with `--url`.
        #[arg(long)]
        method: Option<String>,

        /// Explicit request URL. Must be paired with `--method`.
        #[arg(long)]
        url: Option<String>,

        /// Path to a recorded fixture file or step directory under
        /// `.tarn/fixtures/...`. Accepts a `latest-passed.json` file,
        /// a step directory (auto-picks `latest-passed.json` or the
        /// most recent history entry), or the legacy
        /// `request.json` + `response.json` split form.
        #[arg(long = "from-recorded", value_name = "PATH")]
        from_recorded: Option<String>,

        /// Write the scaffold to this path. Defaults to stdout.
        #[arg(long, value_name = "PATH")]
        out: Option<String>,

        /// Override the top-level `name:` in the generated file.
        /// Default is inferred from the input (op-id, URL path, or
        /// fixture file name).
        #[arg(long, value_name = "NAME")]
        name: Option<String>,

        /// Overwrite the `--out` file if it already exists. Without
        /// this flag, scaffold refuses to clobber an existing file
        /// and exits 2.
        #[arg(long)]
        force: bool,

        /// Output shape: `yaml` (default) prints the scaffold itself;
        /// `json` prints a metadata envelope carrying the inferred
        /// request, the TODO list (with line numbers), validation
        /// status, and the YAML as a string — designed for agent
        /// consumption.
        #[arg(long, default_value = "yaml")]
        format: String,
    },
}

fn main() {
    let cli = Cli::parse();

    let exit_code = match cli.command {
        Commands::Run {
            path,
            format,
            json_mode,
            tag,
            select,
            test_filter,
            step_filter,
            vars,
            env_name,
            verbose,
            verbose_responses,
            max_body,
            only_failed,
            no_progress,
            ndjson,
            dry_run,
            watch,
            parallel,
            jobs,
            no_parallel_warning,
            cookie_jar,
            cookie_jar_per_test,
            proxy,
            no_proxy,
            cacert,
            cert,
            key,
            insecure,
            http1_1,
            http2,
            redact_header,
            no_default_excludes,
            report_json,
            no_last_run_json,
            no_fixtures,
            fixture_retention,
            agent,
        } => run_command(
            path,
            &format,
            &json_mode,
            &vars,
            env_name.as_deref(),
            tag.as_deref(),
            &select,
            test_filter.as_deref(),
            step_filter.as_deref(),
            verbose,
            verbose_responses,
            max_body,
            only_failed,
            no_progress,
            ndjson,
            dry_run,
            watch,
            parallel,
            jobs,
            no_parallel_warning,
            cookie_jar.as_deref(),
            cookie_jar_per_test,
            HttpTransportConfig {
                proxy,
                no_proxy,
                cacert,
                cert,
                key,
                insecure,
                http_version: cli_http_version(http1_1, http2),
            },
            &redact_header,
            no_default_excludes,
            report_json.as_deref(),
            no_last_run_json,
            no_fixtures,
            fixture_retention,
            None,
            agent,
        ),
        Commands::Bench {
            path,
            requests,
            concurrency,
            step,
            ramp_up,
            vars,
            env_name,
            format,
            export,
            fail_under_rps,
            fail_above_error_rate,
            fail_above_p95_ms,
            fail_above_p99_ms,
            proxy,
            no_proxy,
            cacert,
            cert,
            key,
            insecure,
            http1_1,
            http2,
        } => bench_command(
            &path,
            requests,
            concurrency,
            step,
            ramp_up.as_deref(),
            &vars,
            env_name.as_deref(),
            &format,
            &export,
            bench::BenchThresholds {
                min_throughput_rps: fail_under_rps,
                max_error_rate: fail_above_error_rate,
                max_p95_ms: fail_above_p95_ms,
                max_p99_ms: fail_above_p99_ms,
            },
            &HttpTransportConfig {
                proxy,
                no_proxy,
                cacert,
                cert,
                key,
                insecure,
                http_version: cli_http_version(http1_1, http2),
            },
        ),
        Commands::Validate {
            path,
            format,
            no_default_excludes,
        } => validate_command(path, &format, no_default_excludes),
        Commands::Lint {
            path,
            format,
            severity,
            lint_allow_absolute_urls,
            no_default_excludes,
        } => lint_command(
            path,
            &format,
            &severity,
            lint_allow_absolute_urls,
            no_default_excludes,
        ),
        Commands::Fmt { path, check } => fmt_command(path, check),
        Commands::List {
            tag,
            file,
            format,
            no_default_excludes,
        } => list_command(
            tag.as_deref(),
            file.as_deref(),
            &format,
            no_default_excludes,
        ),
        Commands::Env { json } => env_command(json),
        Commands::ImportHurl { path, output } => import_hurl_command(&path, output.as_deref()),
        Commands::Init => init_command(),
        Commands::Update { check } => update_command(check),
        Commands::Completions { shell } => {
            generate(shell, &mut Cli::command(), "tarn", &mut std::io::stdout());
            0
        }
        Commands::Summary { path, format } => summary_command(&path, &format),
        Commands::Failures {
            run,
            format,
            include_cascades,
            no_color,
        } => failures_command(run.as_deref(), &format, include_cascades, no_color),
        Commands::Rerun {
            failed,
            run,
            format,
            json_mode,
            tag,
            select,
            test_filter,
            step_filter,
            vars,
            env_name,
            verbose,
            verbose_responses,
            max_body,
            only_failed,
            no_progress,
            ndjson,
            dry_run,
            parallel,
            jobs,
            no_parallel_warning,
            cookie_jar,
            cookie_jar_per_test,
            proxy,
            no_proxy,
            cacert,
            cert,
            key,
            insecure,
            http1_1,
            http2,
            redact_header,
            report_json,
            no_last_run_json,
            no_fixtures,
            fixture_retention,
        } => rerun_command(
            failed,
            run.as_deref(),
            &format,
            &json_mode,
            &vars,
            env_name.as_deref(),
            tag.as_deref(),
            &select,
            test_filter.as_deref(),
            step_filter.as_deref(),
            verbose,
            verbose_responses,
            max_body,
            only_failed,
            no_progress,
            ndjson,
            dry_run,
            parallel,
            jobs,
            no_parallel_warning,
            cookie_jar.as_deref(),
            cookie_jar_per_test,
            HttpTransportConfig {
                proxy,
                no_proxy,
                cacert,
                cert,
                key,
                insecure,
                http_version: cli_http_version(http1_1, http2),
            },
            &redact_header,
            report_json.as_deref(),
            no_last_run_json,
            no_fixtures,
            fixture_retention,
        ),
        Commands::Inspect {
            run_id,
            target,
            format,
            filter_category,
            fail_on_failure,
        } => inspect_command(
            &run_id,
            target.as_deref(),
            &format,
            filter_category.as_deref(),
            fail_on_failure,
        ),
        Commands::Report {
            run,
            format,
            no_color,
        } => report_command(run.as_deref(), &format, no_color),
        Commands::Diff {
            run_a,
            run_b,
            format,
            file,
            test,
            filter_category,
        } => diff_command(
            &run_a,
            &run_b,
            &format,
            file.as_deref(),
            test.as_deref(),
            filter_category.as_deref(),
        ),
        Commands::PackContext {
            failed,
            run,
            file,
            test,
            format,
            max_chars,
        } => pack_context_command(failed, run.as_deref(), &file, &test, &format, max_chars),
        Commands::Impact {
            diff,
            files,
            endpoints,
            openapi_ops,
            format,
            min_confidence,
            path,
            no_default_excludes,
        } => impact_command(
            diff,
            &files,
            &endpoints,
            &openapi_ops,
            &format,
            &min_confidence,
            path.as_deref(),
            no_default_excludes,
        ),
        Commands::Scaffold {
            from_openapi,
            op_id,
            from_curl,
            method,
            url,
            from_recorded,
            out,
            name,
            force,
            format,
        } => scaffold_command(
            from_openapi.as_deref(),
            op_id.as_deref(),
            from_curl.as_deref(),
            method.as_deref(),
            url.as_deref(),
            from_recorded.as_deref(),
            out.as_deref(),
            name.as_deref(),
            force,
            &format,
        ),
    };

    process::exit(exit_code);
}

#[allow(clippy::too_many_arguments)]
fn run_command(
    path: Option<String>,
    format_specs: &[String],
    json_mode: &str,
    vars: &[String],
    env_name: Option<&str>,
    tag: Option<&str>,
    select: &[String],
    test_filter: Option<&str>,
    step_filter: Option<&str>,
    verbose: bool,
    verbose_responses: bool,
    max_body: usize,
    only_failed: bool,
    no_progress: bool,
    ndjson: bool,
    dry_run: bool,
    watch: bool,
    parallel: bool,
    jobs: Option<usize>,
    no_parallel_warning: bool,
    cookie_jar_path: Option<&str>,
    cookie_jar_per_test: bool,
    cli_http_transport: HttpTransportConfig,
    extra_redact_headers: &[String],
    no_default_excludes: bool,
    report_json_path: Option<&Path>,
    no_last_run_json: bool,
    no_fixtures: bool,
    fixture_retention: usize,
    rerun_selection: Option<tarn::report::rerun::RerunSelection>,
    agent: bool,
) -> i32 {
    let project =
        match load_project_context(path.as_deref().map(Path::new).unwrap_or(Path::new("."))) {
            Ok(project) => project,
            Err(e) => {
                eprintln!("Error: {}", e);
                return e.exit_code();
            }
        };
    let tag_filter = tag.map(runner::parse_tag_filter).unwrap_or_default();
    let mut selectors = match selector::parse_all(select) {
        Ok(s) => s,
        Err(errs) => {
            for err in errs {
                eprintln!("Error: {}", err);
            }
            return 2;
        }
    };
    // --test-filter / --step-filter (NAZ-256): synthesize a "wildcard"
    // selector that applies across every discovered file. The selector
    // module stays the single source of truth for matching logic.
    if test_filter.is_some() || step_filter.is_some() {
        let synth = match runner::build_filter_selector(test_filter, step_filter) {
            Ok(sel) => sel,
            Err(e) => {
                eprintln!("Error: {}", e);
                return 2;
            }
        };
        selectors.push(synth);
    }
    // NAZ-403: union rerun-derived selectors with anything the user passed
    // on the command line so `--failed` and `--select`/`--test-filter`
    // compose instead of clobbering each other.
    if let Some(selection) = rerun_selection.as_ref() {
        selectors.extend(selection.selectors());
    }
    // Pick a TTY-aware default when `--format` was omitted: humans on
    // a terminal expect the colored `human` report; everyone piping to
    // a file, another process, or an LLM expects the stable `llm`
    // format. The caller can still override this by passing `--format`
    // explicitly, including `--format human` when piping. `--ndjson`
    // owns stdout outright, so we skip the auto-selection in that case
    // to avoid a TTY-default `llm` target colliding with the stream.
    let effective_format_specs: Vec<String> = if format_specs.is_empty() {
        if ndjson || agent {
            // `--agent` owns stdout with the AgentReport JSON payload;
            // any default format target would be suppressed anyway, so
            // skip creating it outright. Mirrors how `--ndjson` leaves
            // the stdout target list empty.
            Vec::new()
        } else {
            let default_format = if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
                "human"
            } else {
                "llm"
            };
            vec![default_format.to_string()]
        }
    } else {
        format_specs.to_vec()
    };
    let output_targets = match parse_output_targets(&effective_format_specs) {
        Ok(targets) => targets,
        Err(e) => {
            eprintln!(
                "Error: {}. Use: human, json, junit, tap, html, curl, curl-all, compact, llm, or FORMAT=PATH",
                e
            );
            return 2;
        }
    };

    // Every run produces a machine-readable JSON artifact by default so
    // human-mode runs can still be inspected programmatically after the
    // fact. Since NAZ-400, the primary archive lives under
    // `.tarn/runs/<run_id>/report.json` so repeat runs never destroy
    // prior debugging context; `.tarn/last-run.json` (or the
    // `--report-json=PATH` override) is kept as a copy of the latest
    // run for legacy consumers that already read that path.
    //
    // The actual insertion of the per-run JSON target happens inside
    // `do_run`, because `run_id` is minted per invocation (watch mode
    // reruns must each get their own directory).
    let last_run_pointer_path: Option<PathBuf> = if !no_last_run_json {
        Some(match report_json_path {
            Some(p) => p.to_path_buf(),
            None => project.root_dir.join(".tarn").join("last-run.json"),
        })
    } else {
        None
    };
    let persist_artifacts = !no_last_run_json;
    let json_output_mode = match json_mode.parse::<JsonOutputMode>() {
        Ok(mode) => mode,
        Err(e) => {
            eprintln!("Error: {}. Use: verbose or compact", e);
            return 2;
        }
    };

    let cli_vars = match env::parse_cli_vars(vars) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    // NAZ-403: a rerun bypasses workspace discovery — the files list is
    // derived from the source `failures.json`. That keeps rerun runs
    // narrow (no incidental execution of files that never failed) and
    // sidesteps the discovery warnings (duplicate tree, exclusion summary)
    // which are meaningless for a targeted rerun.
    let discovery_report = if rerun_selection.is_some() {
        None
    } else {
        match resolve_files_with_report(path, no_default_excludes) {
            Ok(r) => Some(r),
            Err(e) => {
                eprintln!("Error: {}", e);
                return e.exit_code();
            }
        }
    };
    let files: Vec<String> = match (&rerun_selection, &discovery_report) {
        (Some(sel), _) => sel.files(),
        (None, Some(report)) => report.files.clone(),
        (None, None) => unreachable!("either rerun_selection or discovery_report is Some"),
    };

    if files.is_empty() {
        eprintln!("No test files found");
        return 2;
    }

    // Only print the human-mode discovery summary when a human-mode
    // stdout target actually exists: if every output target is a
    // structured file (json=run.json, junit=out.xml, etc.) the summary
    // would be stray noise, and NDJSON / --agent require a clean stream.
    let emits_human_stdout = !ndjson
        && !agent
        && output_targets
            .iter()
            .any(|t| matches!(t.format, OutputFormat::Human) && t.path.is_none());
    if emits_human_stdout {
        if let Some(report) = discovery_report.as_ref() {
            if let Some(summary) = format_discovery_summary(report, no_default_excludes) {
                println!("{}", summary);
            }
        }
    }

    let fixture_config = tarn::report::fixture_writer::FixtureWriteConfig {
        enabled: !no_fixtures,
        workspace_root: project.root_dir.clone(),
        retention: fixture_retention.max(1),
    };
    let run_opts = runner::RunOptions {
        verbose,
        dry_run,
        http: cli_http_transport,
        cookie_jar_per_test,
        fail_fast_within_test: project.config.fail_fast_within_test,
        verbose_responses,
        max_body_bytes: max_body,
        fixtures: fixture_config,
    };
    let effective_parallel = parallel || project.config.parallel;

    if effective_parallel && !no_parallel_warning {
        emit_parallel_opt_in_warning(&mut std::io::stderr(), project.config.parallel_opt_in);
    }

    if cookie_jar_path.is_some() && effective_parallel {
        eprintln!("Error: `--cookie-jar` is not supported with parallel execution");
        return 2;
    }

    // `no_color` is flipped on whenever stdout is not a TTY. The llm
    // format uses this to strip ANSI escapes when piped; every other
    // format happens to leave colors on (they were already TTY-only
    // via `colored`'s auto-detection).
    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
    let render_opts = RenderOptions {
        only_failed,
        verbose,
        no_color: !stdout_is_tty,
        verbose_responses,
    };

    // Validate --ndjson does not collide with a non-human stdout format.
    // A stdout-bound `human` target is the default and gets silently
    // dropped by the NDJSON emitter (handled via suppress_stdout_outputs
    // inside execute_run). Any other structured format on stdout would
    // tear the NDJSON stream, so refuse the run.
    if ndjson {
        let conflicting_stdout_format = output_targets.iter().any(|t| {
            t.writes_to_stdout() && t.path.is_none() && !matches!(t.format, OutputFormat::Human)
        });
        if conflicting_stdout_format {
            eprintln!(
                "Error: --ndjson writes to stdout and conflicts with another --format target that also writes to stdout. Route the other format to a file (e.g. --format json=run.json)."
            );
            return 2;
        }
    }

    // --agent owns stdout: the final payload is one AgentReport JSON
    // object. Anything else on stdout (NDJSON stream, watch loop,
    // another structured format target) would corrupt what the agent
    // reads. File-bound targets (`--format json=out.json`, junit=...)
    // still compose because they never touch stdout.
    if agent {
        if ndjson {
            eprintln!(
                "Error: --agent and --ndjson both own stdout. Drop one (agent mode already writes a compact JSON payload)."
            );
            return 2;
        }
        if watch {
            eprintln!(
                "Error: --agent is mutually exclusive with --watch. Agent mode emits a single payload per invocation; loop externally if you need repeated runs."
            );
            return 2;
        }
        let conflicting_stdout_format = output_targets.iter().any(|t| {
            t.writes_to_stdout() && t.path.is_none() && !matches!(t.format, OutputFormat::Human)
        });
        if conflicting_stdout_format {
            eprintln!(
                "Error: --agent writes the AgentReport to stdout and conflicts with another --format target that also writes to stdout. Route the other format to a file (e.g. --format json=run.json)."
            );
            return 2;
        }
    }

    // argv (minus the binary name) drives the reproducible command
    // hint in `state.json` (NAZ-257). We capture it here, before the
    // run closure borrows it, so watch-mode reruns keep a stable record.
    let argv_tail: Vec<String> = std::env::args().skip(1).collect();
    let base_url_guess = cli_vars
        .iter()
        .find(|(k, _)| k == "base_url")
        .map(|(_, v)| v.clone());
    let workspace_root = project.root_dir.clone();

    // Also capture full argv + cwd for the `.tarn/last-run.json` augment
    // step (NAZ-256). The LSP "rerun last failures" flow reads these
    // back so replays use the same `-e <env>` even if the user has
    // changed directories.
    let run_args: Vec<String> = std::env::args().collect();
    let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let env_label = env_name.map(|s| s.to_string());

    // NAZ-403: captured once so both the closure (for StateContext) and
    // any artifact writer can stamp the provenance without passing an
    // extra argument through every call site.
    let rerun_source_for_state: Option<tarn::report::rerun::RerunSource> =
        rerun_selection.as_ref().map(|sel| sel.source.clone());

    // Build the run closure (used by both normal and watch mode)
    let last_run_pointer_clone = last_run_pointer_path.clone();
    let run_args_clone = run_args.clone();
    let working_directory_clone = working_directory.clone();
    let env_label_clone = env_label.clone();
    let base_output_targets = output_targets.clone();
    let env_name_for_state = env_name.map(|s| s.to_string());
    let do_run = move |run_files: &[String]| {
        let start_time = chrono::Utc::now();
        // NAZ-400: each run gets its own immutable directory under
        // `.tarn/runs/<run_id>/`. We mint the id inside the closure so
        // watch-mode reruns do not stomp on each other.
        let run_id = tarn::report::run_dir::generate_run_id(start_time);
        let run_directory = if persist_artifacts {
            match tarn::report::run_dir::ensure_run_directory(&workspace_root, &run_id) {
                Ok(dir) => Some(dir),
                Err(e) => {
                    eprintln!(
                        "Warning: failed to create run directory under {}/.tarn/runs: {}. Falling back to last-run.json only.",
                        workspace_root.display(),
                        e
                    );
                    None
                }
            }
        } else {
            None
        };
        let run_report_path = run_directory.as_ref().map(|d| d.join("report.json"));

        // Compose the per-run output targets: primary archive first
        // (so writes hit the immutable run dir before the pointer copy
        // lands), then any user-supplied targets.
        let mut targets = base_output_targets.clone();
        if let Some(ref p) = run_report_path {
            targets.insert(
                0,
                OutputTarget {
                    format: OutputFormat::Json,
                    path: Some(p.clone()),
                },
            );
        }

        let state_ctx = StateContext {
            workspace_root: workspace_root.clone(),
            args: argv_tail.clone(),
            env_name: env_name_for_state.clone(),
            base_url: base_url_guess.clone(),
            run_id: Some(run_id.clone()),
            run_directory: run_directory.clone(),
            emit_pointer_artifacts: persist_artifacts,
            rerun_source: rerun_source_for_state.clone(),
        };

        let exit = execute_run(
            run_files,
            &cli_vars,
            env_name,
            &tag_filter,
            &selectors,
            &run_opts,
            &targets,
            json_output_mode,
            render_opts,
            no_progress,
            ndjson,
            cookie_jar_path,
            effective_parallel,
            jobs,
            extra_redact_headers,
            &state_ctx,
            agent,
        );
        let end_time = chrono::Utc::now();

        // Copy the in-archive report to the legacy `last-run.json`
        // pointer so existing tooling keeps working. This is a simple
        // atomic copy rather than a second render — the JSON is
        // already on disk.
        if let (Some(src), Some(pointer)) = (&run_report_path, &last_run_pointer_clone) {
            if src != pointer {
                if let Err(e) = tarn::report::run_dir::copy_to_pointer(src, pointer) {
                    eprintln!(
                        "Warning: failed to update pointer at {}: {}",
                        pointer.display(),
                        e
                    );
                } else {
                    eprintln!("json report saved to {}", pointer.display());
                }
            }
        }

        // Inject args/env/working_dir/run_id into both the archive
        // report and the pointer. Augmentation is best-effort: failures
        // are logged but never flip the run's exit code.
        let mut augment_paths: Vec<PathBuf> = Vec::new();
        if let Some(ref p) = run_report_path {
            augment_paths.push(p.clone());
        }
        if let Some(ref p) = last_run_pointer_clone {
            if !augment_paths.iter().any(|x| x == p) {
                augment_paths.push(p.clone());
            }
        }
        for artifact in &augment_paths {
            if let Err(e) = augment_last_run_json(
                artifact,
                &run_args_clone,
                env_label_clone.as_deref(),
                &working_directory_clone,
                start_time,
                end_time,
                Some(&run_id),
            ) {
                eprintln!(
                    "Warning: failed to augment report at {}: {}",
                    artifact.display(),
                    e
                );
            }
        }

        // NAZ-400 acceptance criterion: announce the run id and the
        // artifact directory so both humans and machines can navigate
        // back to a specific run after the fact.
        if let Some(ref dir) = run_directory {
            eprintln!("run id: {}", run_id);
            eprintln!("run artifacts: {}", dir.display());
        }

        exit
    };

    if watch {
        tarn::watch::run_watch_loop(&files, do_run);
    } else {
        do_run(&files)
    }
}

/// Merge `args`, `env_name`, `working_directory`, `start_time`, `end_time`
/// into the top-level object of `artifact_path` (the always-on JSON
/// artifact written by `emit_run_outputs`). The file must already exist
/// and contain a valid JSON object — this function is called after the
/// regular JSON writer has finished so that invariant holds.
///
/// Augmentation is skipped (without error) if the file is missing: the
/// user may have passed `--no-last-run-json` between runs, or moved the
/// artifact elsewhere.
#[allow(clippy::too_many_arguments)]
fn augment_last_run_json(
    artifact_path: &Path,
    args: &[String],
    env_name: Option<&str>,
    working_directory: &Path,
    start_time: chrono::DateTime<chrono::Utc>,
    end_time: chrono::DateTime<chrono::Utc>,
    run_id: Option<&str>,
) -> Result<(), String> {
    let content = match std::fs::read_to_string(artifact_path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(format!("read: {}", e)),
    };
    let mut value: serde_json::Value =
        serde_json::from_str(&content).map_err(|e| format!("parse: {}", e))?;
    let Some(obj) = value.as_object_mut() else {
        return Err("top-level is not a JSON object".to_string());
    };
    obj.insert("args".to_string(), serde_json::json!(args));
    obj.insert(
        "env_name".to_string(),
        match env_name {
            Some(s) => serde_json::Value::String(s.to_string()),
            None => serde_json::Value::Null,
        },
    );
    obj.insert(
        "working_directory".to_string(),
        serde_json::Value::String(working_directory.display().to_string()),
    );
    obj.insert(
        "start_time".to_string(),
        serde_json::Value::String(start_time.to_rfc3339()),
    );
    obj.insert(
        "end_time".to_string(),
        serde_json::Value::String(end_time.to_rfc3339()),
    );
    if let Some(id) = run_id {
        obj.insert(
            "run_id".to_string(),
            serde_json::Value::String(id.to_string()),
        );
    }

    let rendered = serde_json::to_string_pretty(&value).map_err(|e| format!("render: {}", e))?;
    std::fs::write(artifact_path, rendered).map_err(|e| format!("write: {}", e))
}

#[allow(clippy::too_many_arguments)]
fn execute_run(
    files: &[String],
    cli_vars: &[(String, String)],
    env_name: Option<&str>,
    tag_filter: &[String],
    selectors: &[Selector],
    run_opts: &runner::RunOptions,
    output_targets: &[OutputTarget],
    json_output_mode: JsonOutputMode,
    render_opts: RenderOptions,
    no_progress: bool,
    ndjson: bool,
    cookie_jar_path: Option<&str>,
    parallel: bool,
    jobs: Option<usize>,
    extra_redact_headers: &[String],
    state_context: &StateContext,
    agent: bool,
) -> i32 {
    let started_at = chrono::Utc::now();
    let start = std::time::Instant::now();

    let human_on_stdout = output_targets.iter().any(|t| {
        matches!(t.format, OutputFormat::Human) && t.writes_to_stdout() && t.path.is_none()
    });

    // In --agent mode stdout is reserved for the final AgentReport
    // payload. Forcing `no_progress` behavior (no streaming reporter,
    // no human summary on stdout) keeps the output a single JSON
    // object, same contract as `--no-progress` with a structured
    // --format target.
    let progress: Option<Box<dyn ProgressReporter + Send + Sync>> = if agent {
        None
    } else if ndjson {
        let mode = if parallel {
            ProgressMode::Parallel
        } else {
            ProgressMode::Sequential
        };
        Some(Box::new(NdjsonProgress::new(
            Box::new(std::io::stdout()),
            mode,
        )))
    } else if no_progress {
        None
    } else {
        build_progress_reporter(parallel, human_on_stdout, render_opts)
    };
    let streamed_human_to_stdout = progress.is_some() && !ndjson && human_on_stdout;
    let progress_ref: Option<&(dyn ProgressReporter + Send + Sync)> = progress
        .as_ref()
        .map(|p| p.as_ref() as &(dyn ProgressReporter + Send + Sync));

    // NAZ-413: open the append-only events stream under the run
    // directory, if artifact persistence is on. `--no-last-run-json`
    // leaves `state_context.run_directory` at `None`, which
    // short-circuits both the stream and the `.tarn/events.jsonl`
    // pointer for a fully transient run.
    let events_stream = state_context
        .run_directory
        .as_ref()
        .and_then(|dir| {
            let path = dir.join("events.jsonl");
            match tarn::report::event_stream::EventStream::new(
                path,
                state_context.run_id.clone().unwrap_or_default(),
            ) {
                Ok(s) => Some(std::sync::Arc::new(s)),
                Err(e) => {
                    eprintln!(
                        "Warning: failed to open events.jsonl under {}: {}. Event stream disabled for this run.",
                        dir.display(),
                        e
                    );
                    None
                }
            }
        });

    if let Some(ref ev) = events_stream {
        let run_args: Vec<String> = std::env::args().collect();
        ev.emit_run_started(files, parallel, &run_args);
    }

    let events_ref = events_stream.as_ref();

    let file_results = if parallel {
        run_files_parallel(
            files,
            cli_vars,
            env_name,
            tag_filter,
            selectors,
            run_opts,
            jobs,
            progress_ref,
            events_ref,
            extra_redact_headers,
        )
    } else {
        run_files_sequential(
            files,
            cli_vars,
            env_name,
            tag_filter,
            selectors,
            run_opts,
            cookie_jar_path,
            progress_ref,
            events_ref,
            extra_redact_headers,
        )
    };

    let file_results = match file_results {
        Ok(r) => r,
        Err((code, msg)) => {
            eprintln!("Error: {}", msg);
            return code;
        }
    };

    let run_result = RunResult {
        file_results,
        duration_ms: start.elapsed().as_millis() as u64,
    };

    if let Some(p) = progress_ref {
        p.run_finished(&run_result);
    }

    // Suppress batch outputs to stdout when --ndjson or --agent owns
    // stdout. Agent mode prints exactly one AgentReport JSON object
    // at the end of the run; any other stdout-bound format target
    // would corrupt that. File-bound targets (junit=out.xml, etc.)
    // still run.
    let suppress_stdout_outputs = ndjson || agent;

    if let Err(e) = emit_run_outputs(
        &run_result,
        output_targets,
        json_output_mode,
        render_opts,
        streamed_human_to_stdout,
        suppress_stdout_outputs,
        state_context.rerun_source.as_ref(),
    ) {
        eprintln!("Error: {}", e);
        // Still flush `state.json` so the LLM can observe the
        // failed run even when report emission blew up.
        emit_run_completed_event(events_ref, &run_result, 3);
        write_state_sidecar(&run_result, started_at, 3, state_context);
        mirror_events_stream_to_pointer(events_ref, state_context);
        return 3;
    }

    let exit_code = run_result_exit_code(&run_result);
    emit_run_completed_event(events_ref, &run_result, exit_code);
    write_state_sidecar(&run_result, started_at, exit_code, state_context);
    mirror_events_stream_to_pointer(events_ref, state_context);

    // --agent: print exactly one AgentReport JSON object to stdout.
    // Placed after state/summary/failures artifacts land so the
    // `artifacts` block always points at files that actually exist on
    // disk when the payload is emitted.
    if agent {
        let ended_at = chrono::Utc::now();
        let agent_inputs = tarn::report::agent_report::AgentReportInputs {
            run_id: state_context.run_id.clone(),
            exit_code,
            started_at,
            ended_at,
            selected_files: files,
            selectors,
            run_directory: state_context.run_directory.as_deref(),
        };
        let report = tarn::report::agent_report::build(&run_result, agent_inputs);
        print!("{}", tarn::report::agent_report::render_json(&report));
    }

    exit_code
}

/// Emit the terminal `run_completed` envelope with aggregate counts.
/// Called in both the happy and error paths of `execute_run` so the
/// stream always has a last line, making it trivial for readers to
/// know the run is over.
fn emit_run_completed_event(
    events: Option<&std::sync::Arc<tarn::report::event_stream::EventStream>>,
    run_result: &RunResult,
    exit_code: i32,
) {
    let Some(ev) = events else { return };
    let total_files = run_result.total_files();
    let failed_files = run_result.file_results.iter().filter(|f| !f.passed).count();
    let total_tests: usize = run_result
        .file_results
        .iter()
        .map(|f| f.test_results.len())
        .sum();
    let failed_tests: usize = run_result
        .file_results
        .iter()
        .flat_map(|f| f.test_results.iter())
        .filter(|t| !t.passed)
        .count();
    let total_steps = run_result.total_steps();
    let failed_steps = run_result.failed_steps();
    ev.emit_run_completed(tarn::report::event_stream::RunOutcome {
        passed: run_result.passed(),
        exit_code,
        duration_ms: run_result.duration_ms,
        files: total_files,
        tests: total_tests,
        steps: total_steps,
        failed_files,
        failed_tests,
        failed_steps,
    });
}

/// Copy the per-run `events.jsonl` to the `.tarn/events.jsonl`
/// convenience pointer. Mirrors the pattern NAZ-400 and NAZ-401 use for
/// `last-run.json` / `summary.json` / `failures.json`: the run
/// directory is the authoritative home, the pointer is a best-effort
/// convenience for tooling that only ever tails the latest run.
fn mirror_events_stream_to_pointer(
    events: Option<&std::sync::Arc<tarn::report::event_stream::EventStream>>,
    state_context: &StateContext,
) {
    let Some(ev) = events else { return };
    if !state_context.emit_pointer_artifacts {
        return;
    }
    let src = ev.path().to_path_buf();
    let pointer = state_context
        .workspace_root
        .join(".tarn")
        .join("events.jsonl");
    if src == pointer {
        return;
    }
    if let Err(e) = tarn::report::run_dir::copy_to_pointer(&src, &pointer) {
        eprintln!(
            "Warning: failed to update events pointer at {}: {}",
            pointer.display(),
            e
        );
    }
}

/// Non-test-result metadata `execute_run` needs to produce a
/// [`tarn::report::state_writer::StateDoc`]. Grouped into a struct so
/// future additions (e.g. "profile" when NAZ-260 lands) do not bloat
/// the already-long `execute_run` signature.
#[derive(Debug, Clone)]
struct StateContext {
    /// Absolute workspace root. `.tarn/state.json` lives under this
    /// directory.
    workspace_root: PathBuf,
    /// argv for the current process, minus the binary name.
    args: Vec<String>,
    /// Resolved named environment, if any.
    env_name: Option<String>,
    /// Effective `base_url` for the run, when we can detect one.
    base_url: Option<String>,
    /// Stable id for this run. Matches the directory name under
    /// `.tarn/runs/`. `None` if artifact persistence is fully disabled.
    run_id: Option<String>,
    /// Absolute path to the immutable per-run artifact directory
    /// `<workspace_root>/.tarn/runs/<run_id>/`. `None` when the
    /// directory could not be created or was suppressed.
    run_directory: Option<PathBuf>,
    /// Whether the workspace-level pointer artifacts under `.tarn/`
    /// (`summary.json`, `failures.json`) may be written. False under
    /// `--no-last-run-json`, which asks for a fully transient run.
    emit_pointer_artifacts: bool,
    /// NAZ-403: stamped into `summary.json` and the top-level JSON
    /// report when the run was produced by `tarn rerun`. `None` for
    /// ordinary `tarn run` invocations.
    rerun_source: Option<tarn::report::rerun::RerunSource>,
}

/// Persist `.tarn/state.json`, `.tarn/summary.json`, and
/// `.tarn/failures.json` atomically, plus their per-run copies under
/// `.tarn/runs/<run_id>/`. Failures are logged to stderr; the caller
/// never bubbles them up because these sidecars are a convenience, not
/// a correctness signal.
///
/// NAZ-401: `summary.json` and `failures.json` are always emitted
/// (even on a clean run — failures.json carries an empty array) so
/// downstream tooling can key off a single stable filename without
/// conditional discovery logic.
fn write_state_sidecar(
    run_result: &RunResult,
    started_at: chrono::DateTime<chrono::Utc>,
    exit_code: i32,
    ctx: &StateContext,
) {
    let ended_at = chrono::Utc::now();
    let state = tarn::report::state_writer::build_state_with_run_id(
        run_result,
        started_at,
        ended_at,
        exit_code,
        &ctx.args,
        ctx.env_name.clone(),
        ctx.base_url.clone(),
        ctx.run_id.clone(),
    );
    let (summary_doc, failures_doc) = tarn::report::summary::build_summary_and_failures(
        run_result,
        started_at,
        ended_at,
        exit_code,
        ctx.run_id.clone(),
        ctx.rerun_source.clone(),
    );

    // NAZ-400: write the per-run immutable copy first, then refresh
    // the legacy `.tarn/state.json` pointer. Errors on either write
    // are best-effort: the run's exit code remains authoritative.
    if let Some(ref dir) = ctx.run_directory {
        if let Err(err) = tarn::report::state_writer::write_state_to_dir(dir, &state) {
            eprintln!(
                "tarn: failed to write state.json under {}: {}",
                dir.display(),
                err
            );
        }
        if let Err(err) = tarn::report::summary::write_summary_to_dir(dir, &summary_doc) {
            eprintln!(
                "tarn: failed to write summary.json under {}: {}",
                dir.display(),
                err
            );
        }
        if let Err(err) = tarn::report::summary::write_failures_to_dir(dir, &failures_doc) {
            eprintln!(
                "tarn: failed to write failures.json under {}: {}",
                dir.display(),
                err
            );
        }
    }
    if let Err(err) = tarn::report::state_writer::write_state(&ctx.workspace_root, &state) {
        eprintln!(
            "tarn: failed to write .tarn/state.json under {}: {}",
            ctx.workspace_root.display(),
            err
        );
    }
    if ctx.emit_pointer_artifacts {
        let pointer_dir = ctx.workspace_root.join(".tarn");
        if let Err(err) = tarn::report::summary::write_summary_to_dir(&pointer_dir, &summary_doc) {
            eprintln!(
                "tarn: failed to write .tarn/summary.json under {}: {}",
                ctx.workspace_root.display(),
                err
            );
        }
        if let Err(err) = tarn::report::summary::write_failures_to_dir(&pointer_dir, &failures_doc)
        {
            eprintln!(
                "tarn: failed to write .tarn/failures.json under {}: {}",
                ctx.workspace_root.display(),
                err
            );
        }
    }
}

/// Build the appropriate streaming progress reporter based on mode and which
/// format owns stdout. When human is the stdout target, we stream to stdout so
/// the user sees live output; otherwise we stream to stderr to keep stdout clean
/// for structured formats.
fn build_progress_reporter(
    parallel: bool,
    human_on_stdout: bool,
    render_opts: RenderOptions,
) -> Option<Box<dyn ProgressReporter + Send + Sync>> {
    let writer: Box<dyn std::io::Write + Send> = if human_on_stdout {
        Box::new(std::io::stdout())
    } else {
        Box::new(std::io::stderr())
    };
    let mode = if parallel {
        ProgressMode::Parallel
    } else {
        ProgressMode::Sequential
    };
    Some(Box::new(HumanProgress::new(writer, render_opts, mode)))
}

/// Re-render a previously stored JSON report in the LLM or compact
/// format. Accepts a file path or `-` to read from stdin.
fn summary_command(path: &str, format: &str) -> i32 {
    let input = if path == "-" {
        let mut buffer = String::new();
        match std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) {
            Ok(_) => buffer,
            Err(e) => {
                eprintln!("Error: Failed to read stdin: {}", e);
                return 2;
            }
        }
    } else {
        match std::fs::read_to_string(path) {
            Ok(content) => content,
            Err(e) => {
                eprintln!("Error: Failed to read {}: {}", path, e);
                return 2;
            }
        }
    };

    let run_result = match tarn::report::json_parse::parse(&input) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
    let render_opts = RenderOptions {
        only_failed: false,
        verbose: false,
        no_color: !stdout_is_tty,
        verbose_responses: false,
    };

    let output = match format.to_ascii_lowercase().as_str() {
        "llm" => tarn::report::llm::render_with_options(&run_result, render_opts),
        "compact" => tarn::report::compact::render_with_options(&run_result, render_opts),
        other => {
            eprintln!(
                "Error: unknown summary format '{}'. Use 'llm' or 'compact'.",
                other
            );
            return 2;
        }
    };

    print!("{}", output);

    if run_result.passed() {
        0
    } else {
        1
    }
}

/// Load a prior run's `failures.json`, group failures by root cause,
/// and render a human-readable or JSON report. Returns 0 when the
/// archive is empty (no failures), 1 when failures exist, 2 on any
/// load or parse error.
fn failures_command(
    run: Option<&str>,
    format: &str,
    include_cascades: bool,
    no_color: bool,
) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "human" => "human",
        "json" => "json",
        other => {
            eprintln!(
                "Error: unknown failures format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    // Resolve the failures.json path. For `--run <id>` we need a
    // workspace root to anchor `.tarn/runs/<id>/`; fall back to the
    // current directory when no tarn project is detected. For the
    // default (latest run) we read `.tarn/failures.json` relative to
    // the workspace root (same fallback).
    let cwd = match std::env::current_dir() {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return 2;
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());
    let path = match run {
        Some(id) => tarn::report::run_dir::run_directory(&workspace_root, id).join("failures.json"),
        None => workspace_root.join(".tarn").join("failures.json"),
    };
    if !path.is_file() {
        match run {
            Some(id) => eprintln!(
                "Error: no failures.json for run '{}' at {}",
                id,
                path.display()
            ),
            None => eprintln!(
                "Error: no failures.json at {}. Run `tarn run` first.",
                path.display()
            ),
        }
        return 2;
    }

    let raw = match std::fs::read(&path) {
        Ok(bytes) => bytes,
        Err(e) => {
            eprintln!("Error: failed to read {}: {}", path.display(), e);
            return 2;
        }
    };
    let doc: tarn::report::summary::FailuresDoc = match serde_json::from_slice(&raw) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("Error: failed to parse {}: {}", path.display(), e);
            return 2;
        }
    };

    let source = path.display().to_string();
    let report = tarn::report::failures_command::build_report(&doc, source);

    let no_color_effective = no_color || !std::io::IsTerminal::is_terminal(&std::io::stdout());
    let output = match format {
        "json" => tarn::report::failures_command::render_json(&report),
        _ => tarn::report::failures_command::render_human(
            &report,
            include_cascades,
            no_color_effective,
        ),
    };
    print!("{}", output);
    if format == "json" {
        println!();
    }

    if report.total_failures == 0 {
        0
    } else {
        1
    }
}

/// `tarn report [--run <id>] [--format concise|json]` (NAZ-404).
///
/// Reads the persisted `summary.json` + `failures.json` for the
/// targeted run and renders a one-screen verdict (or a stable JSON
/// envelope). Pure reporting — no HTTP, no test execution. This is the
/// built-in replacement for external helper scripts that used to shell
/// out to `python3 bin/parse-results.py` against the JSON report.
///
/// Exit codes:
/// - 0 when the loaded `failures.json` is empty (clean run)
/// - 1 when the loaded `failures.json` has at least one failure
/// - 2 when the artifact is missing, unparseable, or `--run <id>` is
///   unknown
fn report_command(run: Option<&str>, format: &str, no_color: bool) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "concise" => "concise",
        "json" => "json",
        other => {
            eprintln!(
                "Error: unknown report format '{}'. Use 'concise' or 'json'.",
                other
            );
            return 2;
        }
    };

    let cwd = match std::env::current_dir() {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return 2;
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());

    // Resolve the artifact pair. `--run <alias>` goes through the
    // shared resolver so aliases like `last`/`prev`/`@latest` are
    // honored and we match the behavior of `tarn inspect`/`tarn diff`.
    // Without `--run`, we fall back to the workspace-level pointers
    // at `.tarn/summary.json` + `.tarn/failures.json` that NAZ-401
    // refreshes on every run.
    let (summary_path, failures_path, resolved_run_label) = match run {
        Some(alias) => {
            let run_id = match tarn::report::run_dir::resolve_run_id(&workspace_root, alias) {
                Ok(id) => id,
                Err(err) => {
                    eprintln!("Error: {}", err);
                    return 2;
                }
            };
            let dir = tarn::report::run_dir::run_directory(&workspace_root, &run_id);
            (dir.join("summary.json"), dir.join("failures.json"), run_id)
        }
        None => {
            let tarn_dir = workspace_root.join(".tarn");
            (
                tarn_dir.join("summary.json"),
                tarn_dir.join("failures.json"),
                // Label is filled in from the artifact below when we
                // load it; "latest" is a placeholder for the error path.
                "latest".to_string(),
            )
        }
    };

    let summary: tarn::report::summary::SummaryDoc = match read_report_artifact(&summary_path) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let failures: tarn::report::summary::FailuresDoc = match read_report_artifact(&failures_path) {
        Ok(f) => f,
        Err(code) => return code,
    };

    // Prefer the run_id embedded in the summary when we loaded via the
    // latest-run pointer; it's the authoritative label and matches what
    // `tarn inspect` prints for the same archive.
    let run_label = if run.is_some() {
        resolved_run_label
    } else {
        summary
            .run_id
            .clone()
            .unwrap_or_else(|| resolved_run_label.clone())
    };

    let no_color_effective = no_color || !std::io::IsTerminal::is_terminal(&std::io::stdout());

    let output = match format {
        "json" => {
            let v = tarn::report::concise::render_json(&summary, &failures, &run_label);
            serde_json::to_string_pretty(&v).unwrap_or_default()
        }
        _ => tarn::report::concise::render_concise(
            &summary,
            &failures,
            &run_label,
            no_color_effective,
        ),
    };
    print!("{}", output);
    if format == "json" {
        println!();
    }

    if failures.failures.is_empty() {
        0
    } else {
        1
    }
}

/// Shared loader for `tarn report`'s summary + failures inputs. Keeps
/// error handling uniform so a missing summary and a missing failures
/// file both exit 2 with a path-aware message.
fn read_report_artifact<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, i32> {
    if !path.is_file() {
        eprintln!(
            "Error: no artifact at {}. Run `tarn run` first, or pass `--run <id>`.",
            path.display()
        );
        return Err(2);
    }
    let raw = match std::fs::read(path) {
        Ok(bytes) => bytes,
        Err(e) => {
            eprintln!("Error: failed to read {}: {}", path.display(), e);
            return Err(2);
        }
    };
    serde_json::from_slice::<T>(&raw).map_err(|e| {
        eprintln!("Error: failed to parse {}: {}", path.display(), e);
        2
    })
}

/// `tarn pack-context` — assemble a minimum remediation bundle from
/// the persisted run artifacts and print it to stdout.
///
/// The command reads `summary.json` + `failures.json` mandatorily (to
/// identify failing coordinates) and `report.json` / `state.json`
/// opportunistically (to enrich entries with captures lineage, related
/// steps, and the argv/env that produced the run). A missing
/// `report.json` degrades gracefully to empty `captures.consumed_by` +
/// `related_steps`; a missing `state.json` degrades to empty `args` and
/// a null `env_name`.
///
/// Exit codes: 0 on success, 2 on unknown run id / missing artifacts /
/// parse errors / unknown `--format`.
fn pack_context_command(
    failed: bool,
    run: Option<&str>,
    files: &[String],
    tests: &[String],
    format: &str,
    max_chars: usize,
) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "json" => "json",
        "markdown" | "md" => "markdown",
        other => {
            eprintln!(
                "Error: unknown pack-context format '{}'. Use 'json' or 'markdown'.",
                other
            );
            return 2;
        }
    };

    let cwd = match std::env::current_dir() {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return 2;
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());

    // Resolve the artifact triple (summary, failures, report, state).
    // `run_dir_opt` is `Some` when an archive directory exists; the
    // builder uses it to populate the `artifacts` block with stable
    // paths. The latest-run pointer path carries no run directory, so
    // we leave `artifacts` empty in that case.
    let (summary_path, failures_path, report_path, state_path, run_dir_opt) = match run {
        Some(alias) => {
            let run_id = match tarn::report::run_dir::resolve_run_id(&workspace_root, alias) {
                Ok(id) => id,
                Err(err) => {
                    eprintln!("Error: {}", err);
                    return 2;
                }
            };
            let dir = tarn::report::run_dir::run_directory(&workspace_root, &run_id);
            (
                dir.join("summary.json"),
                dir.join("failures.json"),
                dir.join("report.json"),
                dir.join("state.json"),
                Some(dir),
            )
        }
        None => {
            let tarn_dir = workspace_root.join(".tarn");
            (
                tarn_dir.join("summary.json"),
                tarn_dir.join("failures.json"),
                tarn_dir.join("last-run.json"),
                tarn_dir.join("state.json"),
                None,
            )
        }
    };

    let summary: tarn::report::summary::SummaryDoc = match read_report_artifact(&summary_path) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let failures: tarn::report::summary::FailuresDoc = match read_report_artifact(&failures_path) {
        Ok(f) => f,
        Err(code) => return code,
    };

    // `report.json` / `state.json` are best-effort enrichment. A parse
    // error here still blocks execution (exit 2) because silently
    // ignoring a corrupt artifact would hide real issues; a missing
    // file, by contrast, is normal for older runs.
    let report_value: Option<serde_json::Value> = if report_path.is_file() {
        match std::fs::read(&report_path).and_then(|raw| {
            serde_json::from_slice(&raw)
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
        }) {
            Ok(v) => Some(v),
            Err(e) => {
                eprintln!("Error: failed to parse {}: {}", report_path.display(), e);
                return 2;
            }
        }
    } else {
        None
    };
    let state_doc: Option<tarn::report::state_writer::StateDoc> = if state_path.is_file() {
        match std::fs::read(&state_path).and_then(|raw| {
            serde_json::from_slice(&raw)
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
        }) {
            Ok(v) => Some(v),
            Err(e) => {
                eprintln!("Error: failed to parse {}: {}", state_path.display(), e);
                return 2;
            }
        }
    } else {
        None
    };

    let inputs = tarn::report::pack_context::PackContextInputs {
        summary: &summary,
        failures: &failures,
        report: report_value.as_ref(),
        state: state_doc.as_ref(),
        run_dir: run_dir_opt.as_deref(),
        file_filters: files,
        test_filters: tests,
        failed_only: failed,
        workspace_root: &workspace_root,
    };
    let mut pack = tarn::report::pack_context::build(&inputs);

    let output = match format {
        "markdown" => tarn::report::pack_context::render_markdown(&mut pack, max_chars),
        _ => tarn::report::pack_context::render_json(&mut pack, max_chars),
    };
    print!("{}", output);

    0
}

/// `tarn impact` — map a change to the Tarn targets it most likely affects.
///
/// Pure triage: no HTTP, no test execution. Inputs come from four
/// mutually additive sources (`--diff`, `--files`, `--endpoints`,
/// `--openapi-ops`); at least one must be provided. Test discovery
/// follows the same `--path` / `--no-default-excludes` contract as
/// `tarn run` / `tarn validate` so the surface feels uniform.
///
/// Exit codes: 0 on success, 2 on missing inputs / I/O / parse / git
/// errors.
#[allow(clippy::too_many_arguments)]
fn impact_command(
    diff: bool,
    files: &[String],
    endpoints: &[String],
    openapi_ops: &[String],
    format: &str,
    min_confidence: &str,
    path: Option<&str>,
    no_default_excludes: bool,
) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "human" => "human",
        "json" => "json",
        other => {
            eprintln!(
                "Error: unknown impact format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    let min_confidence = match tarn::impact::Confidence::parse(min_confidence) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    // Parse --endpoints before the "no inputs" check so syntax errors are
    // reported even when the user also passed --diff with no matches.
    let mut parsed_endpoints: Vec<tarn::impact::EndpointChange> = Vec::new();
    for spec in endpoints {
        match tarn::impact::parse_endpoint(spec) {
            Ok(e) => parsed_endpoints.push(e),
            Err(e) => {
                eprintln!("Error: {}", e);
                return 2;
            }
        }
    }

    // Read git diff when requested. We keep diff and explicit --files
    // distinct in the report so callers can tell the source apart.
    let diff_files: Vec<String> = if diff {
        match read_git_diff_files() {
            Ok(list) => list,
            Err(e) => {
                eprintln!("Error: {}", e);
                return 2;
            }
        }
    } else {
        Vec::new()
    };

    if !diff && files.is_empty() && parsed_endpoints.is_empty() && openapi_ops.is_empty() {
        eprintln!(
            "Error: tarn impact needs at least one of --diff, --files, --endpoints, or --openapi-ops."
        );
        return 2;
    }

    // Discover test files.
    let discovery = match resolve_files_with_report(path.map(String::from), no_default_excludes) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    // Load and parse every discovered file, keeping the raw source for
    // include / fixture string scanning.
    let mut parsed_files: Vec<(String, tarn::model::TestFile, String)> =
        Vec::with_capacity(discovery.files.len());
    for file in &discovery.files {
        let source = match std::fs::read_to_string(file) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("Error: failed to read {}: {}", file, e);
                return 2;
            }
        };
        let parsed = match parser::parse_str(&source, Path::new(file)) {
            Ok(tf) => tf,
            Err(e) => {
                eprintln!("Error: failed to parse {}: {}", file, e);
                return 2;
            }
        };
        parsed_files.push((file.clone(), parsed, source));
    }

    let tests: Vec<tarn::impact::LoadedTest<'_>> = parsed_files
        .iter()
        .map(|(path, parsed, source)| tarn::impact::LoadedTest {
            path: path.clone(),
            parsed,
            source: source.as_str(),
        })
        .collect();

    let change = tarn::impact::ChangeSet {
        diff_files,
        files: files.to_vec(),
        endpoints: parsed_endpoints,
        openapi_ops: openapi_ops.to_vec(),
    };
    let report = tarn::impact::analyze(&change, &tests);
    let report = tarn::impact::filter_by_confidence(report, min_confidence);

    let output = match format {
        "json" => tarn::impact::render_json(&report),
        _ => {
            let use_color = std::io::IsTerminal::is_terminal(&std::io::stdout());
            tarn::impact::render_human(&report, use_color)
        }
    };
    print!("{}", output);
    if format == "json" {
        println!();
    }

    0
}

/// Read changed file paths from `git diff --name-only HEAD` under the
/// current working directory. Captures both staged and unstaged changes
/// (that's the default behavior of this form). Falls back to `git
/// diff-index --name-only HEAD` under the covers via a second try when
/// the first fails because no commits exist yet (a fresh repo with only
/// staged files). Errors out with a clear message when the CWD is not a
/// git repository at all.
fn read_git_diff_files() -> Result<Vec<String>, String> {
    use std::process::Command as StdCommand;

    let first = StdCommand::new("git")
        .args(["diff", "--name-only", "HEAD"])
        .output();
    let output = match first {
        Ok(o) if o.status.success() => o,
        Ok(o) => {
            // `git diff --name-only HEAD` fails on a brand-new repo with
            // no commits yet; retry without the HEAD ref to capture the
            // index state, which matches what a caller would reasonably
            // expect from `--diff`.
            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
            if stderr.contains("unknown revision")
                || stderr.contains("bad revision")
                || stderr.contains("does not have any commits yet")
            {
                StdCommand::new("git")
                    .args(["diff", "--name-only"])
                    .output()
                    .map_err(|e| format!("failed to run git diff: {e}"))?
            } else {
                return Err(format!(
                    "git diff failed (exit {}): {}",
                    o.status.code().unwrap_or(-1),
                    stderr.trim()
                ));
            }
        }
        Err(e) => {
            return Err(format!(
                "failed to run git (is git installed and CWD a repo?): {e}"
            ));
        }
    };
    if !output.status.success() {
        return Err(format!(
            "git diff failed (exit {}): {}",
            output.status.code().unwrap_or(-1),
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    let text = String::from_utf8_lossy(&output.stdout);
    Ok(text
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect())
}

/// `tarn rerun --failed [--run <id>]` — resolve the failing subset of a
/// prior run into an in-memory selection, announce it on stderr, then
/// delegate to `run_command` so the actual execution path, artifact
/// writers, state.json emission, and last-run pointer update all stay
/// identical to a normal `tarn run`.
///
/// Exit codes:
/// - 0 when the source run had no failures (nothing to rerun — we do not
///   create an empty run artifact)
/// - 2 when `--failed` is missing, the source archive is missing or
///   malformed, or an invalid `--run <id>` is passed
/// - otherwise whatever the underlying run produces (0 pass, 1 fail,
///   3 runtime error)
#[allow(clippy::too_many_arguments)]
fn rerun_command(
    failed: bool,
    run: Option<&str>,
    format_specs: &[String],
    json_mode: &str,
    vars: &[String],
    env_name: Option<&str>,
    tag: Option<&str>,
    select: &[String],
    test_filter: Option<&str>,
    step_filter: Option<&str>,
    verbose: bool,
    verbose_responses: bool,
    max_body: usize,
    only_failed: bool,
    no_progress: bool,
    ndjson: bool,
    dry_run: bool,
    parallel: bool,
    jobs: Option<usize>,
    no_parallel_warning: bool,
    cookie_jar_path: Option<&str>,
    cookie_jar_per_test: bool,
    cli_http_transport: HttpTransportConfig,
    extra_redact_headers: &[String],
    report_json_path: Option<&Path>,
    no_last_run_json: bool,
    no_fixtures: bool,
    fixture_retention: usize,
) -> i32 {
    if !failed {
        eprintln!(
            "Error: `tarn rerun` requires `--failed` so it never silently becomes a full run. \
             Pass `tarn rerun --failed` (optionally with `--run <run_id>`)."
        );
        return 2;
    }

    let source = match resolve_rerun_source(run) {
        Ok(s) => s,
        Err(code) => return code,
    };

    let selection = match tarn::report::rerun::load_selection(&source) {
        Ok(sel) => sel,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    if selection.targets.is_empty() {
        eprintln!(
            "rerun: no failing tests to rerun (source: {})",
            source.display_path()
        );
        return 0;
    }

    announce_rerun_selection(&selection, &source);

    run_command(
        None,
        format_specs,
        json_mode,
        vars,
        env_name,
        tag,
        select,
        test_filter,
        step_filter,
        verbose,
        verbose_responses,
        max_body,
        only_failed,
        no_progress,
        ndjson,
        dry_run,
        // Rerun does not support `--watch`: rerun reads a frozen source
        // archive, and a watch loop on a frozen selection would invoke
        // the same subset forever. Users who want to watch while iterating
        // should use `tarn run --watch`.
        false,
        parallel,
        jobs,
        no_parallel_warning,
        cookie_jar_path,
        cookie_jar_per_test,
        cli_http_transport,
        extra_redact_headers,
        // Discovery is bypassed inside `run_command` when
        // `rerun_selection` is `Some`, so the `no_default_excludes` flag
        // has no effect. Hard-coded to false for clarity.
        false,
        report_json_path,
        no_last_run_json,
        no_fixtures,
        fixture_retention,
        Some(selection),
        // `tarn rerun` never exposes an agent mode of its own; the
        // compact payload is specific to forward `tarn run` and we do
        // not want the rerun path to accidentally print a second JSON
        // blob to stdout.
        false,
    )
}

/// Resolve `--run <id>` (optional) to the archive path under the current
/// workspace root. Mirrors the lookup `failures_command` does; the two
/// commands must agree on where the artifact lives.
fn resolve_rerun_source(run: Option<&str>) -> Result<tarn::report::rerun::RerunSourcePath, i32> {
    let cwd = match std::env::current_dir() {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return Err(2);
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());
    let path = match run {
        Some(id) => tarn::report::run_dir::run_directory(&workspace_root, id).join("failures.json"),
        None => workspace_root.join(".tarn").join("failures.json"),
    };
    Ok(match run {
        Some(id) => tarn::report::rerun::RerunSourcePath::Archive {
            run_id: id.to_string(),
            path,
        },
        None => tarn::report::rerun::RerunSourcePath::LatestPointer(path),
    })
}

/// Print the selection to stderr so the user can confirm what will run
/// before any HTTP traffic happens. Truncated at 20 entries with a
/// trailing "…and M more" to keep terminal scrollback manageable.
fn announce_rerun_selection(
    selection: &tarn::report::rerun::RerunSelection,
    source: &tarn::report::rerun::RerunSourcePath,
) {
    const MAX_ENTRIES: usize = 20;
    let source_label = match source {
        tarn::report::rerun::RerunSourcePath::Archive { run_id, .. } => run_id.clone(),
        tarn::report::rerun::RerunSourcePath::LatestPointer(_) => selection
            .source
            .run_id
            .clone()
            .unwrap_or_else(|| "latest".to_string()),
    };
    eprintln!(
        "rerun: selected {} test{} from run {}:",
        selection.targets.len(),
        if selection.targets.len() == 1 {
            ""
        } else {
            "s"
        },
        source_label
    );
    let total = selection.targets.len();
    for target in selection.targets.iter().take(MAX_ENTRIES) {
        eprintln!("  - {}", target.label());
    }
    if total > MAX_ENTRIES {
        eprintln!("  …and {} more", total - MAX_ENTRIES);
    }
}

/// `tarn inspect <run_id> [target]` (NAZ-405). Loads a prior run's
/// `report.json` and renders the requested view. Pure reporting — no
/// HTTP or filesystem side effects beyond reading the archive.
fn inspect_command(
    run_id: &str,
    target: Option<&str>,
    format: &str,
    filter_category: Option<&str>,
    fail_on_failure: bool,
) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "human" => "human",
        "json" => "json",
        other => {
            eprintln!(
                "Error: unknown inspect format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    if let Some(cat) = filter_category {
        if let Err(msg) = tarn::report::inspect::validate_category(cat) {
            eprintln!("Error: {}", msg);
            return 2;
        }
    }

    let cwd = match std::env::current_dir() {
        Ok(d) => d,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return 2;
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());

    let source = match tarn::report::inspect::resolve_source(&workspace_root, run_id) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let report = match tarn::report::inspect::load_report(&source) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let parsed_target = match tarn::report::inspect::Target::parse(target) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let view = match tarn::report::inspect::build_view(
        &source,
        &report,
        &parsed_target,
        filter_category,
    ) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let output = match format {
        "json" => serde_json::to_string_pretty(&view).unwrap_or_default(),
        _ => tarn::report::inspect::render_human(&view),
    };
    print!("{}", output);
    if format == "json" {
        println!();
    }

    if fail_on_failure && view_is_failing(&view) {
        1
    } else {
        0
    }
}

/// Whether the inspected entity is in a failing state. The run-level
/// view uses `exit_code`; narrower views read the embedded `status`.
fn view_is_failing(view: &serde_json::Value) -> bool {
    let target = view
        .get("target")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("run");
    match target {
        "run" => view
            .get("exit_code")
            .and_then(serde_json::Value::as_i64)
            .map(|c| c != 0)
            .unwrap_or(false),
        "file" => {
            view.get("file")
                .and_then(|f| f.get("status"))
                .and_then(serde_json::Value::as_str)
                == Some("FAILED")
        }
        "test" => {
            view.get("test")
                .and_then(|t| t.get("status"))
                .and_then(serde_json::Value::as_str)
                == Some("FAILED")
        }
        "step" => {
            view.get("step")
                .and_then(|s| s.get("status"))
                .and_then(serde_json::Value::as_str)
                == Some("FAILED")
        }
        _ => false,
    }
}

/// `tarn diff <run_a> <run_b>` (NAZ-405). Loads `summary.json` and
/// `failures.json` from both sides and emits bucketed deltas.
fn diff_command(
    run_a: &str,
    run_b: &str,
    format: &str,
    file: Option<&str>,
    test: Option<&str>,
    filter_category: Option<&str>,
) -> i32 {
    let format = match format.to_ascii_lowercase().as_str() {
        "human" => "human",
        "json" => "json",
        other => {
            eprintln!(
                "Error: unknown diff format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };
    if let Some(cat) = filter_category {
        if let Err(msg) = tarn::report::inspect::validate_category(cat) {
            eprintln!("Error: {}", msg);
            return 2;
        }
    }

    let cwd = match std::env::current_dir() {
        Ok(d) => d,
        Err(e) => {
            eprintln!("Error: failed to resolve current directory: {}", e);
            return 2;
        }
    };
    let workspace_root = config::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());

    let from_side = match tarn::report::diff::resolve_side(&workspace_root, run_a) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };
    let to_side = match tarn::report::diff::resolve_side(&workspace_root, run_b) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let (from_summary, from_failures) = match tarn::report::diff::load_side(&from_side) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };
    let (to_summary, to_failures) = match tarn::report::diff::load_side(&to_side) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let filters = tarn::report::diff::DiffFilters {
        file,
        test,
        category: filter_category,
    };
    let view = tarn::report::diff::build_diff(
        &from_side,
        &from_summary,
        &from_failures,
        &to_side,
        &to_summary,
        &to_failures,
        &filters,
    );

    let output = match format {
        "json" => tarn::report::diff::render_json(&view),
        _ => tarn::report::diff::render_human(&view),
    };
    print!("{}", output);
    if format == "json" {
        println!();
    }
    0
}

fn parse_output_targets(specs: &[String]) -> Result<Vec<OutputTarget>, String> {
    let targets = specs
        .iter()
        .map(|spec| spec.parse::<OutputTarget>())
        .collect::<Result<Vec<_>, _>>()?;

    let stdout_targets = targets
        .iter()
        .filter(|target| target.writes_to_stdout())
        .count();
    if stdout_targets > 1 {
        return Err(
            "Multiple stdout formats requested. Keep only one bare format and use FORMAT=PATH for additional outputs"
                .into(),
        );
    }

    Ok(targets)
}

fn emit_run_outputs(
    run_result: &RunResult,
    output_targets: &[OutputTarget],
    json_output_mode: JsonOutputMode,
    render_opts: RenderOptions,
    streamed_human_to_stdout: bool,
    suppress_stdout_outputs: bool,
    rerun_source: Option<&tarn::report::rerun::RerunSource>,
) -> Result<(), String> {
    for target in output_targets {
        if suppress_stdout_outputs && target.writes_to_stdout() && target.path.is_none() {
            continue;
        }
        let is_stdout_human = matches!(target.format, OutputFormat::Human)
            && target.writes_to_stdout()
            && target.path.is_none();
        let output = match target.format {
            OutputFormat::Json => tarn::report::json::render_with_rerun_source(
                run_result,
                json_output_mode,
                render_opts,
                rerun_source,
            ),
            OutputFormat::Human if is_stdout_human && streamed_human_to_stdout => {
                tarn::report::human::render_summary(run_result)
            }
            _ => report::render_with_options(run_result, target.format, render_opts),
        };
        match target.format {
            OutputFormat::Html => {
                let report_path = target
                    .path
                    .clone()
                    .unwrap_or_else(|| std::env::temp_dir().join("tarn-report.html"));
                write_output_file(&report_path, &output)
                    .map_err(|e| format!("Failed to write HTML report: {}", e))?;
                eprintln!("HTML report saved to {}", report_path.display());
                if target.path.is_none() {
                    open_report_in_browser(&report_path);
                }
            }
            _ => {
                if let Some(path) = target.path.as_ref() {
                    write_output_file(path, &output).map_err(|e| {
                        format!(
                            "Failed to write {} output to {}: {}",
                            format_name(target.format),
                            path.display(),
                            e
                        )
                    })?;
                    eprintln!(
                        "{} report saved to {}",
                        format_name(target.format),
                        path.display()
                    );
                } else {
                    print!("{}", output);
                }
            }
        }
    }

    Ok(())
}

fn write_output_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)?;
        }
    }
    std::fs::write(path, content)
}

fn format_name(format: OutputFormat) -> &'static str {
    match format {
        OutputFormat::Human => "human",
        OutputFormat::Json => "json",
        OutputFormat::Junit => "junit",
        OutputFormat::Tap => "tap",
        OutputFormat::Html => "html",
        OutputFormat::Curl => "curl",
        OutputFormat::CurlAll => "curl-all",
        OutputFormat::Compact => "compact",
        OutputFormat::Llm => "llm",
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BenchOutputFormat {
    Human,
    Json,
    Csv,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct BenchOutputTarget {
    format: BenchOutputFormat,
    path: PathBuf,
}

impl std::str::FromStr for BenchOutputFormat {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_lowercase().as_str() {
            "human" => Ok(Self::Human),
            "json" => Ok(Self::Json),
            "csv" => Ok(Self::Csv),
            other => Err(format!("Unknown bench output format: '{}'", other)),
        }
    }
}

impl std::str::FromStr for BenchOutputTarget {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let Some((format, path)) = s.split_once('=') else {
            return Err("Expected FORMAT=PATH".into());
        };
        Ok(Self {
            format: format.parse()?,
            path: PathBuf::from(path),
        })
    }
}

fn open_report_in_browser(report_path: &Path) {
    #[cfg(target_os = "macos")]
    {
        let _ = std::process::Command::new("open").arg(report_path).spawn();
    }
    #[cfg(target_os = "linux")]
    {
        let _ = std::process::Command::new("xdg-open")
            .arg(report_path)
            .spawn();
    }
    #[cfg(target_os = "windows")]
    {
        let _ = std::process::Command::new("cmd")
            .args(["/C", "start"])
            .arg(report_path)
            .spawn();
    }
}

#[allow(clippy::too_many_arguments)]
fn run_files_sequential(
    files: &[String],
    cli_vars: &[(String, String)],
    env_name: Option<&str>,
    tag_filter: &[String],
    selectors: &[Selector],
    run_opts: &runner::RunOptions,
    cookie_jar_path: Option<&str>,
    progress: Option<&(dyn ProgressReporter + Send + Sync)>,
    events: Option<&std::sync::Arc<tarn::report::event_stream::EventStream>>,
    extra_redact_headers: &[String],
) -> Result<Vec<tarn::assert::types::FileResult>, (i32, String)> {
    let mut results = Vec::new();
    let mut cookie_jars = if let Some(path) = cookie_jar_path {
        cookie::load_named_jars(Path::new(path)).map_err(|e| (e.exit_code(), e.to_string()))?
    } else {
        std::collections::HashMap::new()
    };

    for file_path in files {
        let path = Path::new(file_path);
        let mut test_file = parser::parse_file(path).map_err(|e| (e.exit_code(), e.to_string()))?;
        let project = load_project_context(path.parent().unwrap_or(Path::new(".")))
            .map_err(|e| (e.exit_code(), e.to_string()))?;
        apply_project_defaults(&mut test_file, &project.config);
        apply_cli_extra_redact_headers(&mut test_file, extra_redact_headers);
        let file_run_opts = runner::RunOptions {
            http: resolve_http_transport_config(&project.config, &run_opts.http),
            ..run_opts.clone()
        };
        let resolved_env = resolve_env_for_file(&test_file, path, env_name, cli_vars)
            .map_err(|e| (e.exit_code(), e.to_string()))?;
        let observers = runner::RunObservers::new()
            .with_progress(progress)
            .with_events(events);
        let result = runner::run_file_with_observers(
            &test_file,
            file_path,
            &resolved_env,
            tag_filter,
            selectors,
            &file_run_opts,
            &mut cookie_jars,
            &observers,
        )
        .map_err(|e| (e.exit_code(), e.to_string()))?;
        results.push(result);
    }

    if let Some(path) = cookie_jar_path {
        cookie::save_named_jars(Path::new(path), &cookie_jars)
            .map_err(|e| (e.exit_code(), e.to_string()))?;
    }

    Ok(results)
}

#[allow(clippy::too_many_arguments)]
fn run_files_parallel(
    files: &[String],
    cli_vars: &[(String, String)],
    env_name: Option<&str>,
    tag_filter: &[String],
    selectors: &[Selector],
    run_opts: &runner::RunOptions,
    jobs: Option<usize>,
    progress: Option<&(dyn ProgressReporter + Send + Sync)>,
    events: Option<&std::sync::Arc<tarn::report::event_stream::EventStream>>,
    extra_redact_headers: &[String],
) -> Result<Vec<tarn::assert::types::FileResult>, (i32, String)> {
    use rayon::prelude::*;

    // Parse every file once up front so the scheduler can read
    // `serial_only` and `group:` without re-reading YAML from the
    // parallel workers. Parsed `TestFile`s are also reused by the
    // actual execution below, saving a second parse per file.
    let parsed: Vec<(String, tarn::model::TestFile)> = files
        .iter()
        .map(|file_path| {
            let path = Path::new(file_path);
            let test_file = parser::parse_file(path).map_err(|e| (e.exit_code(), e.to_string()))?;
            Ok::<_, (i32, String)>((file_path.clone(), test_file))
        })
        .collect::<Result<Vec<_>, _>>()?;

    let metadata: Vec<runner::SchedulingMetadata> = parsed
        .iter()
        .map(|(path, tf)| runner::SchedulingMetadata::from_test_file(path, tf))
        .collect();

    let worker_count = jobs.unwrap_or_else(num_cpus_for_scheduling);
    if let Some(j) = jobs {
        rayon::ThreadPoolBuilder::new()
            .num_threads(j)
            .build_global()
            .ok();
    }

    let plan = runner::plan_schedule(&metadata, worker_count);

    // Index parsed files by path so each bucket can borrow them
    // without re-parsing. Cloning the `TestFile` per execution keeps
    // rayon worker closures `Send`-safe without wrestling with `Arc`.
    let parsed_by_path: std::collections::HashMap<String, tarn::model::TestFile> =
        parsed.into_iter().collect();

    // Parallel stage: each bucket runs on one worker and processes its
    // files sequentially. Across buckets rayon drives them
    // concurrently.
    let parallel_results: Vec<tarn::assert::types::FileResult> = plan
        .parallel_buckets
        .par_iter()
        .map(|bucket| {
            let mut bucket_results = Vec::with_capacity(bucket.len());
            for file_path in bucket {
                let test_file = parsed_by_path.get(file_path).ok_or_else(|| {
                    (
                        2,
                        format!("Internal scheduler error: missing parse for {}", file_path),
                    )
                })?;
                bucket_results.push(execute_one_file(
                    file_path,
                    test_file,
                    cli_vars,
                    env_name,
                    tag_filter,
                    selectors,
                    run_opts,
                    extra_redact_headers,
                    progress,
                    events,
                )?);
            }
            Ok::<_, (i32, String)>(bucket_results)
        })
        .collect::<Result<Vec<_>, _>>()?
        .into_iter()
        .flatten()
        .collect();

    // Serial stage: files flagged `serial_only` run sequentially on a
    // single worker, after every parallel bucket has finished. Keeps
    // state-sharing tests out of concurrency entirely.
    let mut serial_results = Vec::with_capacity(plan.serial.len());
    for file_path in &plan.serial {
        let test_file = parsed_by_path.get(file_path).ok_or_else(|| {
            (
                2,
                format!("Internal scheduler error: missing parse for {}", file_path),
            )
        })?;
        serial_results.push(execute_one_file(
            file_path,
            test_file,
            cli_vars,
            env_name,
            tag_filter,
            selectors,
            run_opts,
            extra_redact_headers,
            progress,
            events,
        )?);
    }

    let mut results: Vec<tarn::assert::types::FileResult> =
        parallel_results.into_iter().chain(serial_results).collect();

    // Sort for deterministic output
    results.sort_by(|a, b| a.file.cmp(&b.file));
    Ok(results)
}

/// Run a single parsed test file through the full pipeline (project
/// defaults -> redaction -> env resolution -> execution). Shared
/// between the parallel buckets and the serial tail so both paths
/// exercise identical per-file wiring.
#[allow(clippy::too_many_arguments)]
fn execute_one_file(
    file_path: &str,
    test_file: &tarn::model::TestFile,
    cli_vars: &[(String, String)],
    env_name: Option<&str>,
    tag_filter: &[String],
    selectors: &[Selector],
    run_opts: &runner::RunOptions,
    extra_redact_headers: &[String],
    progress: Option<&(dyn ProgressReporter + Send + Sync)>,
    events: Option<&std::sync::Arc<tarn::report::event_stream::EventStream>>,
) -> Result<tarn::assert::types::FileResult, (i32, String)> {
    let path = Path::new(file_path);
    let mut test_file = test_file.clone();
    let project = load_project_context(path.parent().unwrap_or(Path::new(".")))
        .map_err(|e| (e.exit_code(), e.to_string()))?;
    apply_project_defaults(&mut test_file, &project.config);
    apply_cli_extra_redact_headers(&mut test_file, extra_redact_headers);
    let file_run_opts = runner::RunOptions {
        http: resolve_http_transport_config(&project.config, &run_opts.http),
        ..run_opts.clone()
    };
    let resolved_env = resolve_env_for_file(&test_file, path, env_name, cli_vars)
        .map_err(|e| (e.exit_code(), e.to_string()))?;
    let mut local_jars = std::collections::HashMap::new();
    // In parallel mode the progress reporter only fires on
    // `file_finished` (see `HumanProgress::Parallel`). The events
    // stream, by contrast, wants the full lifecycle so reader agents
    // can correlate every step back to its file — wire it through the
    // full observer path. Progress is intentionally left out of the
    // inner run so the parallel reporter's per-file atomic flush still
    // works via the explicit call below.
    let observers = runner::RunObservers::new().with_events(events);
    let result = runner::run_file_with_observers(
        &test_file,
        file_path,
        &resolved_env,
        tag_filter,
        selectors,
        &file_run_opts,
        &mut local_jars,
        &observers,
    )
    .map_err(|e| (e.exit_code(), e.to_string()))?;
    if let Some(p) = progress {
        p.file_finished(&result);
    }
    Ok(result)
}

/// Scheduling-side fallback for worker count when `--jobs` is not set.
/// Kept tiny and separate from rayon's global thread pool so the
/// scheduler never depends on pool initialization order.
fn num_cpus_for_scheduling() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1)
}

/// Emit the one-line `--parallel` isolation warning to the supplied
/// writer when the project has not opted in via
/// `parallel_opt_in: true` in `tarn.config.yaml`. Callers pass
/// `project.config.parallel_opt_in` as-is; `Some(true)` is the only
/// value that silences the warning.
///
/// Written as a pure function over `std::io::Write` so it can be unit
/// tested without intercepting stderr. The runtime call site passes
/// `std::io::stderr()` so the warning stays out of stdout (which may
/// be an NDJSON stream) and does not break machine-readable output.
fn emit_parallel_opt_in_warning<W: std::io::Write>(writer: &mut W, opt_in: Option<bool>) {
    if opt_in == Some(true) {
        return;
    }
    let _ = writeln!(
        writer,
        "warning: --parallel enabled without parallel_opt_in: true in tarn.config.yaml. Tests without serial_only may share state."
    );
}

#[allow(clippy::too_many_arguments)]
fn bench_command(
    path: &str,
    requests: u64,
    concurrency: u64,
    step_index: usize,
    ramp_up: Option<&str>,
    vars: &[String],
    env_name: Option<&str>,
    format: &str,
    export_specs: &[String],
    thresholds: bench::BenchThresholds,
    cli_http_transport: &HttpTransportConfig,
) -> i32 {
    let cli_vars = match env::parse_cli_vars(vars) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    let file_path = Path::new(path);
    let mut test_file = match parser::parse_file(file_path) {
        Ok(tf) => tf,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };
    let project = match load_project_context(file_path.parent().unwrap_or(Path::new("."))) {
        Ok(project) => project,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };
    apply_project_defaults(&mut test_file, &project.config);
    let http_transport = resolve_http_transport_config(&project.config, cli_http_transport);

    let resolved_env = match resolve_env_for_file(&test_file, file_path, env_name, &cli_vars) {
        Ok(e) => e,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    let ramp_up_duration = ramp_up.and_then(|s| {
        let s = s.trim();
        if let Some(ms) = s.strip_suffix("ms") {
            ms.parse::<u64>().ok().map(std::time::Duration::from_millis)
        } else if let Some(secs) = s.strip_suffix('s') {
            secs.parse::<u64>().ok().map(std::time::Duration::from_secs)
        } else {
            s.parse::<u64>().ok().map(std::time::Duration::from_millis)
        }
    });

    let opts = bench::BenchOptions {
        requests,
        concurrency,
        ramp_up: ramp_up_duration,
        thresholds,
    };
    let export_targets = match parse_bench_output_targets(export_specs) {
        Ok(targets) => targets,
        Err(error) => {
            eprintln!("Error: {}", error);
            return 2;
        }
    };

    match bench::run_bench(
        &test_file,
        step_index,
        &resolved_env,
        &opts,
        &http_transport,
    ) {
        Ok(result) => {
            let output = match format {
                "json" => bench::render_json(&result),
                "csv" => bench::render_csv(&result),
                _ => bench::render_human(&result),
            };
            print!("{}", output);
            if let Err(error) = emit_bench_outputs(&result, &export_targets) {
                eprintln!("Error: {}", error);
                return 3;
            }
            if result.failed == 0 && result.passed_gates {
                0
            } else {
                1
            }
        }
        Err(e) => {
            eprintln!("Error: {}", e);
            e.exit_code()
        }
    }
}

fn parse_bench_output_targets(specs: &[String]) -> Result<Vec<BenchOutputTarget>, String> {
    specs
        .iter()
        .map(|spec| spec.parse::<BenchOutputTarget>())
        .collect()
}

fn emit_bench_outputs(
    result: &bench::BenchResult,
    targets: &[BenchOutputTarget],
) -> Result<(), String> {
    for target in targets {
        let output = match target.format {
            BenchOutputFormat::Human => bench::render_human(result),
            BenchOutputFormat::Json => bench::render_json(result),
            BenchOutputFormat::Csv => bench::render_csv(result),
        };
        write_output_file(&target.path, &output).map_err(|error| {
            format!(
                "Failed to write bench {} output to {}: {}",
                match target.format {
                    BenchOutputFormat::Human => "human",
                    BenchOutputFormat::Json => "json",
                    BenchOutputFormat::Csv => "csv",
                },
                target.path.display(),
                error
            )
        })?;
    }

    Ok(())
}

fn resolve_files(path: Option<String>) -> Result<Vec<String>, TarnError> {
    Ok(resolve_files_with_report(path, false)?.files)
}

/// Like [`resolve_files`] but also returns the excluded directories and any
/// detected duplicate `tests/` trees so the caller (currently only the
/// `run` subcommand) can surface them in a discovery summary.
fn resolve_files_with_report(
    path: Option<String>,
    no_default_excludes: bool,
) -> Result<runner::DiscoveryReport, TarnError> {
    let opts = if no_default_excludes {
        runner::DiscoveryOptions {
            ignored_dirs: Vec::new(),
        }
    } else {
        runner::DiscoveryOptions::default()
    };

    match path {
        Some(p) => {
            let path = Path::new(&p);
            if path.is_file() {
                // Explicit single-file inputs bypass the walker: there's
                // nothing to exclude, and the report should still list the
                // file so the downstream caller can treat all code paths
                // uniformly.
                Ok(runner::DiscoveryReport {
                    root: path
                        .parent()
                        .map(|p| p.to_path_buf())
                        .unwrap_or_else(|| Path::new(".").to_path_buf()),
                    files: vec![p],
                    excluded_roots: Vec::new(),
                    duplicate_test_trees: Vec::new(),
                })
            } else if path.is_dir() {
                runner::discover_test_files_with_report(path, &opts)
            } else {
                Err(TarnError::Config(format!("Path not found: {}", p)))
            }
        }
        None => {
            let project = load_project_context(Path::new("."))?;
            let tests_dir = project.root_dir.join(&project.config.test_dir);
            if tests_dir.is_dir() {
                runner::discover_test_files_with_report(&tests_dir, &opts)
            } else {
                runner::discover_test_files_with_report(&project.root_dir, &opts)
            }
        }
    }
}

/// Render a one-to-three line discovery summary for human output. Returns
/// `None` when the report is uninteresting (a single-file input, no
/// exclusions, no duplicate trees) so the command stays quiet by default.
fn format_discovery_summary(
    report: &runner::DiscoveryReport,
    no_default_excludes: bool,
) -> Option<String> {
    let has_interesting_content = !report.excluded_roots.is_empty()
        || !report.duplicate_test_trees.is_empty()
        || report.files.len() > 1;
    if !has_interesting_content {
        return None;
    }

    let mut out = String::new();
    out.push_str(&format!(
        "Discovered {} test file{} under {}\n",
        report.files.len(),
        if report.files.len() == 1 { "" } else { "s" },
        report.root.display(),
    ));

    if no_default_excludes {
        out.push_str("  (default excludes disabled — scanning every nested directory)\n");
    } else if !report.excluded_roots.is_empty() {
        // Keep the list compact: most repos will only trip one or two
        // (usually `.git` and `node_modules`). Collapse by basename so
        // twenty `.git` copies deep in worktrees don't flood the console.
        let mut names: Vec<String> = report
            .excluded_roots
            .iter()
            .filter_map(|p| {
                Path::new(p)
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
            })
            .collect();
        names.sort();
        names.dedup();
        out.push_str(&format!("  Excluded: {}\n", names.join(", ")));
    }

    if !report.duplicate_test_trees.is_empty() {
        out.push_str("  Warning: multiple `tests` trees discovered under this root:\n");
        for tree in &report.duplicate_test_trees {
            out.push_str(&format!("    - {}\n", tree));
        }
        out.push_str(
            "  If this was not intentional, pass an explicit path or add the stale copy to your ignores.\n",
        );
    }

    Some(out.trim_end().to_string())
}

fn validate_command(path: Option<String>, format: &str, no_default_excludes: bool) -> i32 {
    let json_format = match format.to_ascii_lowercase().as_str() {
        "human" => false,
        "json" => true,
        other => {
            eprintln!(
                "Error: unknown validate format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    let files = match resolve_files_with_report(path, no_default_excludes) {
        Ok(r) => r.files,
        Err(e) => {
            if json_format {
                let output = serde_json::json!({
                    "files": [],
                    "error": e.to_string(),
                });
                println!("{}", serde_json::to_string_pretty(&output).unwrap());
            } else {
                eprintln!("Error: {}", e);
            }
            return e.exit_code();
        }
    };

    if files.is_empty() {
        if json_format {
            let output = serde_json::json!({ "files": [] });
            println!("{}", serde_json::to_string_pretty(&output).unwrap());
        } else {
            eprintln!("No test files found");
        }
        return 2;
    }

    if json_format {
        validate_files_json(&files)
    } else {
        validate_files_human(&files)
    }
}

fn validate_files_human(files: &[String]) -> i32 {
    let mut all_valid = true;
    for file_path in files {
        let path = Path::new(file_path);
        match parser::parse_file(path) {
            Ok(_) => {
                println!("  ✓ {}", file_path);
                // Lint pass: surface brittle-pattern warnings after a
                // successful parse. Warnings never fail validation —
                // they are advisory so operators can tighten tests
                // before the next rerun (NAZ-342).
                for warning in collect_lint_warnings(path) {
                    let loc = warning
                        .location
                        .as_ref()
                        .map(|l| format!(" ({}:{}:{})", file_path, l.line, l.column))
                        .unwrap_or_default();
                    println!("    ⚠ warning{}: {}", loc, warning.message);
                }
            }
            Err(e) => {
                println!("  ✗ {}: {}", file_path, e);
                all_valid = false;
            }
        }
    }
    if all_valid {
        0
    } else {
        2
    }
}

/// Rerun just the warning-severity pass of `validate_document` on a
/// parsed file. Returns an empty vector when the file doesn't parse —
/// the caller has already surfaced that error.
fn collect_lint_warnings(path: &Path) -> Vec<tarn::validation::ValidationMessage> {
    let Ok(source) = std::fs::read_to_string(path) else {
        return Vec::new();
    };
    tarn::validation::validate_document(path, &source)
        .into_iter()
        .filter(|m| matches!(m.severity, tarn::validation::Severity::Warning))
        .collect()
}

fn validate_files_json(files: &[String]) -> i32 {
    let mut all_valid = true;
    let mut file_entries = Vec::with_capacity(files.len());
    for file_path in files {
        let path = Path::new(file_path);
        let errors = collect_validation_errors(path);
        let valid = errors.is_empty();
        if !valid {
            all_valid = false;
        }
        let error_json: Vec<serde_json::Value> = errors
            .iter()
            .map(|err| {
                let mut obj = serde_json::Map::new();
                obj.insert(
                    "message".into(),
                    serde_json::Value::String(err.message.clone()),
                );
                if let Some(line) = err.line {
                    obj.insert("line".into(), serde_json::Value::from(line));
                }
                if let Some(col) = err.column {
                    obj.insert("column".into(), serde_json::Value::from(col));
                }
                serde_json::Value::Object(obj)
            })
            .collect();
        // Warnings only appear when the file parsed successfully. They
        // are advisory and do not flip `valid` to false.
        let warnings_json: Vec<serde_json::Value> = if valid {
            collect_lint_warnings(path)
                .iter()
                .map(|warning| {
                    let mut obj = serde_json::Map::new();
                    obj.insert(
                        "code".into(),
                        serde_json::Value::String(warning.code.as_str().to_string()),
                    );
                    obj.insert(
                        "message".into(),
                        serde_json::Value::String(warning.message.clone()),
                    );
                    if let Some(loc) = &warning.location {
                        obj.insert("line".into(), serde_json::Value::from(loc.line));
                        obj.insert("column".into(), serde_json::Value::from(loc.column));
                    }
                    serde_json::Value::Object(obj)
                })
                .collect()
        } else {
            Vec::new()
        };
        file_entries.push(serde_json::json!({
            "file": file_path,
            "valid": valid,
            "errors": error_json,
            "warnings": warnings_json,
        }));
    }
    let output = serde_json::json!({ "files": file_entries });
    println!("{}", serde_json::to_string_pretty(&output).unwrap());
    if all_valid {
        0
    } else {
        2
    }
}

#[derive(Debug)]
struct ValidationError {
    message: String,
    line: Option<usize>,
    column: Option<usize>,
}

/// Collect structured validation errors for a single file.
///
/// When the error originates from serde_yaml's raw parse we extract
/// line and column directly from the error's location. For semantic
/// errors that come out of `parser::parse_file` we attempt to recover
/// line and column by matching the `path:line:column:` prefix that
/// `enhance_parse_error` embeds in its message.
fn collect_validation_errors(path: &Path) -> Vec<ValidationError> {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            return vec![ValidationError {
                message: format!("Failed to read {}: {}", path.display(), e),
                line: None,
                column: None,
            }]
        }
    };

    if let Err(yaml_err) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
        let location = yaml_err.location();
        return vec![ValidationError {
            message: yaml_err.to_string(),
            line: location.as_ref().map(|l| l.line()),
            column: location.as_ref().map(|l| l.column()),
        }];
    }

    match parser::parse_file(path) {
        Ok(_) => Vec::new(),
        Err(err) => {
            let raw = err.to_string();
            let (message, line, column) = extract_location_prefix(&raw, path);
            vec![ValidationError {
                message,
                line,
                column,
            }]
        }
    }
}

/// Parse the `"<path>:<line>:<column>: <rest>"` prefix that
/// `enhance_parse_error` writes into parser error messages. When the
/// prefix is absent, the full message is returned as the error text with
/// `line` and `column` set to `None`.
fn extract_location_prefix(message: &str, path: &Path) -> (String, Option<usize>, Option<usize>) {
    let prefix = format!("{}:", path.display());
    let Some(rest) = message.strip_prefix(&prefix) else {
        return (message.to_string(), None, None);
    };
    let mut parts = rest.splitn(3, ':');
    let line_part = parts.next();
    let col_part = parts.next();
    let tail = parts.next();
    let (Some(line_str), Some(col_str), Some(tail)) = (line_part, col_part, tail) else {
        return (message.to_string(), None, None);
    };
    let (Ok(line), Ok(col)) = (
        line_str.trim().parse::<usize>(),
        col_str.trim().parse::<usize>(),
    ) else {
        return (message.to_string(), None, None);
    };
    (tail.trim_start().to_string(), Some(line), Some(col))
}

/// Top-level dispatcher for `tarn lint`. Mirrors `validate_command`'s
/// shape: resolve files, pick a renderer, feed each file through the
/// rule set, and aggregate exit codes.
///
/// Exit codes:
/// - `0` — no findings reach `--severity` (default `warning`).
/// - `1` — one or more findings at or above the threshold.
/// - `2` — I/O or parse error (same semantics as `tarn validate`).
fn lint_command(
    path: Option<String>,
    format: &str,
    severity: &str,
    lint_allow_absolute_urls: bool,
    no_default_excludes: bool,
) -> i32 {
    let json_format = match format.to_ascii_lowercase().as_str() {
        "human" => false,
        "json" => true,
        other => {
            eprintln!(
                "Error: unknown lint format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    let threshold = match tarn::lint::Severity::parse_threshold(severity) {
        Some(t) => t,
        None => {
            eprintln!(
                "Error: unknown severity '{}'. Use 'error', 'warning', or 'info'.",
                severity
            );
            return 2;
        }
    };

    let files = match resolve_files_with_report(path, no_default_excludes) {
        Ok(r) => r.files,
        Err(e) => {
            if json_format {
                let output = serde_json::json!({
                    "schema_version": 1,
                    "files_scanned": 0,
                    "findings": [],
                    "error": e.to_string(),
                });
                println!("{}", serde_json::to_string_pretty(&output).unwrap());
            } else {
                eprintln!("Error: {}", e);
            }
            return e.exit_code();
        }
    };

    if files.is_empty() {
        if json_format {
            let output = serde_json::json!({
                "schema_version": 1,
                "files_scanned": 0,
                "findings": [],
            });
            println!("{}", serde_json::to_string_pretty(&output).unwrap());
        } else {
            eprintln!("No test files found");
        }
        return 2;
    }

    let opts = tarn::lint::LintOptions {
        allow_absolute_urls: lint_allow_absolute_urls,
    };

    let mut all_findings: Vec<(String, tarn::lint::Finding)> = Vec::new();
    let mut had_io_error = false;
    for file_path in &files {
        match tarn::lint::lint_path(Path::new(file_path), &opts) {
            Ok(findings) => {
                for f in findings {
                    all_findings.push((file_path.clone(), f));
                }
            }
            Err(msg) => {
                // Parse/read failures exit 2 — same semantics as
                // `tarn validate`. Emit through the chosen format so
                // downstream tooling stays consistent.
                had_io_error = true;
                if !json_format {
                    eprintln!("Error: {}", msg);
                }
            }
        }
    }

    let visible: Vec<&(String, tarn::lint::Finding)> = all_findings
        .iter()
        .filter(|(_, f)| f.severity >= threshold)
        .collect();

    if json_format {
        let findings_json: Vec<serde_json::Value> =
            visible.iter().map(|(_, f)| finding_to_json(f)).collect();
        let mut output = serde_json::json!({
            "schema_version": 1,
            "files_scanned": files.len(),
            "findings": findings_json,
        });
        if had_io_error {
            output["error"] = serde_json::Value::String(
                "one or more files could not be read or parsed".to_string(),
            );
        }
        println!("{}", serde_json::to_string_pretty(&output).unwrap());
    } else {
        render_findings_human(&visible);
        println!(
            "\n{} file{} scanned, {} finding{} at severity >= {}",
            files.len(),
            if files.len() == 1 { "" } else { "s" },
            visible.len(),
            if visible.len() == 1 { "" } else { "s" },
            threshold.as_str(),
        );
    }

    if had_io_error {
        return 2;
    }
    if visible.is_empty() {
        0
    } else {
        1
    }
}

fn finding_to_json(f: &tarn::lint::Finding) -> serde_json::Value {
    serde_json::json!({
        "rule_id": f.rule_id,
        "severity": f.severity.as_str(),
        "file": f.file,
        "line": f.line,
        "column": f.column,
        "step_path": f.step_path,
        "message": f.message,
        "hint": f.hint,
    })
}

fn render_findings_human(findings: &[&(String, tarn::lint::Finding)]) {
    if findings.is_empty() {
        println!("No lint findings.");
        return;
    }
    // Group by file for readability. `findings` is already sorted by
    // (line, rule_id) inside each file thanks to `lint_file`'s sort,
    // and by file insertion order here.
    let mut current_file: Option<&str> = None;
    for (file_path, f) in findings {
        if current_file != Some(file_path.as_str()) {
            println!("\n{}", file_path);
            current_file = Some(file_path.as_str());
        }
        let loc = match (f.line, f.column) {
            (Some(l), Some(c)) => format!("{}:{}", l, c),
            (Some(l), None) => format!("{}", l),
            _ => "-".to_string(),
        };
        println!(
            "  {} [{}] {}: {}",
            loc,
            f.severity.as_str(),
            f.rule_id,
            f.message
        );
        if let Some(hint) = &f.hint {
            println!("      hint: {}", hint);
        }
    }
}

fn fmt_command(path: Option<String>, check: bool) -> i32 {
    let files = match resolve_files(path) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    if files.is_empty() {
        eprintln!("No test files found");
        return 2;
    }

    let mut changed = Vec::new();
    for file_path in &files {
        let path = Path::new(file_path);
        let original = match std::fs::read_to_string(path) {
            Ok(content) => content,
            Err(e) => {
                eprintln!("Error: Failed to read {}: {}", path.display(), e);
                return 2;
            }
        };
        // NAZ-302: route through the shared `tarn::format::format_document`
        // library surface so CLI and LSP share one implementation. The
        // library's "broken YAML → identity no-op" contract is right for
        // the LSP (don't error while the user types) but wrong for the
        // CLI (must still exit 2 on a broken file so CI fails loud). We
        // pre-check YAML parseability and, if the pre-parse fails, route
        // through `parser::format_str` once more to recover the same
        // enhanced "file:line:col" error message the CLI used to emit.
        // On every happy-path call this pre-check is a single O(file)
        // serde_yaml parse, no extra format work.
        if serde_yaml::from_str::<serde_yaml::Value>(&original).is_err() {
            match parser::format_str(&original, path) {
                Ok(_) => unreachable!("format_str cannot succeed after a raw parse failure"),
                Err(e) => {
                    eprintln!("Error: {}", e);
                    return e.exit_code();
                }
            }
        }
        let formatted = match format::format_document(&original) {
            Ok(formatted) => formatted,
            Err(e) => {
                eprintln!("Error: Parse error: {}: {}", path.display(), e);
                return 2;
            }
        };

        if original != formatted {
            changed.push(file_path.clone());
            if !check {
                if let Err(e) = std::fs::write(path, formatted) {
                    eprintln!("Error: Failed to write {}: {}", path.display(), e);
                    return 2;
                }
                println!("formatted {}", file_path);
            }
        }
    }

    if check {
        if changed.is_empty() {
            println!("All Tarn files are already formatted");
            0
        } else {
            for file in &changed {
                println!("needs formatting {}", file);
            }
            1
        }
    } else {
        if changed.is_empty() {
            println!("All Tarn files already formatted");
        }
        0
    }
}

fn list_command(
    tag: Option<&str>,
    file: Option<&Path>,
    format: &str,
    no_default_excludes: bool,
) -> i32 {
    let json_format = match format.to_ascii_lowercase().as_str() {
        "human" => false,
        "json" => true,
        other => {
            eprintln!(
                "Error: unknown list format '{}'. Use 'human' or 'json'.",
                other
            );
            return 2;
        }
    };

    let tag_filter = tag.map(runner::parse_tag_filter).unwrap_or_default();

    let files = match file {
        Some(path) => match resolve_single_list_file(path) {
            Ok(paths) => paths,
            Err(e) => {
                if json_format {
                    let output = serde_json::json!({
                        "files": [],
                        "error": e.to_string(),
                    });
                    println!("{}", serde_json::to_string_pretty(&output).unwrap());
                } else {
                    eprintln!("Error: {}", e);
                }
                return e.exit_code();
            }
        },
        None => match resolve_files_with_report(None, no_default_excludes) {
            Ok(r) => r.files,
            Err(e) => {
                if json_format {
                    let output = serde_json::json!({
                        "files": [],
                        "error": e.to_string(),
                    });
                    println!("{}", serde_json::to_string_pretty(&output).unwrap());
                } else {
                    eprintln!("Error: {}", e);
                }
                return e.exit_code();
            }
        },
    };

    if json_format {
        list_files_json(&files, &tag_filter, file.is_some())
    } else {
        list_files_human(&files, &tag_filter)
    }
}

fn resolve_single_list_file(path: &Path) -> Result<Vec<String>, TarnError> {
    if !path.exists() {
        return Err(TarnError::Config(format!(
            "Path not found: {}",
            path.display()
        )));
    }
    if !path.is_file() {
        return Err(TarnError::Config(format!(
            "--file expects a file, not a directory: {}",
            path.display()
        )));
    }
    Ok(vec![path.display().to_string()])
}

fn list_files_human(files: &[String], tag_filter: &[String]) -> i32 {
    if files.is_empty() {
        println!("No test files found");
        return 0;
    }

    for file_path in files {
        let path = Path::new(file_path);
        match parser::parse_file(path) {
            Ok(tf) => {
                let matches_simple =
                    !tf.steps.is_empty() && runner::matches_tags(&tf.tags, tag_filter);
                let matching_groups: Vec<_> = tf
                    .tests
                    .iter()
                    .filter(|(_, group)| {
                        let combined_tags: Vec<String> =
                            tf.tags.iter().chain(group.tags.iter()).cloned().collect();
                        runner::matches_tags(&combined_tags, tag_filter)
                    })
                    .collect();

                if !tag_filter.is_empty() && !matches_simple && matching_groups.is_empty() {
                    continue;
                }

                println!("{}", file_path);
                println!("  \u{25cf} {}", tf.name);
                if !tf.tags.is_empty() {
                    println!("    tags: {}", tf.tags.join(", "));
                }
                if !tf.setup.is_empty() {
                    println!("    setup: {} step(s)", tf.setup.len());
                }
                for step in tf
                    .steps
                    .iter()
                    .filter(|_| matches_simple || tag_filter.is_empty())
                {
                    println!("    - {}", step.name);
                }
                for (name, group) in matching_groups {
                    let desc = group
                        .description
                        .as_deref()
                        .map(|d| format!(" — {}", d))
                        .unwrap_or_default();
                    println!("    {}{}", name, desc);
                    for step in &group.steps {
                        println!("      - {}", step.name);
                    }
                }
                if !tf.teardown.is_empty() {
                    println!("    teardown: {} step(s)", tf.teardown.len());
                }
                println!();
            }
            Err(e) => {
                eprintln!("  ✗ {}: {}", file_path, e);
            }
        }
    }

    0
}

fn list_files_json(files: &[String], tag_filter: &[String], scoped_to_file: bool) -> i32 {
    let mut file_entries: Vec<serde_json::Value> = Vec::with_capacity(files.len());
    let mut had_error = false;

    for file_path in files {
        let path = Path::new(file_path);
        match parser::parse_file(path) {
            Ok(tf) => {
                let matches_simple =
                    !tf.steps.is_empty() && runner::matches_tags(&tf.tags, tag_filter);
                let matching_groups: Vec<(&String, &tarn::model::TestGroup)> = tf
                    .tests
                    .iter()
                    .filter(|(_, group)| {
                        let combined_tags: Vec<String> =
                            tf.tags.iter().chain(group.tags.iter()).cloned().collect();
                        runner::matches_tags(&combined_tags, tag_filter)
                    })
                    .collect();

                if !tag_filter.is_empty() && !matches_simple && matching_groups.is_empty() {
                    continue;
                }

                let steps_json: Vec<serde_json::Value> = tf
                    .steps
                    .iter()
                    .filter(|_| matches_simple || tag_filter.is_empty())
                    .map(|s| serde_json::json!({ "name": s.name }))
                    .collect();

                let setup_json: Vec<serde_json::Value> = tf
                    .setup
                    .iter()
                    .map(|s| serde_json::json!({ "name": s.name }))
                    .collect();

                let teardown_json: Vec<serde_json::Value> = tf
                    .teardown
                    .iter()
                    .map(|s| serde_json::json!({ "name": s.name }))
                    .collect();

                let tests_json: Vec<serde_json::Value> = matching_groups
                    .iter()
                    .map(|(name, group)| {
                        let group_steps: Vec<serde_json::Value> = group
                            .steps
                            .iter()
                            .map(|s| serde_json::json!({ "name": s.name }))
                            .collect();
                        let mut obj = serde_json::Map::new();
                        obj.insert(
                            "name".into(),
                            serde_json::Value::String((*name).to_string()),
                        );
                        if let Some(desc) = group.description.as_deref() {
                            obj.insert(
                                "description".into(),
                                serde_json::Value::String(desc.to_string()),
                            );
                        }
                        obj.insert(
                            "tags".into(),
                            serde_json::Value::Array(
                                group
                                    .tags
                                    .iter()
                                    .cloned()
                                    .map(serde_json::Value::String)
                                    .collect(),
                            ),
                        );
                        obj.insert("steps".into(), serde_json::Value::Array(group_steps));
                        serde_json::Value::Object(obj)
                    })
                    .collect();

                file_entries.push(serde_json::json!({
                    "file": file_path,
                    "name": tf.name,
                    "tags": tf.tags,
                    "setup": setup_json,
                    "steps": steps_json,
                    "tests": tests_json,
                    "teardown": teardown_json,
                }));
            }
            Err(e) => {
                had_error = true;
                file_entries.push(serde_json::json!({
                    "file": file_path,
                    "error": e.to_string(),
                }));
            }
        }
    }

    let output = serde_json::json!({ "files": file_entries });
    println!("{}", serde_json::to_string_pretty(&output).unwrap());

    if had_error && scoped_to_file {
        2
    } else {
        0
    }
}

fn import_hurl_command(path: &str, output: Option<&str>) -> i32 {
    let input_path = Path::new(path);
    let converted = match tarn::hurl_import::convert_file(input_path) {
        Ok(converted) => converted,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    let output_path = output
        .map(PathBuf::from)
        .unwrap_or_else(|| default_hurl_output_path(input_path));

    if let Err(e) = write_output_file(&output_path, &converted) {
        eprintln!("Error: Failed to write {}: {}", output_path.display(), e);
        return 3;
    }

    println!(
        "converted {} -> {}",
        input_path.display(),
        output_path.display()
    );
    0
}

#[allow(clippy::too_many_arguments)]
fn scaffold_command(
    from_openapi: Option<&str>,
    op_id: Option<&str>,
    from_curl: Option<&str>,
    method: Option<&str>,
    url: Option<&str>,
    from_recorded: Option<&str>,
    out: Option<&str>,
    name: Option<&str>,
    force: bool,
    format: &str,
) -> i32 {
    // Count selected modes: exactly one must be set. `method`+`url`
    // together count as one mode.
    let openapi_set = from_openapi.is_some();
    let curl_set = from_curl.is_some();
    let explicit_set = method.is_some() || url.is_some();
    let recorded_set = from_recorded.is_some();
    let selected = [openapi_set, curl_set, explicit_set, recorded_set]
        .iter()
        .filter(|b| **b)
        .count();
    if selected == 0 {
        eprintln!(
            "Error: tarn scaffold requires exactly one input mode: \
             --from-openapi / --from-curl / --method+--url / --from-recorded"
        );
        return 2;
    }
    if selected > 1 {
        eprintln!(
            "Error: tarn scaffold accepts exactly one input mode at a time; \
             received {}. Pick one of --from-openapi, --from-curl, --method+--url, --from-recorded.",
            selected
        );
        return 2;
    }

    let input = if openapi_set {
        let spec = from_openapi.unwrap();
        let Some(op_id) = op_id else {
            eprintln!("Error: --from-openapi requires --op-id");
            return 2;
        };
        tarn::scaffold::ScaffoldInput::OpenApi {
            spec_path: PathBuf::from(spec),
            op_id: op_id.to_string(),
        }
    } else if curl_set {
        let path = from_curl.unwrap();
        let text = match std::fs::read_to_string(path) {
            Ok(t) => t,
            Err(e) => {
                eprintln!("Error: failed to read {}: {}", path, e);
                return 2;
            }
        };
        tarn::scaffold::ScaffoldInput::Curl {
            curl_text: text,
            source_label: Path::new(path)
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or(path)
                .to_string(),
        }
    } else if explicit_set {
        let Some(method) = method else {
            eprintln!("Error: --method requires --url");
            return 2;
        };
        let Some(url) = url else {
            eprintln!("Error: --url requires --method");
            return 2;
        };
        tarn::scaffold::ScaffoldInput::Explicit {
            method: method.to_string(),
            url: url.to_string(),
        }
    } else {
        let path = from_recorded.unwrap();
        tarn::scaffold::ScaffoldInput::Recorded {
            path: PathBuf::from(path),
        }
    };

    let options = tarn::scaffold::ScaffoldOptions {
        name_override: name.map(str::to_string),
    };

    let result = match tarn::scaffold::generate(&input, &options) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Error: {}", e);
            return 2;
        }
    };

    let format_lower = format.to_ascii_lowercase();
    let payload = match format_lower.as_str() {
        "yaml" => result.yaml.clone(),
        "json" => match serde_json::to_string_pretty(&build_scaffold_json(&result)) {
            Ok(s) => {
                let mut s = s;
                s.push('\n');
                s
            }
            Err(e) => {
                eprintln!("Error: failed to render JSON output: {}", e);
                return 2;
            }
        },
        other => {
            eprintln!(
                "Error: unknown scaffold format '{}'. Use 'yaml' or 'json'.",
                other
            );
            return 2;
        }
    };

    if let Some(out_path) = out {
        let path = Path::new(out_path);
        if path.exists() && !force {
            eprintln!(
                "Error: {} already exists (pass --force to overwrite)",
                path.display()
            );
            return 2;
        }
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                if let Err(e) = std::fs::create_dir_all(parent) {
                    eprintln!("Error: failed to create {}: {}", parent.display(), e);
                    return 2;
                }
            }
        }
        if let Err(e) = std::fs::write(path, payload.as_bytes()) {
            eprintln!("Error: failed to write {}: {}", path.display(), e);
            return 2;
        }
    } else {
        use std::io::Write as _;
        let mut stdout = std::io::stdout().lock();
        if let Err(e) = stdout.write_all(payload.as_bytes()) {
            eprintln!("Error: failed to write to stdout: {}", e);
            return 2;
        }
    }
    0
}

fn build_scaffold_json(result: &tarn::scaffold::ScaffoldResult) -> serde_json::Value {
    let body_shape = match &result.request.body {
        Some(tarn::scaffold::BodyShape::Json(v)) => Some(v.clone()),
        Some(tarn::scaffold::BodyShape::Raw(s)) => Some(serde_json::Value::String(s.clone())),
        None => None,
    };
    let todos: Vec<serde_json::Value> = result
        .todos
        .iter()
        .map(|t| {
            serde_json::json!({
                "line": t.line,
                "category": t.category.as_str(),
                "message": t.message,
            })
        })
        .collect();
    // Determinism: serialise captures as a sorted object (BTreeMap
    // already iterates sorted, but serde_json::Map preserves insertion;
    // so we fold through Map to keep the key order predictable).
    let mut response_captures: Vec<String> = result.request.captures.keys().cloned().collect();
    response_captures.sort();
    let headers: serde_json::Map<String, serde_json::Value> = result
        .request
        .headers
        .iter()
        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
        .collect();
    serde_json::json!({
        "schema_version": 1,
        "source_mode": result.source_mode.as_str(),
        "inferred": {
            "method": result.request.method,
            "url": result.request.url,
            "headers": serde_json::Value::Object(headers),
            "body_shape": body_shape,
            "response_captures": response_captures,
            "response_shape_keys": result.request.response_shape_keys,
            "path_params": result.request.path_params,
        },
        "todos": todos,
        "yaml": result.yaml,
        "validation": {
            "parsed_ok": result.parsed_ok,
            "schema_ok": result.schema_ok,
        },
    })
}

fn default_hurl_output_path(path: &Path) -> PathBuf {
    let parent = path.parent().unwrap_or(Path::new("."));
    let stem = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("converted");
    parent.join(format!("{stem}.tarn.yaml"))
}

#[derive(Debug, Clone)]
struct ProjectContext {
    root_dir: PathBuf,
    config: TarnConfig,
}

fn load_project_context(start_dir: &Path) -> Result<ProjectContext, TarnError> {
    let start_dir = absolute_path(start_dir);
    // Normalize to a directory: callers frequently pass a file path (e.g.
    // `tarn run tests/health.tarn.yaml`) and downstream code treats
    // `root_dir` as a directory for `.join("tests")`,
    // `.join(".tarn/last-run.json")`, etc. Without this step those joins
    // would produce `<file>/…` which is not a valid filesystem path.
    let search_root = if start_dir.is_file() {
        start_dir
            .parent()
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| start_dir.clone())
    } else {
        start_dir.clone()
    };
    let root_dir = config::find_project_root(&search_root).unwrap_or(search_root);
    let config = config::load_config(&root_dir)?;
    // Freeze the faker RNG seed for this process before any
    // interpolation runs. `TARN_FAKER_SEED` wins over the config
    // field; both are optional (see NAZ-398).
    tarn::faker::init_seed_from_sources(config.faker.as_ref().and_then(|f| f.seed));
    Ok(ProjectContext { root_dir, config })
}

fn resolve_env_for_file(
    test_file: &TestFile,
    file_path: &Path,
    env_name: Option<&str>,
    cli_vars: &[(String, String)],
) -> Result<std::collections::HashMap<String, String>, TarnError> {
    let start_dir = file_path.parent().unwrap_or(Path::new("."));
    let project = load_project_context(start_dir)?;
    env::resolve_env_with_profiles(
        &test_file.env,
        env_name,
        cli_vars,
        &project.root_dir,
        &project.config.env_file,
        &project.config.environments,
    )
}

fn apply_project_defaults(test_file: &mut TestFile, config: &TarnConfig) {
    let project_defaults = config.request_defaults();
    let defaults = test_file
        .defaults
        .get_or_insert_with(|| project_defaults.clone());

    for (key, value) in &project_defaults.headers {
        defaults
            .headers
            .entry(key.clone())
            .or_insert_with(|| value.clone());
    }
    if defaults.auth.is_none() {
        defaults.auth = project_defaults.auth.clone();
    }
    if defaults.timeout.is_none() {
        defaults.timeout = project_defaults.timeout;
    }
    if defaults.connect_timeout.is_none() {
        defaults.connect_timeout = project_defaults.connect_timeout;
    }
    if defaults.follow_redirects.is_none() {
        defaults.follow_redirects = project_defaults.follow_redirects;
    }
    if defaults.max_redirs.is_none() {
        defaults.max_redirs = project_defaults.max_redirs;
    }
    if defaults.retries.is_none() {
        defaults.retries = project_defaults.retries;
    }
    if defaults.delay.is_none() {
        defaults.delay = project_defaults.delay.clone();
    }

    if test_file.redaction.is_none() {
        test_file.redaction = config.redaction.clone();
    }
}

/// Merge `--redact-header` CLI values into `test_file.redaction` after
/// the project/file redaction config has already been resolved. Uses
/// `RedactionConfig::merge_headers` so the CLI can only widen — never
/// narrow — the effective list, and header matching stays
/// case-insensitive. If the test file has no redaction block yet, a
/// default one is materialized so CLI headers still take effect.
fn apply_cli_extra_redact_headers(test_file: &mut TestFile, extra: &[String]) {
    if extra.is_empty() {
        return;
    }
    let redaction = test_file
        .redaction
        .get_or_insert_with(tarn::model::RedactionConfig::default);
    redaction.merge_headers(extra.iter().map(String::as_str));
}

fn resolve_http_transport_config(
    config: &TarnConfig,
    cli_http_transport: &HttpTransportConfig,
) -> HttpTransportConfig {
    HttpTransportConfig::merge(&config.http_transport(), cli_http_transport)
}

fn cli_http_version(http1_1: bool, http2: bool) -> Option<HttpVersionPreference> {
    if http1_1 {
        Some(HttpVersionPreference::Http1_1)
    } else if http2 {
        Some(HttpVersionPreference::Http2)
    } else {
        None
    }
}

fn absolute_path(path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(path)
    }
}

fn env_command(json: bool) -> i32 {
    let project = match load_project_context(Path::new(".")) {
        Ok(project) => project,
        Err(e) => {
            eprintln!("Error: {}", e);
            return e.exit_code();
        }
    };

    let mut environments: Vec<_> = project.config.environments.iter().collect();
    environments.sort_by(|a, b| a.0.cmp(b.0));

    let redaction = project.config.redaction.clone().unwrap_or_default();

    if json {
        let environments_json: Vec<serde_json::Value> = environments
            .iter()
            .map(|(name, profile)| {
                let source_file = profile
                    .env_file
                    .clone()
                    .unwrap_or_else(|| default_named_env_path(&project.config.env_file, name));
                let redacted_vars = redact_inline_vars(&profile.vars, &redaction);
                serde_json::json!({
                    "name": name,
                    "source_file": source_file,
                    "vars": redacted_vars,
                })
            })
            .collect();

        let output = serde_json::json!({
            "project_root": project.root_dir,
            "default_env_file": project.config.env_file,
            "environments": environments_json,
        });
        println!("{}", serde_json::to_string_pretty(&output).unwrap());
        return 0;
    }

    if environments.is_empty() {
        println!("No named environments configured in tarn.config.yaml");
        return 0;
    }

    println!("Named environments:");
    for (name, profile) in environments {
        println!(
            "  {name:<16} env_file={} vars={}",
            profile
                .env_file
                .clone()
                .unwrap_or_else(|| default_named_env_path(&project.config.env_file, name)),
            profile.vars.len()
        );
    }
    0
}

/// Apply the project redaction policy to a map of inline environment
/// variables from `tarn.config.yaml` so that `tarn env --json` never
/// prints literal secrets. Values for keys listed in
/// `redaction.env_vars` (case-insensitive) are replaced with the
/// configured replacement marker. Keys themselves are preserved so
/// consumers (editors, CI dashboards) still see which variables exist.
fn redact_inline_vars(
    vars: &std::collections::HashMap<String, String>,
    redaction: &tarn::model::RedactionConfig,
) -> std::collections::BTreeMap<String, String> {
    let redact_set: std::collections::HashSet<String> = redaction
        .env_vars
        .iter()
        .map(|k| k.to_ascii_lowercase())
        .collect();
    let mut out = std::collections::BTreeMap::new();
    for (key, value) in vars {
        let redacted = if redact_set.contains(&key.to_ascii_lowercase()) {
            redaction.replacement.clone()
        } else {
            value.clone()
        };
        out.insert(key.clone(), redacted);
    }
    out
}

fn default_named_env_path(env_file: &str, name: &str) -> String {
    let path = Path::new(env_file);
    let stem = path
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or(env_file);
    match path.extension().and_then(|value| value.to_str()) {
        Some(ext) => format!("{stem}.{name}.{ext}"),
        None => format!("{stem}.{name}"),
    }
}

// Thin CLI wrapper around the shared `tarn::report::compute_exit_code`
// helper so the MCP server and the CLI bin always agree on the exit
// code category precedence. The original in-binary implementation was
// promoted to the library in NAZ-407 to fulfil that parity requirement.
fn run_result_exit_code(run_result: &RunResult) -> i32 {
    tarn::report::compute_exit_code(run_result)
}

fn update_command(check_only: bool) -> i32 {
    eprint!("Checking for updates... ");
    let info = match tarn::update::check_for_update() {
        Ok(info) => info,
        Err(e) => {
            eprintln!("failed");
            eprintln!("Error: {}", e);
            return 3;
        }
    };

    if !info.is_newer {
        eprintln!("up to date");
        println!("tarn v{} is the latest version", info.current_version);
        return 0;
    }

    println!("update available");
    println!(
        "  Current: v{}\n  Latest:  v{}",
        info.current_version, info.latest_version
    );

    if check_only {
        return 0;
    }

    if info.download_url.is_none() {
        eprintln!("No binary available for your platform. Build from source instead.");
        return 3;
    }

    println!();
    match tarn::update::perform_update(&info) {
        Ok(()) => {
            println!("\n  Updated tarn to v{}", info.latest_version);
            0
        }
        Err(e) => {
            eprintln!("Update failed: {}", e);
            3
        }
    }
}

fn init_command() -> i32 {
    let dirs = ["tests", "examples", "fixtures"];
    for d in &dirs {
        if let Err(e) = std::fs::create_dir_all(d) {
            eprintln!("Failed to create {}: {}", d, e);
            return 3;
        }
    }

    let env_file = r#"base_url: "http://localhost:3000"

# Optional credentials used by the example templates in ./examples/
admin_email: "admin@example.com"
admin_password: "secret"
alice_email: "alice@example.com"
alice_password: "secret"
bob_email: "bob@example.com"
bob_password: "secret"
"#;

    let config_file = r#"test_dir: "tests"
env_file: "tarn.env.yaml"
timeout: 10000
retries: 0
parallel: false
# proxy: "http://127.0.0.1:8080"
# no_proxy: "localhost,127.0.0.1"
# cacert: "certs/ca.pem"
# cert: "certs/client.pem"
# key: "certs/client-key.pem"
# insecure: false
"#;

    let files = [
        (
            "tests/health.tarn.yaml",
            include_str!("../init-scaffolds/health.tarn.yaml"),
        ),
        (
            "examples/auth-flow.tarn.yaml",
            include_str!("../init-scaffolds/auth-flow.tarn.yaml"),
        ),
        (
            "examples/polling-job.tarn.yaml",
            include_str!("../init-scaffolds/polling-job.tarn.yaml"),
        ),
        (
            "examples/multipart-upload.tarn.yaml",
            include_str!("../init-scaffolds/multipart-upload.tarn.yaml"),
        ),
        (
            "examples/multi-user-session.tarn.yaml",
            include_str!("../init-scaffolds/multi-user-session.tarn.yaml"),
        ),
        (
            "fixtures/upload-demo.txt",
            include_str!("../init-scaffolds/fixtures/upload-demo.txt"),
        ),
        ("tarn.env.yaml", env_file),
        ("tarn.config.yaml", config_file),
    ];

    for (path, content) in &files {
        if Path::new(path).exists() {
            println!("  skip {} (already exists)", path);
        } else {
            if let Err(e) = std::fs::write(path, content) {
                eprintln!("Failed to write {}: {}", path, e);
                return 3;
            }
            println!("  created {}", path);
        }
    }

    println!(
        "\nProject initialized! Start with `tests/health.tarn.yaml`, then adapt the advanced templates in `examples/` for auth, polling, multipart, and multi-user flows."
    );
    0
}

#[cfg(test)]
mod tests {
    use super::*;
    use tarn::assert::types::{
        AssertionResult, FailureCategory, FileResult, StepResult, TestResult,
    };
    use tempfile::TempDir;

    fn with_current_dir<F>(path: &Path, f: F)
    where
        F: FnOnce(),
    {
        let previous = std::env::current_dir().unwrap();
        std::env::set_current_dir(path).unwrap();
        f();
        std::env::set_current_dir(previous).unwrap();
    }

    #[test]
    fn resolve_env_for_file_uses_project_root_config() {
        let dir = tempfile::tempdir().unwrap();
        let tests_dir = dir.path().join("suite");
        std::fs::create_dir_all(&tests_dir).unwrap();
        std::fs::write(
            dir.path().join("tarn.config.yaml"),
            "test_dir: suite\nenv_file: custom.env.yaml\n",
        )
        .unwrap();
        std::fs::write(
            dir.path().join("custom.env.yaml"),
            "base_url: http://from-root\n",
        )
        .unwrap();

        let test_path = tests_dir.join("health.tarn.yaml");
        std::fs::write(
            &test_path,
            "name: Health\nsteps:\n  - name: GET\n    request:\n      method: GET\n      url: \"{{ env.base_url }}/health\"\n",
        )
        .unwrap();

        let test_file = parser::parse_file(&test_path).unwrap();
        let env = resolve_env_for_file(&test_file, &test_path, None, &[]).unwrap();

        assert_eq!(env.get("base_url").unwrap(), "http://from-root");
    }

    #[test]
    fn resolve_env_for_file_finds_default_env_root_without_config() {
        let dir = tempfile::tempdir().unwrap();
        let tests_dir = dir.path().join("tests");
        std::fs::create_dir_all(&tests_dir).unwrap();
        std::fs::write(
            dir.path().join("tarn.env.yaml"),
            "base_url: http://from-root\n",
        )
        .unwrap();

        let test_path = tests_dir.join("health.tarn.yaml");
        std::fs::write(
            &test_path,
            "name: Health\nsteps:\n  - name: GET\n    request:\n      method: GET\n      url: \"{{ env.base_url }}/health\"\n",
        )
        .unwrap();

        let test_file = parser::parse_file(&test_path).unwrap();
        let env = resolve_env_for_file(&test_file, &test_path, None, &[]).unwrap();

        assert_eq!(env.get("base_url").unwrap(), "http://from-root");
    }

    #[test]
    fn fmt_command_rewrites_unformatted_file() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("example.tarn.yaml");
        std::fs::write(
            &file_path,
            r#"
name: Example
steps:
  - request:
      url: "http://localhost:3000"
      method: GET
    name: Check
"#,
        )
        .unwrap();

        let exit_code = fmt_command(Some(file_path.display().to_string()), false);
        assert_eq!(exit_code, 0);

        let formatted = std::fs::read_to_string(&file_path).unwrap();
        assert!(formatted.contains(
            "- name: Check\n  request:\n    method: GET\n    url: http://localhost:3000\n"
        ));
    }

    #[test]
    fn fmt_command_check_detects_unformatted_file() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("example.tarn.yaml");
        std::fs::write(
            &file_path,
            r#"
name: Example
steps:
  - request:
      url: "http://localhost:3000"
      method: GET
    name: Check
"#,
        )
        .unwrap();

        let exit_code = fmt_command(Some(file_path.display().to_string()), true);
        assert_eq!(exit_code, 1);
    }

    #[test]
    fn init_command_scaffolds_advanced_examples_outside_test_dir() {
        let dir = tempfile::tempdir().unwrap();

        with_current_dir(dir.path(), || {
            let exit_code = init_command();
            assert_eq!(exit_code, 0);
        });

        assert!(dir.path().join("tests/health.tarn.yaml").exists());
        assert!(dir.path().join("examples/auth-flow.tarn.yaml").exists());
        assert!(dir.path().join("examples/polling-job.tarn.yaml").exists());
        assert!(dir
            .path()
            .join("examples/multipart-upload.tarn.yaml")
            .exists());
        assert!(dir
            .path()
            .join("examples/multi-user-session.tarn.yaml")
            .exists());
        assert!(dir.path().join("fixtures/upload-demo.txt").exists());

        let config = std::fs::read_to_string(dir.path().join("tarn.config.yaml")).unwrap();
        assert!(config.contains("test_dir: \"tests\""));
    }

    #[test]
    fn default_hurl_output_path_rewrites_extension() {
        let output = default_hurl_output_path(Path::new("tests/users.hurl"));
        assert_eq!(output, PathBuf::from("tests/users.tarn.yaml"));
    }

    #[test]
    fn run_result_exit_code_prefers_runtime_failure_categories() {
        let make_step = |category| StepResult {
            name: "step".into(),
            description: None,
            debug: false,
            passed: false,
            duration_ms: 0,
            assertion_results: vec![AssertionResult::fail("runtime", "ok", "error", "boom")],
            request_info: None,
            response_info: None,
            error_category: category,
            response_status: None,
            response_summary: None,
            captures_set: vec![],
            location: None,
            response_shape_mismatch: None,
        };

        let make_file = |step| FileResult {
            file: "test.tarn.yaml".into(),
            name: "test".into(),
            passed: false,
            duration_ms: 0,
            redaction: tarn::model::RedactionConfig::default(),
            redacted_values: vec![],
            setup_results: vec![],
            test_results: vec![TestResult {
                name: "group".into(),
                description: None,
                passed: false,
                duration_ms: 0,
                step_results: vec![step],
                captures: std::collections::HashMap::new(),
            }],
            teardown_results: vec![],
        };

        let parse_result = RunResult {
            file_results: vec![make_file(make_step(Some(FailureCategory::ParseError)))],
            duration_ms: 0,
        };
        assert_eq!(run_result_exit_code(&parse_result), 2);

        let runtime_result = RunResult {
            file_results: vec![make_file(make_step(Some(FailureCategory::ConnectionError)))],
            duration_ms: 0,
        };
        assert_eq!(run_result_exit_code(&runtime_result), 3);
    }

    #[test]
    fn parse_output_targets_accepts_multiple_formats_and_paths() {
        let targets = parse_output_targets(&[
            "human".to_string(),
            "json=reports/run.json".to_string(),
            "html=reports/run.html".to_string(),
        ])
        .unwrap();

        assert_eq!(targets.len(), 3);
        assert_eq!(targets[0].format, OutputFormat::Human);
        assert_eq!(
            targets[1].path.as_deref(),
            Some(Path::new("reports/run.json"))
        );
        assert_eq!(targets[2].format, OutputFormat::Html);
    }

    #[test]
    fn parse_output_targets_rejects_multiple_stdout_formats() {
        let err = parse_output_targets(&["human".to_string(), "json".to_string()]).unwrap_err();
        assert!(err.contains("Multiple stdout formats requested"));
    }

    #[test]
    fn emit_run_outputs_writes_non_stdout_targets_to_files() {
        let dir = TempDir::new().unwrap();
        let json_path = dir.path().join("reports").join("run.json");
        let html_path = dir.path().join("reports").join("run.html");
        let run_result = RunResult {
            file_results: vec![],
            duration_ms: 12,
        };

        emit_run_outputs(
            &run_result,
            &[
                OutputTarget {
                    format: OutputFormat::Json,
                    path: Some(json_path.clone()),
                },
                OutputTarget {
                    format: OutputFormat::Html,
                    path: Some(html_path.clone()),
                },
            ],
            JsonOutputMode::Verbose,
            RenderOptions::default(),
            false,
            false,
            None,
        )
        .unwrap();

        let json_output = std::fs::read_to_string(&json_path).unwrap();
        assert!(json_output.contains("\"schema_version\": 1"));

        let html_output = std::fs::read_to_string(&html_path).unwrap();
        assert!(html_output.starts_with("<!DOCTYPE html>"));
    }

    #[test]
    fn apply_project_defaults_fills_missing_timeout_and_retries() {
        let mut test_file = TestFile {
            version: None,
            name: "test".into(),
            description: None,
            tags: vec![],
            openapi_operation_ids: None,
            env: Default::default(),
            redaction: None,
            defaults: Some(Defaults {
                headers: Default::default(),
                auth: None,
                timeout: None,
                connect_timeout: None,
                follow_redirects: None,
                max_redirs: None,
                retries: None,
                delay: None,
            }),
            setup: vec![],
            teardown: vec![],
            tests: Default::default(),
            steps: vec![],
            cookies: None,
            serial_only: false,
            group: None,
        };

        apply_project_defaults(
            &mut test_file,
            &TarnConfig {
                test_dir: "tests".into(),
                env_file: "tarn.env.yaml".into(),
                timeout: 1234,
                retries: 2,
                parallel: true,
                defaults: None,
                redaction: None,
                environments: Default::default(),
                proxy: None,
                no_proxy: None,
                cacert: None,
                cert: None,
                key: None,
                insecure: false,
                fail_fast_within_test: false,
                parallel_opt_in: None,
                faker: None,
            },
        );

        let defaults = test_file.defaults.unwrap();
        assert_eq!(defaults.timeout, Some(1234));
        assert_eq!(defaults.retries, Some(2));
    }

    #[test]
    fn apply_project_defaults_preserves_file_level_values() {
        let mut test_file = TestFile {
            version: None,
            name: "test".into(),
            description: None,
            tags: vec![],
            openapi_operation_ids: None,
            env: Default::default(),
            redaction: None,
            defaults: Some(Defaults {
                headers: Default::default(),
                auth: None,
                timeout: Some(5000),
                connect_timeout: None,
                follow_redirects: None,
                max_redirs: None,
                retries: Some(4),
                delay: None,
            }),
            setup: vec![],
            teardown: vec![],
            tests: Default::default(),
            steps: vec![],
            cookies: None,
            serial_only: false,
            group: None,
        };

        apply_project_defaults(
            &mut test_file,
            &TarnConfig {
                test_dir: "tests".into(),
                env_file: "tarn.env.yaml".into(),
                timeout: 1234,
                retries: 2,
                parallel: false,
                defaults: Some(tarn::config::ProjectDefaults {
                    headers: [("X-Project".into(), "1".into())].into(),
                    connect_timeout: Some(250),
                    follow_redirects: Some(false),
                    max_redirs: Some(3),
                    delay: Some("100ms".into()),
                    ..Default::default()
                }),
                redaction: Some(tarn::model::RedactionConfig {
                    headers: vec!["authorization".into()],
                    replacement: "[hidden]".into(),
                    env_vars: vec![],
                    captures: vec![],
                }),
                environments: Default::default(),
                proxy: None,
                no_proxy: None,
                cacert: None,
                cert: None,
                key: None,
                insecure: false,
                fail_fast_within_test: false,
                parallel_opt_in: None,
                faker: None,
            },
        );

        let defaults = test_file.defaults.unwrap();
        assert_eq!(defaults.timeout, Some(5000));
        assert_eq!(defaults.retries, Some(4));
        assert_eq!(defaults.connect_timeout, Some(250));
        assert_eq!(defaults.follow_redirects, Some(false));
        assert_eq!(defaults.max_redirs, Some(3));
        assert_eq!(defaults.delay.as_deref(), Some("100ms"));
        assert_eq!(
            defaults.headers.get("X-Project").map(String::as_str),
            Some("1")
        );
        assert_eq!(test_file.redaction.unwrap().replacement, "[hidden]");
    }

    #[test]
    fn resolve_http_transport_config_prefers_cli_values() {
        let config = TarnConfig {
            test_dir: "tests".into(),
            env_file: "tarn.env.yaml".into(),
            timeout: 10000,
            retries: 0,
            parallel: false,
            defaults: None,
            redaction: None,
            environments: Default::default(),
            proxy: Some("http://project-proxy:8080".into()),
            no_proxy: Some("project.local".into()),
            cacert: Some("project-ca.pem".into()),
            cert: Some("project-cert.pem".into()),
            key: Some("project-key.pem".into()),
            insecure: false,
            fail_fast_within_test: false,
            parallel_opt_in: None,
            faker: None,
        };

        let transport = resolve_http_transport_config(
            &config,
            &HttpTransportConfig {
                proxy: Some("http://cli-proxy:9090".into()),
                no_proxy: Some("cli.local".into()),
                cacert: Some("cli-ca.pem".into()),
                cert: Some("cli-cert.pem".into()),
                key: Some("cli-key.pem".into()),
                insecure: true,
                http_version: None,
            },
        );

        assert_eq!(transport.proxy.as_deref(), Some("http://cli-proxy:9090"));
        assert_eq!(transport.no_proxy.as_deref(), Some("cli.local"));
        assert_eq!(transport.cacert.as_deref(), Some("cli-ca.pem"));
        assert_eq!(transport.cert.as_deref(), Some("cli-cert.pem"));
        assert_eq!(transport.key.as_deref(), Some("cli-key.pem"));
        assert!(transport.insecure);
    }

    #[test]
    fn resolve_http_transport_config_uses_project_defaults() {
        let config = TarnConfig {
            test_dir: "tests".into(),
            env_file: "tarn.env.yaml".into(),
            timeout: 10000,
            retries: 0,
            parallel: false,
            defaults: None,
            redaction: None,
            environments: Default::default(),
            proxy: Some("http://project-proxy:8080".into()),
            no_proxy: Some("localhost".into()),
            cacert: Some("project-ca.pem".into()),
            cert: Some("project-cert.pem".into()),
            key: Some("project-key.pem".into()),
            insecure: true,
            fail_fast_within_test: false,
            parallel_opt_in: None,
            faker: None,
        };

        let transport = resolve_http_transport_config(&config, &HttpTransportConfig::default());

        assert_eq!(
            transport.proxy.as_deref(),
            Some("http://project-proxy:8080")
        );
        assert_eq!(transport.no_proxy.as_deref(), Some("localhost"));
        assert_eq!(transport.cacert.as_deref(), Some("project-ca.pem"));
        assert_eq!(transport.cert.as_deref(), Some("project-cert.pem"));
        assert_eq!(transport.key.as_deref(), Some("project-key.pem"));
        assert!(transport.insecure);
    }

    #[test]
    fn cli_http_version_maps_flags() {
        assert_eq!(
            cli_http_version(true, false),
            Some(HttpVersionPreference::Http1_1)
        );
        assert_eq!(
            cli_http_version(false, true),
            Some(HttpVersionPreference::Http2)
        );
        assert_eq!(cli_http_version(false, false), None);
    }

    // ---- --parallel opt-in warning ----

    #[test]
    fn parallel_opt_in_warning_fires_without_opt_in() {
        // Default config (opt_in = None) must surface the warning so
        // users learn about the isolation tradeoff on first use.
        let mut buf: Vec<u8> = Vec::new();
        emit_parallel_opt_in_warning(&mut buf, None);
        let output = String::from_utf8(buf).unwrap();
        assert!(
            output.contains("warning: --parallel enabled without parallel_opt_in: true"),
            "warning prefix missing: {output}"
        );
        assert!(
            output.contains("serial_only"),
            "warning should mention serial_only as mitigation: {output}"
        );
        // One-liner: exactly one newline at the end.
        assert_eq!(output.matches('\n').count(), 1);
    }

    #[test]
    fn parallel_opt_in_warning_fires_when_opted_out_explicitly() {
        // `Some(false)` is deliberate opt-out — still warn, matching
        // the "default == not opted in" documented contract.
        let mut buf: Vec<u8> = Vec::new();
        emit_parallel_opt_in_warning(&mut buf, Some(false));
        let output = String::from_utf8(buf).unwrap();
        assert!(output.contains("warning: --parallel enabled without parallel_opt_in: true"));
    }

    #[test]
    fn parallel_opt_in_warning_silent_when_opted_in() {
        // Projects that have audited their tests for cross-file state
        // set `parallel_opt_in: true` — the warning must stay quiet.
        let mut buf: Vec<u8> = Vec::new();
        emit_parallel_opt_in_warning(&mut buf, Some(true));
        assert!(buf.is_empty(), "opt-in should suppress warning");
    }

    // ---- --no-parallel-warning suppression ----

    /// Integration-style test: build a tarn binary invocation via the
    /// clap parser and confirm `--no-parallel-warning` reaches
    /// `run_command` as expected. This guards against accidental
    /// removal of the flag wiring without the test running a real
    /// network request.
    #[test]
    fn no_parallel_warning_flag_parses_cleanly() {
        use clap::Parser;
        let cli = Cli::try_parse_from([
            "tarn",
            "run",
            "tests",
            "--parallel",
            "--no-parallel-warning",
        ])
        .expect("flag should parse");
        match cli.command {
            Commands::Run {
                parallel,
                no_parallel_warning,
                ..
            } => {
                assert!(parallel);
                assert!(no_parallel_warning);
            }
            _ => panic!("Expected Run command"),
        }
    }

    #[test]
    fn no_parallel_warning_defaults_off() {
        use clap::Parser;
        let cli = Cli::try_parse_from(["tarn", "run", "tests", "--parallel"]).unwrap();
        match cli.command {
            Commands::Run {
                no_parallel_warning,
                ..
            } => {
                assert!(!no_parallel_warning);
            }
            _ => panic!("Expected Run command"),
        }
    }
}
