tarn 0.11.7

CLI-first API testing tool
Documentation
pub mod agent_report;
pub mod compact;
pub mod concise;
pub mod curl;
pub mod diff;
pub mod event_stream;
pub mod failure;
pub mod failures_command;
pub mod fixture_writer;
pub mod html;
pub mod human;
pub mod inspect;
pub mod json;
pub mod json_parse;
pub mod junit;
pub mod llm;
pub mod pack_context;
pub mod progress;
pub mod redaction;
pub mod rerun;
pub mod run_dir;
pub mod shape_diagnosis;
pub mod state_writer;
pub mod summary;
pub mod tap;

use crate::assert::types::{FailureCategory, RunResult, StepResult, TestResult};
use std::path::PathBuf;
use std::str::FromStr;

/// Derive the process exit code the CLI (and MCP) should return for a
/// finished run. Extracted from `main.rs` so both the CLI binary and
/// the MCP server agree on exit-code semantics — an agent reading the
/// MCP `tarn_run` response must see the same code the CLI would print.
///
/// Category precedence mirrors the documented exit codes:
/// - 3: runtime errors (connection, timeout, capture failure)
/// - 2: parse / unresolved template errors
/// - 1: at least one step failed otherwise
/// - 0: all green
pub fn compute_exit_code(run_result: &RunResult) -> i32 {
    let mut exit_code = if run_result.passed() { 0 } else { 1 };

    for step in all_steps(run_result) {
        match step.error_category {
            Some(FailureCategory::ConnectionError)
            | Some(FailureCategory::Timeout)
            | Some(FailureCategory::CaptureError) => return 3,
            Some(FailureCategory::ParseError) | Some(FailureCategory::UnresolvedTemplate) => {
                exit_code = exit_code.max(2)
            }
            // Cascade fallout and fail-fast skips never bump the exit
            // code above 1 — the root-cause step already set the
            // correct category (usually CaptureError → 3). Treating
            // a skip as a fresh runtime error would double-count the
            // same failure.
            Some(FailureCategory::SkippedDueToFailedCapture)
            | Some(FailureCategory::SkippedDueToFailFast)
            | Some(FailureCategory::SkippedByCondition)
            | Some(FailureCategory::AssertionFailed)
            | Some(FailureCategory::ResponseShapeMismatch)
            | None => {}
        }
    }

    exit_code
}

fn all_steps(run_result: &RunResult) -> impl Iterator<Item = &StepResult> {
    run_result.file_results.iter().flat_map(|file| {
        file.setup_results
            .iter()
            .chain(file.test_results.iter().flat_map(steps_from_test))
            .chain(file.teardown_results.iter())
    })
}

fn steps_from_test(test: &TestResult) -> impl Iterator<Item = &StepResult> {
    test.step_results.iter()
}

/// Options that tweak how test results are rendered.
#[derive(Debug, Clone, Copy, Default)]
pub struct RenderOptions {
    /// Show only failed tests/steps in the output. Summary counts stay accurate.
    pub only_failed: bool,
    /// Verbose rendering: e.g. compact format shows captured values per test.
    pub verbose: bool,
    /// When true, skip ANSI color escapes in the output. Used by the
    /// llm format whenever stdout is not a TTY (pipes, files, CI logs).
    pub no_color: bool,
    /// When true, include request/response payloads for passing steps
    /// (in addition to failing steps). Mirrors the `--verbose-responses`
    /// CLI flag (NAZ-244). Step-level `debug: true` overrides this at
    /// the per-step level.
    pub verbose_responses: bool,
}

/// Output format for test results.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
    Human,
    Json,
    Junit,
    Tap,
    Html,
    Curl,
    CurlAll,
    Compact,
    Llm,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputTarget {
    pub format: OutputFormat,
    pub path: Option<PathBuf>,
}

impl FromStr for OutputFormat {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "human" => Ok(OutputFormat::Human),
            "json" => Ok(OutputFormat::Json),
            "junit" => Ok(OutputFormat::Junit),
            "tap" => Ok(OutputFormat::Tap),
            "html" => Ok(OutputFormat::Html),
            "curl" => Ok(OutputFormat::Curl),
            "curl-all" => Ok(OutputFormat::CurlAll),
            "compact" => Ok(OutputFormat::Compact),
            "llm" => Ok(OutputFormat::Llm),
            other => Err(format!("Unknown output format: '{}'", other)),
        }
    }
}

impl FromStr for OutputTarget {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (format_raw, path) = match s.split_once('=') {
            Some((format, path)) => (format, Some(PathBuf::from(path))),
            None => (s, None),
        };

        let format = format_raw.parse::<OutputFormat>()?;
        Ok(Self { format, path })
    }
}

impl OutputTarget {
    pub fn writes_to_stdout(&self) -> bool {
        self.path.is_none() && self.format != OutputFormat::Html
    }
}

/// Render test results in the specified format.
pub fn render(result: &RunResult, format: OutputFormat) -> String {
    render_with_options(result, format, RenderOptions::default())
}

/// Render test results in the specified format with rendering options.
pub fn render_with_options(
    result: &RunResult,
    format: OutputFormat,
    opts: RenderOptions,
) -> String {
    match format {
        OutputFormat::Human => human::render_with_options(result, opts),
        OutputFormat::Json => {
            json::render_with_options(result, json::JsonOutputMode::Verbose, opts)
        }
        OutputFormat::Junit => junit::render(result),
        OutputFormat::Tap => tap::render(result),
        OutputFormat::Html => html::render(result),
        OutputFormat::Curl => curl::render_failures(result),
        OutputFormat::CurlAll => curl::render_all(result),
        OutputFormat::Compact => compact::render_with_options(result, opts),
        OutputFormat::Llm => llm::render_with_options(result, opts),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn output_format_from_str() {
        assert_eq!("human".parse::<OutputFormat>(), Ok(OutputFormat::Human));
        assert_eq!("json".parse::<OutputFormat>(), Ok(OutputFormat::Json));
        assert_eq!("junit".parse::<OutputFormat>(), Ok(OutputFormat::Junit));
        assert_eq!("tap".parse::<OutputFormat>(), Ok(OutputFormat::Tap));
        assert_eq!("html".parse::<OutputFormat>(), Ok(OutputFormat::Html));
        assert_eq!("curl".parse::<OutputFormat>(), Ok(OutputFormat::Curl));
        assert_eq!(
            "curl-all".parse::<OutputFormat>(),
            Ok(OutputFormat::CurlAll)
        );
        assert_eq!("JSON".parse::<OutputFormat>(), Ok(OutputFormat::Json));
        assert_eq!("HTML".parse::<OutputFormat>(), Ok(OutputFormat::Html));
        assert_eq!("compact".parse::<OutputFormat>(), Ok(OutputFormat::Compact));
        assert_eq!("llm".parse::<OutputFormat>(), Ok(OutputFormat::Llm));
        assert_eq!("LLM".parse::<OutputFormat>(), Ok(OutputFormat::Llm));
        assert!("unknown".parse::<OutputFormat>().is_err());
    }

    #[test]
    fn output_target_from_format_only() {
        assert_eq!(
            "json".parse::<OutputTarget>(),
            Ok(OutputTarget {
                format: OutputFormat::Json,
                path: None,
            })
        );
    }

    #[test]
    fn output_target_from_format_and_path() {
        assert_eq!(
            "junit=reports/junit.xml".parse::<OutputTarget>(),
            Ok(OutputTarget {
                format: OutputFormat::Junit,
                path: Some(PathBuf::from("reports/junit.xml")),
            })
        );
    }

    #[test]
    fn html_without_path_does_not_write_to_stdout() {
        let target = "html".parse::<OutputTarget>().unwrap();
        assert!(!target.writes_to_stdout());
    }
}