Skip to main content

jscpd_rs/
cli.rs

1use std::collections::HashSet;
2use std::ffi::OsString;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result, bail};
6use clap::Parser;
7use regex::Regex;
8use time::OffsetDateTime;
9use time::format_description::well_known::Rfc3339;
10
11use crate::files::collect_cwd_gitignore_patterns;
12
13mod config;
14mod parsing;
15#[cfg(test)]
16mod tests;
17
18#[cfg(test)]
19use config::{FileConfig, resolve_config_ignore};
20use config::{apply_config, read_config, read_package_json_config};
21#[cfg(test)]
22use parsing::parse_format_mappings;
23use parsing::{
24    compile_patterns, parse_format_mappings_like_upstream, parse_js_number, parse_js_usize,
25    parse_size, split_csv,
26};
27
28const BARE_EXIT_CODE_VALUE: &str = "__jscpd_rs_bare_exit_code_true__";
29const BARE_CONFIG_VALUE: &str = "__jscpd_rs_bare_config_true__";
30const BARE_STRING_VALUE: &str = "__jscpd_rs_bare_string_true__";
31
32#[doc(hidden)]
33#[derive(Debug, Parser)]
34#[command(
35    name = "jscpd",
36    version,
37    about = "detector of copy/paste in files",
38    override_usage = "jscpd [options] <path ...>",
39    disable_version_flag = true,
40    args_override_self = true
41)]
42pub struct Cli {
43    #[arg(short = 'V', long = "version", help = "output the version number")]
44    pub version: bool,
45
46    #[arg(value_name = "path", hide = true)]
47    pub paths: Vec<PathBuf>,
48
49    #[arg(
50        short = 'l',
51        long = "min-lines",
52        value_name = "number",
53        num_args = 0..=1,
54        default_missing_value = "0",
55        value_parser = parse_js_usize,
56        help = "min size of duplication in code lines (Default is 5)"
57    )]
58    pub min_lines: Option<usize>,
59
60    #[arg(
61        short = 'k',
62        long = "min-tokens",
63        value_name = "number",
64        num_args = 0..=1,
65        default_missing_value = "50",
66        value_parser = parse_js_usize,
67        help = "min size of duplication in code tokens (Default is 50)"
68    )]
69    pub min_tokens: Option<usize>,
70
71    #[arg(
72        short = 'x',
73        long = "max-lines",
74        value_name = "number",
75        num_args = 0..=1,
76        default_missing_value = "18446744073709551615",
77        value_parser = parse_js_usize,
78        help = "max size of source in lines (Default is 1000)"
79    )]
80    pub max_lines: Option<usize>,
81
82    #[arg(
83        short = 'z',
84        long = "max-size",
85        value_name = "string",
86        num_args = 0..=1,
87        default_missing_value = "true",
88        help = "max size of source in bytes, examples: 1kb, 1mb, 120kb (Default is 100kb)"
89    )]
90    pub max_size: Option<String>,
91
92    #[arg(
93        short = 't',
94        long = "threshold",
95        value_name = "number",
96        num_args = 0..=1,
97        default_missing_value = "1",
98        value_parser = parse_js_number,
99        help = "threshold for duplication, in case duplications >= threshold jscpd will exit with error"
100    )]
101    pub threshold: Option<f64>,
102
103    #[arg(
104        short = 'c',
105        long = "config",
106        value_name = "string",
107        num_args = 0..=1,
108        default_missing_value = BARE_CONFIG_VALUE,
109        help = "path to config file (Default is .jscpd.json in <path>)"
110    )]
111    pub config: Option<PathBuf>,
112
113    #[arg(
114        short = 'i',
115        long = "ignore",
116        value_name = "string",
117        num_args = 0..=1,
118        default_missing_value = BARE_STRING_VALUE,
119        help = "glob pattern for files what should be excluded from duplication detection"
120    )]
121    pub ignore: Option<String>,
122
123    #[arg(
124        short = 'r',
125        long = "reporters",
126        value_name = "string",
127        num_args = 0..=1,
128        default_missing_value = BARE_STRING_VALUE,
129        help = "reporters or list of reporters separated with comma to use (Default is time,console)"
130    )]
131    pub reporters: Option<String>,
132
133    #[arg(
134        short = 'o',
135        long = "output",
136        value_name = "string",
137        num_args = 0..=1,
138        default_missing_value = BARE_STRING_VALUE,
139        help = "reporters to use (Default is ./report/)"
140    )]
141    pub output: Option<String>,
142
143    #[arg(
144        short = 'm',
145        long = "mode",
146        value_name = "string",
147        num_args = 0..=1,
148        default_missing_value = BARE_STRING_VALUE,
149        help = "mode of quality of search, can be \"strict\", \"mild\" and \"weak\""
150    )]
151    pub mode: Option<String>,
152
153    #[arg(
154        short = 'f',
155        long = "format",
156        value_name = "string",
157        num_args = 0..=1,
158        default_missing_value = BARE_STRING_VALUE,
159        help = "format or formats separated by comma (Example php,javascript,python)"
160    )]
161    pub format: Option<String>,
162
163    #[arg(
164        short = 'p',
165        long = "pattern",
166        value_name = "string",
167        num_args = 0..=1,
168        default_missing_value = "true",
169        help = "glob pattern to file search (Example **/*.txt)"
170    )]
171    pub pattern: Option<String>,
172
173    #[arg(
174        short = 'b',
175        long = "blame",
176        help = "blame authors of duplications (get information about authors from git)"
177    )]
178    pub blame: bool,
179
180    #[arg(
181        short = 's',
182        long = "silent",
183        help = "do not write detection progress and result to a console"
184    )]
185    pub silent: bool,
186
187    #[arg(
188        long = "store",
189        value_name = "string",
190        num_args = 0..=1,
191        default_missing_value = "true",
192        help = "use for define custom store (e.g. --store leveldb used for big codebase)"
193    )]
194    pub store: Option<String>,
195
196    #[arg(
197        long = "store-path",
198        value_name = "string",
199        num_args = 0..=1,
200        default_missing_value = "true",
201        help = "directory to use for store cache (e.g. --store-path /tmp/jscpd-cache, useful when running multiple instances in parallel)"
202    )]
203    pub store_path: Option<PathBuf>,
204
205    #[arg(short = 'a', long = "absolute", help = "use absolute path in reports")]
206    pub absolute: bool,
207
208    #[arg(
209        short = 'n',
210        long = "noSymlinks",
211        help = "dont use symlinks for detection in files"
212    )]
213    pub no_symlinks: bool,
214
215    #[arg(
216        long = "ignoreCase",
217        help = "ignore case of symbols in code (experimental)"
218    )]
219    pub ignore_case: bool,
220
221    #[arg(
222        short = 'g',
223        long = "gitignore",
224        help = "respect .gitignore files (default: enabled, use --no-gitignore to disable)"
225    )]
226    pub gitignore: bool,
227
228    #[arg(long = "no-gitignore", help = "do not respect .gitignore files")]
229    pub no_gitignore: bool,
230
231    #[arg(
232        short = 'd',
233        long = "debug",
234        help = "show debug information, not run detection process(options list and selected files)"
235    )]
236    pub debug: bool,
237
238    #[arg(
239        short = 'v',
240        long = "verbose",
241        help = "show full information during detection process"
242    )]
243    pub verbose: bool,
244
245    #[arg(long = "list", help = "show list of total supported formats")]
246    pub list: bool,
247
248    #[arg(
249        long = "skipLocal",
250        help = "skip duplicates in local folders, just detect cross folders duplications"
251    )]
252    pub skip_local: bool,
253
254    #[arg(
255        long = "exitCode",
256        value_name = "number",
257        num_args = 0..=1,
258        default_missing_value = "__jscpd_rs_bare_exit_code_true__",
259        help = "exit code to use when code duplications are detected"
260    )]
261    pub exit_code: Option<String>,
262
263    #[arg(
264        long = "noTips",
265        help = "do not print tips and promotional messages after detection"
266    )]
267    pub no_tips: bool,
268
269    #[arg(
270        long = "skipComments",
271        help = "ignore comments during detection (alias for --mode weak)"
272    )]
273    pub skip_comments: bool,
274
275    #[arg(
276        long = "ignore-pattern",
277        value_name = "string",
278        num_args = 0..=1,
279        default_missing_value = BARE_STRING_VALUE,
280        help = "Ignore code blocks matching the regexp patterns"
281    )]
282    pub ignore_pattern: Option<String>,
283
284    #[arg(
285        long = "formats-exts",
286        value_name = "string",
287        num_args = 0..=1,
288        default_missing_value = BARE_STRING_VALUE,
289        help = "list of formats with file extensions (javascript:es,es6;dart:dt)"
290    )]
291    pub formats_exts: Option<String>,
292
293    #[arg(
294        long = "formats-names",
295        value_name = "string",
296        num_args = 0..=1,
297        default_missing_value = BARE_STRING_VALUE,
298        help = "list of formats with specific filenames (makefile:Makefile,GNUmakefile;docker:Dockerfile)"
299    )]
300    pub formats_names: Option<String>,
301}
302
303/// Duplicate-detection token filtering mode.
304#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305pub enum Mode {
306    /// Keep all parser tokens, including whitespace-like tokens where available.
307    Strict,
308    /// Upstream default mode: skip comments while preserving code tokens.
309    Mild,
310    /// Weak mode, used by `--skipComments`, with broader comment skipping.
311    Weak,
312}
313
314/// Node-compatible exit-code value preserved from CLI/config input.
315///
316/// Upstream accepts values that are not plain integers and only resolves them
317/// when a duplicate-triggered exit is needed.
318#[derive(Clone, Debug, PartialEq)]
319pub enum ExitCode {
320    /// Numeric exit code value.
321    Number(f64),
322    /// String value to parse with Node-like exit-code semantics.
323    String(String),
324    /// Boolean value produced by bare optional CLI flags.
325    Boolean(bool),
326}
327
328impl ExitCode {
329    fn from_cli(value: String) -> Self {
330        if value == BARE_EXIT_CODE_VALUE {
331            Self::Boolean(true)
332        } else {
333            Self::String(value)
334        }
335    }
336}
337
338/// Normalized detector options shared by the CLI, server, and Rust API.
339///
340/// Values are usually created with `get_default_options`,
341/// `get_options_from_args`, or the `jscpd` binary. Fields are public so native
342/// integrations can construct focused option sets without going through CLI
343/// parsing.
344#[derive(Debug, Clone)]
345pub struct Options {
346    /// Upstream-style execution identifier used in reports.
347    pub execution_id: Option<String>,
348    /// Loaded configuration file path, when one was used.
349    pub config: Option<PathBuf>,
350    /// Paths to scan.
351    pub paths: Vec<PathBuf>,
352    /// Glob pattern used during discovery.
353    pub pattern: String,
354    /// Explicit ignore globs.
355    pub ignore: Vec<String>,
356    /// Reporter names to run.
357    pub reporters: Vec<String>,
358    /// Listener names preserved for option-surface compatibility.
359    pub listeners: Vec<String>,
360    /// Reporter-specific option map from config.
361    pub reporters_options: serde_json::Map<String, serde_json::Value>,
362    /// Report output directory.
363    pub output: PathBuf,
364    /// True when `--output` was passed without a value.
365    pub output_is_bare: bool,
366    /// Optional allowlist of source formats.
367    pub formats: Option<HashSet<String>>,
368    /// User-provided format order for debug/report compatibility.
369    pub format_order: Option<Vec<String>>,
370    /// Custom extension-to-format mappings.
371    pub formats_exts: FormatMappings,
372    /// Custom filename-to-format mappings.
373    pub formats_names: FormatMappings,
374    /// Regular expressions for skipping matched code blocks.
375    pub ignore_pattern: Vec<Regex>,
376    /// Minimum duplicated fragment size in lines.
377    pub min_lines: usize,
378    /// Minimum duplicated fragment size in detection tokens.
379    pub min_tokens: usize,
380    /// Maximum source line count to scan.
381    pub max_lines: usize,
382    /// Maximum source byte size to scan.
383    pub max_size_bytes: u64,
384    /// Duplicated-line percentage threshold.
385    pub threshold: Option<f64>,
386    /// Token filtering mode.
387    pub mode: Mode,
388    /// Optional external store name, currently preserved for compatibility.
389    pub store: Option<String>,
390    /// Optional external store path, currently preserved for compatibility.
391    pub store_path: Option<PathBuf>,
392    /// Populate duplicate fragments with `git blame -w` metadata.
393    pub blame: bool,
394    /// Cache option preserved for upstream option-surface compatibility.
395    pub cache: bool,
396    /// Suppress reporter output where upstream does.
397    pub silent: bool,
398    /// Write absolute source paths in reports.
399    pub absolute: bool,
400    /// Do not follow symlinks during discovery.
401    pub no_symlinks: bool,
402    /// Detect duplicates case-insensitively.
403    pub ignore_case: bool,
404    /// Respect Git ignore files during discovery.
405    pub gitignore: bool,
406    /// Print debug discovery/config output and skip detection.
407    pub debug: bool,
408    /// Print verbose detector events and skip reasons.
409    pub verbose: bool,
410    /// Skip clones local to the same configured scan root.
411    pub skip_local: bool,
412    /// Exit code to use when duplicates are found.
413    pub exit_code: ExitCode,
414    /// Suppress post-run tips.
415    pub no_tips: bool,
416    /// Token names preserved for upstream option-surface compatibility.
417    pub tokens_to_skip: Vec<String>,
418}
419
420/// Additional format mappings from extensions or exact filenames to formats.
421///
422/// This is the Rust API counterpart of the CLI `--formats-exts` and
423/// `--formats-names` options.
424#[derive(Clone, Debug, Default, PartialEq, Eq)]
425pub struct FormatMappings(Vec<(String, Vec<String>)>);
426
427impl FormatMappings {
428    /// Build mappings from `(format, values)` pairs.
429    pub fn from_pairs<I, S, V, T>(pairs: I) -> Self
430    where
431        I: IntoIterator<Item = (S, V)>,
432        S: Into<String>,
433        V: IntoIterator<Item = T>,
434        T: Into<String>,
435    {
436        Self(
437            pairs
438                .into_iter()
439                .map(|(format, values)| {
440                    (format.into(), values.into_iter().map(Into::into).collect())
441                })
442                .collect(),
443        )
444    }
445
446    /// Return true when no mappings are defined.
447    pub fn is_empty(&self) -> bool {
448        self.0.is_empty()
449    }
450
451    /// Iterate through `(format, values)` pairs.
452    pub fn iter(&self) -> impl Iterator<Item = (&String, &Vec<String>)> {
453        self.0.iter().map(|(format, values)| (format, values))
454    }
455
456    /// Return the format mapped to an extension or exact filename.
457    pub fn find_format_for_value(&self, value: &str) -> Option<&str> {
458        self.0.iter().find_map(|(format, values)| {
459            values
460                .iter()
461                .any(|item| item == value)
462                .then_some(format.as_str())
463        })
464    }
465}
466
467impl Default for Options {
468    fn default() -> Self {
469        Self {
470            execution_id: Some(default_execution_id()),
471            config: None,
472            paths: vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))],
473            pattern: "**/*".to_string(),
474            ignore: Vec::new(),
475            reporters: vec!["console".to_string()],
476            listeners: Vec::new(),
477            reporters_options: serde_json::Map::new(),
478            output: PathBuf::from("./report"),
479            output_is_bare: false,
480            formats: None,
481            format_order: None,
482            formats_exts: FormatMappings::default(),
483            formats_names: FormatMappings::default(),
484            ignore_pattern: Vec::new(),
485            min_lines: 5,
486            min_tokens: 50,
487            max_lines: 1000,
488            max_size_bytes: 100 * 1024,
489            threshold: None,
490            mode: Mode::Mild,
491            store: None,
492            store_path: None,
493            blame: false,
494            cache: true,
495            silent: false,
496            absolute: false,
497            no_symlinks: false,
498            ignore_case: false,
499            gitignore: true,
500            debug: false,
501            verbose: false,
502            skip_local: false,
503            exit_code: ExitCode::Number(0.0),
504            no_tips: std::env::var_os("CI").is_some(),
505            tokens_to_skip: Vec::new(),
506        }
507    }
508}
509
510fn default_execution_id() -> String {
511    OffsetDateTime::now_utc()
512        .format(&Rfc3339)
513        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
514}
515
516impl Options {
517    /// Parse upstream-style command-line arguments into normalized options.
518    ///
519    /// The first argument should be the binary name.
520    pub fn from_args<I, T>(args: I) -> Result<Self>
521    where
522        I: IntoIterator<Item = T>,
523        T: Into<OsString> + Clone,
524    {
525        let cli = Cli::try_parse_from(args)?;
526        Self::from_cli(cli)
527    }
528
529    /// Convert the raw clap parser output into normalized detector options.
530    ///
531    /// Prefer [`Options::from_args`] or [`crate::get_options_from_args`] unless
532    /// you already have a `Cli` value.
533    pub fn from_cli(cli: Cli) -> Result<Self> {
534        let mut options = Self::default();
535
536        if matches!(cli.config.as_deref(), Some(path) if path == std::path::Path::new(BARE_CONFIG_VALUE))
537        {
538            bail!(
539                "TypeError [ERR_INVALID_ARG_TYPE]: The \"paths[0]\" argument must be of type string. Received type boolean (true)"
540            );
541        }
542
543        if let Some((config, config_dir, config_path)) = read_package_json_config()? {
544            options.config = Some(config_path);
545            apply_config(&mut options, config, &config_dir)?;
546        }
547        if let Some((config, config_dir, config_path)) = read_config(cli.config.as_deref())? {
548            options.config = Some(config_path);
549            apply_config(&mut options, config, &config_dir)?;
550        }
551
552        if !cli.paths.is_empty() {
553            options.paths = cli.paths;
554        }
555        if let Some(pattern) = cli.pattern {
556            options.pattern = pattern;
557        }
558        if let Some(ignore) = cli.ignore {
559            if is_bare_string(&ignore) {
560                bail!("TypeError: cli.ignore.split is not a function");
561            }
562            options.ignore = split_csv(&ignore);
563        }
564        if let Some(reporters) = cli.reporters {
565            if is_bare_string(&reporters) {
566                bail!("TypeError: cli.reporters.split is not a function");
567            }
568            options.reporters = split_csv(&reporters);
569        }
570        if let Some(output) = cli.output {
571            if is_bare_string(&output) {
572                options.output = PathBuf::from("true");
573                options.output_is_bare = true;
574            } else {
575                options.output = PathBuf::from(output);
576                options.output_is_bare = false;
577            }
578        }
579        if let Some(format) = cli.format {
580            if is_bare_string(&format) {
581                bail!("TypeError: cli.format.split is not a function");
582            }
583            let formats = split_csv(&format);
584            options.formats = Some(formats.iter().cloned().collect());
585            options.format_order = Some(formats);
586        }
587        if let Some(formats_exts) = cli.formats_exts {
588            if is_bare_string(&formats_exts) {
589                bail!("TypeError: extensions.split is not a function");
590            }
591            options.formats_exts = parse_format_mappings_like_upstream(&formats_exts)?;
592        }
593        if let Some(formats_names) = cli.formats_names {
594            if is_bare_string(&formats_names) {
595                bail!("TypeError: extensions.split is not a function");
596            }
597            options.formats_names = parse_format_mappings_like_upstream(&formats_names)?;
598        }
599        if let Some(ignore_pattern) = cli.ignore_pattern {
600            if is_bare_string(&ignore_pattern) {
601                bail!("TypeError: cli.ignorePattern.split is not a function");
602            }
603            options.ignore_pattern = compile_patterns(split_csv(&ignore_pattern))
604                .context("invalid --ignore-pattern value")?;
605        }
606        if let Some(min_lines) = cli.min_lines {
607            options.min_lines = min_lines;
608        }
609        if let Some(min_tokens) = cli.min_tokens {
610            options.min_tokens = min_tokens;
611        }
612        if let Some(max_lines) = cli.max_lines {
613            options.max_lines = max_lines;
614        }
615        if let Some(max_size) = cli.max_size {
616            options.max_size_bytes = parse_size(&max_size)
617                .with_context(|| format!("invalid --max-size value `{max_size}`"))?;
618        }
619        if let Some(threshold) = cli.threshold {
620            options.threshold = Some(threshold);
621        }
622        if let Some(mode) = cli.mode.as_deref() {
623            if is_bare_string(mode) {
624                bail!("TypeError: mode is not a function");
625            }
626            options.mode = parse_mode(mode)?;
627        }
628        if cli.skip_comments && cli.mode.is_none() {
629            options.mode = Mode::Weak;
630        }
631        if let Some(store) = cli.store {
632            options.store = Some(store);
633        }
634        if let Some(store_path) = cli.store_path {
635            options.store_path = Some(store_path);
636        }
637        if cli.blame {
638            options.blame = true;
639        }
640        if cli.silent {
641            options.silent = true;
642        }
643        if cli.absolute {
644            options.absolute = true;
645        }
646        if cli.no_symlinks {
647            options.no_symlinks = true;
648        }
649        if cli.ignore_case {
650            options.ignore_case = true;
651        }
652        if cli.no_gitignore {
653            options.gitignore = false;
654        } else if cli.gitignore {
655            options.gitignore = true;
656        }
657        if cli.debug {
658            options.debug = true;
659        }
660        if cli.verbose {
661            options.verbose = true;
662        }
663        if cli.skip_local {
664            options.skip_local = true;
665        }
666        if let Some(exit_code) = cli.exit_code {
667            options.exit_code = ExitCode::from_cli(exit_code);
668        }
669        if cli.no_tips {
670            options.no_tips = true;
671        }
672
673        apply_cwd_gitignore_patterns(&mut options)?;
674        normalize_reporters(&mut options);
675
676        Ok(options)
677    }
678}
679
680fn is_bare_string(value: &str) -> bool {
681    value == BARE_STRING_VALUE
682}
683
684pub fn resolve_node_exit_code(exit_code: &ExitCode) -> std::result::Result<i32, String> {
685    parsing::node_exit_code(exit_code).map_err(|error| error.message())
686}
687
688pub fn store_warning(options: &Options) -> Option<String> {
689    options
690        .store
691        .as_ref()
692        .map(|store| format!("store name {store} not installed."))
693}
694
695pub(super) fn parse_mode(value: &str) -> Result<Mode> {
696    match value {
697        "strict" => Ok(Mode::Strict),
698        "mild" => Ok(Mode::Mild),
699        "weak" => Ok(Mode::Weak),
700        _ => bail!("Mode {value} does not supported yet."),
701    }
702}
703
704fn normalize_reporters(options: &mut Options) {
705    if options.silent {
706        options
707            .reporters
708            .retain(|reporter| !reporter.contains("console"));
709        options.reporters.push("silent".to_string());
710    }
711    if options.threshold.is_some() {
712        options.reporters.push("threshold".to_string());
713    }
714}
715
716fn apply_cwd_gitignore_patterns(options: &mut Options) -> Result<()> {
717    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
718    apply_gitignore_patterns_from(options, &cwd);
719    Ok(())
720}
721
722fn apply_gitignore_patterns_from(options: &mut Options, cwd: &std::path::Path) {
723    if options.gitignore {
724        options.ignore.extend(collect_cwd_gitignore_patterns(cwd));
725    }
726}