Skip to main content

cargo_crap/report/
json.rs

1//! `--format json` and `--format json --baseline …` envelope output.
2//!
3//! Outputs a versioned, schema-tagged envelope so consumers can detect
4//! breaking changes between releases. The envelope shape is mirrored on
5//! input as well — `delta::load_baseline` deserializes the same struct.
6
7use crate::delta::{DeltaEntry, DeltaReport};
8use crate::merge::{CrapEntry, ScopeDiagnostics};
9use anyhow::Result;
10use std::io::Write;
11
12/// Schema/release version stamped onto every JSON envelope so consumers can
13/// detect breaking changes between releases. Mirrors the crate version.
14pub const SCHEMA_VERSION: &str = env!("CARGO_PKG_VERSION");
15
16/// Build the published HTTPS URL for a schema file in this repo.
17///
18/// `concat!` only takes literals, so the base URL is repeated by expansion
19/// rather than reference. Centralized here so a repo move or schema-version
20/// bump changes only this macro and the filename arguments below.
21macro_rules! schema_url {
22    ($file:literal) => {
23        concat!(
24            "https://raw.githubusercontent.com/minikin/cargo-crap/main/schemas/",
25            $file
26        )
27    };
28}
29
30/// Stable HTTPS URL of the JSON Schema describing the absolute envelope shape.
31pub const REPORT_SCHEMA_URL: &str = schema_url!("report-v1.json");
32
33/// Stable HTTPS URL of the JSON Schema describing the delta envelope shape.
34///
35/// Bumped to `delta-v2.json` in spec 13: adds the `moved` status value and
36/// the optional `previous_file` field. Consumers reading v1 see one new
37/// enum value and one new optional field — strictly additive.
38pub const DELTA_SCHEMA_URL: &str = schema_url!("delta-v2.json");
39
40/// JSON wire format for `--format json` output and `--baseline` input.
41#[derive(serde::Serialize, serde::Deserialize)]
42pub struct Envelope {
43    /// URL of the JSON Schema this document conforms to. Optional on input
44    /// (older baselines may predate the field) and always emitted on output.
45    #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
46    pub schema: Option<String>,
47    pub version: String,
48    pub entries: Vec<CrapEntry>,
49    /// Source/LCOV scope diagnostics (spec 24). Present only when the run
50    /// had an `--lcov` input; ignored when the envelope is read back as a
51    /// `--baseline` (the mismatch is a property of the producing run).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub diagnostics: Option<ScopeDiagnostics>,
54}
55
56pub(crate) fn render_json(
57    entries: &[CrapEntry],
58    diagnostics: Option<&ScopeDiagnostics>,
59    out: &mut dyn Write,
60) -> Result<()> {
61    let envelope = Envelope {
62        schema: Some(REPORT_SCHEMA_URL.to_string()),
63        version: SCHEMA_VERSION.to_string(),
64        entries: entries.to_vec(),
65        diagnostics: diagnostics.cloned(),
66    };
67    serde_json::to_writer_pretty(&mut *out, &envelope)?;
68    out.write_all(b"\n")?;
69    Ok(())
70}
71
72pub(crate) fn render_delta_json(
73    report: &DeltaReport,
74    diagnostics: Option<&ScopeDiagnostics>,
75    out: &mut dyn Write,
76) -> Result<()> {
77    #[derive(serde::Serialize)]
78    struct DeltaOutput<'a> {
79        #[serde(rename = "$schema")]
80        schema: &'static str,
81        version: &'static str,
82        entries: &'a [DeltaEntry],
83        removed: &'a [crate::delta::RemovedEntry],
84        #[serde(skip_serializing_if = "Option::is_none")]
85        diagnostics: Option<&'a ScopeDiagnostics>,
86    }
87    serde_json::to_writer_pretty(
88        &mut *out,
89        &DeltaOutput {
90            schema: DELTA_SCHEMA_URL,
91            version: SCHEMA_VERSION,
92            entries: &report.entries,
93            removed: &report.removed,
94            diagnostics,
95        },
96    )?;
97    out.write_all(b"\n")?;
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::super::test_support::{opts, sample};
104    use super::super::{Format, RenderOptions, render};
105    use super::*;
106    use std::path::PathBuf;
107
108    #[test]
109    fn json_output_is_envelope_with_version_and_entries() {
110        let mut buf = Vec::new();
111        render(&sample(), &opts(30.0, Format::Json), &mut buf).unwrap();
112        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
113        assert!(parsed.is_object(), "JSON output must be an envelope object");
114        assert_eq!(
115            parsed["version"].as_str(),
116            Some(SCHEMA_VERSION),
117            "version field must equal SCHEMA_VERSION"
118        );
119        assert!(
120            parsed["entries"].is_array(),
121            "entries field must be an array"
122        );
123        assert_eq!(
124            parsed["entries"].as_array().map(std::vec::Vec::len),
125            Some(2)
126        );
127    }
128
129    #[test]
130    fn diagnostics_embedded_when_present_and_absent_otherwise() {
131        use crate::merge::StrayFiles;
132        let diag = ScopeDiagnostics {
133            analyzed_files: 4,
134            lcov_files: 3,
135            matched_files: 2,
136            source_only: StrayFiles {
137                count: 2,
138                examples: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
139            },
140            lcov_only: StrayFiles {
141                count: 1,
142                examples: vec![PathBuf::from("src/gone.rs")],
143            },
144        };
145
146        let mut buf = Vec::new();
147        render(
148            &sample(),
149            &RenderOptions {
150                threshold: 30.0,
151                format: Format::Json,
152                diagnostics: Some(&diag),
153                ..Default::default()
154            },
155            &mut buf,
156        )
157        .unwrap();
158        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
159        assert_eq!(parsed["diagnostics"]["analyzed_files"], 4);
160        assert_eq!(parsed["diagnostics"]["lcov_files"], 3);
161        assert_eq!(parsed["diagnostics"]["matched_files"], 2);
162        assert_eq!(parsed["diagnostics"]["source_only"]["count"], 2);
163        assert_eq!(
164            parsed["diagnostics"]["source_only"]["examples"][0],
165            "src/a.rs"
166        );
167        assert_eq!(parsed["diagnostics"]["lcov_only"]["count"], 1);
168
169        let mut buf = Vec::new();
170        render(&sample(), &opts(30.0, Format::Json), &mut buf).unwrap();
171        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
172        assert!(
173            parsed.get("diagnostics").is_none(),
174            "no diagnostics → no key in the envelope"
175        );
176    }
177
178    #[test]
179    fn json_format_unaffected_by_links() {
180        use super::super::SourceLinks;
181        let entries = vec![CrapEntry {
182            file: PathBuf::from("src/a.rs"),
183            function: "foo".into(),
184            line: 1,
185            cyclomatic: 1.0,
186            coverage: Some(100.0),
187            crap: 1.0,
188            crate_name: None,
189        }];
190        let links = SourceLinks::new("https://github.com/o/r".into(), "sha".into());
191        let mut buf = Vec::new();
192        render(
193            &entries,
194            &RenderOptions {
195                threshold: 30.0,
196                format: Format::Json,
197                links: Some(&links),
198                ..Default::default()
199            },
200            &mut buf,
201        )
202        .unwrap();
203        let s = String::from_utf8(buf).unwrap();
204        assert!(
205            !s.contains("](https://"),
206            "JSON output must not contain markdown links:\n{s}"
207        );
208    }
209}