1use super::{DeadCodeContext, DeadCodeReporter, reasons_clause, severity};
4use crate::reporter::{ReporterError, ReporterOptions};
5use crate::shared::{Style, write_report_file};
6use cpd_core::deadcode::{Finding, Report, Stats};
7use std::path::Path;
8
9pub struct JsonReporter {
14 style: Style,
15}
16
17impl JsonReporter {
18 pub fn new(options: &ReporterOptions) -> Self {
19 Self {
20 style: Style::new(options.no_colors),
21 }
22 }
23}
24
25impl DeadCodeReporter for JsonReporter {
26 fn name(&self) -> &str {
27 "json"
28 }
29
30 fn report(
31 &self,
32 findings: &[Finding],
33 ctx: &DeadCodeContext<'_>,
34 output_dir: &Path,
35 ) -> Result<(), ReporterError> {
36 let report = Report {
37 findings: findings.to_vec(),
38 statistics: ctx.stats.clone(),
39 };
40 let json = serde_json::to_string_pretty(&report)
41 .map_err(|e| ReporterError::Format(e.to_string()))?;
42 write_report_file(
43 output_dir,
44 "basta-report.json",
45 json,
46 &self.style,
47 "Dead code JSON",
48 )?;
49 Ok(())
50 }
51}
52
53pub struct XmlReporter {
55 style: Style,
56}
57
58impl XmlReporter {
59 pub fn new(options: &ReporterOptions) -> Self {
60 Self {
61 style: Style::new(options.no_colors),
62 }
63 }
64}
65
66impl DeadCodeReporter for XmlReporter {
67 fn name(&self) -> &str {
68 "xml"
69 }
70
71 fn report(
72 &self,
73 findings: &[Finding],
74 ctx: &DeadCodeContext<'_>,
75 output_dir: &Path,
76 ) -> Result<(), ReporterError> {
77 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
78 xml.push_str("<checkstyle version=\"4.3\">\n");
79 for (path, group) in super::group_by_path(findings) {
80 xml.push_str(&format!(" <file name=\"{}\">\n", escape_xml(path)));
81 for finding in group {
82 xml.push_str(&format!(
83 " <error line=\"{}\" column=\"{}\" severity=\"{}\" message=\"{}\" source=\"basta.{}\"/>\n",
84 finding.start.line,
85 finding.start.column + 1,
86 severity(finding).sarif(),
87 escape_xml(&finding.message),
88 finding.category.as_str(),
89 ));
90 }
91 xml.push_str(" </file>\n");
92 }
93 xml.push_str("</checkstyle>\n");
94 let _ = ctx;
95 write_report_file(
96 output_dir,
97 "basta-report.xml",
98 xml,
99 &self.style,
100 "Dead code XML",
101 )?;
102 Ok(())
103 }
104}
105
106pub struct CsvReporter {
108 style: Style,
109}
110
111impl CsvReporter {
112 pub fn new(options: &ReporterOptions) -> Self {
113 Self {
114 style: Style::new(options.no_colors),
115 }
116 }
117}
118
119impl DeadCodeReporter for CsvReporter {
120 fn name(&self) -> &str {
121 "csv"
122 }
123
124 fn report(
125 &self,
126 findings: &[Finding],
127 ctx: &DeadCodeContext<'_>,
128 output_dir: &Path,
129 ) -> Result<(), ReporterError> {
130 let mut writer = csv::Writer::from_writer(Vec::new());
131 writer
132 .write_record([
133 "category",
134 "path",
135 "line",
136 "column",
137 "name",
138 "kind",
139 "parent",
140 "lines",
141 "confidence",
142 "level",
143 "reasons",
144 "message",
145 ])
146 .map_err(|e| ReporterError::Format(e.to_string()))?;
147 for finding in findings {
148 writer
149 .write_record([
150 finding.category.as_str(),
151 &finding.path,
152 &finding.start.line.to_string(),
153 &(finding.start.column + 1).to_string(),
154 &finding.name,
155 finding.symbol_kind.map(|k| k.noun()).unwrap_or("file"),
156 finding.parent.as_deref().unwrap_or(""),
157 &finding.lines.to_string(),
158 &finding.confidence.to_string(),
159 finding.level().as_str(),
160 &reasons_clause(finding),
161 &finding.message,
162 ])
163 .map_err(|e| ReporterError::Format(e.to_string()))?;
164 }
165 let bytes = writer
166 .into_inner()
167 .map_err(|e| ReporterError::Format(e.to_string()))?;
168 let _ = ctx;
169 write_report_file(
170 output_dir,
171 "basta-report.csv",
172 bytes,
173 &self.style,
174 "Dead code CSV",
175 )?;
176 Ok(())
177 }
178}
179
180pub(crate) fn summary_line(stats: &Stats) -> String {
182 format!(
183 "{} findings across {} files ({:.1}% of {} lines)",
184 stats.total_findings(),
185 stats.files,
186 stats.percentage,
187 stats.total_lines
188 )
189}
190
191pub(crate) fn escape_xml(text: &str) -> String {
192 let mut out = String::with_capacity(text.len());
193 for ch in text.chars() {
194 match ch {
195 '&' => out.push_str("&"),
196 '<' => out.push_str("<"),
197 '>' => out.push_str(">"),
198 '"' => out.push_str("""),
199 '\'' => out.push_str("'"),
200 c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
203 c => out.push(c),
204 }
205 }
206 out
207}
208
209#[cfg(test)]
210mod tests {
211 use super::super::fixtures;
212 use super::*;
213 use cpd_core::deadcode::Category;
214 use std::path::PathBuf;
215 use std::time::Duration;
216
217 fn output_dir(name: &str) -> PathBuf {
218 fixtures::unique_dir(name)
219 }
220
221 fn write(name: &str, reporter: &dyn DeadCodeReporter) -> String {
222 let dir = output_dir(name);
223 let stats = fixtures::stats();
224 let ctx = DeadCodeContext::new(&stats, Duration::from_millis(5));
225 reporter
226 .report(&fixtures::findings(), &ctx, &dir)
227 .expect("report");
228 let file = std::fs::read_dir(&dir)
229 .unwrap()
230 .next()
231 .unwrap()
232 .unwrap()
233 .path();
234 let content = std::fs::read_to_string(file).unwrap();
235 std::fs::remove_dir_all(&dir).ok();
236 content
237 }
238
239 #[test]
240 fn json_round_trips_back_into_a_report() {
241 let options = ReporterOptions::new(PathBuf::from("/tmp"));
242 let json = write("json", &JsonReporter::new(&options));
243 let parsed: Report = serde_json::from_str(&json).expect("valid report JSON");
244 assert_eq!(parsed.findings.len(), 3);
245 assert_eq!(parsed.statistics.files, 12);
246 assert_eq!(parsed.findings[0].category, Category::UnusedFile);
247 }
248
249 #[test]
250 fn xml_is_well_formed_checkstyle() {
251 let options = ReporterOptions::new(PathBuf::from("/tmp"));
252 let xml = write("xml", &XmlReporter::new(&options));
253 assert!(xml.starts_with("<?xml version=\"1.0\""));
254 assert!(xml.contains("<checkstyle"));
255 assert!(xml.contains("source=\"basta.unused-export\""), "{xml}");
256 assert_eq!(
257 xml.matches("<file ").count(),
258 3,
259 "one file element per path: {xml}"
260 );
261 }
262
263 #[test]
264 fn csv_has_one_header_and_one_row_per_finding() {
265 let options = ReporterOptions::new(PathBuf::from("/tmp"));
266 let csv = write("csv", &CsvReporter::new(&options));
267 let lines: Vec<&str> = csv.lines().collect();
268 assert_eq!(lines.len(), 4, "{csv}");
269 assert!(lines[0].starts_with("category,path,line,column,name"));
270 assert!(lines.iter().skip(1).any(|l| l.contains("unused-import")));
271 }
272
273 #[test]
274 fn xml_escaping_covers_the_characters_that_break_parsers() {
275 assert_eq!(
276 escape_xml("a & b < c > d \" e ' f"),
277 "a & b < c > d " e ' f"
278 );
279 assert_eq!(
280 escape_xml("keep\ttabs\nand\rnewlines\u{0}but not nulls"),
281 "keep\ttabs\nand\rnewlinesbut not nulls"
282 );
283 }
284
285 #[test]
286 fn an_empty_run_still_writes_a_valid_file() {
287 let options = ReporterOptions::new(PathBuf::from("/tmp"));
288 let dir = output_dir("empty");
289 let stats = cpd_core::deadcode::Stats::default();
290 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
291 for reporter in [
292 Box::new(JsonReporter::new(&options)) as Box<dyn DeadCodeReporter>,
293 Box::new(XmlReporter::new(&options)),
294 Box::new(CsvReporter::new(&options)),
295 ] {
296 assert!(reporter.report(&[], &ctx, &dir).is_ok());
297 }
298 std::fs::remove_dir_all(&dir).ok();
299 }
300
301 #[test]
302 fn the_summary_line_reads_as_a_sentence_fragment() {
303 assert_eq!(
304 summary_line(&fixtures::stats()),
305 "3 findings across 12 files (3.4% of 1000 lines)"
306 );
307 }
308}