ctrf_rs/
report.rs

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