Skip to main content

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