Skip to main content

ephemeris_report/
report.rs

1use std::fs;
2use std::path::Path;
3
4use facet::Facet;
5
6use crate::{Details, Outcome, ReportError, SkipReason};
7
8const FILE: &str = "report.json";
9
10/// A file stored next to the report.
11#[derive(Clone, Debug, Eq, Facet, PartialEq)]
12pub struct Attachment {
13    /// The attachment name.
14    pub name: String,
15    /// The path relative to the report directory.
16    pub path: String,
17}
18
19impl Attachment {
20    /// The name of the attachment that holds the captured command output.
21    pub const OUTPUT: &str = "output";
22}
23
24/// The result of one check run.
25#[derive(Clone, Debug, Eq, Facet, PartialEq)]
26pub struct Report {
27    /// The check name.
28    pub check: String,
29    /// How the check ended.
30    pub outcome: Outcome,
31    /// Why the check did not run.
32    #[facet(default, skip_serializing_if = Option::is_none)]
33    pub skipped: Option<SkipReason>,
34    /// The exit status of the command, when it ran.
35    #[facet(default, skip_serializing_if = Option::is_none)]
36    pub status: Option<i32>,
37    /// The structured result, when a converter produced one.
38    #[facet(default, skip_serializing_if = Option::is_none)]
39    pub details: Option<Details>,
40    /// Files stored next to the report.
41    #[facet(default)]
42    pub attachments: Vec<Attachment>,
43}
44
45impl Report {
46    /// A report with an outcome and an exit status and nothing else.
47    #[must_use]
48    pub fn plain(check: &str, outcome: Outcome, status: Option<i32>) -> Self {
49        Self {
50            check: check.to_owned(),
51            outcome,
52            skipped: None,
53            status,
54            details: None,
55            attachments: Vec::new(),
56        }
57    }
58
59    /// A report for a check that did not run.
60    #[must_use]
61    pub fn skipped(check: &str, reason: SkipReason) -> Self {
62        Self {
63            skipped: Some(reason),
64            ..Self::plain(check, Outcome::Skip, None)
65        }
66    }
67
68    /// Whether the check did not fail. A skipped check passes.
69    #[must_use]
70    pub fn passed(&self) -> bool {
71        self.outcome != Outcome::Fail
72    }
73
74    /// Store `contents` as `file` in `directory` and list it as an attachment.
75    ///
76    /// The directory is created when it is missing.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when the directory or the file cannot be written.
81    pub fn attach(
82        &mut self,
83        directory: &Path,
84        name: &str,
85        file: &str,
86        contents: &[u8],
87    ) -> Result<(), ReportError> {
88        fs::create_dir_all(directory).map_err(|source| ReportError::io(directory, source))?;
89        let path = directory.join(file);
90        fs::write(&path, contents).map_err(|source| ReportError::io(path, source))?;
91        self.attachments.push(Attachment {
92            name: name.to_owned(),
93            path: file.to_owned(),
94        });
95        Ok(())
96    }
97
98    /// Write `report.json` into `directory` and create the directory first.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when the directory or the file cannot be written.
103    pub fn write(&self, directory: &Path) -> Result<(), ReportError> {
104        fs::create_dir_all(directory).map_err(|source| ReportError::io(directory, source))?;
105        let path = directory.join(FILE);
106        let mut text =
107            facet_json::to_string_pretty(self).map_err(|source| ReportError::Encode {
108                source: Box::new(source),
109            })?;
110        text.push('\n');
111        fs::write(&path, text).map_err(|source| ReportError::io(path, source))
112    }
113
114    /// Read `report.json` from `directory`.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error when the file is missing or is not a valid report.
119    pub fn read(directory: &Path) -> Result<Self, ReportError> {
120        let path = directory.join(FILE);
121        let text = fs::read_to_string(&path).map_err(|source| ReportError::io(&path, source))?;
122        facet_json::from_str(&text).map_err(|source| ReportError::Decode {
123            path,
124            source: Box::new(source),
125        })
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use std::fs;
132
133    use super::Report;
134    use crate::{Outcome, ReportError};
135
136    #[test]
137    fn a_tests_report_round_trips_through_its_json() {
138        let json = r#"{
139  "check": "nextest",
140  "outcome": "pass",
141  "details": {
142    "kind": "tests",
143    "summary": {
144      "passed": 1,
145      "failed": 1,
146      "skipped": 0
147    },
148    "items": [
149      {
150        "name": "a::b",
151        "outcome": "pass"
152      },
153      {
154        "name": "a::c",
155        "outcome": "fail",
156        "message": "boom"
157      }
158    ]
159  },
160  "attachments": [
161    {
162      "name": "junit.xml",
163      "path": "junit.xml"
164    }
165  ]
166}"#;
167        let report: Report = facet_json::from_str(json).expect("decodes");
168        let directory = tempfile::tempdir().expect("directory");
169        report.write(directory.path()).expect("writes");
170        assert_eq!(Report::read(directory.path()).expect("reads"), report);
171        let written = fs::read_to_string(directory.path().join("report.json")).expect("file");
172        assert_eq!(written, format!("{json}\n"));
173    }
174
175    #[test]
176    fn a_plain_report_round_trips_without_optional_fields() {
177        let report = Report::plain("lint", Outcome::Fail, Some(42));
178        let directory = tempfile::tempdir().expect("directory");
179        let nested = directory.path().join("reports").join("lint");
180        report.write(&nested).expect("writes");
181        let written = fs::read_to_string(nested.join("report.json")).expect("file");
182        assert_eq!(
183            written,
184            "{\n  \"check\": \"lint\",\n  \"outcome\": \"fail\",\n  \"status\": 42,\n  \
185             \"attachments\": []\n}\n"
186        );
187        assert_eq!(Report::read(&nested).expect("reads"), report);
188    }
189
190    #[test]
191    fn reading_a_missing_directory_is_an_io_error() {
192        let directory = tempfile::tempdir().expect("directory");
193        let error = Report::read(&directory.path().join("missing")).unwrap_err();
194        assert!(
195            matches!(error, ReportError::Io { path: _, source: _ }),
196            "{error:?}"
197        );
198    }
199}