use crate::analyze;
use crate::config::BastaConfig;
use crate::framework::DetectedFramework;
use cpd_reporter::deadcode::{DeadCodeContext, create_dead_code_reporter};
use cpd_reporter::reporter::{ReporterError, ReporterOptions};
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct OutputOptions {
pub reporters: Vec<String>,
pub output_dir: PathBuf,
pub no_colors: bool,
pub silent: bool,
pub threshold: Option<f64>,
pub exit_code: Option<i32>,
pub tool_version: String,
}
impl Default for OutputOptions {
fn default() -> Self {
Self {
reporters: vec!["console".to_string()],
output_dir: PathBuf::from("report"),
no_colors: false,
silent: false,
threshold: None,
exit_code: None,
tool_version: env!("CARGO_PKG_VERSION").to_string(),
}
}
}
fn is_console_reporter(name: &str) -> bool {
matches!(
normalize_reporter_name(name),
"ai" | "console" | "console-full" | "silent" | "xcode"
)
}
pub fn normalize_reporter_name(name: &str) -> &str {
match name {
"full" | "consoleFull" => "console-full",
"gitlab" => "codeclimate",
other => other,
}
}
fn frameworks_line(frameworks: &[DetectedFramework]) -> Option<String> {
if frameworks.is_empty() {
return None;
}
let listed: Vec<String> = frameworks
.iter()
.map(|framework| {
let directory = framework.directory.to_string_lossy();
match directory.is_empty() {
true => framework.name.clone(),
false => format!("{} ({directory})", framework.name),
}
})
.collect();
Some(format!("Frameworks: {}", listed.join(", ")))
}
pub struct Outcome {
pub report: cpd_core::deadcode::Report,
pub exit_code: i32,
}
pub fn run_and_report(config: &BastaConfig, output: &OutputOptions) -> Outcome {
let started = Instant::now();
let result = analyze::run(config);
let elapsed = started.elapsed();
let reporter_options = ReporterOptions {
output_dir: output.output_dir.clone(),
threshold: output.threshold,
blame: false,
no_colors: output.no_colors,
blame_data: Default::default(),
absolute: false,
tool_version: output.tool_version.clone(),
sarif_error_tokens: None,
};
let mut names: Vec<String> = output.reporters.clone();
if output.silent {
names.retain(|name| !is_console_reporter(name));
}
names.retain(|name| normalize_reporter_name(name) != "threshold");
let wants_threshold = output.threshold.is_some()
|| output
.reporters
.iter()
.any(|name| normalize_reporter_name(name) == "threshold");
let (console, files): (Vec<String>, Vec<String>) = names
.into_iter()
.partition(|name| is_console_reporter(name));
if let Some(line) = frameworks_line(&result.frameworks)
&& console
.iter()
.any(|name| matches!(normalize_reporter_name(name), "console" | "console-full"))
{
println!("{line}");
}
let roots = config.paths.clone();
let mut threshold_exceeded = false;
let mut reporter_failed = false;
let mut run_batch = |batch: &[String]| {
for name in batch {
let Some(reporter) =
create_dead_code_reporter(normalize_reporter_name(name), &reporter_options)
else {
eprintln!("Warning: unknown reporter '{name}'");
continue;
};
let ctx = DeadCodeContext::new(&result.report.statistics, elapsed).with_roots(&roots);
match reporter.report(&result.report.findings, &ctx, &output.output_dir) {
Ok(()) => {}
Err(ReporterError::ThresholdExceeded { actual, threshold }) => {
eprintln!(
"ERROR: basta found too much dead code ({actual:.1}%) over threshold ({threshold:.1}%)"
);
threshold_exceeded = true;
}
Err(error) => {
eprintln!("Reporter '{name}' error: {error}");
reporter_failed = true;
}
}
}
};
run_batch(&console);
run_batch(&files);
if wants_threshold {
run_batch(&["threshold".to_string()]);
}
let exit_code = if reporter_failed {
eprintln!("ERROR: a reporter failed to write its output (see the message above)");
1
} else if threshold_exceeded {
1
} else {
match output.exit_code {
Some(code) if !result.report.findings.is_empty() => code,
_ => 0,
}
};
Outcome {
report: result.report,
exit_code,
}
}
#[cfg(test)]
mod tests {
use super::*;
use cpd_core::deadcode::Category;
fn fixture(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("basta-run-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/index.ts"), "import './api';\n").unwrap();
std::fs::write(
root.join("src/api.ts"),
"export function neverImported() {\n return 1;\n}\n",
)
.unwrap();
std::fs::write(root.join("src/orphan.ts"), "export const x = 1;\n").unwrap();
root
}
fn config(root: &std::path::Path) -> BastaConfig {
BastaConfig {
paths: vec![root.to_path_buf()],
categories: Category::ALL.to_vec(),
min_confidence: 0,
..BastaConfig::default()
}
}
#[test]
fn a_clean_run_exits_zero() {
let root = std::env::temp_dir().join(format!("basta-run-clean-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("index.ts"), "export const a = 1;\n").unwrap();
let outcome = run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["silent".to_string()],
..OutputOptions::default()
},
);
assert!(outcome.report.findings.is_empty());
assert_eq!(outcome.exit_code, 0);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn exit_code_is_returned_only_when_something_was_found() {
let root = fixture("exit");
let options = OutputOptions {
reporters: vec!["silent".to_string()],
exit_code: Some(3),
..OutputOptions::default()
};
let outcome = run_and_report(&config(&root), &options);
assert!(!outcome.report.findings.is_empty());
assert_eq!(outcome.exit_code, 3);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn the_threshold_gate_runs_even_when_it_was_not_named() {
let root = fixture("threshold");
let outcome = run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["silent".to_string()],
threshold: Some(0.0),
..OutputOptions::default()
},
);
assert_eq!(
outcome.exit_code, 1,
"--threshold implies the gate without naming the reporter"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_threshold_that_is_not_reached_passes() {
let root = fixture("threshold-ok");
let outcome = run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["silent".to_string()],
threshold: Some(99.0),
..OutputOptions::default()
},
);
assert_eq!(outcome.exit_code, 0);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn file_reporters_write_into_the_output_directory() {
let root = fixture("files");
let output_dir = root.join("report");
let outcome = run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["json".to_string(), "sarif".to_string(), "html".to_string()],
output_dir: output_dir.clone(),
no_colors: true,
..OutputOptions::default()
},
);
assert_eq!(outcome.exit_code, 0);
for file in [
"basta-report.json",
"basta-report.sarif",
"basta-report.html",
] {
assert!(output_dir.join(file).is_file(), "{file} was not written");
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn silent_keeps_file_reporters_and_drops_console_ones() {
let root = fixture("silent");
let output_dir = root.join("report");
run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["console".to_string(), "json".to_string()],
output_dir: output_dir.clone(),
silent: true,
no_colors: true,
..OutputOptions::default()
},
);
assert!(
output_dir.join("basta-report.json").is_file(),
"a quiet CI job still needs its artifact"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn an_unknown_reporter_warns_instead_of_failing_the_run() {
let root = fixture("unknown");
let outcome = run_and_report(
&config(&root),
&OutputOptions {
reporters: vec!["nonsense".to_string(), "silent".to_string()],
..OutputOptions::default()
},
);
assert_eq!(outcome.exit_code, 0);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn reporter_aliases_resolve_the_way_jscpd_spells_them() {
assert_eq!(normalize_reporter_name("full"), "console-full");
assert_eq!(normalize_reporter_name("consoleFull"), "console-full");
assert_eq!(normalize_reporter_name("gitlab"), "codeclimate");
assert_eq!(normalize_reporter_name("json"), "json");
}
#[test]
fn detected_frameworks_are_named_with_where_they_were_found() {
assert_eq!(frameworks_line(&[]), None);
let found = [
DetectedFramework {
name: "vitest".to_string(),
directory: PathBuf::new(),
},
DetectedFramework {
name: "next".to_string(),
directory: PathBuf::from("apps/web"),
},
];
assert_eq!(
frameworks_line(&found).as_deref(),
Some("Frameworks: vitest, next (apps/web)")
);
}
}