1use super::structured::summary_line;
6use super::{DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause};
7use crate::reporter::{ReporterError, ReporterOptions};
8use crate::shared::{Style, write_report_file};
9use cpd_core::deadcode::Finding;
10use std::fmt::Write;
11use std::path::Path;
12
13pub struct MarkdownReporter {
15 style: Style,
16}
17
18impl MarkdownReporter {
19 pub fn new(options: &ReporterOptions) -> Self {
20 Self {
21 style: Style::new(options.no_colors),
22 }
23 }
24}
25
26impl DeadCodeReporter for MarkdownReporter {
27 fn name(&self) -> &str {
28 "markdown"
29 }
30
31 fn report(
32 &self,
33 findings: &[Finding],
34 ctx: &DeadCodeContext<'_>,
35 output_dir: &Path,
36 ) -> Result<(), ReporterError> {
37 let mut out = String::from("# Dead code report\n\n");
38 if findings.is_empty() {
39 out.push_str("No dead code found.\n\n");
40 }
41 let _ = writeln!(out, "{}\n", summary_line(ctx.stats));
42
43 if !findings.is_empty() {
44 out.push_str("| Category | Findings | Lines |\n|---|---:|---:|\n");
45 for (category, group) in group_by_category(findings) {
46 let lines: u32 = group.iter().map(|f| f.lines).sum();
47 let _ = writeln!(
48 out,
49 "| {} | {} | {} |",
50 category.title(),
51 group.len(),
52 lines
53 );
54 }
55 out.push('\n');
56
57 for (category, group) in group_by_category(findings) {
58 let _ = writeln!(out, "## {} ({})\n", category.title(), group.len());
59 out.push_str(
60 "| Location | Name | Confidence | Why not certain |\n|---|---|---|---|\n",
61 );
62 for finding in group {
63 let reasons = reasons_clause(finding);
64 let _ = writeln!(
65 out,
66 "| `{}` | {} | {}% | {} |",
67 location(finding),
68 if finding.name.is_empty() {
69 "—".to_string()
70 } else {
71 format!("`{}`", escape_pipes(&finding.name))
72 },
73 finding.confidence,
74 if reasons.is_empty() {
75 "—".to_string()
76 } else {
77 escape_pipes(&reasons)
78 },
79 );
80 }
81 out.push('\n');
82 }
83 }
84
85 let _ = writeln!(
86 out,
87 "<sub>Generated by [basta](https://jscpd.dev) on {}.</sub>",
88 ctx.stats.detection_date
89 );
90 write_report_file(
91 output_dir,
92 "basta-report.md",
93 out,
94 &self.style,
95 "Dead code Markdown",
96 )?;
97 Ok(())
98 }
99}
100
101pub struct AiReporter {
104 limit: usize,
105}
106
107impl AiReporter {
108 pub fn new(_options: &ReporterOptions) -> Self {
109 Self { limit: 200 }
112 }
113}
114
115impl DeadCodeReporter for AiReporter {
116 fn name(&self) -> &str {
117 "ai"
118 }
119
120 fn report(
121 &self,
122 findings: &[Finding],
123 ctx: &DeadCodeContext<'_>,
124 _output_dir: &Path,
125 ) -> Result<(), ReporterError> {
126 if findings.is_empty() {
127 println!("basta: no dead code found in {} files.", ctx.stats.files);
128 return Ok(());
129 }
130 println!("basta dead code report — {}", summary_line(ctx.stats));
131 println!(
132 "Confidence is 0-100; anything below 90 has a listed reason it may be wrong. \
133 Verify a finding before deleting the code."
134 );
135 for finding in findings.iter().take(self.limit) {
136 let reasons = reasons_clause(finding);
137 println!(
138 "{} {} confidence={} {}{}",
139 finding.category.as_str(),
140 location(finding),
141 finding.confidence,
142 finding.message,
143 if reasons.is_empty() {
144 String::new()
145 } else {
146 format!(" (uncertain: {reasons})")
147 }
148 );
149 }
150 if findings.len() > self.limit {
151 println!(
152 "... and {} more; re-run with -r json for the full list.",
153 findings.len() - self.limit
154 );
155 }
156 Ok(())
157 }
158}
159
160pub struct SilentReporter;
162
163impl DeadCodeReporter for SilentReporter {
164 fn name(&self) -> &str {
165 "silent"
166 }
167
168 fn report(
169 &self,
170 _findings: &[Finding],
171 _ctx: &DeadCodeContext<'_>,
172 _output_dir: &Path,
173 ) -> Result<(), ReporterError> {
174 Ok(())
175 }
176}
177
178fn escape_pipes(text: &str) -> String {
182 text.replace('|', "\\|")
183}
184
185#[cfg(test)]
186mod tests {
187 use super::super::fixtures;
188 use super::*;
189 use cpd_core::deadcode::{Category, Stats};
190 use std::path::PathBuf;
191 use std::time::Duration;
192
193 fn markdown(findings: &[Finding], stats: &Stats) -> String {
194 let dir = fixtures::unique_dir("markdown");
195 let options = ReporterOptions::new(PathBuf::from("/tmp"));
196 let ctx = DeadCodeContext::new(stats, Duration::ZERO);
197 MarkdownReporter::new(&options)
198 .report(findings, &ctx, &dir)
199 .unwrap();
200 let content = std::fs::read_to_string(dir.join("basta-report.md")).unwrap();
201 std::fs::remove_dir_all(&dir).ok();
202 content
203 }
204
205 #[test]
206 fn markdown_has_a_summary_table_and_a_section_per_category() {
207 let stats = fixtures::stats();
208 let md = markdown(&fixtures::findings(), &stats);
209 assert!(md.starts_with("# Dead code report"));
210 assert!(md.contains("| Category | Findings | Lines |"), "{md}");
211 assert!(md.contains("## Unused files (1)"), "{md}");
212 assert!(md.contains("## Unused exports (1)"), "{md}");
213 assert!(md.contains("`src/api.ts:10:1`"), "{md}");
214 }
215
216 #[test]
217 fn markdown_says_so_plainly_when_there_is_nothing_to_report() {
218 let stats = Stats::default();
219 let md = markdown(&[], &stats);
220 assert!(md.contains("No dead code found."), "{md}");
221 assert!(!md.contains("| Category |"), "no empty tables: {md}");
222 }
223
224 #[test]
225 fn a_pipe_in_a_message_does_not_break_the_table() {
226 let mut finding = fixtures::finding(Category::UnusedExport, "a.ts", "a|b", 85);
227 finding.reasons.clear();
228 let stats = fixtures::stats();
229 let md = markdown(&[finding], &stats);
230 assert!(md.contains(r"`a\|b`"), "{md}");
231 for line in md.lines().filter(|l| l.starts_with("| `a")) {
232 assert_eq!(
233 line.matches('|').count() - line.matches(r"\|").count(),
234 5,
235 "an escaped pipe must not add a column: {line}"
236 );
237 }
238 }
239
240 #[test]
241 fn the_ai_report_states_its_own_uncertainty() {
242 let stats = fixtures::stats();
243 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
244 let options = ReporterOptions::new(PathBuf::from("/tmp"));
245 assert!(
246 AiReporter::new(&options)
247 .report(&fixtures::findings(), &ctx, Path::new("/tmp"))
248 .is_ok()
249 );
250 }
251
252 #[test]
253 fn the_ai_report_caps_how_much_it_prints() {
254 let findings: Vec<Finding> = (0..500)
255 .map(|i| fixtures::finding(Category::UnusedExport, &format!("src/f{i}.ts"), "x", 90))
256 .collect();
257 let stats = fixtures::stats();
258 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
259 let options = ReporterOptions::new(PathBuf::from("/tmp"));
260 let reporter = AiReporter::new(&options);
261 assert_eq!(reporter.limit, 200);
262 assert!(reporter.report(&findings, &ctx, Path::new("/tmp")).is_ok());
263 }
264
265 #[test]
266 fn the_silent_reporter_writes_nothing_and_succeeds() {
267 let stats = fixtures::stats();
268 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
269 assert!(
270 SilentReporter
271 .report(&fixtures::findings(), &ctx, Path::new("/nonexistent"))
272 .is_ok(),
273 "silence cannot fail, even with nowhere to write"
274 );
275 }
276}