1use 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#[derive(Debug, Clone)]
79pub struct InspectSource {
80 pub run_id: Option<String>,
81 pub path: PathBuf,
82}
83
84impl InspectSource {
85 pub fn display_path(&self) -> String {
88 crate::path_util::to_forward_slash(&self.path)
89 }
90}
91
92#[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#[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 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 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
201pub 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 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
246pub 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
264pub 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 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
610pub 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
625pub 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 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 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 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 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}