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