Skip to main content

cpd_reporter/deadcode/
mod.rs

1//! Reporters for a dead-code run.
2//!
3//! A parallel set to the clone reporters, under the same names: `console`,
4//! `json`, `sarif`, `html` and the rest mean the same thing on both sides of
5//! jscpd, take the same [`ReporterOptions`], and write into the same output
6//! directory. What changes is the subject — findings rather than clone pairs —
7//! so the two cannot share a trait without one of them lying about its input.
8
9pub mod ci;
10pub mod console;
11pub mod html;
12pub mod structured;
13pub mod text;
14
15use crate::reporter::{ReporterError, ReporterOptions};
16use cpd_core::deadcode::{Category, Finding, Stats};
17use std::collections::BTreeMap;
18use std::path::Path;
19use std::time::Duration;
20
21/// Run-level context every dead-code reporter receives.
22pub struct DeadCodeContext<'a> {
23    pub stats: &'a Stats,
24    /// Wall-clock time of the run, for the trailer line.
25    pub duration: Duration,
26    /// Scan roots the finding paths are relative to. A reporter that shows
27    /// source needs them: the report is written to be read from anywhere, so
28    /// it cannot assume the working directory is still the scan root.
29    pub roots: &'a [std::path::PathBuf],
30}
31
32impl<'a> DeadCodeContext<'a> {
33    pub fn new(stats: &'a Stats, duration: Duration) -> Self {
34        Self {
35            stats,
36            duration,
37            roots: &[],
38        }
39    }
40
41    /// Attach the scan roots so source excerpts can be resolved.
42    pub fn with_roots(mut self, roots: &'a [std::path::PathBuf]) -> Self {
43        self.roots = roots;
44        self
45    }
46}
47
48/// Resolve a finding's display path to a file on disk, trying the path as
49/// written and then each scan root. Returns `None` when the file has moved or
50/// the report is being read on another machine.
51pub fn resolve_path(path: &str, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
52    let direct = std::path::Path::new(path);
53    if direct.is_file() {
54        return Some(direct.to_path_buf());
55    }
56    roots
57        .iter()
58        .map(|root| root.join(path))
59        .find(|candidate| candidate.is_file())
60}
61
62/// Core dead-code reporter trait. Object-safe, mirroring [`crate::Reporter`].
63pub trait DeadCodeReporter: Send {
64    fn report(
65        &self,
66        findings: &[Finding],
67        ctx: &DeadCodeContext<'_>,
68        output_dir: &Path,
69    ) -> Result<(), ReporterError>;
70
71    /// Name this reporter is selected by.
72    fn name(&self) -> &str;
73}
74
75/// Factory: creates a boxed [`DeadCodeReporter`] by name.
76///
77/// The names match [`crate::create_reporter`] exactly, so `-r sarif` means
78/// SARIF whether the run is looking for clones or for dead code.
79pub fn create_dead_code_reporter(
80    name: &str,
81    options: &ReporterOptions,
82) -> Option<Box<dyn DeadCodeReporter>> {
83    match name {
84        "console" => Some(Box::new(console::ConsoleReporter::new(options))),
85        "console-full" | "consoleFull" | "full" => {
86            Some(Box::new(console::ConsoleFullReporter::new(options)))
87        }
88        "json" => Some(Box::new(structured::JsonReporter::new(options))),
89        "xml" => Some(Box::new(structured::XmlReporter::new(options))),
90        "csv" => Some(Box::new(structured::CsvReporter::new(options))),
91        "sarif" => Some(Box::new(ci::SarifReporter::new(options))),
92        "codeclimate" | "gitlab" => Some(Box::new(ci::CodeClimateReporter::new(options))),
93        "openmetrics" => Some(Box::new(ci::OpenMetricsReporter::new(options))),
94        "badge" => Some(Box::new(ci::BadgeReporter::new(options))),
95        "xcode" => Some(Box::new(ci::XcodeReporter::new(options))),
96        "threshold" => Some(Box::new(ci::ThresholdReporter::new(options))),
97        "markdown" => Some(Box::new(text::MarkdownReporter::new(options))),
98        "ai" => Some(Box::new(text::AiReporter::new(options))),
99        "html" => Some(Box::new(html::HtmlReporter::new(options))),
100        "silent" => Some(Box::new(text::SilentReporter)),
101        _ => None,
102    }
103}
104
105/// Every reporter name a dead-code run accepts, for `--help` and diagnostics.
106pub fn dead_code_reporter_names() -> &'static [&'static str] {
107    &[
108        "console",
109        "console-full",
110        "json",
111        "xml",
112        "csv",
113        "sarif",
114        "codeclimate",
115        "openmetrics",
116        "badge",
117        "xcode",
118        "threshold",
119        "markdown",
120        "ai",
121        "html",
122        "silent",
123    ]
124}
125
126// ── helpers shared by the reporters ────────────────────────────────────────
127
128/// Findings grouped by category, in the order [`Category::ALL`] declares —
129/// whole files first, then the narrowing rules — so every format tells the
130/// story in the same order.
131pub fn group_by_category(findings: &[Finding]) -> Vec<(Category, Vec<&Finding>)> {
132    let mut groups: BTreeMap<Category, Vec<&Finding>> = BTreeMap::new();
133    for finding in findings {
134        groups.entry(finding.category).or_default().push(finding);
135    }
136    groups.into_iter().collect()
137}
138
139/// Findings grouped by file, paths in lexical order.
140pub fn group_by_path(findings: &[Finding]) -> Vec<(&str, Vec<&Finding>)> {
141    let mut groups: BTreeMap<&str, Vec<&Finding>> = BTreeMap::new();
142    for finding in findings {
143        groups
144            .entry(finding.path.as_str())
145            .or_default()
146            .push(finding);
147    }
148    groups.into_iter().collect()
149}
150
151/// `path:line:column`, one-based, the form an editor and a terminal both
152/// turn into a link.
153pub fn location(finding: &Finding) -> String {
154    format!(
155        "{}:{}:{}",
156        finding.path,
157        finding.start.line,
158        finding.start.column + 1
159    )
160}
161
162/// The severity a machine-readable format should carry a finding at.
163///
164/// Confidence is the only input: a category is not more serious than another,
165/// it is more or less certain, and a CI gate should react to certainty.
166pub fn severity(finding: &Finding) -> Severity {
167    match finding.confidence {
168        90..=u8::MAX => Severity::Error,
169        60..=89 => Severity::Warning,
170        _ => Severity::Note,
171    }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Severity {
176    Error,
177    Warning,
178    Note,
179}
180
181impl Severity {
182    /// SARIF `level`.
183    pub fn sarif(self) -> &'static str {
184        match self {
185            Self::Error => "error",
186            Self::Warning => "warning",
187            Self::Note => "note",
188        }
189    }
190
191    /// Code Climate `severity`.
192    pub fn codeclimate(self) -> &'static str {
193        match self {
194            Self::Error => "major",
195            Self::Warning => "minor",
196            Self::Note => "info",
197        }
198    }
199
200    /// Xcode diagnostic keyword.
201    pub fn xcode(self) -> &'static str {
202        match self {
203            Self::Error => "error",
204            Self::Warning | Self::Note => "warning",
205        }
206    }
207}
208
209/// The reasons behind a finding as one human-readable clause, empty when the
210/// finding has nothing against it.
211pub fn reasons_clause(finding: &Finding) -> String {
212    finding
213        .reasons
214        .iter()
215        .map(|r| r.explain())
216        .collect::<Vec<_>>()
217        .join("; ")
218}
219
220/// Test fixtures shared by the reporter tests.
221#[cfg(test)]
222pub(crate) mod fixtures {
223    use cpd_core::deadcode::{Category, CategoryCount, Finding, Reason, Stats, SymbolKind};
224    use cpd_core::models::Location;
225
226    /// A temporary directory no other test can collide with.
227    ///
228    /// Reporter tests write real files and delete them afterwards; two tests
229    /// sharing a path is a race that only shows up when the suite runs in
230    /// parallel, which is exactly when nobody is watching.
231    pub fn unique_dir(label: &str) -> std::path::PathBuf {
232        use std::sync::atomic::{AtomicU32, Ordering};
233        static COUNTER: AtomicU32 = AtomicU32::new(0);
234        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
235        let dir =
236            std::env::temp_dir().join(format!("basta-report-{}-{label}-{n}", std::process::id()));
237        std::fs::remove_dir_all(&dir).ok();
238        dir
239    }
240
241    pub fn location(line: u32) -> Location {
242        Location {
243            line,
244            column: 0,
245            offset: 0,
246        }
247    }
248
249    pub fn finding(category: Category, path: &str, name: &str, confidence: u8) -> Finding {
250        Finding {
251            category,
252            path: path.to_string(),
253            name: name.to_string(),
254            exported_as: None,
255            symbol_kind: Some(SymbolKind::Function),
256            parent: None,
257            language: "js".into(),
258            start: location(10),
259            end: location(14),
260            lines: 5,
261            confidence,
262            reasons: if confidence < 90 {
263                vec![Reason::DynamicAccess]
264            } else {
265                Vec::new()
266            },
267            message: format!("`{name}` is never used"),
268        }
269    }
270
271    pub fn findings() -> Vec<Finding> {
272        vec![
273            Finding {
274                name: String::new(),
275                symbol_kind: None,
276                lines: 24,
277                message: "src/orphan.ts is never imported".into(),
278                ..finding(Category::UnusedFile, "src/orphan.ts", "", 95)
279            },
280            finding(Category::UnusedExport, "src/api.ts", "neverImported", 85),
281            finding(Category::UnusedImport, "src/main.ts", "unusedDep", 100),
282        ]
283    }
284
285    pub fn stats() -> Stats {
286        Stats {
287            files: 12,
288            unparsed: 0,
289            unparsed_files: Vec::new(),
290            reachable_files: 10,
291            symbols: 140,
292            entry_points: 2,
293            by_category: vec![
294                CategoryCount {
295                    category: Category::UnusedFile,
296                    count: 1,
297                    lines: 24,
298                },
299                CategoryCount {
300                    category: Category::UnusedExport,
301                    count: 1,
302                    lines: 5,
303                },
304                CategoryCount {
305                    category: Category::UnusedImport,
306                    count: 1,
307                    lines: 5,
308                },
309            ],
310            dead_lines: 34,
311            total_lines: 1000,
312            percentage: 3.4,
313            detection_date: "2026-09-15T10:00:00.000Z".into(),
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use cpd_core::deadcode::Reason;
322
323    #[test]
324    fn every_advertised_name_resolves_to_a_reporter() {
325        let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
326        for name in dead_code_reporter_names() {
327            assert!(
328                create_dead_code_reporter(name, &options).is_some(),
329                "reporter '{name}' must resolve"
330            );
331        }
332        assert!(create_dead_code_reporter("nonsense", &options).is_none());
333    }
334
335    #[test]
336    fn dead_code_reporter_names_match_the_clone_reporter_names() {
337        let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
338        for name in dead_code_reporter_names() {
339            assert!(
340                crate::create_reporter(name, &options).is_some(),
341                "'{name}' must mean the same thing on both sides of jscpd"
342            );
343        }
344    }
345
346    #[test]
347    fn aliases_resolve_to_their_canonical_reporter() {
348        let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
349        for (alias, canonical) in [
350            ("full", "console-full"),
351            ("consoleFull", "console-full"),
352            ("gitlab", "codeclimate"),
353        ] {
354            let reporter = create_dead_code_reporter(alias, &options).expect(alias);
355            assert_eq!(reporter.name(), canonical);
356        }
357    }
358
359    #[test]
360    fn severity_follows_confidence_not_category() {
361        let certain = fixtures::finding(Category::UnusedMember, "a.ts", "x", 95);
362        let likely = fixtures::finding(Category::UnusedImport, "a.ts", "x", 70);
363        let unsure = fixtures::finding(Category::UnusedImport, "a.ts", "x", 40);
364        assert_eq!(severity(&certain), Severity::Error);
365        assert_eq!(severity(&likely), Severity::Warning);
366        assert_eq!(severity(&unsure), Severity::Note);
367    }
368
369    #[test]
370    fn grouping_orders_files_before_the_narrower_rules() {
371        let findings = fixtures::findings();
372        let groups = group_by_category(&findings);
373        let categories: Vec<Category> = groups.iter().map(|(c, _)| *c).collect();
374        assert_eq!(
375            categories,
376            vec![
377                Category::UnusedFile,
378                Category::UnusedExport,
379                Category::UnusedImport
380            ]
381        );
382    }
383
384    #[test]
385    fn locations_are_one_based_and_clickable() {
386        let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 90);
387        finding.start.column = 4;
388        assert_eq!(location(&finding), "src/a.ts:10:5");
389    }
390
391    #[test]
392    fn reasons_read_as_one_clause() {
393        let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 50);
394        finding.reasons = vec![Reason::DynamicAccess, Reason::InTestFile];
395        assert_eq!(
396            reasons_clause(&finding),
397            "file resolves names at runtime; declared in a test file"
398        );
399        finding.reasons.clear();
400        assert!(reasons_clause(&finding).is_empty());
401    }
402}