use std::ffi::OsString;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use clap::Parser;
use crate::cli::{Cli, ExitCode, Options};
use crate::detector::CloneMatch;
use crate::files::SourceFile;
use crate::{cli, files, formats, report, verbose};
#[derive(Clone, Debug, Default)]
pub struct JscpdOutcome {
pub clones: Vec<CloneMatch>,
pub exit_code: Option<i32>,
}
pub fn jscpd<I, T>(args: I) -> Result<Vec<CloneMatch>>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
Ok(run_cli_args(args)?.clones)
}
pub fn jscpd_with_exit_callback<I, T, F>(args: I, mut exit_callback: F) -> Result<Vec<CloneMatch>>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
F: FnMut(i32),
{
let outcome = run_cli_args(args)?;
if let Some(code) = outcome.exit_code {
exit_callback(code);
}
Ok(outcome.clones)
}
pub fn run_cli_args<I, T>(args: I) -> Result<JscpdOutcome>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
run_cli(Cli::try_parse_from(args)?)
}
pub fn run_current_process() -> Result<JscpdOutcome> {
run_cli(Cli::parse())
}
fn run_cli(cli: Cli) -> Result<JscpdOutcome> {
if cli.version {
println!("{}", env!("CARGO_PKG_VERSION"));
return Ok(JscpdOutcome::default());
}
let list = cli.list;
let options = Options::from_cli(cli)?;
if list {
print!("{}", list_output());
return Ok(JscpdOutcome::default());
}
let files = files::discover(&options)?;
if options.debug {
print_debug(&options, &files);
return Ok(JscpdOutcome::default());
}
print_store_warning(&options);
report::write_unknown_reporter_warnings(&options);
let started = Instant::now();
if files.is_empty() {
print_terminal_footer(&options, started.elapsed());
return Ok(JscpdOutcome::default());
}
let result = crate::detect_source_files(files, &options);
if options.verbose {
verbose::write_detection_events(&result);
}
report::write_progress(&result, &options);
report::write_reports(&result, &options)?;
print_terminal_footer(&options, started.elapsed());
let clones = result.clones;
let exit_code = if clones.is_empty() {
None
} else {
Some(match cli::resolve_node_exit_code(&options.exit_code) {
Ok(code) => code,
Err(message) => bail!("{message}"),
})
};
Ok(JscpdOutcome { clones, exit_code })
}
pub fn upstream_stdout_error(message: &str) -> Option<String> {
if message.starts_with("TypeError [ERR_INVALID_ARG_TYPE]")
|| message.starts_with("TypeError:")
|| message.starts_with("RangeError ")
|| message.starts_with("SyntaxError:")
{
return Some(message.to_string());
}
if message.starts_with("Mode ") && message.ends_with(" does not supported yet.") {
return Some(format!("Error: {message}"));
}
None
}
fn print_debug(options: &Options, files: &[SourceFile]) {
print!("{}", debug_output(options, files));
}
fn print_store_warning(options: &Options) {
if let Some(warning) = cli::store_warning(options) {
eprintln!("{warning}");
}
}
fn print_terminal_footer(options: &Options, elapsed: Duration) {
if let Some(output) = terminal_footer_output(options, elapsed) {
print!("{output}");
}
}
fn terminal_footer_output(options: &Options, elapsed: Duration) -> Option<String> {
if options.silent {
return None;
}
let mut output = format!("time: {:.3}ms\n", elapsed.as_secs_f64() * 1000.0);
if !options.no_tips {
output.push('\n');
for tip in TIPS {
output.push_str(tip);
output.push('\n');
}
}
Some(output)
}
const TIPS: &[&str] =
&["💡 Auto-refactor with AI: npx skills add vv-bogdanov/jscpd-rs --skill dry-refactoring"];
fn debug_output(options: &Options, files: &[SourceFile]) -> String {
let mut output = String::new();
output.push_str("Options:\n");
output.push_str(&debug_options_output(options));
output.push('\n');
for file in files {
output.push_str(&file.source_id);
output.push('\n');
}
output.push_str(&format!("Found {} files to detect.\n", files.len()));
output
}
fn debug_options_output(options: &Options) -> String {
let mut fields = vec![
debug_string_field("executionId", options.execution_id.as_deref().unwrap_or("")),
debug_array_field(
"path",
&options
.paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
),
format!(" mode: [Function: {}]", mode_name(options.mode)),
format!(" minLines: {}", options.min_lines),
format!(" maxLines: {}", options.max_lines),
debug_string_field("maxSize", &debug_size(options.max_size_bytes)),
format!(" minTokens: {}", options.min_tokens),
debug_output_field(options),
debug_array_field("reporters", &options.reporters),
debug_array_field("ignore", &options.ignore),
debug_optional_number_field("threshold", options.threshold),
debug_format_mappings_field("formatsExts", &options.formats_exts),
debug_format_mappings_field("formatsNames", &options.formats_names),
format!(" debug: {}", options.debug),
format!(" silent: {}", options.silent),
format!(" blame: {}", options.blame),
format!(" cache: {}", options.cache),
format!(" absolute: {}", options.absolute),
format!(" noSymlinks: {}", options.no_symlinks),
format!(" skipLocal: {}", options.skip_local),
format!(" ignoreCase: {}", options.ignore_case),
format!(" gitignore: {}", options.gitignore),
debug_reporter_options_field(options),
debug_exit_code_field(&options.exit_code),
format!(" noTips: {}", options.no_tips),
];
if let Some(config) = &options.config {
fields.push(debug_string_field("config", &config.display().to_string()));
}
fields.extend([
debug_array_field("listeners", &options.listeners),
debug_array_field("format", &debug_formats(options)),
]);
if options.pattern != "**/*" {
fields.push(debug_string_field("pattern", &options.pattern));
}
if let Some(store) = &options.store {
fields.push(debug_string_field("store", store));
}
if let Some(store_path) = &options.store_path {
fields.push(debug_string_field(
"storePath",
&store_path.display().to_string(),
));
}
if !options.tokens_to_skip.is_empty() {
fields.push(debug_array_field("tokensToSkip", &options.tokens_to_skip));
}
format!("{{\n{}\n}}", fields.join(",\n"))
}
fn debug_string_field(name: &str, value: &str) -> String {
format!(" {name}: '{}'", js_quote(value))
}
fn debug_output_field(options: &Options) -> String {
if options.output_is_bare {
" output: true".to_string()
} else {
debug_string_field("output", &options.output.display().to_string())
}
}
fn debug_array_field(name: &str, values: &[String]) -> String {
if values.is_empty() {
return format!(" {name}: []");
}
let values = values
.iter()
.map(|value| format!("'{}'", js_quote(value)))
.collect::<Vec<_>>()
.join(", ");
format!(" {name}: [ {values} ]")
}
fn debug_optional_number_field(name: &str, value: Option<f64>) -> String {
match value {
Some(value) => format!(" {name}: {value}"),
None => format!(" {name}: undefined"),
}
}
fn debug_exit_code_field(exit_code: &ExitCode) -> String {
match exit_code {
ExitCode::Number(value) => format!(" exitCode: {}", debug_js_number(*value)),
ExitCode::String(value) => debug_string_field("exitCode", value),
ExitCode::Boolean(value) => format!(" exitCode: {value}"),
}
}
fn debug_js_number(value: f64) -> String {
if value.is_nan() {
"NaN".to_string()
} else if value == f64::INFINITY {
"Infinity".to_string()
} else if value == f64::NEG_INFINITY {
"-Infinity".to_string()
} else if value.fract() == 0.0 {
format!("{value:.0}")
} else {
value.to_string()
}
}
fn debug_format_mappings_field(name: &str, mappings: &cli::FormatMappings) -> String {
if mappings.is_empty() {
return format!(" {name}: {{}}");
}
let entries = mappings
.iter()
.map(|(format, values)| {
let values = values
.iter()
.map(|value| format!("'{}'", js_quote(value)))
.collect::<Vec<_>>()
.join(", ");
format!("{}: [ {values} ]", js_quote(format))
})
.collect::<Vec<_>>()
.join(", ");
format!(" {name}: {{ {entries} }}")
}
fn debug_reporter_options_field(options: &Options) -> String {
if options.reporters_options.is_empty() {
return " reportersOptions: {}".to_string();
}
let json = serde_json::to_string(&options.reporters_options).unwrap_or_else(|_| "{}".into());
format!(" reportersOptions: {json}")
}
fn debug_formats(options: &Options) -> Vec<String> {
if let Some(formats) = &options.format_order {
return formats.clone();
}
let supported = formats::supported_formats();
match &options.formats {
Some(selected) => supported
.into_iter()
.filter(|format| selected.contains(*format))
.map(str::to_string)
.collect(),
None => supported.into_iter().map(str::to_string).collect(),
}
}
fn debug_size(bytes: u64) -> String {
if bytes.is_multiple_of(1024 * 1024) {
format!("{}mb", bytes / (1024 * 1024))
} else if bytes.is_multiple_of(1024) {
format!("{}kb", bytes / 1024)
} else {
format!("{bytes}b")
}
}
fn mode_name(mode: cli::Mode) -> &'static str {
match mode {
cli::Mode::Strict => "strict",
cli::Mode::Mild => "mild",
cli::Mode::Weak => "weak",
}
}
fn js_quote(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "\\'")
}
fn list_output() -> String {
format!(
"Supported formats: \n{}\n",
formats::supported_formats().join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jscpd_api_returns_clones_and_calls_exit_callback() {
let mut exit_codes = Vec::new();
let clones = jscpd_with_exit_callback(
[
"jscpd",
"jscpd/fixtures/clike/file2.c",
"--format",
"c",
"--min-tokens",
"20",
"--min-lines",
"3",
"--max-size",
"1mb",
"--silent",
"--noTips",
"--exitCode",
"7",
],
|code| exit_codes.push(code),
)
.expect("run jscpd app API");
assert_eq!(clones.len(), 1);
assert_eq!(exit_codes, vec![7]);
}
#[test]
fn run_cli_args_handles_version_without_detection() {
let outcome = run_cli_args(["jscpd", "--version"]).expect("run version");
assert!(outcome.clones.is_empty());
assert_eq!(outcome.exit_code, None);
}
#[test]
fn debug_output_lists_options_and_files() {
let options = Options {
debug: true,
config: Some(std::path::PathBuf::from("/repo/.jscpd.json")),
formats: Some(std::collections::HashSet::from(["typescript".to_string()])),
format_order: Some(vec!["typescript".to_string(), "javascript".to_string()]),
..Options::default()
};
let files = vec![
SourceFile {
source_id: "src/a.ts".to_string(),
format: "typescript".to_string(),
content: "const a = 1;".to_string(),
},
SourceFile {
source_id: "src/b.ts".to_string(),
format: "typescript".to_string(),
content: "const b = 1;".to_string(),
},
];
let output = debug_output(&options, &files);
assert!(output.starts_with("Options:\n"));
assert!(!output.contains("Options {"));
assert!(output.contains("executionId: '"));
assert!(output.contains("path: [ '"));
assert!(output.contains("debug: true"));
assert!(output.contains("config: '/repo/.jscpd.json'"));
assert!(output.contains("mode: [Function: mild]"));
assert!(output.contains("maxSize: '100kb'"));
assert!(output.contains("format: [ 'typescript', 'javascript' ]"));
assert!(output.contains("src/a.ts\nsrc/b.ts"));
assert!(output.ends_with("Found 2 files to detect.\n"));
assert!(!output.contains("const a = 1"));
}
#[test]
fn list_output_matches_upstream_shape() {
let output = list_output();
assert!(output.starts_with("Supported formats: \n"));
assert!(output.contains("abap, actionscript, ada"));
assert!(output.contains(", typescript, "));
assert!(!output.lines().skip(1).any(|line| line == "typescript"));
}
#[test]
fn store_warning_matches_upstream_fallback_shape() {
let options = Options {
store: Some("leveldb".to_string()),
..Options::default()
};
assert_eq!(
cli::store_warning(&options).as_deref(),
Some("store name leveldb not installed.")
);
assert!(cli::store_warning(&Options::default()).is_none());
}
#[test]
fn node_like_errors_match_upstream_stdout_shape() {
assert_eq!(
upstream_stdout_error("Mode zzz does not supported yet.").as_deref(),
Some("Error: Mode zzz does not supported yet.")
);
assert_eq!(
upstream_stdout_error(
"TypeError [ERR_INVALID_ARG_TYPE]: The \"paths[0]\" argument must be of type string."
)
.as_deref(),
Some(
"TypeError [ERR_INVALID_ARG_TYPE]: The \"paths[0]\" argument must be of type string."
)
);
assert_eq!(
upstream_stdout_error("TypeError: cli.ignore.split is not a function").as_deref(),
Some("TypeError: cli.ignore.split is not a function")
);
assert_eq!(
upstream_stdout_error(
"RangeError [ERR_OUT_OF_RANGE]: The value of \"code\" is out of range."
)
.as_deref(),
Some("RangeError [ERR_OUT_OF_RANGE]: The value of \"code\" is out of range.")
);
assert!(upstream_stdout_error("regular anyhow failure").is_none());
}
#[test]
fn terminal_footer_matches_upstream_silent_and_tips_rules() {
let elapsed = Duration::from_millis(42);
let verbose = Options {
no_tips: false,
..Options::default()
};
let output = terminal_footer_output(&verbose, elapsed).unwrap();
assert!(output.starts_with("time: "));
assert!(output.contains(
"Auto-refactor with AI: npx skills add vv-bogdanov/jscpd-rs --skill dry-refactoring"
));
assert!(!output.contains("Gangsta Agents"));
assert!(!output.contains("Support jscpd project"));
let no_tips = Options {
no_tips: true,
..Options::default()
};
let output = terminal_footer_output(&no_tips, elapsed).unwrap();
assert!(output.starts_with("time: "));
assert!(!output.contains("Auto-refactor with AI"));
let silent = Options {
silent: true,
..Options::default()
};
assert!(terminal_footer_output(&silent, elapsed).is_none());
}
}