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