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 key-value-evidence attachment from any iterable of pairs.
227 ///
228 /// # Example
229 ///
230 /// ```
231 /// use dev_report::Evidence;
232 ///
233 /// let e = Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]);
234 /// assert_eq!(e.label, "env");
235 /// ```
236 pub fn kv<I, K, V>(label: impl Into<String>, pairs: I) -> Self
237 where
238 I: IntoIterator<Item = (K, V)>,
239 K: Into<String>,
240 V: Into<String>,
241 {
242 let map: BTreeMap<String, String> = pairs
243 .into_iter()
244 .map(|(k, v)| (k.into(), v.into()))
245 .collect();
246 Self {
247 label: label.into(),
248 data: EvidenceData::KeyValue(map),
249 }
250 }
251
252 /// Build a snippet-evidence attachment.
253 ///
254 /// # Example
255 ///
256 /// ```
257 /// use dev_report::Evidence;
258 ///
259 /// let e = Evidence::snippet("panic", "thread 'main' panicked at ...");
260 /// assert_eq!(e.label, "panic");
261 /// ```
262 pub fn snippet(label: impl Into<String>, text: impl Into<String>) -> Self {
263 Self {
264 label: label.into(),
265 data: EvidenceData::Snippet(text.into()),
266 }
267 }
268
269 /// Build a file-reference-evidence attachment with no line range.
270 ///
271 /// # Example
272 ///
273 /// ```
274 /// use dev_report::Evidence;
275 ///
276 /// let e = Evidence::file_ref("source", "src/lib.rs");
277 /// assert_eq!(e.label, "source");
278 /// ```
279 pub fn file_ref(label: impl Into<String>, path: impl Into<String>) -> Self {
280 Self {
281 label: label.into(),
282 data: EvidenceData::FileRef(FileRef::new(path)),
283 }
284 }
285
286 /// Build a file-reference-evidence attachment with a `[start, end]`
287 /// line range (1-indexed, inclusive).
288 ///
289 /// # Example
290 ///
291 /// ```
292 /// use dev_report::Evidence;
293 ///
294 /// let e = Evidence::file_ref_lines("call_site", "src/lib.rs", 42, 47);
295 /// assert_eq!(e.label, "call_site");
296 /// ```
297 pub fn file_ref_lines(
298 label: impl Into<String>,
299 path: impl Into<String>,
300 start: u32,
301 end: u32,
302 ) -> Self {
303 Self {
304 label: label.into(),
305 data: EvidenceData::FileRef(FileRef::new(path).with_line_range(start, end)),
306 }
307 }
308
309 /// Discriminator for the payload variant.
310 ///
311 /// # Example
312 ///
313 /// ```
314 /// use dev_report::{Evidence, EvidenceKind};
315 ///
316 /// assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
317 /// ```
318 pub fn kind(&self) -> EvidenceKind {
319 match &self.data {
320 EvidenceData::Numeric(_) => EvidenceKind::Numeric,
321 EvidenceData::KeyValue(_) => EvidenceKind::KeyValue,
322 EvidenceData::Snippet(_) => EvidenceKind::Snippet,
323 EvidenceData::FileRef(_) => EvidenceKind::FileRef,
324 }
325 }
326}
327
328/// Result of a single check.
329///
330/// # Example
331///
332/// ```
333/// use dev_report::{CheckResult, Severity, Verdict};
334///
335/// let c = CheckResult::fail("unit::math", Severity::Error)
336/// .with_detail("expected 42, got 41")
337/// .with_duration_ms(7);
338/// assert_eq!(c.verdict, Verdict::Fail);
339/// ```
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct CheckResult {
342 /// Stable identifier for the check (e.g. `compile`, `test::round_trip`).
343 pub name: String,
344 /// Outcome of the check.
345 pub verdict: Verdict,
346 /// Severity when the verdict is `Fail` or `Warn`. `None` for `Pass` and `Skip`.
347 pub severity: Option<Severity>,
348 /// Human-readable detail. Optional.
349 pub detail: Option<String>,
350 /// Time the check ran. UTC.
351 pub at: DateTime<Utc>,
352 /// Duration of the check, in milliseconds. Optional.
353 pub duration_ms: Option<u64>,
354 /// Free-form tags for filtering (e.g. `"slow"`, `"flaky"`, `"bench"`).
355 ///
356 /// Defaults to empty. v0.1.0 reports deserialize cleanly with no tags.
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub tags: Vec<String>,
359 /// Structured evidence backing this check.
360 ///
361 /// Defaults to empty. v0.1.0 reports deserialize cleanly with no evidence.
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub evidence: Vec<Evidence>,
364}
365
366impl CheckResult {
367 /// Build a passing check result with the given name.
368 ///
369 /// # Example
370 ///
371 /// ```
372 /// use dev_report::{CheckResult, Verdict};
373 ///
374 /// let c = CheckResult::pass("compile");
375 /// assert_eq!(c.verdict, Verdict::Pass);
376 /// assert!(c.severity.is_none());
377 /// ```
378 pub fn pass(name: impl Into<String>) -> Self {
379 Self {
380 name: name.into(),
381 verdict: Verdict::Pass,
382 severity: None,
383 detail: None,
384 at: Utc::now(),
385 duration_ms: None,
386 tags: Vec::new(),
387 evidence: Vec::new(),
388 }
389 }
390
391 /// Build a failing check result with the given name and severity.
392 ///
393 /// # Example
394 ///
395 /// ```
396 /// use dev_report::{CheckResult, Severity, Verdict};
397 ///
398 /// let c = CheckResult::fail("test::round_trip", Severity::Error);
399 /// assert_eq!(c.verdict, Verdict::Fail);
400 /// assert_eq!(c.severity, Some(Severity::Error));
401 /// ```
402 pub fn fail(name: impl Into<String>, severity: Severity) -> Self {
403 Self {
404 name: name.into(),
405 verdict: Verdict::Fail,
406 severity: Some(severity),
407 detail: None,
408 at: Utc::now(),
409 duration_ms: None,
410 tags: Vec::new(),
411 evidence: Vec::new(),
412 }
413 }
414
415 /// Build a warning check result with the given name and severity.
416 ///
417 /// # Example
418 ///
419 /// ```
420 /// use dev_report::{CheckResult, Severity, Verdict};
421 ///
422 /// let c = CheckResult::warn("flaky", Severity::Warning);
423 /// assert_eq!(c.verdict, Verdict::Warn);
424 /// ```
425 pub fn warn(name: impl Into<String>, severity: Severity) -> Self {
426 Self {
427 name: name.into(),
428 verdict: Verdict::Warn,
429 severity: Some(severity),
430 detail: None,
431 at: Utc::now(),
432 duration_ms: None,
433 tags: Vec::new(),
434 evidence: Vec::new(),
435 }
436 }
437
438 /// Build a skipped check result with the given name.
439 ///
440 /// # Example
441 ///
442 /// ```
443 /// use dev_report::{CheckResult, Verdict};
444 ///
445 /// let c = CheckResult::skip("not_applicable");
446 /// assert_eq!(c.verdict, Verdict::Skip);
447 /// ```
448 pub fn skip(name: impl Into<String>) -> Self {
449 Self {
450 name: name.into(),
451 verdict: Verdict::Skip,
452 severity: None,
453 detail: None,
454 at: Utc::now(),
455 duration_ms: None,
456 tags: Vec::new(),
457 evidence: Vec::new(),
458 }
459 }
460
461 /// Attach a human-readable detail to this check result.
462 ///
463 /// # Example
464 ///
465 /// ```
466 /// use dev_report::CheckResult;
467 ///
468 /// let c = CheckResult::pass("a").with_detail("ran in single thread");
469 /// assert_eq!(c.detail.as_deref(), Some("ran in single thread"));
470 /// ```
471 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
472 self.detail = Some(detail.into());
473 self
474 }
475
476 /// Attach a duration measurement (milliseconds) to this check result.
477 ///
478 /// # Example
479 ///
480 /// ```
481 /// use dev_report::CheckResult;
482 ///
483 /// let c = CheckResult::pass("a").with_duration_ms(42);
484 /// assert_eq!(c.duration_ms, Some(42));
485 /// ```
486 pub fn with_duration_ms(mut self, ms: u64) -> Self {
487 self.duration_ms = Some(ms);
488 self
489 }
490
491 /// Override the severity of this check result.
492 ///
493 /// Useful when escalating or de-escalating a check after construction
494 /// (e.g. promote a `Warn+Warning` to `Warn+Error` based on a config flag).
495 ///
496 /// # Example
497 ///
498 /// ```
499 /// use dev_report::{CheckResult, Severity};
500 ///
501 /// let c = CheckResult::warn("flaky", Severity::Warning)
502 /// .with_severity(Severity::Error);
503 /// assert_eq!(c.severity, Some(Severity::Error));
504 /// ```
505 pub fn with_severity(mut self, severity: Severity) -> Self {
506 self.severity = Some(severity);
507 self
508 }
509
510 /// Attach a single tag to this check result.
511 ///
512 /// # Example
513 ///
514 /// ```
515 /// use dev_report::CheckResult;
516 ///
517 /// let c = CheckResult::pass("compile").with_tag("slow");
518 /// assert!(c.has_tag("slow"));
519 /// ```
520 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
521 self.tags.push(tag.into());
522 self
523 }
524
525 /// Attach many tags at once from any iterable of strings.
526 ///
527 /// # Example
528 ///
529 /// ```
530 /// use dev_report::CheckResult;
531 ///
532 /// let c = CheckResult::pass("compile").with_tags(["slow", "flaky"]);
533 /// assert!(c.has_tag("flaky"));
534 /// ```
535 pub fn with_tags<I, S>(mut self, tags: I) -> Self
536 where
537 I: IntoIterator<Item = S>,
538 S: Into<String>,
539 {
540 self.tags.extend(tags.into_iter().map(Into::into));
541 self
542 }
543
544 /// Return `true` if this check has the given tag.
545 ///
546 /// # Example
547 ///
548 /// ```
549 /// use dev_report::CheckResult;
550 ///
551 /// let c = CheckResult::pass("compile").with_tag("slow");
552 /// assert!(c.has_tag("slow"));
553 /// assert!(!c.has_tag("flaky"));
554 /// ```
555 pub fn has_tag(&self, tag: &str) -> bool {
556 self.tags.iter().any(|t| t == tag)
557 }
558
559 /// Attach a single piece of [`Evidence`] to this check result.
560 ///
561 /// # Example
562 ///
563 /// ```
564 /// use dev_report::{CheckResult, Evidence};
565 ///
566 /// let c = CheckResult::pass("bench")
567 /// .with_evidence(Evidence::numeric("mean_ns", 1234.0));
568 /// assert_eq!(c.evidence.len(), 1);
569 /// ```
570 pub fn with_evidence(mut self, e: Evidence) -> Self {
571 self.evidence.push(e);
572 self
573 }
574
575 /// Attach many [`Evidence`] items at once from any iterable.
576 ///
577 /// # Example
578 ///
579 /// ```
580 /// use dev_report::{CheckResult, Evidence};
581 ///
582 /// let c = CheckResult::pass("bench").with_evidences([
583 /// Evidence::numeric("mean_ns", 1234.0),
584 /// Evidence::numeric("baseline_ns", 1100.0),
585 /// ]);
586 /// assert_eq!(c.evidence.len(), 2);
587 /// ```
588 pub fn with_evidences<I>(mut self, items: I) -> Self
589 where
590 I: IntoIterator<Item = Evidence>,
591 {
592 self.evidence.extend(items);
593 self
594 }
595}
596
597/// A full report. The output of one verification run.
598///
599/// # Example
600///
601/// ```
602/// use dev_report::{CheckResult, Report, Verdict};
603///
604/// let mut r = Report::new("my-crate", "0.1.0").with_producer("my-harness");
605/// r.push(CheckResult::pass("compile"));
606/// r.finish();
607/// assert_eq!(r.overall_verdict(), Verdict::Pass);
608/// ```
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct Report {
611 /// Schema version for this report format.
612 pub schema_version: u32,
613 /// Crate or project being reported on.
614 pub subject: String,
615 /// Version of the subject at the time of the run.
616 pub subject_version: String,
617 /// Producer of the report (e.g. `dev-bench`, `dev-async`).
618 pub producer: Option<String>,
619 /// Time the report was started.
620 pub started_at: DateTime<Utc>,
621 /// Time the report was finalized.
622 pub finished_at: Option<DateTime<Utc>>,
623 /// All individual check results in this report.
624 pub checks: Vec<CheckResult>,
625}
626
627impl Report {
628 /// Begin a new report for the given subject and version.
629 ///
630 /// # Example
631 ///
632 /// ```
633 /// use dev_report::Report;
634 ///
635 /// let r = Report::new("my-crate", "0.1.0");
636 /// assert_eq!(r.subject, "my-crate");
637 /// assert_eq!(r.schema_version, 1);
638 /// ```
639 pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
640 Self {
641 schema_version: 1,
642 subject: subject.into(),
643 subject_version: subject_version.into(),
644 producer: None,
645 started_at: Utc::now(),
646 finished_at: None,
647 checks: Vec::new(),
648 }
649 }
650
651 /// Set the producer of this report.
652 ///
653 /// # Example
654 ///
655 /// ```
656 /// use dev_report::Report;
657 ///
658 /// let r = Report::new("crate", "0.1.0").with_producer("dev-bench");
659 /// assert_eq!(r.producer.as_deref(), Some("dev-bench"));
660 /// ```
661 pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
662 self.producer = Some(producer.into());
663 self
664 }
665
666 /// Append a check result to this report.
667 ///
668 /// # Example
669 ///
670 /// ```
671 /// use dev_report::{CheckResult, Report};
672 ///
673 /// let mut r = Report::new("crate", "0.1.0");
674 /// r.push(CheckResult::pass("compile"));
675 /// assert_eq!(r.checks.len(), 1);
676 /// ```
677 pub fn push(&mut self, result: CheckResult) {
678 self.checks.push(result);
679 }
680
681 /// Mark the report as finished, stamping the finish time.
682 ///
683 /// # Example
684 ///
685 /// ```
686 /// use dev_report::Report;
687 ///
688 /// let mut r = Report::new("crate", "0.1.0");
689 /// r.finish();
690 /// assert!(r.finished_at.is_some());
691 /// ```
692 pub fn finish(&mut self) {
693 self.finished_at = Some(Utc::now());
694 }
695
696 /// Compute the overall verdict for this report.
697 ///
698 /// Rules:
699 /// - Any `Fail` -> `Fail`
700 /// - Else any `Warn` -> `Warn`
701 /// - Else any `Pass` -> `Pass`
702 /// - Else (all `Skip` or empty) -> `Skip`
703 ///
704 /// # Example
705 ///
706 /// ```
707 /// use dev_report::{CheckResult, Report, Severity, Verdict};
708 ///
709 /// let mut r = Report::new("crate", "0.1.0");
710 /// r.push(CheckResult::pass("a"));
711 /// r.push(CheckResult::fail("b", Severity::Error));
712 /// assert_eq!(r.overall_verdict(), Verdict::Fail);
713 /// ```
714 pub fn overall_verdict(&self) -> Verdict {
715 let mut saw_fail = false;
716 let mut saw_warn = false;
717 let mut saw_pass = false;
718 for c in &self.checks {
719 match c.verdict {
720 Verdict::Fail => saw_fail = true,
721 Verdict::Warn => saw_warn = true,
722 Verdict::Pass => saw_pass = true,
723 Verdict::Skip => {}
724 }
725 }
726 if saw_fail {
727 Verdict::Fail
728 } else if saw_warn {
729 Verdict::Warn
730 } else if saw_pass {
731 Verdict::Pass
732 } else {
733 Verdict::Skip
734 }
735 }
736
737 /// Iterate over checks that carry the given tag.
738 ///
739 /// # Example
740 ///
741 /// ```
742 /// use dev_report::{CheckResult, Report};
743 ///
744 /// let mut r = Report::new("crate", "0.1.0");
745 /// r.push(CheckResult::pass("a").with_tag("slow"));
746 /// r.push(CheckResult::pass("b"));
747 /// r.push(CheckResult::pass("c").with_tag("slow"));
748 ///
749 /// let slow: Vec<_> = r.checks_with_tag("slow").collect();
750 /// assert_eq!(slow.len(), 2);
751 /// ```
752 pub fn checks_with_tag<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a CheckResult> {
753 self.checks.iter().filter(move |c| c.has_tag(tag))
754 }
755
756 /// Serialize this report to JSON.
757 ///
758 /// # Example
759 ///
760 /// ```
761 /// use dev_report::Report;
762 ///
763 /// let r = Report::new("crate", "0.1.0");
764 /// let json = r.to_json().unwrap();
765 /// assert!(json.contains("\"subject\": \"crate\""));
766 /// ```
767 pub fn to_json(&self) -> serde_json::Result<String> {
768 serde_json::to_string_pretty(self)
769 }
770
771 /// Deserialize a report from JSON.
772 ///
773 /// # Example
774 ///
775 /// ```
776 /// use dev_report::Report;
777 ///
778 /// let r = Report::new("crate", "0.1.0");
779 /// let json = r.to_json().unwrap();
780 /// let parsed = Report::from_json(&json).unwrap();
781 /// assert_eq!(parsed.subject, "crate");
782 /// ```
783 pub fn from_json(s: &str) -> serde_json::Result<Self> {
784 serde_json::from_str(s)
785 }
786
787 /// Render this report to a TTY-friendly string. Monochrome.
788 ///
789 /// Available with the `terminal` feature.
790 #[cfg(feature = "terminal")]
791 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
792 pub fn to_terminal(&self) -> String {
793 terminal::to_terminal(self)
794 }
795
796 /// Render this report with ANSI color codes.
797 ///
798 /// Available with the `terminal` feature.
799 #[cfg(feature = "terminal")]
800 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
801 pub fn to_terminal_color(&self) -> String {
802 terminal::to_terminal_color(self)
803 }
804
805 /// Render this report to a Markdown string.
806 ///
807 /// Available with the `markdown` feature.
808 #[cfg(feature = "markdown")]
809 #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
810 pub fn to_markdown(&self) -> String {
811 markdown::to_markdown(self)
812 }
813
814 /// Compare this report against a baseline using default options.
815 ///
816 /// `self` is the new report; `baseline` is the previous one.
817 /// Default options flag duration regressions over 20% slower.
818 ///
819 /// # Example
820 ///
821 /// ```
822 /// use dev_report::{CheckResult, Report, Severity};
823 ///
824 /// let mut prev = Report::new("c", "0.1.0");
825 /// prev.push(CheckResult::pass("a"));
826 ///
827 /// let mut curr = Report::new("c", "0.1.0");
828 /// curr.push(CheckResult::fail("a", Severity::Error));
829 ///
830 /// let diff = curr.diff(&prev);
831 /// assert_eq!(diff.newly_failing, vec!["a".to_string()]);
832 /// ```
833 pub fn diff(&self, baseline: &Self) -> Diff {
834 diff::diff_reports(self, baseline, &DiffOptions::default())
835 }
836
837 /// Compare this report against a baseline using custom options.
838 ///
839 /// # Example
840 ///
841 /// ```
842 /// use dev_report::{CheckResult, DiffOptions, Report};
843 ///
844 /// let mut prev = Report::new("c", "0.1.0");
845 /// prev.push(CheckResult::pass("a").with_duration_ms(100));
846 ///
847 /// let mut curr = Report::new("c", "0.1.0");
848 /// curr.push(CheckResult::pass("a").with_duration_ms(150));
849 ///
850 /// let opts = DiffOptions {
851 /// duration_regression_pct: Some(10.0),
852 /// duration_regression_abs_ms: None,
853 /// };
854 /// let diff = curr.diff_with(&prev, &opts);
855 /// assert_eq!(diff.duration_regressions.len(), 1);
856 /// ```
857 pub fn diff_with(&self, baseline: &Self, opts: &DiffOptions) -> Diff {
858 diff::diff_reports(self, baseline, opts)
859 }
860}
861
862/// A producer of reports. Implement this on your harness type to integrate
863/// with the dev-* suite.
864///
865/// # Example
866///
867/// ```
868/// use dev_report::{CheckResult, Producer, Report};
869///
870/// struct CompileChecker;
871/// impl Producer for CompileChecker {
872/// fn produce(&self) -> Report {
873/// let mut r = Report::new("my-crate", "0.1.0").with_producer("compile-checker");
874/// r.push(CheckResult::pass("compile"));
875/// r.finish();
876/// r
877/// }
878/// }
879///
880/// let r = CompileChecker.produce();
881/// assert_eq!(r.checks.len(), 1);
882/// ```
883pub trait Producer {
884 /// Run the producer and return a finalized report.
885 ///
886 /// Implementations SHOULD encode setup failures as `Fail` checks
887 /// inside the returned `Report` rather than panicking.
888 fn produce(&self) -> Report;
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn build_and_roundtrip_a_report() {
897 let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-self-test");
898 r.push(CheckResult::pass("compile"));
899 r.push(CheckResult::fail("unit::math", Severity::Error).with_detail("off by one"));
900 r.finish();
901
902 let json = r.to_json().unwrap();
903 let parsed = Report::from_json(&json).unwrap();
904 assert_eq!(parsed.subject, "widget");
905 assert_eq!(parsed.checks.len(), 2);
906 assert_eq!(parsed.overall_verdict(), Verdict::Fail);
907 }
908
909 #[test]
910 fn empty_report_is_skip() {
911 let r = Report::new("nothing", "0.0.0");
912 assert_eq!(r.overall_verdict(), Verdict::Skip);
913 }
914
915 #[test]
916 fn tags_attach_and_query() {
917 let c = CheckResult::pass("compile")
918 .with_tag("slow")
919 .with_tags(["flaky", "bench"]);
920 assert!(c.has_tag("slow"));
921 assert!(c.has_tag("flaky"));
922 assert!(c.has_tag("bench"));
923 assert!(!c.has_tag("missing"));
924 assert_eq!(c.tags.len(), 3);
925 }
926
927 #[test]
928 fn evidence_constructors_set_kind() {
929 assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
930 assert_eq!(
931 Evidence::kv("env", [("K", "V")]).kind(),
932 EvidenceKind::KeyValue
933 );
934 assert_eq!(
935 Evidence::snippet("log", "boom").kind(),
936 EvidenceKind::Snippet
937 );
938 assert_eq!(
939 Evidence::file_ref("src", "lib.rs").kind(),
940 EvidenceKind::FileRef
941 );
942 assert_eq!(
943 Evidence::file_ref_lines("src", "lib.rs", 1, 2).kind(),
944 EvidenceKind::FileRef
945 );
946 }
947
948 #[test]
949 fn evidence_round_trips_through_json() {
950 let mut r = Report::new("subject", "0.2.0");
951 r.push(
952 CheckResult::pass("bench::parse")
953 .with_tag("bench")
954 .with_evidence(Evidence::numeric("mean_ns", 1234.5))
955 .with_evidence(Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]))
956 .with_evidence(Evidence::snippet("note", "fast path taken"))
957 .with_evidence(Evidence::file_ref_lines("site", "src/parse.rs", 10, 20)),
958 );
959 r.finish();
960
961 let json = r.to_json().unwrap();
962 let parsed = Report::from_json(&json).unwrap();
963 assert_eq!(parsed.checks.len(), 1);
964 let c = &parsed.checks[0];
965 assert_eq!(c.tags, vec!["bench".to_string()]);
966 assert_eq!(c.evidence.len(), 4);
967 assert_eq!(c.evidence[0].kind(), EvidenceKind::Numeric);
968 assert_eq!(c.evidence[1].kind(), EvidenceKind::KeyValue);
969 assert_eq!(c.evidence[2].kind(), EvidenceKind::Snippet);
970 assert_eq!(c.evidence[3].kind(), EvidenceKind::FileRef);
971 }
972
973 #[test]
974 fn v0_1_0_json_deserializes_with_empty_tags_and_evidence() {
975 // A v0.1.0-shaped report (no tags, no evidence) MUST still parse.
976 let v0_1_0_json = r#"{
977 "schema_version": 1,
978 "subject": "legacy",
979 "subject_version": "0.1.0",
980 "producer": "dev-report-self-test",
981 "started_at": "2026-01-01T00:00:00Z",
982 "finished_at": "2026-01-01T00:00:01Z",
983 "checks": [
984 {
985 "name": "compile",
986 "verdict": "pass",
987 "severity": null,
988 "detail": null,
989 "at": "2026-01-01T00:00:00Z",
990 "duration_ms": null
991 }
992 ]
993 }"#;
994 let parsed = Report::from_json(v0_1_0_json).unwrap();
995 assert_eq!(parsed.checks.len(), 1);
996 let c = &parsed.checks[0];
997 assert!(c.tags.is_empty());
998 assert!(c.evidence.is_empty());
999 assert_eq!(parsed.overall_verdict(), Verdict::Pass);
1000 }
1001
1002 #[test]
1003 fn checks_with_tag_filters() {
1004 let mut r = Report::new("subject", "0.2.0");
1005 r.push(CheckResult::pass("a").with_tag("slow"));
1006 r.push(CheckResult::pass("b"));
1007 r.push(CheckResult::pass("c").with_tags(["slow", "flaky"]));
1008 let slow: Vec<&CheckResult> = r.checks_with_tag("slow").collect();
1009 assert_eq!(slow.len(), 2);
1010 assert_eq!(slow[0].name, "a");
1011 assert_eq!(slow[1].name, "c");
1012 }
1013
1014 #[test]
1015 fn with_severity_overrides_severity() {
1016 let c = CheckResult::warn("x", Severity::Warning).with_severity(Severity::Error);
1017 assert_eq!(c.severity, Some(Severity::Error));
1018 }
1019
1020 #[test]
1021 fn empty_tags_and_evidence_are_omitted_in_json() {
1022 let mut r = Report::new("s", "0.2.0");
1023 r.push(CheckResult::pass("a"));
1024 let json = r.to_json().unwrap();
1025 assert!(!json.contains("\"tags\""));
1026 assert!(!json.contains("\"evidence\""));
1027 }
1028
1029 // ------------------------------------------------------------
1030 // Verdict precedence: explicit coverage of every transition edge.
1031 // Required by DIRECTIVES.md section 7 and REPS section 6.
1032 // Order: Fail > Warn > Pass > Skip (and empty -> Skip).
1033 // ------------------------------------------------------------
1034
1035 fn r_with(checks: &[Verdict]) -> Report {
1036 let mut r = Report::new("vp", "0.0.0");
1037 for v in checks {
1038 r.push(match v {
1039 Verdict::Pass => CheckResult::pass("c"),
1040 Verdict::Fail => CheckResult::fail("c", Severity::Error),
1041 Verdict::Warn => CheckResult::warn("c", Severity::Warning),
1042 Verdict::Skip => CheckResult::skip("c"),
1043 });
1044 }
1045 r
1046 }
1047
1048 #[test]
1049 fn vp_empty_is_skip() {
1050 assert_eq!(r_with(&[]).overall_verdict(), Verdict::Skip);
1051 }
1052
1053 #[test]
1054 fn vp_only_skip_is_skip() {
1055 assert_eq!(
1056 r_with(&[Verdict::Skip, Verdict::Skip]).overall_verdict(),
1057 Verdict::Skip
1058 );
1059 }
1060
1061 #[test]
1062 fn vp_only_pass_is_pass() {
1063 assert_eq!(r_with(&[Verdict::Pass]).overall_verdict(), Verdict::Pass);
1064 }
1065
1066 #[test]
1067 fn vp_pass_with_skip_is_pass() {
1068 assert_eq!(
1069 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Skip]).overall_verdict(),
1070 Verdict::Pass
1071 );
1072 }
1073
1074 #[test]
1075 fn vp_only_warn_is_warn() {
1076 assert_eq!(r_with(&[Verdict::Warn]).overall_verdict(), Verdict::Warn);
1077 }
1078
1079 #[test]
1080 fn vp_warn_with_pass_is_warn() {
1081 assert_eq!(
1082 r_with(&[Verdict::Pass, Verdict::Warn]).overall_verdict(),
1083 Verdict::Warn
1084 );
1085 }
1086
1087 #[test]
1088 fn vp_warn_with_skip_is_warn() {
1089 assert_eq!(
1090 r_with(&[Verdict::Skip, Verdict::Warn]).overall_verdict(),
1091 Verdict::Warn
1092 );
1093 }
1094
1095 #[test]
1096 fn vp_warn_with_pass_and_skip_is_warn() {
1097 assert_eq!(
1098 r_with(&[Verdict::Pass, Verdict::Skip, Verdict::Warn]).overall_verdict(),
1099 Verdict::Warn
1100 );
1101 }
1102
1103 #[test]
1104 fn vp_only_fail_is_fail() {
1105 assert_eq!(r_with(&[Verdict::Fail]).overall_verdict(), Verdict::Fail);
1106 }
1107
1108 #[test]
1109 fn vp_fail_with_pass_is_fail() {
1110 assert_eq!(
1111 r_with(&[Verdict::Pass, Verdict::Fail]).overall_verdict(),
1112 Verdict::Fail
1113 );
1114 }
1115
1116 #[test]
1117 fn vp_fail_with_warn_is_fail() {
1118 assert_eq!(
1119 r_with(&[Verdict::Warn, Verdict::Fail]).overall_verdict(),
1120 Verdict::Fail
1121 );
1122 }
1123
1124 #[test]
1125 fn vp_fail_with_skip_is_fail() {
1126 assert_eq!(
1127 r_with(&[Verdict::Skip, Verdict::Fail]).overall_verdict(),
1128 Verdict::Fail
1129 );
1130 }
1131
1132 #[test]
1133 fn vp_fail_dominates_all_others() {
1134 assert_eq!(
1135 r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Warn, Verdict::Fail,])
1136 .overall_verdict(),
1137 Verdict::Fail
1138 );
1139 }
1140
1141 #[test]
1142 fn vp_order_independence() {
1143 // Precedence MUST NOT depend on insertion order.
1144 let a = r_with(&[Verdict::Fail, Verdict::Warn, Verdict::Pass]).overall_verdict();
1145 let b = r_with(&[Verdict::Pass, Verdict::Warn, Verdict::Fail]).overall_verdict();
1146 let c = r_with(&[Verdict::Warn, Verdict::Pass, Verdict::Fail]).overall_verdict();
1147 assert_eq!(a, Verdict::Fail);
1148 assert_eq!(a, b);
1149 assert_eq!(b, c);
1150 }
1151}