Skip to main content

dev_report/
lib.rs

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