1pub 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
21pub struct DeadCodeContext<'a> {
23 pub stats: &'a Stats,
24 pub duration: Duration,
26 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 pub fn with_roots(mut self, roots: &'a [std::path::PathBuf]) -> Self {
43 self.roots = roots;
44 self
45 }
46}
47
48pub 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
62pub trait DeadCodeReporter: Send {
64 fn report(
65 &self,
66 findings: &[Finding],
67 ctx: &DeadCodeContext<'_>,
68 output_dir: &Path,
69 ) -> Result<(), ReporterError>;
70
71 fn name(&self) -> &str;
73}
74
75pub 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
105pub 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
126pub 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
139pub 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
151pub fn location(finding: &Finding) -> String {
154 format!(
155 "{}:{}:{}",
156 finding.path,
157 finding.start.line,
158 finding.start.column + 1
159 )
160}
161
162pub 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 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 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 pub fn xcode(self) -> &'static str {
202 match self {
203 Self::Error => "error",
204 Self::Warning | Self::Note => "warning",
205 }
206 }
207}
208
209pub 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#[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 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}