Skip to main content

cpd_reporter/deadcode/
structured.rs

1//! Machine-readable dead-code reports: JSON, XML and CSV.
2
3use 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
9/// The full report as JSON — the format every other tool reads.
10///
11/// The shape is additive: new keys may appear, existing ones keep their
12/// meaning, so a consumer that pins to `findings[].path` keeps working.
13pub 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
53/// A Checkstyle-shaped XML report, which most CI systems already ingest.
54pub 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
106/// One row per finding, for spreadsheets and ad-hoc analysis.
107pub 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
180/// Summary counters every text format needs.
181pub(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("&amp;"),
196            '<' => out.push_str("&lt;"),
197            '>' => out.push_str("&gt;"),
198            '"' => out.push_str("&quot;"),
199            '\'' => out.push_str("&apos;"),
200            // Control characters are not legal in XML 1.0 at all, so they are
201            // dropped rather than escaped into something a parser rejects.
202            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 &amp; b &lt; c &gt; d &quot; e &apos; 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}