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 chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40/// Top-level verdict for a check or a whole report.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Verdict {
44    /// Check passed. No action required.
45    Pass,
46    /// Check failed. Action required.
47    Fail,
48    /// Check produced a warning. Review recommended.
49    Warn,
50    /// Check was skipped. No data to report.
51    Skip,
52}
53
54/// Severity classification when a check fails or warns.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58    /// Informational. Does not block acceptance.
59    Info,
60    /// Warning. Acceptance allowed with explicit acknowledgement.
61    Warning,
62    /// Error. Blocks acceptance.
63    Error,
64    /// Critical. Blocks acceptance and signals a regression.
65    Critical,
66}
67
68/// Result of a single check.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CheckResult {
71    /// Stable identifier for the check (e.g. `compile`, `test::round_trip`).
72    pub name: String,
73    /// Outcome of the check.
74    pub verdict: Verdict,
75    /// Severity when the verdict is `Fail` or `Warn`. `None` for `Pass` and `Skip`.
76    pub severity: Option<Severity>,
77    /// Human-readable detail. Optional.
78    pub detail: Option<String>,
79    /// Time the check ran. UTC.
80    pub at: DateTime<Utc>,
81    /// Duration of the check, in milliseconds. Optional.
82    pub duration_ms: Option<u64>,
83}
84
85impl CheckResult {
86    /// Build a passing check result with the given name.
87    pub fn pass(name: impl Into<String>) -> Self {
88        Self {
89            name: name.into(),
90            verdict: Verdict::Pass,
91            severity: None,
92            detail: None,
93            at: Utc::now(),
94            duration_ms: None,
95        }
96    }
97
98    /// Build a failing check result with the given name and severity.
99    pub fn fail(name: impl Into<String>, severity: Severity) -> Self {
100        Self {
101            name: name.into(),
102            verdict: Verdict::Fail,
103            severity: Some(severity),
104            detail: None,
105            at: Utc::now(),
106            duration_ms: None,
107        }
108    }
109
110    /// Build a warning check result with the given name and severity.
111    pub fn warn(name: impl Into<String>, severity: Severity) -> Self {
112        Self {
113            name: name.into(),
114            verdict: Verdict::Warn,
115            severity: Some(severity),
116            detail: None,
117            at: Utc::now(),
118            duration_ms: None,
119        }
120    }
121
122    /// Build a skipped check result with the given name.
123    pub fn skip(name: impl Into<String>) -> Self {
124        Self {
125            name: name.into(),
126            verdict: Verdict::Skip,
127            severity: None,
128            detail: None,
129            at: Utc::now(),
130            duration_ms: None,
131        }
132    }
133
134    /// Attach a human-readable detail to this check result.
135    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
136        self.detail = Some(detail.into());
137        self
138    }
139
140    /// Attach a duration measurement (milliseconds) to this check result.
141    pub fn with_duration_ms(mut self, ms: u64) -> Self {
142        self.duration_ms = Some(ms);
143        self
144    }
145}
146
147/// A full report. The output of one verification run.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Report {
150    /// Schema version for this report format.
151    pub schema_version: u32,
152    /// Crate or project being reported on.
153    pub subject: String,
154    /// Version of the subject at the time of the run.
155    pub subject_version: String,
156    /// Producer of the report (e.g. `dev-bench`, `dev-async`).
157    pub producer: Option<String>,
158    /// Time the report was started.
159    pub started_at: DateTime<Utc>,
160    /// Time the report was finalized.
161    pub finished_at: Option<DateTime<Utc>>,
162    /// All individual check results in this report.
163    pub checks: Vec<CheckResult>,
164}
165
166impl Report {
167    /// Begin a new report for the given subject and version.
168    pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
169        Self {
170            schema_version: 1,
171            subject: subject.into(),
172            subject_version: subject_version.into(),
173            producer: None,
174            started_at: Utc::now(),
175            finished_at: None,
176            checks: Vec::new(),
177        }
178    }
179
180    /// Set the producer of this report.
181    pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
182        self.producer = Some(producer.into());
183        self
184    }
185
186    /// Append a check result to this report.
187    pub fn push(&mut self, result: CheckResult) {
188        self.checks.push(result);
189    }
190
191    /// Mark the report as finished, stamping the finish time.
192    pub fn finish(&mut self) {
193        self.finished_at = Some(Utc::now());
194    }
195
196    /// Compute the overall verdict for this report.
197    ///
198    /// Rules:
199    /// - Any `Fail` -> `Fail`
200    /// - Else any `Warn` -> `Warn`
201    /// - Else any `Pass` -> `Pass`
202    /// - Else (all `Skip` or empty) -> `Skip`
203    pub fn overall_verdict(&self) -> Verdict {
204        let mut saw_fail = false;
205        let mut saw_warn = false;
206        let mut saw_pass = false;
207        for c in &self.checks {
208            match c.verdict {
209                Verdict::Fail => saw_fail = true,
210                Verdict::Warn => saw_warn = true,
211                Verdict::Pass => saw_pass = true,
212                Verdict::Skip => {}
213            }
214        }
215        if saw_fail {
216            Verdict::Fail
217        } else if saw_warn {
218            Verdict::Warn
219        } else if saw_pass {
220            Verdict::Pass
221        } else {
222            Verdict::Skip
223        }
224    }
225
226    /// Serialize this report to JSON.
227    pub fn to_json(&self) -> serde_json::Result<String> {
228        serde_json::to_string_pretty(self)
229    }
230
231    /// Deserialize a report from JSON.
232    pub fn from_json(s: &str) -> serde_json::Result<Self> {
233        serde_json::from_str(s)
234    }
235}
236
237/// A producer of reports. Implement this on your harness type to integrate
238/// with the dev-* suite.
239pub trait Producer {
240    /// Run the producer and return a finalized report.
241    fn produce(&self) -> Report;
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn build_and_roundtrip_a_report() {
250        let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-self-test");
251        r.push(CheckResult::pass("compile"));
252        r.push(CheckResult::fail("unit::math", Severity::Error).with_detail("off by one"));
253        r.finish();
254
255        let json = r.to_json().unwrap();
256        let parsed = Report::from_json(&json).unwrap();
257        assert_eq!(parsed.subject, "widget");
258        assert_eq!(parsed.checks.len(), 2);
259        assert_eq!(parsed.overall_verdict(), Verdict::Fail);
260    }
261
262    #[test]
263    fn empty_report_is_skip() {
264        let r = Report::new("nothing", "0.0.0");
265        assert_eq!(r.overall_verdict(), Verdict::Skip);
266    }
267}