pub mod ci;
pub mod console;
pub mod html;
pub mod structured;
pub mod text;
use crate::reporter::{ReporterError, ReporterOptions};
use cpd_core::deadcode::{Category, Finding, Stats};
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
pub struct DeadCodeContext<'a> {
pub stats: &'a Stats,
pub duration: Duration,
pub roots: &'a [std::path::PathBuf],
}
impl<'a> DeadCodeContext<'a> {
pub fn new(stats: &'a Stats, duration: Duration) -> Self {
Self {
stats,
duration,
roots: &[],
}
}
pub fn with_roots(mut self, roots: &'a [std::path::PathBuf]) -> Self {
self.roots = roots;
self
}
}
pub fn resolve_path(path: &str, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
let direct = std::path::Path::new(path);
if direct.is_file() {
return Some(direct.to_path_buf());
}
roots
.iter()
.map(|root| root.join(path))
.find(|candidate| candidate.is_file())
}
pub trait DeadCodeReporter: Send {
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
output_dir: &Path,
) -> Result<(), ReporterError>;
fn name(&self) -> &str;
}
pub fn create_dead_code_reporter(
name: &str,
options: &ReporterOptions,
) -> Option<Box<dyn DeadCodeReporter>> {
match name {
"console" => Some(Box::new(console::ConsoleReporter::new(options))),
"console-full" | "consoleFull" | "full" => {
Some(Box::new(console::ConsoleFullReporter::new(options)))
}
"json" => Some(Box::new(structured::JsonReporter::new(options))),
"xml" => Some(Box::new(structured::XmlReporter::new(options))),
"csv" => Some(Box::new(structured::CsvReporter::new(options))),
"sarif" => Some(Box::new(ci::SarifReporter::new(options))),
"codeclimate" | "gitlab" => Some(Box::new(ci::CodeClimateReporter::new(options))),
"openmetrics" => Some(Box::new(ci::OpenMetricsReporter::new(options))),
"badge" => Some(Box::new(ci::BadgeReporter::new(options))),
"xcode" => Some(Box::new(ci::XcodeReporter::new(options))),
"threshold" => Some(Box::new(ci::ThresholdReporter::new(options))),
"markdown" => Some(Box::new(text::MarkdownReporter::new(options))),
"ai" => Some(Box::new(text::AiReporter::new(options))),
"html" => Some(Box::new(html::HtmlReporter::new(options))),
"silent" => Some(Box::new(text::SilentReporter)),
_ => None,
}
}
pub fn dead_code_reporter_names() -> &'static [&'static str] {
&[
"console",
"console-full",
"json",
"xml",
"csv",
"sarif",
"codeclimate",
"openmetrics",
"badge",
"xcode",
"threshold",
"markdown",
"ai",
"html",
"silent",
]
}
pub fn group_by_category(findings: &[Finding]) -> Vec<(Category, Vec<&Finding>)> {
let mut groups: BTreeMap<Category, Vec<&Finding>> = BTreeMap::new();
for finding in findings {
groups.entry(finding.category).or_default().push(finding);
}
groups.into_iter().collect()
}
pub fn group_by_path(findings: &[Finding]) -> Vec<(&str, Vec<&Finding>)> {
let mut groups: BTreeMap<&str, Vec<&Finding>> = BTreeMap::new();
for finding in findings {
groups
.entry(finding.path.as_str())
.or_default()
.push(finding);
}
groups.into_iter().collect()
}
pub fn location(finding: &Finding) -> String {
format!(
"{}:{}:{}",
finding.path,
finding.start.line,
finding.start.column + 1
)
}
pub fn severity(finding: &Finding) -> Severity {
match finding.confidence {
90..=u8::MAX => Severity::Error,
60..=89 => Severity::Warning,
_ => Severity::Note,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
pub fn sarif(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
Self::Note => "note",
}
}
pub fn codeclimate(self) -> &'static str {
match self {
Self::Error => "major",
Self::Warning => "minor",
Self::Note => "info",
}
}
pub fn xcode(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning | Self::Note => "warning",
}
}
}
pub fn reasons_clause(finding: &Finding) -> String {
finding
.reasons
.iter()
.map(|r| r.explain())
.collect::<Vec<_>>()
.join("; ")
}
#[cfg(test)]
pub(crate) mod fixtures {
use cpd_core::deadcode::{Category, CategoryCount, Finding, Reason, Stats, SymbolKind};
use cpd_core::models::Location;
pub fn unique_dir(label: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("basta-report-{}-{label}-{n}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
dir
}
pub fn location(line: u32) -> Location {
Location {
line,
column: 0,
offset: 0,
}
}
pub fn finding(category: Category, path: &str, name: &str, confidence: u8) -> Finding {
Finding {
category,
path: path.to_string(),
name: name.to_string(),
exported_as: None,
symbol_kind: Some(SymbolKind::Function),
parent: None,
language: "js".into(),
start: location(10),
end: location(14),
lines: 5,
confidence,
reasons: if confidence < 90 {
vec![Reason::DynamicAccess]
} else {
Vec::new()
},
message: format!("`{name}` is never used"),
}
}
pub fn findings() -> Vec<Finding> {
vec![
Finding {
name: String::new(),
symbol_kind: None,
lines: 24,
message: "src/orphan.ts is never imported".into(),
..finding(Category::UnusedFile, "src/orphan.ts", "", 95)
},
finding(Category::UnusedExport, "src/api.ts", "neverImported", 85),
finding(Category::UnusedImport, "src/main.ts", "unusedDep", 100),
]
}
pub fn stats() -> Stats {
Stats {
files: 12,
unparsed: 0,
unparsed_files: Vec::new(),
reachable_files: 10,
symbols: 140,
entry_points: 2,
by_category: vec![
CategoryCount {
category: Category::UnusedFile,
count: 1,
lines: 24,
},
CategoryCount {
category: Category::UnusedExport,
count: 1,
lines: 5,
},
CategoryCount {
category: Category::UnusedImport,
count: 1,
lines: 5,
},
],
dead_lines: 34,
total_lines: 1000,
percentage: 3.4,
detection_date: "2026-09-15T10:00:00.000Z".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cpd_core::deadcode::Reason;
#[test]
fn every_advertised_name_resolves_to_a_reporter() {
let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
for name in dead_code_reporter_names() {
assert!(
create_dead_code_reporter(name, &options).is_some(),
"reporter '{name}' must resolve"
);
}
assert!(create_dead_code_reporter("nonsense", &options).is_none());
}
#[test]
fn dead_code_reporter_names_match_the_clone_reporter_names() {
let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
for name in dead_code_reporter_names() {
assert!(
crate::create_reporter(name, &options).is_some(),
"'{name}' must mean the same thing on both sides of jscpd"
);
}
}
#[test]
fn aliases_resolve_to_their_canonical_reporter() {
let options = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
for (alias, canonical) in [
("full", "console-full"),
("consoleFull", "console-full"),
("gitlab", "codeclimate"),
] {
let reporter = create_dead_code_reporter(alias, &options).expect(alias);
assert_eq!(reporter.name(), canonical);
}
}
#[test]
fn severity_follows_confidence_not_category() {
let certain = fixtures::finding(Category::UnusedMember, "a.ts", "x", 95);
let likely = fixtures::finding(Category::UnusedImport, "a.ts", "x", 70);
let unsure = fixtures::finding(Category::UnusedImport, "a.ts", "x", 40);
assert_eq!(severity(&certain), Severity::Error);
assert_eq!(severity(&likely), Severity::Warning);
assert_eq!(severity(&unsure), Severity::Note);
}
#[test]
fn grouping_orders_files_before_the_narrower_rules() {
let findings = fixtures::findings();
let groups = group_by_category(&findings);
let categories: Vec<Category> = groups.iter().map(|(c, _)| *c).collect();
assert_eq!(
categories,
vec![
Category::UnusedFile,
Category::UnusedExport,
Category::UnusedImport
]
);
}
#[test]
fn locations_are_one_based_and_clickable() {
let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 90);
finding.start.column = 4;
assert_eq!(location(&finding), "src/a.ts:10:5");
}
#[test]
fn reasons_read_as_one_clause() {
let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 50);
finding.reasons = vec![Reason::DynamicAccess, Reason::InTestFile];
assert_eq!(
reasons_clause(&finding),
"file resolves names at runtime; declared in a test file"
);
finding.reasons.clear();
assert!(reasons_clause(&finding).is_empty());
}
}