Skip to main content

tarn/report/
inspect.rs

1//! `tarn inspect` — drill-down into a run's `report.json` (NAZ-405).
2//!
3//! Surfaces the same information a human would get by opening the
4//! archive JSON by hand, but keyed by a stable `FILE::TEST::STEP`
5//! target address and filtered to the level the user cares about.
6//! Keeping inspection in one command means a failing-step view from
7//! the acceptance criteria ("open a failed step without opening the
8//! whole JSON file") stays a single invocation — not a `jq` recipe.
9//!
10//! # Loading
11//!
12//! The module reads the per-run archive at
13//! `<workspace_root>/.tarn/runs/<run_id>/report.json`, falling back to
14//! the latest-run pointer at `<workspace_root>/.tarn/last-run.json`
15//! when the user passes `last` / `latest` / `@latest` / `prev`. Run
16//! id aliases are resolved through
17//! [`crate::report::run_dir::resolve_run_id`].
18//!
19//! # Target syntax
20//!
21//! - `None` → run-level view (totals, exit code, failed files)
22//! - `FILE` → file-level view (tests + setup/teardown outcome)
23//! - `FILE::TEST` → test-level view (steps + captures)
24//! - `FILE::TEST::STEP` → step-level view (request, response,
25//!   assertions, failure category)
26//!
27//! The separator is the same `::` grammar used by `--select` and
28//! `tarn rerun`, so the address strings round-trip with existing
29//! tooling.
30//!
31//! # JSON shape
32//!
33//! `--format json` emits one of four envelopes keyed by `target`:
34//!
35//! ```json
36//! { "schema_version": 1, "target": "run",
37//!   "run_id": "…",
38//!   "source": "…/report.json",
39//!   "exit_code": 0, "duration_ms": 0,
40//!   "totals": { "files": N, "tests": N, "steps": N },
41//!   "failed": { "files": N, "tests": N, "steps": N },
42//!   "failed_files": [ { "file": "…", "failed_tests": N, "failed_steps": N } ] }
43//!
44//! { "schema_version": 1, "target": "file",
45//!   "run_id": "…", "source": "…",
46//!   "file": { "file": "…", "status": "PASSED|FAILED", "duration_ms": N,
47//!             "setup": [ {step} … ], "teardown": [ {step} … ],
48//!             "tests": [ { "name": "…", "status": "…", "duration_ms": N,
49//!                          "steps": [ {short-step} … ] } ] } }
50//!
51//! { "schema_version": 1, "target": "test",
52//!   "run_id": "…", "source": "…",
53//!   "file": "…", "test": { "name": "…", "status": "…",
54//!                           "duration_ms": N,
55//!                           "steps": [ {short-step} … ],
56//!                           "captures": { … } } }
57//!
58//! { "schema_version": 1, "target": "step",
59//!   "run_id": "…", "source": "…",
60//!   "file": "…", "test": "…",
61//!   "step": { "name": "…", "status": "…", "duration_ms": N,
62//!             "failure_category": "…"|null,
63//!             "request": {…}|null, "response": {…}|null,
64//!             "assertions": [ {assertion} … ] } }
65//! ```
66//!
67//! Redaction has already been applied by `report::json` at write time,
68//! so no further scrubbing happens here.
69
70use crate::assert::types::FailureCategory;
71use crate::report::run_dir::{resolve_run_id, run_directory};
72use serde_json::{json, Value};
73use std::path::{Path, PathBuf};
74
75pub const INSPECT_SCHEMA_VERSION: u32 = 1;
76
77/// Where the report.json that seeds inspection lives.
78#[derive(Debug, Clone)]
79pub struct InspectSource {
80    pub run_id: Option<String>,
81    pub path: PathBuf,
82}
83
84impl InspectSource {
85    /// Path string for JSON output. Forward-slash separators on every
86    /// platform so artifacts stay byte-identical between Unix and Windows.
87    pub fn display_path(&self) -> String {
88        crate::path_util::to_forward_slash(&self.path)
89    }
90}
91
92/// Errors the command layer maps to exit code 2.
93#[derive(Debug)]
94pub enum InspectError {
95    NotFound(PathBuf),
96    Io {
97        path: PathBuf,
98        error: std::io::Error,
99    },
100    Parse {
101        path: PathBuf,
102        error: String,
103    },
104    UnknownFile(String),
105    UnknownTest {
106        file: String,
107        test: String,
108    },
109    UnknownStep {
110        file: String,
111        test: String,
112        step: String,
113    },
114    InvalidTarget(String),
115}
116
117impl std::fmt::Display for InspectError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            InspectError::NotFound(p) => write!(f, "no report at {}", p.display()),
121            InspectError::Io { path, error } => {
122                write!(f, "failed to read {}: {}", path.display(), error)
123            }
124            InspectError::Parse { path, error } => {
125                write!(f, "failed to parse {}: {}", path.display(), error)
126            }
127            InspectError::UnknownFile(file) => write!(f, "file not found in report: {}", file),
128            InspectError::UnknownTest { file, test } => {
129                write!(f, "test '{}' not found in file '{}'", test, file)
130            }
131            InspectError::UnknownStep { file, test, step } => {
132                write!(f, "step '{}' not found in {}::{}", step, file, test)
133            }
134            InspectError::InvalidTarget(s) => {
135                write!(
136                    f,
137                    "invalid inspect target '{}': expected FILE[::TEST[::STEP]]",
138                    s
139                )
140            }
141        }
142    }
143}
144
145impl std::error::Error for InspectError {}
146
147/// Parsed `FILE[::TEST[::STEP]]` address. `None` means a run-level view.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Target {
150    Run,
151    File {
152        file: String,
153    },
154    Test {
155        file: String,
156        test: String,
157    },
158    Step {
159        file: String,
160        test: String,
161        step: String,
162    },
163}
164
165impl Target {
166    /// Parse a `FILE::TEST::STEP` expression. Empty string or `None`
167    /// yields [`Target::Run`]. The grammar deliberately mirrors
168    /// `crate::selector::Selector::parse` so inspection addresses are
169    /// transferable to `--select`.
170    pub fn parse(raw: Option<&str>) -> Result<Self, InspectError> {
171        let raw = match raw {
172            Some(s) if !s.is_empty() => s,
173            _ => return Ok(Target::Run),
174        };
175        // Split on `::`; empty segments are rejected. Three-or-more
176        // segments fold the remainder into the step name so tests with
177        // literal `::` in their step labels round-trip (rare but
178        // possible).
179        let parts: Vec<&str> = raw.split("::").collect();
180        if parts.iter().any(|p| p.is_empty()) {
181            return Err(InspectError::InvalidTarget(raw.to_string()));
182        }
183        match parts.as_slice() {
184            [file] => Ok(Target::File {
185                file: (*file).to_string(),
186            }),
187            [file, test] => Ok(Target::Test {
188                file: (*file).to_string(),
189                test: (*test).to_string(),
190            }),
191            [file, test, step @ ..] => Ok(Target::Step {
192                file: (*file).to_string(),
193                test: (*test).to_string(),
194                step: step.join("::"),
195            }),
196            _ => Err(InspectError::InvalidTarget(raw.to_string())),
197        }
198    }
199}
200
201/// Resolve a `run_id` alias (see [`resolve_run_id`]) to the on-disk
202/// report.json path. When `alias` is `last` / `latest` / `@latest`
203/// and no archives exist yet, fall back to the legacy pointer at
204/// `.tarn/last-run.json` so users who have run once (before archives
205/// were introduced, or with `--no-last-run-json` disabling archives)
206/// can still inspect the most recent output.
207pub fn resolve_source(workspace_root: &Path, alias: &str) -> Result<InspectSource, InspectError> {
208    let latest_like = matches!(
209        alias.to_ascii_lowercase().as_str(),
210        "last" | "latest" | "@latest"
211    );
212    match resolve_run_id(workspace_root, alias) {
213        Ok(run_id) => {
214            let path = run_directory(workspace_root, &run_id).join("report.json");
215            if !path.is_file() {
216                return Err(InspectError::NotFound(path));
217            }
218            Ok(InspectSource {
219                run_id: Some(run_id),
220                path,
221            })
222        }
223        Err(e) if latest_like => {
224            // Fall back to the top-level pointer so `tarn inspect last`
225            // still works on a workspace where archives aren't present.
226            let pointer = workspace_root.join(".tarn").join("last-run.json");
227            if pointer.is_file() {
228                Ok(InspectSource {
229                    run_id: None,
230                    path: pointer,
231                })
232            } else {
233                Err(InspectError::Io {
234                    path: pointer,
235                    error: e,
236                })
237            }
238        }
239        Err(e) => Err(InspectError::Io {
240            path: workspace_root.join(".tarn").join("runs").join(alias),
241            error: e,
242        }),
243    }
244}
245
246/// Load the report JSON and return it alongside its source path.
247pub fn load_report(source: &InspectSource) -> Result<Value, InspectError> {
248    let raw = std::fs::read(&source.path).map_err(|error| {
249        if error.kind() == std::io::ErrorKind::NotFound {
250            InspectError::NotFound(source.path.clone())
251        } else {
252            InspectError::Io {
253                path: source.path.clone(),
254                error,
255            }
256        }
257    })?;
258    serde_json::from_slice::<Value>(&raw).map_err(|e| InspectError::Parse {
259        path: source.path.clone(),
260        error: e.to_string(),
261    })
262}
263
264/// Build the JSON envelope for the requested target. Filters out files
265/// / tests that don't carry a failure in `filter_category` at the
266/// run-level view; the flag is a no-op on deeper targets (they are
267/// already scoped to one location).
268pub fn build_view(
269    source: &InspectSource,
270    report: &Value,
271    target: &Target,
272    filter_category: Option<&str>,
273) -> Result<Value, InspectError> {
274    match target {
275        Target::Run => Ok(build_run_view(source, report, filter_category)),
276        Target::File { file } => build_file_view(source, report, file),
277        Target::Test { file, test } => build_test_view(source, report, file, test),
278        Target::Step { file, test, step } => build_step_view(source, report, file, test, step),
279    }
280}
281
282fn build_run_view(source: &InspectSource, report: &Value, filter_category: Option<&str>) -> Value {
283    let files = report
284        .get("files")
285        .and_then(Value::as_array)
286        .cloned()
287        .unwrap_or_default();
288
289    let mut totals = Counts::default();
290    let mut failed = Counts::default();
291    let mut failed_files: Vec<Value> = Vec::new();
292
293    for file in &files {
294        let file_failed = file.get("status").and_then(Value::as_str) == Some("FAILED");
295        totals.files += 1;
296        if file_failed {
297            failed.files += 1;
298        }
299        let mut per_file_failed_tests = 0usize;
300        let mut per_file_failed_steps = 0usize;
301        let mut per_file_matches_filter = filter_category.is_none();
302
303        for setup in file
304            .get("setup")
305            .and_then(Value::as_array)
306            .into_iter()
307            .flatten()
308        {
309            totals.steps += 1;
310            if setup.get("status").and_then(Value::as_str) == Some("FAILED") {
311                failed.steps += 1;
312                per_file_failed_steps += 1;
313                if category_matches(setup, filter_category) {
314                    per_file_matches_filter = true;
315                }
316            }
317        }
318        for teardown in file
319            .get("teardown")
320            .and_then(Value::as_array)
321            .into_iter()
322            .flatten()
323        {
324            totals.steps += 1;
325            if teardown.get("status").and_then(Value::as_str) == Some("FAILED") {
326                failed.steps += 1;
327                per_file_failed_steps += 1;
328                if category_matches(teardown, filter_category) {
329                    per_file_matches_filter = true;
330                }
331            }
332        }
333        for test in file
334            .get("tests")
335            .and_then(Value::as_array)
336            .into_iter()
337            .flatten()
338        {
339            totals.tests += 1;
340            let test_failed = test.get("status").and_then(Value::as_str) == Some("FAILED");
341            if test_failed {
342                failed.tests += 1;
343                per_file_failed_tests += 1;
344            }
345            for step in test
346                .get("steps")
347                .and_then(Value::as_array)
348                .into_iter()
349                .flatten()
350            {
351                totals.steps += 1;
352                if step.get("status").and_then(Value::as_str) == Some("FAILED") {
353                    failed.steps += 1;
354                    per_file_failed_steps += 1;
355                    if category_matches(step, filter_category) {
356                        per_file_matches_filter = true;
357                    }
358                }
359            }
360        }
361
362        if file_failed && per_file_matches_filter {
363            failed_files.push(json!({
364                "file": file.get("file").cloned().unwrap_or(Value::Null),
365                "failed_tests": per_file_failed_tests,
366                "failed_steps": per_file_failed_steps,
367            }));
368        }
369    }
370
371    json!({
372        "schema_version": INSPECT_SCHEMA_VERSION,
373        "target": "run",
374        "run_id": source.run_id.clone().or_else(|| run_id_from_report(report)),
375        "source": source.display_path(),
376        "exit_code": report.get("exit_code").cloned().unwrap_or_else(|| {
377            // `exit_code` isn't stamped on report.json by the writer
378            // today — infer from the run's top-level status so the
379            // run-level view still carries a useful "did this run
380            // pass?" signal.
381            let status = report
382                .get("summary")
383                .and_then(|s| s.get("status"))
384                .and_then(Value::as_str)
385                .unwrap_or("FAILED");
386            json!(if status == "PASSED" { 0 } else { 1 })
387        }),
388        "duration_ms": report.get("duration_ms").cloned().unwrap_or(json!(0)),
389        "start_time": report.get("start_time").cloned().unwrap_or(Value::Null),
390        "end_time": report.get("end_time").cloned().unwrap_or(Value::Null),
391        "totals": totals.to_json(),
392        "failed": failed.to_json(),
393        "failed_files": failed_files,
394        "filter_category": filter_category,
395    })
396}
397
398fn build_file_view(
399    source: &InspectSource,
400    report: &Value,
401    file_name: &str,
402) -> Result<Value, InspectError> {
403    let file = find_file(report, file_name)?;
404    let tests = file
405        .get("tests")
406        .and_then(Value::as_array)
407        .cloned()
408        .unwrap_or_default()
409        .into_iter()
410        .map(|t| {
411            json!({
412                "name": t.get("name").cloned().unwrap_or(Value::Null),
413                "status": t.get("status").cloned().unwrap_or(Value::Null),
414                "duration_ms": t.get("duration_ms").cloned().unwrap_or(json!(0)),
415                "steps": t
416                    .get("steps")
417                    .and_then(Value::as_array)
418                    .cloned()
419                    .unwrap_or_default()
420                    .into_iter()
421                    .map(short_step)
422                    .collect::<Vec<_>>(),
423            })
424        })
425        .collect::<Vec<_>>();
426
427    Ok(json!({
428        "schema_version": INSPECT_SCHEMA_VERSION,
429        "target": "file",
430        "run_id": source.run_id.clone().or_else(|| run_id_from_report(report)),
431        "source": source.display_path(),
432        "file": {
433            "file": file.get("file").cloned().unwrap_or(Value::Null),
434            "name": file.get("name").cloned().unwrap_or(Value::Null),
435            "status": file.get("status").cloned().unwrap_or(Value::Null),
436            "duration_ms": file.get("duration_ms").cloned().unwrap_or(json!(0)),
437            "setup": file
438                .get("setup")
439                .and_then(Value::as_array)
440                .cloned()
441                .unwrap_or_default()
442                .into_iter()
443                .map(short_step)
444                .collect::<Vec<_>>(),
445            "teardown": file
446                .get("teardown")
447                .and_then(Value::as_array)
448                .cloned()
449                .unwrap_or_default()
450                .into_iter()
451                .map(short_step)
452                .collect::<Vec<_>>(),
453            "tests": tests,
454        }
455    }))
456}
457
458fn build_test_view(
459    source: &InspectSource,
460    report: &Value,
461    file_name: &str,
462    test_name: &str,
463) -> Result<Value, InspectError> {
464    let file = find_file(report, file_name)?;
465    let test = find_test(file, file_name, test_name)?;
466    let steps = test
467        .get("steps")
468        .and_then(Value::as_array)
469        .cloned()
470        .unwrap_or_default()
471        .into_iter()
472        .map(short_step)
473        .collect::<Vec<_>>();
474    let captures = test.get("captures").cloned().unwrap_or(json!({}));
475
476    Ok(json!({
477        "schema_version": INSPECT_SCHEMA_VERSION,
478        "target": "test",
479        "run_id": source.run_id.clone().or_else(|| run_id_from_report(report)),
480        "source": source.display_path(),
481        "file": file_name,
482        "test": {
483            "name": test.get("name").cloned().unwrap_or(Value::Null),
484            "status": test.get("status").cloned().unwrap_or(Value::Null),
485            "duration_ms": test.get("duration_ms").cloned().unwrap_or(json!(0)),
486            "steps": steps,
487            "captures": captures,
488        }
489    }))
490}
491
492fn build_step_view(
493    source: &InspectSource,
494    report: &Value,
495    file_name: &str,
496    test_name: &str,
497    step_name: &str,
498) -> Result<Value, InspectError> {
499    let file = find_file(report, file_name)?;
500    let test = find_test(file, file_name, test_name)?;
501    let step = find_step(test, file_name, test_name, step_name)?;
502
503    let assertions = step
504        .get("assertions")
505        .and_then(|a| a.get("details"))
506        .cloned()
507        .unwrap_or(Value::Array(Vec::new()));
508
509    Ok(json!({
510        "schema_version": INSPECT_SCHEMA_VERSION,
511        "target": "step",
512        "run_id": source.run_id.clone().or_else(|| run_id_from_report(report)),
513        "source": source.display_path(),
514        "file": file_name,
515        "test": test_name,
516        "step": {
517            "name": step.get("name").cloned().unwrap_or(Value::Null),
518            "status": step.get("status").cloned().unwrap_or(Value::Null),
519            "duration_ms": step.get("duration_ms").cloned().unwrap_or(json!(0)),
520            "failure_category": step.get("failure_category").cloned().unwrap_or(Value::Null),
521            "error_code": step.get("error_code").cloned().unwrap_or(Value::Null),
522            "response_status": step.get("response_status").cloned().unwrap_or(Value::Null),
523            "response_summary": step.get("response_summary").cloned().unwrap_or(Value::Null),
524            "request": step.get("request").cloned().unwrap_or(Value::Null),
525            "response": step.get("response").cloned().unwrap_or(Value::Null),
526            "assertions": assertions,
527        }
528    }))
529}
530
531fn find_file<'a>(report: &'a Value, file_name: &str) -> Result<&'a Value, InspectError> {
532    report
533        .get("files")
534        .and_then(Value::as_array)
535        .and_then(|files| {
536            files
537                .iter()
538                .find(|f| f.get("file").and_then(Value::as_str) == Some(file_name))
539        })
540        .ok_or_else(|| InspectError::UnknownFile(file_name.to_string()))
541}
542
543fn find_test<'a>(
544    file: &'a Value,
545    file_name: &str,
546    test_name: &str,
547) -> Result<&'a Value, InspectError> {
548    file.get("tests")
549        .and_then(Value::as_array)
550        .and_then(|tests| {
551            tests
552                .iter()
553                .find(|t| t.get("name").and_then(Value::as_str) == Some(test_name))
554        })
555        .ok_or_else(|| InspectError::UnknownTest {
556            file: file_name.to_string(),
557            test: test_name.to_string(),
558        })
559}
560
561fn find_step<'a>(
562    test: &'a Value,
563    file_name: &str,
564    test_name: &str,
565    step_name: &str,
566) -> Result<&'a Value, InspectError> {
567    test.get("steps")
568        .and_then(Value::as_array)
569        .and_then(|steps| {
570            steps
571                .iter()
572                .find(|s| s.get("name").and_then(Value::as_str) == Some(step_name))
573        })
574        .ok_or_else(|| InspectError::UnknownStep {
575            file: file_name.to_string(),
576            test: test_name.to_string(),
577            step: step_name.to_string(),
578        })
579}
580
581fn short_step(step: Value) -> Value {
582    json!({
583        "name": step.get("name").cloned().unwrap_or(Value::Null),
584        "status": step.get("status").cloned().unwrap_or(Value::Null),
585        "duration_ms": step.get("duration_ms").cloned().unwrap_or(json!(0)),
586        "failure_category": step.get("failure_category").cloned().unwrap_or(Value::Null),
587        "response_status": step.get("response_status").cloned().unwrap_or(Value::Null),
588        "response_summary": step.get("response_summary").cloned().unwrap_or(Value::Null),
589    })
590}
591
592fn category_matches(step: &Value, filter: Option<&str>) -> bool {
593    match filter {
594        None => true,
595        Some(want) => step
596            .get("failure_category")
597            .and_then(Value::as_str)
598            .map(|s| s.eq_ignore_ascii_case(want))
599            .unwrap_or(false),
600    }
601}
602
603fn run_id_from_report(report: &Value) -> Option<String> {
604    report
605        .get("run_id")
606        .and_then(Value::as_str)
607        .map(|s| s.to_string())
608}
609
610/// Validate a `--filter-category` value against the known enum so users
611/// get a clear error rather than a silently-empty result.
612pub fn validate_category(raw: &str) -> Result<(), String> {
613    let try_parse: Result<FailureCategory, _> =
614        serde_json::from_value(Value::String(raw.to_string()));
615    try_parse.map(|_| ()).map_err(|_| {
616        format!(
617            "unknown failure category '{}'. Valid values: assertion_failed, connection_error, \
618             timeout, parse_error, capture_error, unresolved_template, \
619             skipped_due_to_failed_capture, skipped_due_to_fail_fast, skipped_by_condition",
620            raw
621        )
622    })
623}
624
625/// Render the inspect view as a human-readable block. Output is
626/// structured text, not a dashboard — the audience is humans scanning
627/// the terminal, so every line stands alone and grep-friendly.
628pub fn render_human(view: &Value) -> String {
629    let mut out = String::new();
630    let target = view.get("target").and_then(Value::as_str).unwrap_or("run");
631    match target {
632        "run" => render_run_human(view, &mut out),
633        "file" => render_file_human(view, &mut out),
634        "test" => render_test_human(view, &mut out),
635        "step" => render_step_human(view, &mut out),
636        _ => out.push_str("tarn inspect: unknown target\n"),
637    }
638    out
639}
640
641fn render_run_human(view: &Value, out: &mut String) {
642    out.push_str(&format!(
643        "run: {}\n",
644        view.get("run_id").and_then(Value::as_str).unwrap_or("?"),
645    ));
646    out.push_str(&format!(
647        "source: {}\n",
648        view.get("source").and_then(Value::as_str).unwrap_or("?"),
649    ));
650    if let Some(exit) = view.get("exit_code").and_then(Value::as_i64) {
651        out.push_str(&format!("exit_code: {}\n", exit));
652    }
653    if let Some(dur) = view.get("duration_ms").and_then(Value::as_u64) {
654        out.push_str(&format!("duration_ms: {}\n", dur));
655    }
656    let totals = view.get("totals").cloned().unwrap_or(Value::Null);
657    let failed = view.get("failed").cloned().unwrap_or(Value::Null);
658    out.push_str(&format!(
659        "totals: files={} tests={} steps={}\n",
660        counts_field(&totals, "files"),
661        counts_field(&totals, "tests"),
662        counts_field(&totals, "steps"),
663    ));
664    out.push_str(&format!(
665        "failed: files={} tests={} steps={}\n",
666        counts_field(&failed, "files"),
667        counts_field(&failed, "tests"),
668        counts_field(&failed, "steps"),
669    ));
670
671    let empty = Vec::new();
672    let failed_files = view
673        .get("failed_files")
674        .and_then(Value::as_array)
675        .unwrap_or(&empty);
676    if failed_files.is_empty() {
677        out.push_str("failed_files: none\n");
678    } else {
679        out.push_str("failed_files:\n");
680        for ff in failed_files {
681            out.push_str(&format!(
682                "  - {} (tests={}, steps={})\n",
683                ff.get("file").and_then(Value::as_str).unwrap_or("?"),
684                ff.get("failed_tests").and_then(Value::as_u64).unwrap_or(0),
685                ff.get("failed_steps").and_then(Value::as_u64).unwrap_or(0),
686            ));
687        }
688    }
689}
690
691fn render_file_human(view: &Value, out: &mut String) {
692    let file = view.get("file").cloned().unwrap_or(Value::Null);
693    out.push_str(&format!(
694        "file: {}\n",
695        file.get("file").and_then(Value::as_str).unwrap_or("?"),
696    ));
697    out.push_str(&format!(
698        "status: {}  duration_ms={}\n",
699        file.get("status").and_then(Value::as_str).unwrap_or("?"),
700        file.get("duration_ms").and_then(Value::as_u64).unwrap_or(0),
701    ));
702    let empty = Vec::new();
703    let setup = file
704        .get("setup")
705        .and_then(Value::as_array)
706        .unwrap_or(&empty);
707    if !setup.is_empty() {
708        out.push_str("setup:\n");
709        for s in setup {
710            out.push_str(&format_short_step_line(s, "  "));
711        }
712    }
713    let tests = file
714        .get("tests")
715        .and_then(Value::as_array)
716        .unwrap_or(&empty);
717    if tests.is_empty() {
718        out.push_str("tests: none\n");
719    } else {
720        out.push_str("tests:\n");
721        for t in tests {
722            out.push_str(&format!(
723                "  - {} [{}] duration_ms={}\n",
724                t.get("name").and_then(Value::as_str).unwrap_or("?"),
725                t.get("status").and_then(Value::as_str).unwrap_or("?"),
726                t.get("duration_ms").and_then(Value::as_u64).unwrap_or(0),
727            ));
728            let steps = t.get("steps").and_then(Value::as_array).unwrap_or(&empty);
729            for s in steps {
730                out.push_str(&format_short_step_line(s, "      "));
731            }
732        }
733    }
734    let teardown = file
735        .get("teardown")
736        .and_then(Value::as_array)
737        .unwrap_or(&empty);
738    if !teardown.is_empty() {
739        out.push_str("teardown:\n");
740        for s in teardown {
741            out.push_str(&format_short_step_line(s, "  "));
742        }
743    }
744}
745
746fn render_test_human(view: &Value, out: &mut String) {
747    let file = view.get("file").and_then(Value::as_str).unwrap_or("?");
748    let test = view.get("test").cloned().unwrap_or(Value::Null);
749    out.push_str(&format!(
750        "test: {}::{}\n",
751        file,
752        test.get("name").and_then(Value::as_str).unwrap_or("?"),
753    ));
754    out.push_str(&format!(
755        "status: {}  duration_ms={}\n",
756        test.get("status").and_then(Value::as_str).unwrap_or("?"),
757        test.get("duration_ms").and_then(Value::as_u64).unwrap_or(0),
758    ));
759    let empty = Vec::new();
760    let steps = test
761        .get("steps")
762        .and_then(Value::as_array)
763        .unwrap_or(&empty);
764    out.push_str("steps:\n");
765    for s in steps {
766        out.push_str(&format_short_step_line(s, "  "));
767    }
768    if let Some(captures) = test.get("captures").and_then(Value::as_object) {
769        if captures.is_empty() {
770            out.push_str("captures: none\n");
771        } else {
772            out.push_str("captures:\n");
773            for (k, v) in captures {
774                out.push_str(&format!("  {} = {}\n", k, v));
775            }
776        }
777    }
778}
779
780fn render_step_human(view: &Value, out: &mut String) {
781    let file = view.get("file").and_then(Value::as_str).unwrap_or("?");
782    let test = view.get("test").and_then(Value::as_str).unwrap_or("?");
783    let step = view.get("step").cloned().unwrap_or(Value::Null);
784    out.push_str(&format!(
785        "step: {}::{}::{}\n",
786        file,
787        test,
788        step.get("name").and_then(Value::as_str).unwrap_or("?"),
789    ));
790    out.push_str(&format!(
791        "status: {}  duration_ms={}\n",
792        step.get("status").and_then(Value::as_str).unwrap_or("?"),
793        step.get("duration_ms").and_then(Value::as_u64).unwrap_or(0),
794    ));
795    if let Some(cat) = step.get("failure_category").and_then(Value::as_str) {
796        out.push_str(&format!("failure_category: {}\n", cat));
797    }
798    if let Some(code) = step.get("error_code").and_then(Value::as_str) {
799        out.push_str(&format!("error_code: {}\n", code));
800    }
801    if let Some(req) = step.get("request").and_then(Value::as_object) {
802        out.push_str("request:\n");
803        if let (Some(method), Some(url)) = (
804            req.get("method").and_then(Value::as_str),
805            req.get("url").and_then(Value::as_str),
806        ) {
807            out.push_str(&format!("  {} {}\n", method, url));
808        }
809        if let Some(headers) = req.get("headers").and_then(Value::as_object) {
810            for (k, v) in headers {
811                out.push_str(&format!("  > {}: {}\n", k, v.as_str().unwrap_or("")));
812            }
813        }
814        if let Some(body) = req.get("body") {
815            if !body.is_null() {
816                out.push_str(&format!(
817                    "  body: {}\n",
818                    serde_json::to_string(body).unwrap_or_default()
819                ));
820            }
821        }
822    }
823    if let Some(resp) = step.get("response").and_then(Value::as_object) {
824        out.push_str("response:\n");
825        if let Some(status) = resp.get("status").and_then(Value::as_u64) {
826            out.push_str(&format!("  status: {}\n", status));
827        }
828        if let Some(headers) = resp.get("headers").and_then(Value::as_object) {
829            for (k, v) in headers {
830                out.push_str(&format!("  < {}: {}\n", k, v.as_str().unwrap_or("")));
831            }
832        }
833        if let Some(body) = resp.get("body") {
834            if !body.is_null() {
835                out.push_str(&format!(
836                    "  body: {}\n",
837                    serde_json::to_string(body).unwrap_or_default()
838                ));
839            }
840        }
841    }
842    if let Some(assertions) = step.get("assertions").and_then(Value::as_array) {
843        out.push_str("assertions:\n");
844        for a in assertions {
845            let passed = a.get("passed").and_then(Value::as_bool).unwrap_or(false);
846            let marker = if passed { "PASS" } else { "FAIL" };
847            out.push_str(&format!(
848                "  [{}] {} expected={} actual={}\n",
849                marker,
850                a.get("assertion").and_then(Value::as_str).unwrap_or("?"),
851                a.get("expected").and_then(Value::as_str).unwrap_or(""),
852                a.get("actual").and_then(Value::as_str).unwrap_or(""),
853            ));
854            if !passed {
855                if let Some(msg) = a.get("message").and_then(Value::as_str) {
856                    if !msg.is_empty() {
857                        out.push_str(&format!("        {}\n", msg));
858                    }
859                }
860            }
861        }
862    }
863}
864
865fn format_short_step_line(step: &Value, indent: &str) -> String {
866    let name = step.get("name").and_then(Value::as_str).unwrap_or("?");
867    let status = step.get("status").and_then(Value::as_str).unwrap_or("?");
868    let duration = step.get("duration_ms").and_then(Value::as_u64).unwrap_or(0);
869    let mut line = format!("{}- {} [{}] duration_ms={}", indent, name, status, duration);
870    if let Some(cat) = step.get("failure_category").and_then(Value::as_str) {
871        line.push_str(&format!(" category={}", cat));
872    }
873    if let Some(status) = step.get("response_status").and_then(Value::as_u64) {
874        line.push_str(&format!(" http={}", status));
875    }
876    line.push('\n');
877    line
878}
879
880#[derive(Default, Debug)]
881struct Counts {
882    files: usize,
883    tests: usize,
884    steps: usize,
885}
886
887impl Counts {
888    fn to_json(&self) -> Value {
889        json!({
890            "files": self.files,
891            "tests": self.tests,
892            "steps": self.steps,
893        })
894    }
895}
896
897fn counts_field(value: &Value, key: &str) -> u64 {
898    value.get(key).and_then(Value::as_u64).unwrap_or(0)
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    fn sample_report() -> Value {
906        // Two files: one fully passing, one failing. The failing file
907        // carries a failing step with assertions + a request/response
908        // block so step-level rendering has real data to exercise.
909        json!({
910            "schema_version": 1,
911            "duration_ms": 123,
912            "run_id": "20260401-120000-aabbcc",
913            "start_time": "2026-04-01T12:00:00Z",
914            "end_time": "2026-04-01T12:00:00Z",
915            "exit_code": 1,
916            "summary": { "status": "FAILED" },
917            "files": [
918                {
919                    "file": "tests/ok.tarn.yaml",
920                    "name": "ok",
921                    "status": "PASSED",
922                    "duration_ms": 5,
923                    "setup": [],
924                    "teardown": [],
925                    "tests": [
926                        {
927                            "name": "t1",
928                            "status": "PASSED",
929                            "duration_ms": 5,
930                            "steps": [
931                                {
932                                    "name": "ping",
933                                    "status": "PASSED",
934                                    "duration_ms": 5,
935                                    "response_status": 200,
936                                    "assertions": {"details": []}
937                                }
938                            ],
939                            "captures": {}
940                        }
941                    ]
942                },
943                {
944                    "file": "tests/bad.tarn.yaml",
945                    "name": "bad",
946                    "status": "FAILED",
947                    "duration_ms": 7,
948                    "setup": [],
949                    "teardown": [],
950                    "tests": [
951                        {
952                            "name": "sad",
953                            "status": "FAILED",
954                            "duration_ms": 7,
955                            "steps": [
956                                {
957                                    "name": "boom",
958                                    "status": "FAILED",
959                                    "duration_ms": 7,
960                                    "failure_category": "assertion_failed",
961                                    "response_status": 500,
962                                    "request": {
963                                        "method": "GET",
964                                        "url": "https://api.test/x",
965                                        "headers": {"accept": "application/json"}
966                                    },
967                                    "response": {
968                                        "status": 500,
969                                        "headers": {"content-type": "application/json"},
970                                        "body": {"error": "boom"}
971                                    },
972                                    "assertions": {
973                                        "details": [
974                                            {
975                                                "assertion": "status",
976                                                "passed": false,
977                                                "expected": "200",
978                                                "actual": "500",
979                                                "message": "status mismatch"
980                                            }
981                                        ]
982                                    }
983                                }
984                            ],
985                            "captures": {"token": "abc"}
986                        },
987                        {
988                            "name": "sad2",
989                            "status": "FAILED",
990                            "duration_ms": 2,
991                            "steps": [
992                                {
993                                    "name": "net",
994                                    "status": "FAILED",
995                                    "duration_ms": 2,
996                                    "failure_category": "connection_error",
997                                    "assertions": {"details": []}
998                                }
999                            ],
1000                            "captures": {}
1001                        }
1002                    ]
1003                }
1004            ]
1005        })
1006    }
1007
1008    fn sample_source() -> InspectSource {
1009        InspectSource {
1010            run_id: Some("20260401-120000-aabbcc".into()),
1011            path: PathBuf::from("/tmp/report.json"),
1012        }
1013    }
1014
1015    #[test]
1016    fn target_parse_none_yields_run_target() {
1017        assert_eq!(Target::parse(None).unwrap(), Target::Run);
1018        assert_eq!(Target::parse(Some("")).unwrap(), Target::Run);
1019    }
1020
1021    #[test]
1022    fn target_parse_file_test_step_levels() {
1023        assert_eq!(
1024            Target::parse(Some("a.yaml")).unwrap(),
1025            Target::File {
1026                file: "a.yaml".into()
1027            }
1028        );
1029        assert_eq!(
1030            Target::parse(Some("a.yaml::t")).unwrap(),
1031            Target::Test {
1032                file: "a.yaml".into(),
1033                test: "t".into()
1034            }
1035        );
1036        assert_eq!(
1037            Target::parse(Some("a.yaml::t::s")).unwrap(),
1038            Target::Step {
1039                file: "a.yaml".into(),
1040                test: "t".into(),
1041                step: "s".into()
1042            }
1043        );
1044    }
1045
1046    #[test]
1047    fn target_parse_rejects_empty_segment() {
1048        assert!(matches!(
1049            Target::parse(Some("a.yaml::")),
1050            Err(InspectError::InvalidTarget(_))
1051        ));
1052        assert!(matches!(
1053            Target::parse(Some("::a")),
1054            Err(InspectError::InvalidTarget(_))
1055        ));
1056    }
1057
1058    #[test]
1059    fn build_view_run_counts_reflect_report() {
1060        let report = sample_report();
1061        let view = build_view(&sample_source(), &report, &Target::Run, None).unwrap();
1062        assert_eq!(view["target"], "run");
1063        assert_eq!(view["totals"]["files"], 2);
1064        assert_eq!(view["totals"]["tests"], 3);
1065        assert_eq!(view["failed"]["files"], 1);
1066        assert_eq!(view["failed"]["tests"], 2);
1067        assert_eq!(view["failed"]["steps"], 2);
1068        let failed_files = view["failed_files"].as_array().unwrap();
1069        assert_eq!(failed_files.len(), 1);
1070        assert_eq!(failed_files[0]["file"], "tests/bad.tarn.yaml");
1071        assert_eq!(failed_files[0]["failed_tests"], 2);
1072    }
1073
1074    #[test]
1075    fn build_view_run_filter_category_narrows_failed_files() {
1076        let report = sample_report();
1077        // A filter that matches only connection_error: the bad file
1078        // should still appear because one of its tests has the cascade.
1079        let view = build_view(
1080            &sample_source(),
1081            &report,
1082            &Target::Run,
1083            Some("connection_error"),
1084        )
1085        .unwrap();
1086        let failed_files = view["failed_files"].as_array().unwrap();
1087        assert_eq!(failed_files.len(), 1);
1088        assert_eq!(failed_files[0]["file"], "tests/bad.tarn.yaml");
1089
1090        // Filter that matches nothing in the fail set → empty list.
1091        let view = build_view(&sample_source(), &report, &Target::Run, Some("timeout")).unwrap();
1092        assert!(view["failed_files"].as_array().unwrap().is_empty());
1093    }
1094
1095    #[test]
1096    fn build_view_file_returns_setup_teardown_and_tests() {
1097        let report = sample_report();
1098        let view = build_view(
1099            &sample_source(),
1100            &report,
1101            &Target::File {
1102                file: "tests/bad.tarn.yaml".into(),
1103            },
1104            None,
1105        )
1106        .unwrap();
1107        assert_eq!(view["target"], "file");
1108        assert_eq!(view["file"]["status"], "FAILED");
1109        assert_eq!(view["file"]["tests"].as_array().unwrap().len(), 2);
1110        assert_eq!(
1111            view["file"]["tests"][0]["steps"][0]["failure_category"],
1112            "assertion_failed"
1113        );
1114    }
1115
1116    #[test]
1117    fn build_view_test_includes_captures() {
1118        let report = sample_report();
1119        let view = build_view(
1120            &sample_source(),
1121            &report,
1122            &Target::Test {
1123                file: "tests/bad.tarn.yaml".into(),
1124                test: "sad".into(),
1125            },
1126            None,
1127        )
1128        .unwrap();
1129        assert_eq!(view["target"], "test");
1130        assert_eq!(view["test"]["captures"]["token"], "abc");
1131        assert_eq!(view["test"]["steps"].as_array().unwrap().len(), 1);
1132    }
1133
1134    #[test]
1135    fn build_view_step_embeds_request_response_and_assertions() {
1136        let report = sample_report();
1137        let view = build_view(
1138            &sample_source(),
1139            &report,
1140            &Target::Step {
1141                file: "tests/bad.tarn.yaml".into(),
1142                test: "sad".into(),
1143                step: "boom".into(),
1144            },
1145            None,
1146        )
1147        .unwrap();
1148        assert_eq!(view["target"], "step");
1149        assert_eq!(view["step"]["failure_category"], "assertion_failed");
1150        assert_eq!(view["step"]["request"]["method"], "GET");
1151        assert_eq!(view["step"]["response"]["status"], 500);
1152        let details = view["step"]["assertions"].as_array().unwrap();
1153        assert_eq!(details.len(), 1);
1154        assert_eq!(details[0]["passed"], false);
1155    }
1156
1157    #[test]
1158    fn build_view_unknown_file_errors() {
1159        let report = sample_report();
1160        let err = build_view(
1161            &sample_source(),
1162            &report,
1163            &Target::File {
1164                file: "missing.yaml".into(),
1165            },
1166            None,
1167        )
1168        .unwrap_err();
1169        assert!(matches!(err, InspectError::UnknownFile(_)));
1170    }
1171
1172    #[test]
1173    fn build_view_unknown_test_errors() {
1174        let report = sample_report();
1175        let err = build_view(
1176            &sample_source(),
1177            &report,
1178            &Target::Test {
1179                file: "tests/bad.tarn.yaml".into(),
1180                test: "nope".into(),
1181            },
1182            None,
1183        )
1184        .unwrap_err();
1185        assert!(matches!(err, InspectError::UnknownTest { .. }));
1186    }
1187
1188    #[test]
1189    fn build_view_unknown_step_errors() {
1190        let report = sample_report();
1191        let err = build_view(
1192            &sample_source(),
1193            &report,
1194            &Target::Step {
1195                file: "tests/bad.tarn.yaml".into(),
1196                test: "sad".into(),
1197                step: "nope".into(),
1198            },
1199            None,
1200        )
1201        .unwrap_err();
1202        assert!(matches!(err, InspectError::UnknownStep { .. }));
1203    }
1204
1205    #[test]
1206    fn validate_category_accepts_known_snake_case_values() {
1207        assert!(validate_category("assertion_failed").is_ok());
1208        assert!(validate_category("timeout").is_ok());
1209        assert!(validate_category("not_a_category").is_err());
1210    }
1211
1212    #[test]
1213    fn render_human_run_includes_counts_and_failed_files() {
1214        let report = sample_report();
1215        let view = build_view(&sample_source(), &report, &Target::Run, None).unwrap();
1216        let rendered = render_human(&view);
1217        assert!(rendered.contains("tests/bad.tarn.yaml"));
1218        assert!(rendered.contains("failed: files=1"));
1219    }
1220
1221    #[test]
1222    fn render_human_step_includes_request_url_and_assertion() {
1223        let report = sample_report();
1224        let view = build_view(
1225            &sample_source(),
1226            &report,
1227            &Target::Step {
1228                file: "tests/bad.tarn.yaml".into(),
1229                test: "sad".into(),
1230                step: "boom".into(),
1231            },
1232            None,
1233        )
1234        .unwrap();
1235        let rendered = render_human(&view);
1236        assert!(rendered.contains("GET https://api.test/x"));
1237        assert!(rendered.contains("[FAIL] status"));
1238        assert!(rendered.contains("status mismatch"));
1239    }
1240
1241    #[test]
1242    fn resolve_source_reads_from_archive_when_present() {
1243        let tmp = tempfile::TempDir::new().unwrap();
1244        let dir =
1245            crate::report::run_dir::ensure_run_directory(tmp.path(), "20260101-120000-abcdef")
1246                .unwrap();
1247        let report_path = dir.join("report.json");
1248        std::fs::write(
1249            &report_path,
1250            serde_json::to_string(&sample_report()).unwrap(),
1251        )
1252        .unwrap();
1253        let source = resolve_source(tmp.path(), "20260101-120000-abcdef").unwrap();
1254        assert_eq!(source.path, report_path);
1255        assert_eq!(source.run_id.as_deref(), Some("20260101-120000-abcdef"));
1256    }
1257
1258    #[test]
1259    fn resolve_source_falls_back_to_last_run_pointer_for_latest_alias() {
1260        let tmp = tempfile::TempDir::new().unwrap();
1261        // No `.tarn/runs/` at all — only the legacy pointer.
1262        let pointer = tmp.path().join(".tarn").join("last-run.json");
1263        std::fs::create_dir_all(pointer.parent().unwrap()).unwrap();
1264        std::fs::write(&pointer, serde_json::to_string(&sample_report()).unwrap()).unwrap();
1265        let source = resolve_source(tmp.path(), "last").unwrap();
1266        assert_eq!(source.path, pointer);
1267        assert!(source.run_id.is_none());
1268    }
1269
1270    #[test]
1271    fn resolve_source_unknown_id_errors() {
1272        let tmp = tempfile::TempDir::new().unwrap();
1273        let err = resolve_source(tmp.path(), "does-not-exist").unwrap_err();
1274        assert!(matches!(err, InspectError::Io { .. }));
1275    }
1276}