Skip to main content

cpd_reporter/deadcode/
text.rs

1//! Prose-shaped dead-code reports: Markdown for a pull request, a compact
2//! form for a language model, and silence for when the exit code is the
3//! whole answer.
4
5use 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
13/// A Markdown report, shaped to be pasted into a pull request.
14pub 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
101/// A compact report for a language model: every finding on one line, no
102/// decoration, and the instruction a model needs to act on it safely.
103pub struct AiReporter {
104    limit: usize,
105}
106
107impl AiReporter {
108    pub fn new(_options: &ReporterOptions) -> Self {
109        // A model reading this has a context budget; a 5000-finding dump helps
110        // nobody. The count in the trailer says how much was cut.
111        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
160/// Writes nothing. The exit code carries the result.
161pub 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
178/// A `|` inside a Markdown table cell ends the cell, so it has to be escaped
179/// even though it is legal in an identifier in neither language basta reads —
180/// a message or a reason can still contain one.
181fn 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}