ctrf_rs/
report.rs

1// crate import(s)
2use crate::{
3    baseline::Baseline, impl_extra, insights::run_insights::RunInsights, results::Results,
4};
5
6// std import(s)
7use std::{
8    collections::HashMap,
9    io::{Read, Write},
10    str::FromStr,
11    time::SystemTime,
12};
13
14// other import(s)
15use semver::Version;
16use serde::{de::Error, Deserialize, Deserializer, Serialize};
17use serde_json::{Result, Value};
18use uuid::Uuid;
19
20pub const REPORT_FORMAT: &str = "CTRF";
21pub const SPEC_VERSION: Version = Version::new(0, 0, 0);
22
23/// Top-level element for a CTRF report.
24/// Corresponds to the spec's ["Root"](https://ctrf.io/docs/specification/root) object.
25#[derive(Deserialize, Serialize, Debug, PartialEq)]
26#[serde(rename_all = "camelCase")]
27pub struct Report {
28    #[serde(deserialize_with = "deserialize_format")]
29    report_format: String,
30    spec_version: Version,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub report_id: Option<Uuid>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub timestamp: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub generated_by: Option<String>,
37    results: Results,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub insights: Option<RunInsights>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub baseline: Option<Baseline>,
42    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
43    extra: HashMap<String, Value>,
44}
45
46impl Report {
47    /// Creates an instance of a CTRF report
48    pub fn new(
49        report_id: Option<Uuid>,
50        timestamp: Option<SystemTime>,
51        generated_by: Option<String>,
52        results: Results,
53        insights: Option<RunInsights>,
54        baseline: Option<Baseline>,
55    ) -> Self {
56        Report {
57            report_format: String::from(REPORT_FORMAT),
58            spec_version: SPEC_VERSION,
59            report_id,
60            timestamp: timestamp.map(|ts| format!("{ts:?}")),
61            generated_by,
62            results,
63            insights,
64            baseline,
65            extra: HashMap::new(),
66        }
67    }
68
69    /// Deserializes a `Report` instance from bytes of JSON text
70    pub fn from_slice(s: &[u8]) -> Result<Self> {
71        serde_json::from_slice(s)
72    }
73
74    /// Parses a `serde_json::Value` as a `Report` instance
75    pub fn from_value(v: Value) -> Result<Self> {
76        serde_json::from_value(v)
77    }
78
79    /// Deserializes a `Report` instance from an I/O stream of JSON text
80    pub fn from_reader(r: impl Read) -> Result<Self> {
81        serde_json::from_reader(r)
82    }
83
84    /// Borrows the contained Results
85    pub fn results(&self) -> &Results {
86        &self.results
87    }
88
89    /// Outputs the report as a String of JSON
90    pub fn to_string(&self) -> Result<String> {
91        serde_json::to_string(self)
92    }
93
94    /// Outputs the report as a pretty-printed String of JSON
95    pub fn to_string_pretty(&self) -> Result<String> {
96        serde_json::to_string_pretty(self)
97    }
98
99    /// Outputs the report as a JSON byte vector
100    pub fn to_vec(&self) -> Result<Vec<u8>> {
101        serde_json::to_vec(self)
102    }
103
104    /// Outputs the report as a pretty-printed JSON byte vector
105    pub fn to_vec_pretty(&self) -> Result<Vec<u8>> {
106        serde_json::to_vec_pretty(self)
107    }
108
109    /// Outputs the report as JSON to the provided I/O stream
110    pub fn to_writer(&self, writer: impl Write) -> Result<()> {
111        serde_json::to_writer(writer, self)
112    }
113
114    /// Outputs the report as pretty-printed JSON to the provided I/O stream
115    pub fn to_writer_pretty(&self, writer: impl Write) -> Result<()> {
116        serde_json::to_writer_pretty(writer, self)
117    }
118}
119
120impl FromStr for Report {
121    type Err = serde_json::Error;
122
123    /// Deserializes a `Report` instance from a string of JSON text
124    fn from_str(s: &str) -> Result<Self> {
125        serde_json::from_str(s)
126    }
127}
128
129fn deserialize_format<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
130where
131    D: Deserializer<'de>,
132{
133    let s: String = Deserialize::deserialize(deserializer)?;
134
135    if s == REPORT_FORMAT {
136        Ok(s)
137    } else {
138        Err(D::Error::custom(format!(
139            "unrecognized report format '{s}'"
140        )))
141    }
142}
143
144impl_extra!(Report);
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    use crate::{results::ResultsBuilder, tool::Tool};
151
152    use std::time::{Duration, UNIX_EPOCH};
153
154    const TEMPLATE_JSON: &str = r#"{
155  "reportFormat": "CTRF",
156  "specVersion": "0.0.0",
157  "results": {
158    "tool": {
159      "name": "ctrf-rs",
160      "version": "VERSION"
161    },
162    "summary": {
163      "tests": 0,
164      "passed": 0,
165      "failed": 0,
166      "pending": 0,
167      "skipped": 0,
168      "other": 0,
169      "start": START,
170      "stop": STOP,
171      "duration": 0
172    },
173    "tests": []
174  }
175}"#;
176
177    #[test]
178    fn create_empty_report() {
179        let time = SystemTime::now();
180        let results = ResultsBuilder::new(Tool::new()).build(time, time);
181        let report = Report::new(None, None, None, results, None, None);
182
183        assert_eq!(report.report_format, REPORT_FORMAT);
184        assert_eq!(report.spec_version, SPEC_VERSION);
185        assert_eq!(report.report_id, None);
186        assert_eq!(report.timestamp, None);
187        assert_eq!(report.generated_by, None);
188    }
189
190    #[test]
191    fn create_report_with_id() {
192        let time = SystemTime::now();
193        let results = ResultsBuilder::new(Tool::new()).build(time, time);
194        let id = Some(Uuid::new_v4());
195        let report = Report::new(id, None, None, results, None, None);
196
197        assert_eq!(report.report_format, REPORT_FORMAT);
198        assert_eq!(report.spec_version, SPEC_VERSION);
199        assert_eq!(report.report_id, id);
200        assert_eq!(report.timestamp, None);
201        assert_eq!(report.generated_by, None);
202    }
203
204    #[test]
205    fn create_report_with_timestamp() {
206        let time = SystemTime::now();
207        let results = ResultsBuilder::new(Tool::new()).build(time, time);
208        let ts = Some(time);
209        let report = Report::new(None, ts, None, results, None, None);
210
211        assert_eq!(report.report_format, REPORT_FORMAT);
212        assert_eq!(report.spec_version, SPEC_VERSION);
213        assert_eq!(report.report_id, None);
214        assert_eq!(report.timestamp, Some(format!("{time:?}")));
215        assert_eq!(report.generated_by, None);
216    }
217
218    #[test]
219    fn create_report_with_generated_by() {
220        let time = SystemTime::now();
221        let results = ResultsBuilder::new(Tool::new()).build(time, time);
222        let gen_by = Some(String::from("ctrf-rs"));
223        let report = Report::new(None, None, gen_by, results, None, None);
224
225        assert_eq!(report.report_format, REPORT_FORMAT);
226        assert_eq!(report.spec_version, SPEC_VERSION);
227        assert_eq!(report.report_id, None);
228        assert_eq!(report.timestamp, None);
229        assert_eq!(report.generated_by, Some(String::from("ctrf-rs")));
230    }
231
232    #[test]
233    fn serialize_to_string() {
234        let time = SystemTime::now();
235        let results = ResultsBuilder::new(Tool::new()).build(time, time);
236        let report = Report::new(None, None, None, results, None, None);
237
238        assert_eq!(report.report_format, REPORT_FORMAT);
239        assert_eq!(report.spec_version, SPEC_VERSION);
240
241        let report_text = report.to_string().expect("report generation failed");
242        let exp_text = r#"{"reportFormat":"CTRF","specVersion":"0.0.0","results":{"tool":{"name":"ctrf-rs","version":"VERSION"},"summary":{"tests":0,"passed":0,"failed":0,"pending":0,"skipped":0,"other":0,"start":START,"stop":STOP,"duration":0},"tests":[]}}"#
243            .replace("VERSION", env!("CARGO_PKG_VERSION"));
244        let time_str = time
245            .duration_since(UNIX_EPOCH)
246            .expect("time conversion error")
247            .as_millis()
248            .to_string();
249
250        assert_eq!(
251            report_text,
252            exp_text
253                .replace("START", &time_str)
254                .replace("STOP", &time_str)
255        );
256    }
257
258    #[test]
259    fn serialize_to_string_pretty() {
260        let time = SystemTime::now();
261        let results = ResultsBuilder::new(Tool::new()).build(time, time);
262        let report = Report::new(None, None, None, results, None, None);
263
264        assert_eq!(report.report_format, REPORT_FORMAT);
265        assert_eq!(report.spec_version, SPEC_VERSION);
266
267        let report_text = report.to_string_pretty().expect("report generation failed");
268        let time_str = time
269            .duration_since(UNIX_EPOCH)
270            .expect("time conversion error")
271            .as_millis()
272            .to_string();
273
274        assert_eq!(
275            report_text,
276            TEMPLATE_JSON
277                .replace("START", &time_str)
278                .replace("STOP", &time_str)
279                .replace("VERSION", env!("CARGO_PKG_VERSION"))
280        );
281    }
282
283    // TODO: serialize full report
284
285    #[test]
286    fn deserialize_happy_path() -> Result<()> {
287        let time = 1234567890000_u64;
288        let time_str = time.to_string();
289        let json = TEMPLATE_JSON
290            .replace("START", &time_str)
291            .replace("STOP", &time_str)
292            .replace("VERSION", env!("CARGO_PKG_VERSION"));
293
294        let report = Report::from_str(&json)?;
295
296        assert_eq!(report.report_format, REPORT_FORMAT);
297        assert_eq!(report.spec_version, SPEC_VERSION);
298
299        let time_sys = SystemTime::UNIX_EPOCH + Duration::from_millis(time);
300        let results = ResultsBuilder::new(Tool::new()).build(time_sys, time_sys);
301        let report_exp = Report::new(None, None, None, results, None, None);
302
303        assert_eq!(report, report_exp);
304
305        Ok(())
306    }
307
308    #[test]
309    fn deserialize_bad_format() {
310        let bad_format = "INVALID";
311        let json = TEMPLATE_JSON.replace("CTRF", bad_format);
312
313        let report_result = Report::from_str(&json);
314        let exp_msg = format!("unrecognized report format '{bad_format}'");
315
316        match report_result {
317            Ok(_) => panic!("report deserialization should have failed"),
318            Err(e) => {
319                if !e.to_string().contains(&exp_msg) {
320                    panic!(
321                        "deserialization result did not contain expected message \"{}\"",
322                        exp_msg
323                    );
324                }
325            }
326        }
327    }
328
329    // TODO: deserialize full JSON
330}