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