dev_report/lib.rs
1//! # dev-report
2//!
3//! Structured, machine-readable reports for AI-assisted Rust development.
4//!
5//! `dev-report` is the foundation schema of the `dev-*` verification suite.
6//! Every other crate in the suite (`dev-bench`, `dev-fixtures`, `dev-async`,
7//! `dev-stress`, `dev-chaos`) emits results that conform to this schema.
8//!
9//! ## Why a separate crate
10//!
11//! AI agents need decision-grade output. A test runner that prints colored
12//! checkmarks to a TTY is unreadable to an agent. `dev-report` defines a
13//! stable, versioned schema that:
14//!
15//! - Serializes to JSON for programmatic consumption
16//! - Carries enough evidence for an agent to decide accept / reject / retry
17//! - Keeps verdicts separate from logs so consumers do not have to parse text
18//!
19//! ## Quick example
20//!
21//! ```no_run
22//! use dev_report::{Report, Verdict, Severity, CheckResult};
23//!
24//! let mut report = Report::new("my-crate", "0.1.0");
25//! report.push(CheckResult::pass("compile"));
26//! report.push(CheckResult::fail("test_round_trip", Severity::Error)
27//! .with_detail("expected 42, got 41"));
28//!
29//! let verdict = report.overall_verdict();
30//! let json = report.to_json().unwrap();
31//! ```
32
33#![cfg_attr(docsrs, feature(doc_cfg))]
34#![warn(missing_docs)]
35#![warn(rust_2018_idioms)]
36
37use std::collections::BTreeMap;
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42#[cfg(feature = "terminal")]
43#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
44pub mod terminal;
45
46#[cfg(feature = "markdown")]
47#[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
48pub mod markdown;
49
50#[cfg(feature = "sarif")]
51#[cfg_attr(docsrs, doc(cfg(feature = "sarif")))]
52pub mod sarif;
53
54#[cfg(feature = "junit")]
55#[cfg_attr(docsrs, doc(cfg(feature = "junit")))]
56pub mod junit;
57
58mod diff;
59pub use diff::{Diff, DiffOptions, DurationRegression, SeverityChange};
60
61mod multi;
62pub use multi::MultiReport;
63
64/// Top-level verdict for a check or a whole report.
65///
66/// Precedence when summarized over many checks: `Fail` > `Warn` > `Pass` > `Skip`.
67///
68/// # Example
69///
70/// ```
71/// use dev_report::Verdict;
72///
73/// let v = Verdict::Pass;
74/// assert_ne!(v, Verdict::Fail);
75/// ```
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum Verdict {
79 /// Check passed. No action required.
80 Pass,
81 /// Check failed. Action required.
82 Fail,
83 /// Check produced a warning. Review recommended.
84 Warn,
85 /// Check was skipped. No data to report.
86 Skip,
87}
88
89/// Severity classification when a check fails or warns.
90///
91/// `None` for `Pass` and `Skip` verdicts; `Some(_)` for `Fail` and `Warn`.
92///
93/// # Example
94///
95/// ```
96/// use dev_report::{CheckResult, Severity};
97///
98/// let c = CheckResult::fail("oops", Severity::Error);
99/// assert_eq!(c.severity, Some(Severity::Error));
100/// ```
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Severity {
104 /// Informational. Does not block acceptance.
105 Info,
106 /// Warning. Acceptance allowed with explicit acknowledgement.
107 Warning,
108 /// Error. Blocks acceptance.
109 Error,
110 /// Critical. Blocks acceptance and signals a regression.
111 Critical,
112}
113
114/// Reference to a file at a specific (optional) line range.
115///
116/// # Example
117///
118/// ```
119/// use dev_report::FileRef;
120///
121/// let r = FileRef::new("src/lib.rs").with_line_range(10, 20);
122/// assert_eq!(r.path, "src/lib.rs");
123/// assert_eq!(r.line_start, Some(10));
124/// assert_eq!(r.line_end, Some(20));
125/// ```
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct FileRef {
128 /// Path to the file. Either absolute or relative to the producer's CWD.
129 pub path: String,
130 /// Optional starting line (1-indexed, inclusive).
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub line_start: Option<u32>,
133 /// Optional ending line (1-indexed, inclusive).
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub line_end: Option<u32>,
136}
137
138impl FileRef {
139 /// Build a [`FileRef`] for the given path with no line range.
140 pub fn new(path: impl Into<String>) -> Self {
141 Self {
142 path: path.into(),
143 line_start: None,
144 line_end: None,
145 }
146 }
147
148 /// Attach a `[start, end]` line range (1-indexed, inclusive).
149 pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
150 self.line_start = Some(start);
151 self.line_end = Some(end);
152 self
153 }
154}
155
156/// Discriminator describing the shape of an [`Evidence`] payload.
157///
158/// Returned by [`Evidence::kind`].
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160pub enum EvidenceKind {
161 /// A single labeled numeric measurement.
162 Numeric,
163 /// A bag of string-to-string pairs.
164 KeyValue,
165 /// A short text or code snippet.
166 Snippet,
167 /// A reference to a file on disk.
168 FileRef,
169}
170
171/// Typed payload for an [`Evidence`] attachment.
172///
173/// Externally tagged: a numeric evidence serializes as
174/// `{ "numeric": 42.0 }`, a snippet as `{ "snippet": "..." }`, etc.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum EvidenceData {
178 /// A single floating-point value (e.g. `ops_per_sec`, `mean_ns`).
179 Numeric(f64),
180 /// String-to-string pairs (e.g. environment, configuration).
181 ///
182 /// Stored as a `BTreeMap` so JSON output is deterministic.
183 KeyValue(BTreeMap<String, String>),
184 /// Short snippet of text or code.
185 Snippet(String),
186 /// File reference with optional line range.
187 FileRef(FileRef),
188}
189
190/// A piece of structured evidence backing a [`CheckResult`].
191///
192/// Use this to attach decision-grade data (numbers, key-value pairs,
193/// code snippets, file refs) instead of formatting them into the
194/// free-form `detail` field. Consumers can read the typed payload
195/// directly without parsing text.
196///
197/// # Example
198///
199/// ```
200/// use dev_report::{CheckResult, Evidence};
201///
202/// let check = CheckResult::pass("bench::parse")
203/// .with_evidence(Evidence::numeric("mean_ns", 1234.0))
204/// .with_evidence(Evidence::numeric("baseline_ns", 1100.0));
205///
206/// assert_eq!(check.evidence.len(), 2);
207/// ```
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct Evidence {
210 /// Short human-readable label (e.g. `"ops_per_sec"`).
211 pub label: String,
212 /// Typed payload.
213 pub data: EvidenceData,
214}
215
216impl Evidence {
217 /// Build a numeric-evidence attachment.
218 ///
219 /// # Example
220 ///
221 /// ```
222 /// use dev_report::Evidence;
223 ///
224 /// let e = Evidence::numeric("ops_per_sec", 12_500.0);
225 /// assert_eq!(e.label, "ops_per_sec");
226 /// ```
227 pub fn numeric(label: impl Into<String>, value: f64) -> Self {
228 Self {
229 label: label.into(),
230 data: EvidenceData::Numeric(value),
231 }
232 }
233
234 /// Build a numeric-evidence attachment from an integer value.
235 ///
236 /// Preserves precision for counters that exceed `f64`'s 53-bit
237 /// integer range (e.g. iteration counts, byte sizes). The value is
238 /// stored as `f64` on the wire (the schema is unchanged), but
239 /// callers don't have to perform a possibly-lossy `as f64` cast.
240 ///
241 /// For values up to `2^53` the round-trip is exact. Above that,
242 /// precision degrades the same way it would for any `f64`.
243 ///
244 /// # Example
245 ///
246 /// ```
247 /// use dev_report::Evidence;
248 ///
249 /// let e = Evidence::numeric_int("iterations", 1_000_000_i64);
250 /// assert_eq!(e.label, "iterations");
251 /// ```
252 pub fn numeric_int(label: impl Into<String>, value: i64) -> Self {
253 Self::numeric(label, value as f64)
254 }
255
256 /// Build a key-value-evidence attachment from any iterable of pairs.
257 ///
258 /// # Example
259 ///
260 /// ```
261 /// use dev_report::Evidence;
262 ///
263 /// let e = Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]);
264 /// assert_eq!(e.label, "env");
265 /// ```
266 pub fn kv<I, K, V>(label: impl Into<String>, pairs: I) -> Self
267 where
268 I: IntoIterator<Item = (K, V)>,
269 K: Into<String>,
270 V: Into<String>,
271 {
272 let map: BTreeMap<String, String> = pairs
273 .into_iter()
274 .map(|(k, v)| (k.into(), v.into()))
275 .collect();
276 Self {
277 label: label.into(),
278 data: EvidenceData::KeyValue(map),
279 }
280 }
281
282 /// Build a snippet-evidence attachment.
283 ///
284 /// # Example
285 ///
286 /// ```
287 /// use dev_report::Evidence;
288 ///
289 /// let e = Evidence::snippet("panic", "thread 'main' panicked at ...");
290 /// assert_eq!(e.label, "panic");
291 /// ```
292 pub fn snippet(label: impl Into<String>, text: impl Into<String>) -> Self {
293 Self {
294 label: label.into(),
295 data: EvidenceData::Snippet(text.into()),
296 }
297 }
298
299 /// Build a file-reference-evidence attachment with no line range.
300 ///
301 /// # Example
302 ///
303 /// ```
304 /// use dev_report::Evidence;
305 ///
306 /// let e = Evidence::file_ref("source", "src/lib.rs");
307 /// assert_eq!(e.label, "source");
308 /// ```
309 pub fn file_ref(label: impl Into<String>, path: impl Into<String>) -> Self {
310 Self {
311 label: label.into(),
312 data: EvidenceData::FileRef(FileRef::new(path)),
313 }
314 }
315
316 /// Build a file-reference-evidence attachment with a `[start, end]`
317 /// line range (1-indexed, inclusive).
318 ///
319 /// # Example
320 ///
321 /// ```
322 /// use dev_report::Evidence;
323 ///
324 /// let e = Evidence::file_ref_lines("call_site", "src/lib.rs", 42, 47);
325 /// assert_eq!(e.label, "call_site");
326 /// ```
327 pub fn file_ref_lines(
328 label: impl Into<String>,
329 path: impl Into<String>,
330 start: u32,
331 end: u32,
332 ) -> Self {
333 Self {
334 label: label.into(),
335 data: EvidenceData::FileRef(FileRef::new(path).with_line_range(start, end)),
336 }
337 }
338
339 /// Discriminator for the payload variant.
340 ///
341 /// # Example
342 ///
343 /// ```
344 /// use dev_report::{Evidence, EvidenceKind};
345 ///
346 /// assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
347 /// ```
348 pub fn kind(&self) -> EvidenceKind {
349 match &self.data {
350 EvidenceData::Numeric(_) => EvidenceKind::Numeric,
351 EvidenceData::KeyValue(_) => EvidenceKind::KeyValue,
352 EvidenceData::Snippet(_) => EvidenceKind::Snippet,
353 EvidenceData::FileRef(_) => EvidenceKind::FileRef,
354 }
355 }
356}
357
358/// Result of a single check.
359///
360/// # Example
361///
362/// ```
363/// use dev_report::{CheckResult, Severity, Verdict};
364///
365/// let c = CheckResult::fail("unit::math", Severity::Error)
366/// .with_detail("expected 42, got 41")
367/// .with_duration_ms(7);
368/// assert_eq!(c.verdict, Verdict::Fail);
369/// ```
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct CheckResult {
372 /// Stable identifier for the check (e.g. `compile`, `test::round_trip`).
373 pub name: String,
374 /// Outcome of the check.
375 pub verdict: Verdict,
376 /// Severity when the verdict is `Fail` or `Warn`. `None` for `Pass` and `Skip`.
377 pub severity: Option<Severity>,
378 /// Human-readable detail. Optional.
379 pub detail: Option<String>,
380 /// Time the check ran. UTC.
381 pub at: DateTime<Utc>,
382 /// Duration of the check, in milliseconds. Optional.
383 pub duration_ms: Option<u64>,
384 /// Free-form tags for filtering (e.g. `"slow"`, `"flaky"`, `"bench"`).
385 ///
386 /// Defaults to empty. v0.1.0 reports deserialize cleanly with no tags.
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
388 pub tags: Vec<String>,
389 /// Structured evidence backing this check.
390 ///
391 /// Defaults to empty. v0.1.0 reports deserialize cleanly with no evidence.
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
393 pub evidence: Vec<Evidence>,
394}
395
396impl CheckResult {
397 /// Build a passing check result with the given name.
398 ///
399 /// # Example
400 ///
401 /// ```
402 /// use dev_report::{CheckResult, Verdict};
403 ///
404 /// let c = CheckResult::pass("compile");
405 /// assert_eq!(c.verdict, Verdict::Pass);
406 /// assert!(c.severity.is_none());
407 /// ```
408 pub fn pass(name: impl Into<String>) -> Self {
409 Self {
410 name: name.into(),
411 verdict: Verdict::Pass,
412 severity: None,
413 detail: None,
414 at: Utc::now(),
415 duration_ms: None,
416 tags: Vec::new(),
417 evidence: Vec::new(),
418 }
419 }
420
421 /// Build a failing check result with the given name and severity.
422 ///
423 /// # Example
424 ///
425 /// ```
426 /// use dev_report::{CheckResult, Severity, Verdict};
427 ///
428 /// let c = CheckResult::fail("test::round_trip", Severity::Error);
429 /// assert_eq!(c.verdict, Verdict::Fail);
430 /// assert_eq!(c.severity, Some(Severity::Error));
431 /// ```
432 pub fn fail(name: impl Into<String>, severity: Severity) -> Self {
433 Self {
434 name: name.into(),
435 verdict: Verdict::Fail,
436 severity: Some(severity),
437 detail: None,
438 at: Utc::now(),
439 duration_ms: None,
440 tags: Vec::new(),
441 evidence: Vec::new(),
442 }
443 }
444
445 /// Build a warning check result with the given name and severity.
446 ///
447 /// # Example
448 ///
449 /// ```
450 /// use dev_report::{CheckResult, Severity, Verdict};
451 ///
452 /// let c = CheckResult::warn("flaky", Severity::Warning);
453 /// assert_eq!(c.verdict, Verdict::Warn);
454 /// ```
455 pub fn warn(name: impl Into<String>, severity: Severity) -> Self {
456 Self {
457 name: name.into(),
458 verdict: Verdict::Warn,
459 severity: Some(severity),
460 detail: None,
461 at: Utc::now(),
462 duration_ms: None,
463 tags: Vec::new(),
464 evidence: Vec::new(),
465 }
466 }
467
468 /// Build a skipped check result with the given name.
469 ///
470 /// # Example
471 ///
472 /// ```
473 /// use dev_report::{CheckResult, Verdict};
474 ///
475 /// let c = CheckResult::skip("not_applicable");
476 /// assert_eq!(c.verdict, Verdict::Skip);
477 /// ```
478 pub fn skip(name: impl Into<String>) -> Self {
479 Self {
480 name: name.into(),
481 verdict: Verdict::Skip,
482 severity: None,
483 detail: None,
484 at: Utc::now(),
485 duration_ms: None,
486 tags: Vec::new(),
487 evidence: Vec::new(),
488 }
489 }
490
491 /// Attach a human-readable detail to this check result.
492 ///
493 /// # Example
494 ///
495 /// ```
496 /// use dev_report::CheckResult;
497 ///
498 /// let c = CheckResult::pass("a").with_detail("ran in single thread");
499 /// assert_eq!(c.detail.as_deref(), Some("ran in single thread"));
500 /// ```
501 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
502 self.detail = Some(detail.into());
503 self
504 }
505
506 /// Attach a duration measurement (milliseconds) to this check result.
507 ///
508 /// # Example
509 ///
510 /// ```
511 /// use dev_report::CheckResult;
512 ///
513 /// let c = CheckResult::pass("a").with_duration_ms(42);
514 /// assert_eq!(c.duration_ms, Some(42));
515 /// ```
516 pub fn with_duration_ms(mut self, ms: u64) -> Self {
517 self.duration_ms = Some(ms);
518 self
519 }
520
521 /// Override the severity of this check result.
522 ///
523 /// Useful when escalating or de-escalating a check after construction
524 /// (e.g. promote a `Warn+Warning` to `Warn+Error` based on a config flag).
525 ///
526 /// # Example
527 ///
528 /// ```
529 /// use dev_report::{CheckResult, Severity};
530 ///
531 /// let c = CheckResult::warn("flaky", Severity::Warning)
532 /// .with_severity(Severity::Error);
533 /// assert_eq!(c.severity, Some(Severity::Error));
534 /// ```
535 pub fn with_severity(mut self, severity: Severity) -> Self {
536 self.severity = Some(severity);
537 self
538 }
539
540 /// Attach a single tag to this check result.
541 ///
542 /// # Example
543 ///
544 /// ```
545 /// use dev_report::CheckResult;
546 ///
547 /// let c = CheckResult::pass("compile").with_tag("slow");
548 /// assert!(c.has_tag("slow"));
549 /// ```
550 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
551 self.tags.push(tag.into());
552 self
553 }
554
555 /// Attach many tags at once from any iterable of strings.
556 ///
557 /// # Example
558 ///
559 /// ```
560 /// use dev_report::CheckResult;
561 ///
562 /// let c = CheckResult::pass("compile").with_tags(["slow", "flaky"]);
563 /// assert!(c.has_tag("flaky"));
564 /// ```
565 pub fn with_tags<I, S>(mut self, tags: I) -> Self
566 where
567 I: IntoIterator<Item = S>,
568 S: Into<String>,
569 {
570 self.tags.extend(tags.into_iter().map(Into::into));
571 self
572 }
573
574 /// Return `true` if this check has the given tag.
575 ///
576 /// # Example
577 ///
578 /// ```
579 /// use dev_report::CheckResult;
580 ///
581 /// let c = CheckResult::pass("compile").with_tag("slow");
582 /// assert!(c.has_tag("slow"));
583 /// assert!(!c.has_tag("flaky"));
584 /// ```
585 pub fn has_tag(&self, tag: &str) -> bool {
586 self.tags.iter().any(|t| t == tag)
587 }
588
589 /// Attach a single piece of [`Evidence`] to this check result.
590 ///
591 /// # Example
592 ///
593 /// ```
594 /// use dev_report::{CheckResult, Evidence};
595 ///
596 /// let c = CheckResult::pass("bench")
597 /// .with_evidence(Evidence::numeric("mean_ns", 1234.0));
598 /// assert_eq!(c.evidence.len(), 1);
599 /// ```
600 pub fn with_evidence(mut self, e: Evidence) -> Self {
601 self.evidence.push(e);
602 self
603 }
604
605 /// Attach many [`Evidence`] items at once from any iterable.
606 ///
607 /// # Example
608 ///
609 /// ```
610 /// use dev_report::{CheckResult, Evidence};
611 ///
612 /// let c = CheckResult::pass("bench").with_evidences([
613 /// Evidence::numeric("mean_ns", 1234.0),
614 /// Evidence::numeric("baseline_ns", 1100.0),
615 /// ]);
616 /// assert_eq!(c.evidence.len(), 2);
617 /// ```
618 pub fn with_evidences<I>(mut self, items: I) -> Self
619 where
620 I: IntoIterator<Item = Evidence>,
621 {
622 self.evidence.extend(items);
623 self
624 }
625}
626
627/// A full report. The output of one verification run.
628///
629/// # Example
630///
631/// ```
632/// use dev_report::{CheckResult, Report, Verdict};
633///
634/// let mut r = Report::new("my-crate", "0.1.0").with_producer("my-harness");
635/// r.push(CheckResult::pass("compile"));
636/// r.finish();
637/// assert_eq!(r.overall_verdict(), Verdict::Pass);
638/// ```
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct Report {
641 /// Schema version for this report format.
642 pub schema_version: u32,
643 /// Crate or project being reported on.
644 pub subject: String,
645 /// Version of the subject at the time of the run.
646 pub subject_version: String,
647 /// Producer of the report (e.g. `dev-bench`, `dev-async`).
648 pub producer: Option<String>,
649 /// Time the report was started.
650 pub started_at: DateTime<Utc>,
651 /// Time the report was finalized.
652 pub finished_at: Option<DateTime<Utc>>,
653 /// All individual check results in this report.
654 pub checks: Vec<CheckResult>,
655}
656
657impl Report {
658 /// Begin a new report for the given subject and version.
659 ///
660 /// # Example
661 ///
662 /// ```
663 /// use dev_report::Report;
664 ///
665 /// let r = Report::new("my-crate", "0.1.0");
666 /// assert_eq!(r.subject, "my-crate");
667 /// assert_eq!(r.schema_version, 1);
668 /// ```
669 pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
670 Self {
671 schema_version: 1,
672 subject: subject.into(),
673 subject_version: subject_version.into(),
674 producer: None,
675 started_at: Utc::now(),
676 finished_at: None,
677 checks: Vec::new(),
678 }
679 }
680
681 /// Set the producer of this report.
682 ///
683 /// # Example
684 ///
685 /// ```
686 /// use dev_report::Report;
687 ///
688 /// let r = Report::new("crate", "0.1.0").with_producer("dev-bench");
689 /// assert_eq!(r.producer.as_deref(), Some("dev-bench"));
690 /// ```
691 pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
692 self.producer = Some(producer.into());
693 self
694 }
695
696 /// Append a check result to this report.
697 ///
698 /// # Example
699 ///
700 /// ```
701 /// use dev_report::{CheckResult, Report};
702 ///
703 /// let mut r = Report::new("crate", "0.1.0");
704 /// r.push(CheckResult::pass("compile"));
705 /// assert_eq!(r.checks.len(), 1);
706 /// ```
707 pub fn push(&mut self, result: CheckResult) {
708 self.checks.push(result);
709 }
710
711 /// Mark the report as finished, stamping the finish time.
712 ///
713 /// # Example
714 ///
715 /// ```
716 /// use dev_report::Report;
717 ///
718 /// let mut r = Report::new("crate", "0.1.0");
719 /// r.finish();
720 /// assert!(r.finished_at.is_some());
721 /// ```
722 pub fn finish(&mut self) {
723 self.finished_at = Some(Utc::now());
724 }
725
726 /// Override `started_at` with a fixed timestamp.
727 ///
728 /// Useful when reconstructing a `Report` from external data
729 /// (replay, import from a different schema, deterministic test
730 /// fixtures). Most producers should let `Report::new` capture the
731 /// real start time and not call this.
732 ///
733 /// # Example
734 ///
735 /// ```
736 /// use chrono::TimeZone;
737 /// use dev_report::Report;
738 ///
739 /// let mut r = Report::new("crate", "0.1.0");
740 /// let frozen = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
741 /// r.set_started_at(frozen);
742 /// assert_eq!(r.started_at, frozen);
743 /// ```
744 pub fn set_started_at(&mut self, ts: DateTime<Utc>) {
745 self.started_at = ts;
746 }
747
748 /// Override `finished_at` with a fixed timestamp.
749 ///
750 /// Useful for replay / import scenarios where the real finish
751 /// time is known but `Utc::now()` would be wrong.
752 ///
753 /// # Example
754 ///
755 /// ```
756 /// use chrono::TimeZone;
757 /// use dev_report::Report;
758 ///
759 /// let mut r = Report::new("crate", "0.1.0");
760 /// let frozen = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 1).unwrap();
761 /// r.set_finished_at(Some(frozen));
762 /// assert_eq!(r.finished_at, Some(frozen));
763 /// ```
764 pub fn set_finished_at(&mut self, ts: Option<DateTime<Utc>>) {
765 self.finished_at = ts;
766 }
767
768 /// Count of checks per verdict, returned as `(pass, fail, warn, skip)`.
769 ///
770 /// # Example
771 ///
772 /// ```
773 /// use dev_report::{CheckResult, Report, Severity};
774 ///
775 /// let mut r = Report::new("c", "0.1.0");
776 /// r.push(CheckResult::pass("a"));
777 /// r.push(CheckResult::pass("b"));
778 /// r.push(CheckResult::fail("c", Severity::Error));
779 /// let (pass, fail, warn, skip) = r.verdict_counts();
780 /// assert_eq!((pass, fail, warn, skip), (2, 1, 0, 0));
781 /// ```
782 pub fn verdict_counts(&self) -> (usize, usize, usize, usize) {
783 let (mut p, mut f, mut w, mut s) = (0, 0, 0, 0);
784 for c in &self.checks {
785 match c.verdict {
786 Verdict::Pass => p += 1,
787 Verdict::Fail => f += 1,
788 Verdict::Warn => w += 1,
789 Verdict::Skip => s += 1,
790 }
791 }
792 (p, f, w, s)
793 }
794
795 /// Compute the overall verdict for this report.
796 ///
797 /// Rules:
798 /// - Any `Fail` -> `Fail`
799 /// - Else any `Warn` -> `Warn`
800 /// - Else any `Pass` -> `Pass`
801 /// - Else (all `Skip` or empty) -> `Skip`
802 ///
803 /// # Example
804 ///
805 /// ```
806 /// use dev_report::{CheckResult, Report, Severity, Verdict};
807 ///
808 /// let mut r = Report::new("crate", "0.1.0");
809 /// r.push(CheckResult::pass("a"));
810 /// r.push(CheckResult::fail("b", Severity::Error));
811 /// assert_eq!(r.overall_verdict(), Verdict::Fail);
812 /// ```
813 pub fn overall_verdict(&self) -> Verdict {
814 let mut saw_fail = false;
815 let mut saw_warn = false;
816 let mut saw_pass = false;
817 for c in &self.checks {
818 match c.verdict {
819 Verdict::Fail => saw_fail = true,
820 Verdict::Warn => saw_warn = true,
821 Verdict::Pass => saw_pass = true,
822 Verdict::Skip => {}
823 }
824 }
825 if saw_fail {
826 Verdict::Fail
827 } else if saw_warn {
828 Verdict::Warn
829 } else if saw_pass {
830 Verdict::Pass
831 } else {
832 Verdict::Skip
833 }
834 }
835
836 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Pass`.
837 ///
838 /// # Example
839 ///
840 /// ```
841 /// use dev_report::{CheckResult, Report};
842 ///
843 /// let mut r = Report::new("c", "0.1.0");
844 /// r.push(CheckResult::pass("ok"));
845 /// assert!(r.passed());
846 /// ```
847 pub fn passed(&self) -> bool {
848 self.overall_verdict() == Verdict::Pass
849 }
850
851 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Fail`.
852 ///
853 /// # Example
854 ///
855 /// ```
856 /// use dev_report::{CheckResult, Report, Severity};
857 ///
858 /// let mut r = Report::new("c", "0.1.0");
859 /// r.push(CheckResult::fail("oops", Severity::Error));
860 /// assert!(r.failed());
861 /// ```
862 pub fn failed(&self) -> bool {
863 self.overall_verdict() == Verdict::Fail
864 }
865
866 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Warn`.
867 pub fn warned(&self) -> bool {
868 self.overall_verdict() == Verdict::Warn
869 }
870
871 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Skip`
872 /// (all checks were skipped, or there were no checks).
873 pub fn skipped(&self) -> bool {
874 self.overall_verdict() == Verdict::Skip
875 }
876
877 /// Iterate over checks whose [`severity`](CheckResult::severity)
878 /// matches the given level.
879 ///
880 /// `Pass` and `Skip` checks have `severity = None` and never match.
881 ///
882 /// # Example
883 ///
884 /// ```
885 /// use dev_report::{CheckResult, Report, Severity};
886 ///
887 /// let mut r = Report::new("c", "0.1.0");
888 /// r.push(CheckResult::fail("a", Severity::Error));
889 /// r.push(CheckResult::warn("b", Severity::Warning));
890 /// r.push(CheckResult::fail("c", Severity::Error));
891 ///
892 /// let errors: Vec<_> = r.checks_with_severity(Severity::Error).collect();
893 /// assert_eq!(errors.len(), 2);
894 /// ```
895 pub fn checks_with_severity(&self, severity: Severity) -> impl Iterator<Item = &CheckResult> {
896 self.checks
897 .iter()
898 .filter(move |c| c.severity == Some(severity))
899 }
900
901 /// Iterate over checks that carry the given tag.
902 ///
903 /// # Example
904 ///
905 /// ```
906 /// use dev_report::{CheckResult, Report};
907 ///
908 /// let mut r = Report::new("crate", "0.1.0");
909 /// r.push(CheckResult::pass("a").with_tag("slow"));
910 /// r.push(CheckResult::pass("b"));
911 /// r.push(CheckResult::pass("c").with_tag("slow"));
912 ///
913 /// let slow: Vec<_> = r.checks_with_tag("slow").collect();
914 /// assert_eq!(slow.len(), 2);
915 /// ```
916 pub fn checks_with_tag<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a CheckResult> {
917 self.checks.iter().filter(move |c| c.has_tag(tag))
918 }
919
920 /// Serialize this report to JSON.
921 ///
922 /// # Example
923 ///
924 /// ```
925 /// use dev_report::Report;
926 ///
927 /// let r = Report::new("crate", "0.1.0");
928 /// let json = r.to_json().unwrap();
929 /// assert!(json.contains("\"subject\": \"crate\""));
930 /// ```
931 pub fn to_json(&self) -> serde_json::Result<String> {
932 serde_json::to_string_pretty(self)
933 }
934
935 /// Deserialize a report from JSON.
936 ///
937 /// # Example
938 ///
939 /// ```
940 /// use dev_report::Report;
941 ///
942 /// let r = Report::new("crate", "0.1.0");
943 /// let json = r.to_json().unwrap();
944 /// let parsed = Report::from_json(&json).unwrap();
945 /// assert_eq!(parsed.subject, "crate");
946 /// ```
947 pub fn from_json(s: &str) -> serde_json::Result<Self> {
948 serde_json::from_str(s)
949 }
950
951 /// Render this report to a TTY-friendly string. Monochrome.
952 ///
953 /// Available with the `terminal` feature.
954 #[cfg(feature = "terminal")]
955 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
956 pub fn to_terminal(&self) -> String {
957 terminal::to_terminal(self)
958 }
959
960 /// Render this report with ANSI color codes.
961 ///
962 /// Available with the `terminal` feature.
963 #[cfg(feature = "terminal")]
964 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
965 pub fn to_terminal_color(&self) -> String {
966 terminal::to_terminal_color(self)
967 }
968
969 /// Render this report to a Markdown string.
970 ///
971 /// Available with the `markdown` feature.
972 #[cfg(feature = "markdown")]
973 #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
974 pub fn to_markdown(&self) -> String {
975 markdown::to_markdown(self)
976 }
977
978 /// Render this report as a SARIF 2.1.0 document.
979 ///
980 /// Only `Fail` and `Warn` checks are emitted; `Pass` and `Skip` are
981 /// omitted (SARIF is a defect report format). See [`crate::sarif`]
982 /// for the severity-to-level mapping.
983 ///
984 /// Available with the `sarif` feature.
985 #[cfg(feature = "sarif")]
986 #[cfg_attr(docsrs, doc(cfg(feature = "sarif")))]
987 pub fn to_sarif(&self) -> String {
988 sarif::to_sarif(self)
989 }
990
991 /// Render this report as a JUnit XML document.
992 ///
993 /// Every check becomes a `<testcase>`; fail verdicts emit a
994 /// `<failure>` child, skip verdicts emit a `<skipped/>` child. See
995 /// [`crate::junit`] for the verdict-to-element mapping.
996 ///
997 /// Available with the `junit` feature.
998 #[cfg(feature = "junit")]
999 #[cfg_attr(docsrs, doc(cfg(feature = "junit")))]
1000 pub fn to_junit_xml(&self) -> String {
1001 junit::to_junit_xml(self)
1002 }
1003
1004 /// Compare this report against a baseline using default options.
1005 ///
1006 /// `self` is the new report; `baseline` is the previous one.
1007 /// Default options flag duration regressions over 20% slower.
1008 ///
1009 /// # Example
1010 ///
1011 /// ```
1012 /// use dev_report::{CheckResult, Report, Severity};
1013 ///
1014 /// let mut prev = Report::new("c", "0.1.0");
1015 /// prev.push(CheckResult::pass("a"));
1016 ///
1017 /// let mut curr = Report::new("c", "0.1.0");
1018 /// curr.push(CheckResult::fail("a", Severity::Error));
1019 ///
1020 /// let diff = curr.diff(&prev);
1021 /// assert_eq!(diff.newly_failing, vec!["a".to_string()]);
1022 /// ```
1023 pub fn diff(&self, baseline: &Self) -> Diff {
1024 diff::diff_reports(self, baseline, &DiffOptions::default())
1025 }
1026
1027 /// Compare this report against a baseline using custom options.
1028 ///
1029 /// # Example
1030 ///
1031 /// ```
1032 /// use dev_report::{CheckResult, DiffOptions, Report};
1033 ///
1034 /// let mut prev = Report::new("c", "0.1.0");
1035 /// prev.push(CheckResult::pass("a").with_duration_ms(100));
1036 ///
1037 /// let mut curr = Report::new("c", "0.1.0");
1038 /// curr.push(CheckResult::pass("a").with_duration_ms(150));
1039 ///
1040 /// let opts = DiffOptions {
1041 /// duration_regression_pct: Some(10.0),
1042 /// duration_regression_abs_ms: None,
1043 /// };
1044 /// let diff = curr.diff_with(&prev, &opts);
1045 /// assert_eq!(diff.duration_regressions.len(), 1);
1046 /// ```
1047 pub fn diff_with(&self, baseline: &Self, opts: &DiffOptions) -> Diff {
1048 diff::diff_reports(self, baseline, opts)
1049 }
1050}
1051
1052/// A producer of reports. Implement this on your harness type to integrate
1053/// with the dev-* suite.
1054///
1055/// # Example
1056///
1057/// ```
1058/// use dev_report::{CheckResult, Producer, Report};
1059///
1060/// struct CompileChecker;
1061/// impl Producer for CompileChecker {
1062/// fn produce(&self) -> Report {
1063/// let mut r = Report::new("my-crate", "0.1.0").with_producer("compile-checker");
1064/// r.push(CheckResult::pass("compile"));
1065/// r.finish();
1066/// r
1067/// }
1068/// }
1069///
1070/// let r = CompileChecker.produce();
1071/// assert_eq!(r.checks.len(), 1);
1072/// ```
1073pub trait Producer {
1074 /// Run the producer and return a finalized report.
1075 ///
1076 /// Implementations SHOULD encode setup failures as `Fail` checks
1077 /// inside the returned `Report` rather than panicking.
1078 fn produce(&self) -> Report;
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 #[test]
1086 fn build_and_roundtrip_a_report() {
1087 let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-self-test");
1088 r.push(CheckResult::pass("compile"));
1089 r.push(CheckResult::fail("unit::math", Severity::Error).with_detail("off by one"));
1090 r.finish();
1091
1092 let json = r.to_json().unwrap();
1093 let parsed = Report::from_json(&json).unwrap();
1094 assert_eq!(parsed.subject, "widget");
1095 assert_eq!(parsed.checks.len(), 2);
1096 assert_eq!(parsed.overall_verdict(), Verdict::Fail);
1097 }
1098
1099 #[test]
1100 fn empty_report_is_skip() {
1101 let r = Report::new("nothing", "0.0.0");
1102 assert_eq!(r.overall_verdict(), Verdict::Skip);
1103 }
1104
1105 #[test]
1106 fn tags_attach_and_query() {
1107 let c = CheckResult::pass("compile")
1108 .with_tag("slow")
1109 .with_tags(["flaky", "bench"]);
1110 assert!(c.has_tag("slow"));
1111 assert!(c.has_tag("flaky"));
1112 assert!(c.has_tag("bench"));
1113 assert!(!c.has_tag("missing"));
1114 assert_eq!(c.tags.len(), 3);
1115 }
1116
1117 #[test]
1118 fn evidence_constructors_set_kind() {
1119 assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
1120 assert_eq!(
1121 Evidence::kv("env", [("K", "V")]).kind(),
1122 EvidenceKind::KeyValue
1123 );
1124 assert_eq!(
1125 Evidence::snippet("log", "boom").kind(),
1126 EvidenceKind::Snippet
1127 );
1128 assert_eq!(
1129 Evidence::file_ref("src", "lib.rs").kind(),
1130 EvidenceKind::FileRef
1131 );
1132 assert_eq!(
1133 Evidence::file_ref_lines("src", "lib.rs", 1, 2).kind(),
1134 EvidenceKind::FileRef
1135 );
1136 }
1137
1138 #[test]
1139 fn evidence_round_trips_through_json() {
1140 let mut r = Report::new("subject", "0.2.0");
1141 r.push(
1142 CheckResult::pass("bench::parse")
1143 .with_tag("bench")
1144 .with_evidence(Evidence::numeric("mean_ns", 1234.5))
1145 .with_evidence(Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]))
1146 .with_evidence(Evidence::snippet("note", "fast path taken"))
1147 .with_evidence(Evidence::file_ref_lines("site", "src/parse.rs", 10, 20)),
1148 );
1149 r.finish();
1150
1151 let json = r.to_json().unwrap();
1152 let parsed = Report::from_json(&json).unwrap();
1153 assert_eq!(parsed.checks.len(), 1);
1154 let c = &parsed.checks[0];
1155 assert_eq!(c.tags, vec!["bench".to_string()]);
1156 assert_eq!(c.evidence.len(), 4);
1157 assert_eq!(c.evidence[0].kind(), EvidenceKind::Numeric);
1158 assert_eq!(c.evidence[1].kind(), EvidenceKind::KeyValue);
1159 assert_eq!(c.evidence[2].kind(), EvidenceKind::Snippet);
1160 assert_eq!(c.evidence[3].kind(), EvidenceKind::FileRef);
1161 }
1162
1163 #[test]
1164 fn v0_1_0_json_deserializes_with_empty_tags_and_evidence() {
1165 // A v0.1.0-shaped report (no tags, no evidence) MUST still parse.
1166 let v0_1_0_json = r#"{
1167 "schema_version": 1,
1168 "subject": "legacy",
1169 "subject_version": "0.1.0",
1170 "producer": "dev-report-self-test",
1171 "started_at": "2026-01-01T00:00:00Z",
1172 "finished_at": "2026-01-01T00:00:01Z",
1173 "checks": [
1174 {
1175 "name": "compile",
1176 "verdict": "pass",
1177 "severity": null,
1178 "detail": null,
1179 "at": "2026-01-01T00:00:00Z",
1180 "duration_ms": null
1181 }
1182 ]
1183 }"#;
1184 let parsed = Report::from_json(v0_1_0_json).unwrap();
1185 assert_eq!(parsed.checks.len(), 1);
1186 let c = &parsed.checks[0];
1187 assert!(c.tags.is_empty());
1188 assert!(c.evidence.is_empty());
1189 assert_eq!(parsed.overall_verdict(), Verdict::Pass);
1190 }
1191
1192 #[test]
1193 fn checks_with_tag_filters() {
1194 let mut r = Report::new("subject", "0.2.0");
1195 r.push(CheckResult::pass("a").with_tag("slow"));
1196 r.push(CheckResult::pass("b"));
1197 r.push(CheckResult::pass("c").with_tags(["slow", "flaky"]));
1198 let slow: Vec<&CheckResult> = r.checks_with_tag("slow").collect();
1199 assert_eq!(slow.len(), 2);
1200 assert_eq!(slow[0].name, "a");
1201 assert_eq!(slow[1].name, "c");
1202 }
1203
1204 #[test]
1205 fn with_severity_overrides_severity() {
1206 let c = CheckResult::warn("x", Severity::Warning).with_severity(Severity::Error);
1207 assert_eq!(c.severity, Some(Severity::Error));
1208 }
1209
1210 #[test]
1211 fn empty_tags_and_evidence_are_omitted_in_json() {
1212 let mut r = Report::new("s", "0.2.0");
1213 r.push(CheckResult::pass("a"));
1214 let json = r.to_json().unwrap();
1215 assert!(!json.contains("\"tags\""));
1216 assert!(!json.contains("\"evidence\""));
1217 }
1218
1219 #[test]
1220 fn evidence_numeric_int_preserves_value() {
1221 let e = Evidence::numeric_int("count", 1_000_000_i64);
1222 if let EvidenceData::Numeric(n) = e.data {
1223 assert_eq!(n as i64, 1_000_000_i64);
1224 } else {
1225 panic!("expected Numeric");
1226 }
1227 }
1228
1229 #[test]
1230 fn report_passed_failed_warned_skipped_shortcuts() {
1231 let mut p = Report::new("c", "0.1.0");
1232 p.push(CheckResult::pass("ok"));
1233 assert!(p.passed() && !p.failed() && !p.warned() && !p.skipped());
1234
1235 let mut f = Report::new("c", "0.1.0");
1236 f.push(CheckResult::fail("oops", Severity::Error));
1237 assert!(f.failed() && !f.passed());
1238
1239 let mut w = Report::new("c", "0.1.0");
1240 w.push(CheckResult::warn("flaky", Severity::Warning));
1241 assert!(w.warned() && !w.passed() && !w.failed());
1242
1243 let s = Report::new("c", "0.1.0");
1244 assert!(s.skipped() && !s.passed());
1245 }
1246
1247 #[test]
1248 fn checks_with_severity_filters_by_severity() {
1249 let mut r = Report::new("c", "0.1.0");
1250 r.push(CheckResult::fail("a", Severity::Error));
1251 r.push(CheckResult::warn("b", Severity::Warning));
1252 r.push(CheckResult::fail("c", Severity::Error));
1253 r.push(CheckResult::pass("d"));
1254
1255 let errs: Vec<_> = r.checks_with_severity(Severity::Error).collect();
1256 assert_eq!(errs.len(), 2);
1257 assert_eq!(errs[0].name, "a");
1258 assert_eq!(errs[1].name, "c");
1259
1260 let warns: Vec<_> = r.checks_with_severity(Severity::Warning).collect();
1261 assert_eq!(warns.len(), 1);
1262 }
1263
1264 #[test]
1265 fn report_verdict_counts() {
1266 let mut r = Report::new("c", "0.1.0");
1267 r.push(CheckResult::pass("a"));
1268 r.push(CheckResult::pass("b"));
1269 r.push(CheckResult::fail("c", Severity::Error));
1270 r.push(CheckResult::warn("d", Severity::Warning));
1271 r.push(CheckResult::skip("e"));
1272 assert_eq!(r.verdict_counts(), (2, 1, 1, 1));
1273 }
1274
1275 #[test]
1276 fn report_set_started_finished_at_overrides() {
1277 use chrono::TimeZone;
1278 let mut r = Report::new("c", "0.1.0");
1279 let frozen_start = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1280 let frozen_end = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 1).unwrap();
1281 r.set_started_at(frozen_start);
1282 r.set_finished_at(Some(frozen_end));
1283 assert_eq!(r.started_at, frozen_start);
1284 assert_eq!(r.finished_at, Some(frozen_end));
1285 // Round-trip through JSON preserves the override.
1286 let json = r.to_json().unwrap();
1287 let parsed = Report::from_json(&json).unwrap();
1288 assert_eq!(parsed.started_at, frozen_start);
1289 assert_eq!(parsed.finished_at, Some(frozen_end));
1290 }
1291
1292 // ------------------------------------------------------------
1293 // Verdict precedence: explicit coverage of every transition edge.
1294 // Required by DIRECTIVES.md section 7 and REPS section 6.
1295 // Order: Fail > Warn > Pass > Skip (and empty -> Skip).
1296 // ------------------------------------------------------------
1297
1298 fn r_with(checks: &[Verdict]) -> Report {
1299 let mut r = Report::new("vp", "0.0.0");
1300 for v in checks {
1301 r.push(match v {
1302 Verdict::Pass => CheckResult::pass("c"),
1303 Verdict::Fail => CheckResult::fail("c", Severity::Error),
1304 Verdict::Warn => CheckResult::warn("c", Severity::Warning),
1305 Verdict::Skip => CheckResult::skip("c"),
1306 });
1307 }
1308 r
1309 }
1310
1311 #[test]
1312 fn vp_empty_is_skip() {
1313 assert_eq!(r_with(&[]).overall_verdict(), Verdict::Skip);
1314 }
1315
1316 #[test]
1317 fn vp_only_skip_is_skip() {
1318 assert_eq!(
1319 r_with(&[Verdict::Skip, Verdict::Skip]).overall_verdict(),
1320 Verdict::Skip
1321 );
1322 }
1323
1324 #[test]
1325 fn vp_only_pass_is_pass() {
1326 assert_eq!(r_with(&[Verdict::Pass]).overall_verdict(), Verdict::Pass);
1327 }
1328
1329 #[test]
1330 fn vp_pass_with_skip_is_pass() {
1331 assert_eq!(
1332 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Skip]).overall_verdict(),
1333 Verdict::Pass
1334 );
1335 }
1336
1337 #[test]
1338 fn vp_only_warn_is_warn() {
1339 assert_eq!(r_with(&[Verdict::Warn]).overall_verdict(), Verdict::Warn);
1340 }
1341
1342 #[test]
1343 fn vp_warn_with_pass_is_warn() {
1344 assert_eq!(
1345 r_with(&[Verdict::Pass, Verdict::Warn]).overall_verdict(),
1346 Verdict::Warn
1347 );
1348 }
1349
1350 #[test]
1351 fn vp_warn_with_skip_is_warn() {
1352 assert_eq!(
1353 r_with(&[Verdict::Skip, Verdict::Warn]).overall_verdict(),
1354 Verdict::Warn
1355 );
1356 }
1357
1358 #[test]
1359 fn vp_warn_with_pass_and_skip_is_warn() {
1360 assert_eq!(
1361 r_with(&[Verdict::Pass, Verdict::Skip, Verdict::Warn]).overall_verdict(),
1362 Verdict::Warn
1363 );
1364 }
1365
1366 #[test]
1367 fn vp_only_fail_is_fail() {
1368 assert_eq!(r_with(&[Verdict::Fail]).overall_verdict(), Verdict::Fail);
1369 }
1370
1371 #[test]
1372 fn vp_fail_with_pass_is_fail() {
1373 assert_eq!(
1374 r_with(&[Verdict::Pass, Verdict::Fail]).overall_verdict(),
1375 Verdict::Fail
1376 );
1377 }
1378
1379 #[test]
1380 fn vp_fail_with_warn_is_fail() {
1381 assert_eq!(
1382 r_with(&[Verdict::Warn, Verdict::Fail]).overall_verdict(),
1383 Verdict::Fail
1384 );
1385 }
1386
1387 #[test]
1388 fn vp_fail_with_skip_is_fail() {
1389 assert_eq!(
1390 r_with(&[Verdict::Skip, Verdict::Fail]).overall_verdict(),
1391 Verdict::Fail
1392 );
1393 }
1394
1395 #[test]
1396 fn vp_fail_dominates_all_others() {
1397 assert_eq!(
1398 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Warn, Verdict::Fail,])
1399 .overall_verdict(),
1400 Verdict::Fail
1401 );
1402 }
1403
1404 #[test]
1405 fn vp_order_independence() {
1406 // Precedence MUST NOT depend on insertion order.
1407 let a = r_with(&[Verdict::Fail, Verdict::Warn, Verdict::Pass]).overall_verdict();
1408 let b = r_with(&[Verdict::Pass, Verdict::Warn, Verdict::Fail]).overall_verdict();
1409 let c = r_with(&[Verdict::Warn, Verdict::Pass, Verdict::Fail]).overall_verdict();
1410 assert_eq!(a, Verdict::Fail);
1411 assert_eq!(a, b);
1412 assert_eq!(b, c);
1413 }
1414}