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}