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        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}