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