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}