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