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 /// Compute the overall verdict for this report.
719 ///
720 /// Rules:
721 /// - Any `Fail` -> `Fail`
722 /// - Else any `Warn` -> `Warn`
723 /// - Else any `Pass` -> `Pass`
724 /// - Else (all `Skip` or empty) -> `Skip`
725 ///
726 /// # Example
727 ///
728 /// ```
729 /// use dev_report::{CheckResult, Report, Severity, Verdict};
730 ///
731 /// let mut r = Report::new("crate", "0.1.0");
732 /// r.push(CheckResult::pass("a"));
733 /// r.push(CheckResult::fail("b", Severity::Error));
734 /// assert_eq!(r.overall_verdict(), Verdict::Fail);
735 /// ```
736 pub fn overall_verdict(&self) -> Verdict {
737 let mut saw_fail = false;
738 let mut saw_warn = false;
739 let mut saw_pass = false;
740 for c in &self.checks {
741 match c.verdict {
742 Verdict::Fail => saw_fail = true,
743 Verdict::Warn => saw_warn = true,
744 Verdict::Pass => saw_pass = true,
745 Verdict::Skip => {}
746 }
747 }
748 if saw_fail {
749 Verdict::Fail
750 } else if saw_warn {
751 Verdict::Warn
752 } else if saw_pass {
753 Verdict::Pass
754 } else {
755 Verdict::Skip
756 }
757 }
758
759 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Pass`.
760 ///
761 /// # Example
762 ///
763 /// ```
764 /// use dev_report::{CheckResult, Report};
765 ///
766 /// let mut r = Report::new("c", "0.1.0");
767 /// r.push(CheckResult::pass("ok"));
768 /// assert!(r.passed());
769 /// ```
770 pub fn passed(&self) -> bool {
771 self.overall_verdict() == Verdict::Pass
772 }
773
774 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Fail`.
775 ///
776 /// # Example
777 ///
778 /// ```
779 /// use dev_report::{CheckResult, Report, Severity};
780 ///
781 /// let mut r = Report::new("c", "0.1.0");
782 /// r.push(CheckResult::fail("oops", Severity::Error));
783 /// assert!(r.failed());
784 /// ```
785 pub fn failed(&self) -> bool {
786 self.overall_verdict() == Verdict::Fail
787 }
788
789 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Warn`.
790 pub fn warned(&self) -> bool {
791 self.overall_verdict() == Verdict::Warn
792 }
793
794 /// `true` when [`overall_verdict`](Self::overall_verdict) is `Skip`
795 /// (all checks were skipped, or there were no checks).
796 pub fn skipped(&self) -> bool {
797 self.overall_verdict() == Verdict::Skip
798 }
799
800 /// Iterate over checks whose [`severity`](CheckResult::severity)
801 /// matches the given level.
802 ///
803 /// `Pass` and `Skip` checks have `severity = None` and never match.
804 ///
805 /// # Example
806 ///
807 /// ```
808 /// use dev_report::{CheckResult, Report, Severity};
809 ///
810 /// let mut r = Report::new("c", "0.1.0");
811 /// r.push(CheckResult::fail("a", Severity::Error));
812 /// r.push(CheckResult::warn("b", Severity::Warning));
813 /// r.push(CheckResult::fail("c", Severity::Error));
814 ///
815 /// let errors: Vec<_> = r.checks_with_severity(Severity::Error).collect();
816 /// assert_eq!(errors.len(), 2);
817 /// ```
818 pub fn checks_with_severity(&self, severity: Severity) -> impl Iterator<Item = &CheckResult> {
819 self.checks
820 .iter()
821 .filter(move |c| c.severity == Some(severity))
822 }
823
824 /// Iterate over checks that carry the given tag.
825 ///
826 /// # Example
827 ///
828 /// ```
829 /// use dev_report::{CheckResult, Report};
830 ///
831 /// let mut r = Report::new("crate", "0.1.0");
832 /// r.push(CheckResult::pass("a").with_tag("slow"));
833 /// r.push(CheckResult::pass("b"));
834 /// r.push(CheckResult::pass("c").with_tag("slow"));
835 ///
836 /// let slow: Vec<_> = r.checks_with_tag("slow").collect();
837 /// assert_eq!(slow.len(), 2);
838 /// ```
839 pub fn checks_with_tag<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a CheckResult> {
840 self.checks.iter().filter(move |c| c.has_tag(tag))
841 }
842
843 /// Serialize this report to JSON.
844 ///
845 /// # Example
846 ///
847 /// ```
848 /// use dev_report::Report;
849 ///
850 /// let r = Report::new("crate", "0.1.0");
851 /// let json = r.to_json().unwrap();
852 /// assert!(json.contains("\"subject\": \"crate\""));
853 /// ```
854 pub fn to_json(&self) -> serde_json::Result<String> {
855 serde_json::to_string_pretty(self)
856 }
857
858 /// Deserialize a report from JSON.
859 ///
860 /// # Example
861 ///
862 /// ```
863 /// use dev_report::Report;
864 ///
865 /// let r = Report::new("crate", "0.1.0");
866 /// let json = r.to_json().unwrap();
867 /// let parsed = Report::from_json(&json).unwrap();
868 /// assert_eq!(parsed.subject, "crate");
869 /// ```
870 pub fn from_json(s: &str) -> serde_json::Result<Self> {
871 serde_json::from_str(s)
872 }
873
874 /// Render this report to a TTY-friendly string. Monochrome.
875 ///
876 /// Available with the `terminal` feature.
877 #[cfg(feature = "terminal")]
878 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
879 pub fn to_terminal(&self) -> String {
880 terminal::to_terminal(self)
881 }
882
883 /// Render this report with ANSI color codes.
884 ///
885 /// Available with the `terminal` feature.
886 #[cfg(feature = "terminal")]
887 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
888 pub fn to_terminal_color(&self) -> String {
889 terminal::to_terminal_color(self)
890 }
891
892 /// Render this report to a Markdown string.
893 ///
894 /// Available with the `markdown` feature.
895 #[cfg(feature = "markdown")]
896 #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
897 pub fn to_markdown(&self) -> String {
898 markdown::to_markdown(self)
899 }
900
901 /// Compare this report against a baseline using default options.
902 ///
903 /// `self` is the new report; `baseline` is the previous one.
904 /// Default options flag duration regressions over 20% slower.
905 ///
906 /// # Example
907 ///
908 /// ```
909 /// use dev_report::{CheckResult, Report, Severity};
910 ///
911 /// let mut prev = Report::new("c", "0.1.0");
912 /// prev.push(CheckResult::pass("a"));
913 ///
914 /// let mut curr = Report::new("c", "0.1.0");
915 /// curr.push(CheckResult::fail("a", Severity::Error));
916 ///
917 /// let diff = curr.diff(&prev);
918 /// assert_eq!(diff.newly_failing, vec!["a".to_string()]);
919 /// ```
920 pub fn diff(&self, baseline: &Self) -> Diff {
921 diff::diff_reports(self, baseline, &DiffOptions::default())
922 }
923
924 /// Compare this report against a baseline using custom options.
925 ///
926 /// # Example
927 ///
928 /// ```
929 /// use dev_report::{CheckResult, DiffOptions, Report};
930 ///
931 /// let mut prev = Report::new("c", "0.1.0");
932 /// prev.push(CheckResult::pass("a").with_duration_ms(100));
933 ///
934 /// let mut curr = Report::new("c", "0.1.0");
935 /// curr.push(CheckResult::pass("a").with_duration_ms(150));
936 ///
937 /// let opts = DiffOptions {
938 /// duration_regression_pct: Some(10.0),
939 /// duration_regression_abs_ms: None,
940 /// };
941 /// let diff = curr.diff_with(&prev, &opts);
942 /// assert_eq!(diff.duration_regressions.len(), 1);
943 /// ```
944 pub fn diff_with(&self, baseline: &Self, opts: &DiffOptions) -> Diff {
945 diff::diff_reports(self, baseline, opts)
946 }
947}
948
949/// A producer of reports. Implement this on your harness type to integrate
950/// with the dev-* suite.
951///
952/// # Example
953///
954/// ```
955/// use dev_report::{CheckResult, Producer, Report};
956///
957/// struct CompileChecker;
958/// impl Producer for CompileChecker {
959/// fn produce(&self) -> Report {
960/// let mut r = Report::new("my-crate", "0.1.0").with_producer("compile-checker");
961/// r.push(CheckResult::pass("compile"));
962/// r.finish();
963/// r
964/// }
965/// }
966///
967/// let r = CompileChecker.produce();
968/// assert_eq!(r.checks.len(), 1);
969/// ```
970pub trait Producer {
971 /// Run the producer and return a finalized report.
972 ///
973 /// Implementations SHOULD encode setup failures as `Fail` checks
974 /// inside the returned `Report` rather than panicking.
975 fn produce(&self) -> Report;
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 #[test]
983 fn build_and_roundtrip_a_report() {
984 let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-self-test");
985 r.push(CheckResult::pass("compile"));
986 r.push(CheckResult::fail("unit::math", Severity::Error).with_detail("off by one"));
987 r.finish();
988
989 let json = r.to_json().unwrap();
990 let parsed = Report::from_json(&json).unwrap();
991 assert_eq!(parsed.subject, "widget");
992 assert_eq!(parsed.checks.len(), 2);
993 assert_eq!(parsed.overall_verdict(), Verdict::Fail);
994 }
995
996 #[test]
997 fn empty_report_is_skip() {
998 let r = Report::new("nothing", "0.0.0");
999 assert_eq!(r.overall_verdict(), Verdict::Skip);
1000 }
1001
1002 #[test]
1003 fn tags_attach_and_query() {
1004 let c = CheckResult::pass("compile")
1005 .with_tag("slow")
1006 .with_tags(["flaky", "bench"]);
1007 assert!(c.has_tag("slow"));
1008 assert!(c.has_tag("flaky"));
1009 assert!(c.has_tag("bench"));
1010 assert!(!c.has_tag("missing"));
1011 assert_eq!(c.tags.len(), 3);
1012 }
1013
1014 #[test]
1015 fn evidence_constructors_set_kind() {
1016 assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
1017 assert_eq!(
1018 Evidence::kv("env", [("K", "V")]).kind(),
1019 EvidenceKind::KeyValue
1020 );
1021 assert_eq!(
1022 Evidence::snippet("log", "boom").kind(),
1023 EvidenceKind::Snippet
1024 );
1025 assert_eq!(
1026 Evidence::file_ref("src", "lib.rs").kind(),
1027 EvidenceKind::FileRef
1028 );
1029 assert_eq!(
1030 Evidence::file_ref_lines("src", "lib.rs", 1, 2).kind(),
1031 EvidenceKind::FileRef
1032 );
1033 }
1034
1035 #[test]
1036 fn evidence_round_trips_through_json() {
1037 let mut r = Report::new("subject", "0.2.0");
1038 r.push(
1039 CheckResult::pass("bench::parse")
1040 .with_tag("bench")
1041 .with_evidence(Evidence::numeric("mean_ns", 1234.5))
1042 .with_evidence(Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]))
1043 .with_evidence(Evidence::snippet("note", "fast path taken"))
1044 .with_evidence(Evidence::file_ref_lines("site", "src/parse.rs", 10, 20)),
1045 );
1046 r.finish();
1047
1048 let json = r.to_json().unwrap();
1049 let parsed = Report::from_json(&json).unwrap();
1050 assert_eq!(parsed.checks.len(), 1);
1051 let c = &parsed.checks[0];
1052 assert_eq!(c.tags, vec!["bench".to_string()]);
1053 assert_eq!(c.evidence.len(), 4);
1054 assert_eq!(c.evidence[0].kind(), EvidenceKind::Numeric);
1055 assert_eq!(c.evidence[1].kind(), EvidenceKind::KeyValue);
1056 assert_eq!(c.evidence[2].kind(), EvidenceKind::Snippet);
1057 assert_eq!(c.evidence[3].kind(), EvidenceKind::FileRef);
1058 }
1059
1060 #[test]
1061 fn v0_1_0_json_deserializes_with_empty_tags_and_evidence() {
1062 // A v0.1.0-shaped report (no tags, no evidence) MUST still parse.
1063 let v0_1_0_json = r#"{
1064 "schema_version": 1,
1065 "subject": "legacy",
1066 "subject_version": "0.1.0",
1067 "producer": "dev-report-self-test",
1068 "started_at": "2026-01-01T00:00:00Z",
1069 "finished_at": "2026-01-01T00:00:01Z",
1070 "checks": [
1071 {
1072 "name": "compile",
1073 "verdict": "pass",
1074 "severity": null,
1075 "detail": null,
1076 "at": "2026-01-01T00:00:00Z",
1077 "duration_ms": null
1078 }
1079 ]
1080 }"#;
1081 let parsed = Report::from_json(v0_1_0_json).unwrap();
1082 assert_eq!(parsed.checks.len(), 1);
1083 let c = &parsed.checks[0];
1084 assert!(c.tags.is_empty());
1085 assert!(c.evidence.is_empty());
1086 assert_eq!(parsed.overall_verdict(), Verdict::Pass);
1087 }
1088
1089 #[test]
1090 fn checks_with_tag_filters() {
1091 let mut r = Report::new("subject", "0.2.0");
1092 r.push(CheckResult::pass("a").with_tag("slow"));
1093 r.push(CheckResult::pass("b"));
1094 r.push(CheckResult::pass("c").with_tags(["slow", "flaky"]));
1095 let slow: Vec<&CheckResult> = r.checks_with_tag("slow").collect();
1096 assert_eq!(slow.len(), 2);
1097 assert_eq!(slow[0].name, "a");
1098 assert_eq!(slow[1].name, "c");
1099 }
1100
1101 #[test]
1102 fn with_severity_overrides_severity() {
1103 let c = CheckResult::warn("x", Severity::Warning).with_severity(Severity::Error);
1104 assert_eq!(c.severity, Some(Severity::Error));
1105 }
1106
1107 #[test]
1108 fn empty_tags_and_evidence_are_omitted_in_json() {
1109 let mut r = Report::new("s", "0.2.0");
1110 r.push(CheckResult::pass("a"));
1111 let json = r.to_json().unwrap();
1112 assert!(!json.contains("\"tags\""));
1113 assert!(!json.contains("\"evidence\""));
1114 }
1115
1116 #[test]
1117 fn evidence_numeric_int_preserves_value() {
1118 let e = Evidence::numeric_int("count", 1_000_000_i64);
1119 if let EvidenceData::Numeric(n) = e.data {
1120 assert_eq!(n as i64, 1_000_000_i64);
1121 } else {
1122 panic!("expected Numeric");
1123 }
1124 }
1125
1126 #[test]
1127 fn report_passed_failed_warned_skipped_shortcuts() {
1128 let mut p = Report::new("c", "0.1.0");
1129 p.push(CheckResult::pass("ok"));
1130 assert!(p.passed() && !p.failed() && !p.warned() && !p.skipped());
1131
1132 let mut f = Report::new("c", "0.1.0");
1133 f.push(CheckResult::fail("oops", Severity::Error));
1134 assert!(f.failed() && !f.passed());
1135
1136 let mut w = Report::new("c", "0.1.0");
1137 w.push(CheckResult::warn("flaky", Severity::Warning));
1138 assert!(w.warned() && !w.passed() && !w.failed());
1139
1140 let s = Report::new("c", "0.1.0");
1141 assert!(s.skipped() && !s.passed());
1142 }
1143
1144 #[test]
1145 fn checks_with_severity_filters_by_severity() {
1146 let mut r = Report::new("c", "0.1.0");
1147 r.push(CheckResult::fail("a", Severity::Error));
1148 r.push(CheckResult::warn("b", Severity::Warning));
1149 r.push(CheckResult::fail("c", Severity::Error));
1150 r.push(CheckResult::pass("d"));
1151
1152 let errs: Vec<_> = r.checks_with_severity(Severity::Error).collect();
1153 assert_eq!(errs.len(), 2);
1154 assert_eq!(errs[0].name, "a");
1155 assert_eq!(errs[1].name, "c");
1156
1157 let warns: Vec<_> = r.checks_with_severity(Severity::Warning).collect();
1158 assert_eq!(warns.len(), 1);
1159 }
1160
1161 // ------------------------------------------------------------
1162 // Verdict precedence: explicit coverage of every transition edge.
1163 // Required by DIRECTIVES.md section 7 and REPS section 6.
1164 // Order: Fail > Warn > Pass > Skip (and empty -> Skip).
1165 // ------------------------------------------------------------
1166
1167 fn r_with(checks: &[Verdict]) -> Report {
1168 let mut r = Report::new("vp", "0.0.0");
1169 for v in checks {
1170 r.push(match v {
1171 Verdict::Pass => CheckResult::pass("c"),
1172 Verdict::Fail => CheckResult::fail("c", Severity::Error),
1173 Verdict::Warn => CheckResult::warn("c", Severity::Warning),
1174 Verdict::Skip => CheckResult::skip("c"),
1175 });
1176 }
1177 r
1178 }
1179
1180 #[test]
1181 fn vp_empty_is_skip() {
1182 assert_eq!(r_with(&[]).overall_verdict(), Verdict::Skip);
1183 }
1184
1185 #[test]
1186 fn vp_only_skip_is_skip() {
1187 assert_eq!(
1188 r_with(&[Verdict::Skip, Verdict::Skip]).overall_verdict(),
1189 Verdict::Skip
1190 );
1191 }
1192
1193 #[test]
1194 fn vp_only_pass_is_pass() {
1195 assert_eq!(r_with(&[Verdict::Pass]).overall_verdict(), Verdict::Pass);
1196 }
1197
1198 #[test]
1199 fn vp_pass_with_skip_is_pass() {
1200 assert_eq!(
1201 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Skip]).overall_verdict(),
1202 Verdict::Pass
1203 );
1204 }
1205
1206 #[test]
1207 fn vp_only_warn_is_warn() {
1208 assert_eq!(r_with(&[Verdict::Warn]).overall_verdict(), Verdict::Warn);
1209 }
1210
1211 #[test]
1212 fn vp_warn_with_pass_is_warn() {
1213 assert_eq!(
1214 r_with(&[Verdict::Pass, Verdict::Warn]).overall_verdict(),
1215 Verdict::Warn
1216 );
1217 }
1218
1219 #[test]
1220 fn vp_warn_with_skip_is_warn() {
1221 assert_eq!(
1222 r_with(&[Verdict::Skip, Verdict::Warn]).overall_verdict(),
1223 Verdict::Warn
1224 );
1225 }
1226
1227 #[test]
1228 fn vp_warn_with_pass_and_skip_is_warn() {
1229 assert_eq!(
1230 r_with(&[Verdict::Pass, Verdict::Skip, Verdict::Warn]).overall_verdict(),
1231 Verdict::Warn
1232 );
1233 }
1234
1235 #[test]
1236 fn vp_only_fail_is_fail() {
1237 assert_eq!(r_with(&[Verdict::Fail]).overall_verdict(), Verdict::Fail);
1238 }
1239
1240 #[test]
1241 fn vp_fail_with_pass_is_fail() {
1242 assert_eq!(
1243 r_with(&[Verdict::Pass, Verdict::Fail]).overall_verdict(),
1244 Verdict::Fail
1245 );
1246 }
1247
1248 #[test]
1249 fn vp_fail_with_warn_is_fail() {
1250 assert_eq!(
1251 r_with(&[Verdict::Warn, Verdict::Fail]).overall_verdict(),
1252 Verdict::Fail
1253 );
1254 }
1255
1256 #[test]
1257 fn vp_fail_with_skip_is_fail() {
1258 assert_eq!(
1259 r_with(&[Verdict::Skip, Verdict::Fail]).overall_verdict(),
1260 Verdict::Fail
1261 );
1262 }
1263
1264 #[test]
1265 fn vp_fail_dominates_all_others() {
1266 assert_eq!(
1267 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Warn, Verdict::Fail,])
1268 .overall_verdict(),
1269 Verdict::Fail
1270 );
1271 }
1272
1273 #[test]
1274 fn vp_order_independence() {
1275 // Precedence MUST NOT depend on insertion order.
1276 let a = r_with(&[Verdict::Fail, Verdict::Warn, Verdict::Pass]).overall_verdict();
1277 let b = r_with(&[Verdict::Pass, Verdict::Warn, Verdict::Fail]).overall_verdict();
1278 let c = r_with(&[Verdict::Warn, Verdict::Pass, Verdict::Fail]).overall_verdict();
1279 assert_eq!(a, Verdict::Fail);
1280 assert_eq!(a, b);
1281 assert_eq!(b, c);
1282 }
1283}