1use crate::delta::{DeltaEntry, DeltaReport};
8use crate::merge::{CrapEntry, ScopeDiagnostics};
9use anyhow::Result;
10use std::io::Write;
11
12pub const SCHEMA_VERSION: &str = env!("CARGO_PKG_VERSION");
15
16macro_rules! schema_url {
22 ($file:literal) => {
23 concat!(
24 "https://raw.githubusercontent.com/minikin/cargo-crap/main/schemas/",
25 $file
26 )
27 };
28}
29
30pub const REPORT_SCHEMA_URL: &str = schema_url!("report-v1.json");
32
33pub const DELTA_SCHEMA_URL: &str = schema_url!("delta-v2.json");
39
40#[derive(serde::Serialize, serde::Deserialize)]
42pub struct Envelope {
43 #[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 #[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}