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    /// 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}