Skip to main content

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}