Skip to main content

dupes_core/
cli.rs

1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3
4use crate::AnalysisResult;
5use crate::analyzer::LanguageAnalyzer;
6use crate::config::Config;
7use crate::fingerprint::Fingerprint;
8use crate::ignore::{self, IgnoreEntry};
9use crate::output::Reporter;
10use crate::output::json::JsonReporter;
11use crate::output::text::TextReporter;
12
13// ---------------------------------------------------------------------------
14// Error types
15// ---------------------------------------------------------------------------
16
17/// Errors returned by CLI command functions.
18#[derive(Debug)]
19pub enum CliError {
20    /// An I/O error (exit code 2).
21    Io(io::Error),
22    /// No source files found (exit code 2).
23    NoSourceFiles(PathBuf),
24    /// No recognized source files for language auto-detection (exit code 2).
25    NoRecognizedFiles,
26    /// Analysis pipeline failed (exit code 2).
27    Analysis(crate::error::Error),
28    /// Invalid fingerprint string (exit code 2).
29    InvalidFingerprint(String),
30    /// Check thresholds exceeded (exit code 1).
31    CheckFailed,
32}
33
34impl CliError {
35    /// Map to an appropriate process exit code.
36    #[must_use]
37    pub const fn exit_code(&self) -> i32 {
38        match self {
39            Self::CheckFailed => 1,
40            Self::Io(_)
41            | Self::NoSourceFiles(_)
42            | Self::NoRecognizedFiles
43            | Self::Analysis(_)
44            | Self::InvalidFingerprint(_) => 2,
45        }
46    }
47}
48
49impl std::fmt::Display for CliError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Io(e) => write!(f, "{e}"),
53            Self::NoSourceFiles(path) => {
54                write!(f, "No source files found in {}", path.display())
55            }
56            Self::NoRecognizedFiles => {
57                write!(
58                    f,
59                    "No recognized source files found. Use --language to specify the language."
60                )
61            }
62            Self::Analysis(e) => write!(f, "{e}"),
63            Self::InvalidFingerprint(fp) => write!(f, "Invalid fingerprint: {fp}"),
64            Self::CheckFailed => write!(f, "Check failed"),
65        }
66    }
67}
68
69impl std::error::Error for CliError {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        match self {
72            Self::Io(e) => Some(e),
73            Self::Analysis(e) => Some(e),
74            _ => None,
75        }
76    }
77}
78
79impl From<io::Error> for CliError {
80    fn from(e: io::Error) -> Self {
81        Self::Io(e)
82    }
83}
84
85impl From<crate::error::Error> for CliError {
86    fn from(e: crate::error::Error) -> Self {
87        Self::Analysis(e)
88    }
89}
90
91/// Result type for CLI operations.
92pub type CliResult<T = ()> = Result<T, CliError>;
93
94// ---------------------------------------------------------------------------
95// Shared CLI types
96// ---------------------------------------------------------------------------
97
98/// Output format for CLI reports.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
101pub enum OutputFormat {
102    #[default]
103    Text,
104    Json,
105}
106
107/// CLI subcommands shared between `cargo-dupes` and `code-dupes`.
108#[derive(Debug, Clone)]
109#[cfg_attr(feature = "cli", derive(clap::Subcommand))]
110pub enum Command {
111    /// Show duplication statistics only.
112    Stats,
113    /// Show full duplication report (default).
114    Report,
115    /// Check for duplicates and exit with non-zero if thresholds exceeded.
116    Check {
117        /// Maximum allowed exact duplicate groups (exit 1 if exceeded).
118        #[cfg_attr(feature = "cli", arg(long))]
119        max_exact: Option<usize>,
120        /// Maximum allowed near duplicate groups (exit 1 if exceeded).
121        #[cfg_attr(feature = "cli", arg(long))]
122        max_near: Option<usize>,
123        /// Maximum allowed exact duplicate percentage (exit 1 if exceeded).
124        #[cfg_attr(feature = "cli", arg(long))]
125        max_exact_percent: Option<f64>,
126        /// Maximum allowed near duplicate percentage (exit 1 if exceeded).
127        #[cfg_attr(feature = "cli", arg(long))]
128        max_near_percent: Option<f64>,
129    },
130    /// Add a fingerprint to the ignore list.
131    Ignore {
132        /// The fingerprint to ignore (hex string).
133        fingerprint: String,
134        /// Reason for ignoring.
135        #[cfg_attr(feature = "cli", arg(long))]
136        reason: Option<String>,
137    },
138    /// List all ignored fingerprints.
139    Ignored,
140    /// Remove stale entries from the ignore file.
141    Cleanup {
142        /// Only list stale entries without removing them.
143        #[cfg_attr(feature = "cli", arg(long))]
144        dry_run: bool,
145    },
146}
147
148/// Thresholds for the `check` subcommand.
149#[derive(Debug, Clone, Default)]
150pub struct CheckThresholds {
151    pub max_exact: Option<usize>,
152    pub max_near: Option<usize>,
153    pub max_exact_percent: Option<f64>,
154    pub max_near_percent: Option<f64>,
155}
156
157/// Optional CLI overrides applied on top of file-based config.
158#[derive(Debug, Clone, Default)]
159pub struct CliOverrides {
160    pub min_nodes: Option<usize>,
161    pub min_lines: Option<usize>,
162    pub threshold: Option<f64>,
163    pub exclude: Vec<String>,
164    pub exclude_tests: Option<bool>,
165    pub sub_function: Option<bool>,
166    pub min_sub_nodes: Option<usize>,
167}
168
169/// Result of [`run_analysis`].
170pub struct AnalysisOutput {
171    pub config: Config,
172    pub result: AnalysisResult,
173    pub reporter: Box<dyn Reporter>,
174}
175
176// ---------------------------------------------------------------------------
177// Config helpers
178// ---------------------------------------------------------------------------
179
180/// Apply CLI overrides to a loaded `Config`.
181///
182/// CLI `--exclude` patterns are *appended* to config-file excludes (not replaced).
183pub fn apply_overrides(config: &mut Config, overrides: &CliOverrides) {
184    if let Some(min_nodes) = overrides.min_nodes {
185        config.min_nodes = min_nodes;
186    }
187    if let Some(min_lines) = overrides.min_lines {
188        config.min_lines = min_lines;
189    }
190    if let Some(threshold) = overrides.threshold {
191        config.similarity_threshold = threshold;
192    }
193    if !overrides.exclude.is_empty() {
194        config.exclude.extend(overrides.exclude.iter().cloned());
195    }
196    if let Some(v) = overrides.exclude_tests {
197        config.exclude_tests = v;
198    }
199    if let Some(v) = overrides.sub_function {
200        config.sub_function = v;
201    }
202    if let Some(min_sub_nodes) = overrides.min_sub_nodes {
203        config.min_sub_nodes = min_sub_nodes;
204    }
205}
206
207/// Create a reporter for the given output format.
208#[must_use]
209pub fn create_reporter(format: OutputFormat, root: Option<&Path>) -> Box<dyn Reporter> {
210    match format {
211        OutputFormat::Text => Box::new(TextReporter::new(root.map(Path::to_path_buf))),
212        OutputFormat::Json => Box::new(JsonReporter::new(root.map(Path::to_path_buf))),
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Analysis
218// ---------------------------------------------------------------------------
219
220/// Scan files, run the analysis pipeline, and return the output.
221///
222/// Warnings are stored in [`AnalysisOutput::result`] but **not** printed;
223/// the caller is responsible for writing them to stderr.
224pub fn run_analysis(
225    analyzer: &dyn LanguageAnalyzer,
226    root: &Path,
227    format: OutputFormat,
228    overrides: &CliOverrides,
229) -> CliResult<AnalysisOutput> {
230    let mut config = Config::load(root);
231    apply_overrides(&mut config, overrides);
232
233    let scan_config = crate::scanner::ScanConfig::new(config.root.clone())
234        .with_excludes(config.exclude.clone())
235        .with_extensions(
236            analyzer
237                .file_extensions()
238                .iter()
239                .map(std::string::ToString::to_string)
240                .collect(),
241        );
242    let files = crate::scanner::scan_files(&scan_config);
243
244    if files.is_empty() {
245        return Err(CliError::NoSourceFiles(config.root));
246    }
247
248    let result = crate::analyze(analyzer, &files, &config)?;
249    let reporter = create_reporter(format, Some(root));
250
251    Ok(AnalysisOutput {
252        config,
253        result,
254        reporter,
255    })
256}
257
258// ---------------------------------------------------------------------------
259// Command implementations
260// ---------------------------------------------------------------------------
261
262/// Show duplication statistics only.
263pub fn cmd_stats(
264    result: &AnalysisResult,
265    reporter: &dyn Reporter,
266    writer: &mut impl Write,
267) -> CliResult {
268    reporter.report_stats(&result.stats, writer)?;
269    Ok(())
270}
271
272/// Show a full duplication report (stats + groups).
273pub fn cmd_report(
274    result: &AnalysisResult,
275    reporter: &dyn Reporter,
276    writer: &mut impl Write,
277) -> CliResult {
278    reporter.report_stats(&result.stats, writer)?;
279    writeln!(writer)?;
280    reporter.report_exact(&result.exact_groups, writer)?;
281    if !result.near_groups.is_empty() {
282        reporter.report_near(&result.near_groups, writer)?;
283    }
284    if !result.sub_exact_groups.is_empty() {
285        reporter.report_sub_exact(&result.sub_exact_groups, writer)?;
286    }
287    if !result.sub_near_groups.is_empty() {
288        reporter.report_sub_near(&result.sub_near_groups, writer)?;
289    }
290    Ok(())
291}
292
293/// Check thresholds; returns `Err(CliError::CheckFailed)` if any are exceeded.
294pub fn cmd_check(
295    config: &Config,
296    result: &AnalysisResult,
297    reporter: &dyn Reporter,
298    writer: &mut impl Write,
299    thresholds: &CheckThresholds,
300) -> CliResult {
301    let max_exact = thresholds.max_exact.or(config.max_exact_duplicates);
302    let max_near = thresholds.max_near.or(config.max_near_duplicates);
303    let max_exact_pct = thresholds.max_exact_percent.or(config.max_exact_percent);
304    let max_near_pct = thresholds.max_near_percent.or(config.max_near_percent);
305
306    reporter.report_stats(&result.stats, writer)?;
307
308    let mut failed = false;
309
310    if let Some(threshold) = max_exact
311        && result.stats.exact_duplicate_groups > threshold
312    {
313        writeln!(
314            writer,
315            "\nCheck FAILED: {} exact duplicate groups (max: {})",
316            result.stats.exact_duplicate_groups, threshold
317        )?;
318        reporter.report_exact(&result.exact_groups, writer)?;
319        failed = true;
320    }
321
322    if let Some(threshold) = max_near
323        && result.stats.near_duplicate_groups > threshold
324    {
325        writeln!(
326            writer,
327            "\nCheck FAILED: {} near duplicate groups (max: {})",
328            result.stats.near_duplicate_groups, threshold
329        )?;
330        reporter.report_near(&result.near_groups, writer)?;
331        failed = true;
332    }
333
334    if let Some(threshold) = max_exact_pct {
335        let actual = result.stats.exact_duplicate_percent();
336        if actual > threshold {
337            writeln!(
338                writer,
339                "\nCheck FAILED: {actual:.1}% exact duplicate lines (max: {threshold:.1}%)"
340            )?;
341            reporter.report_exact(&result.exact_groups, writer)?;
342            failed = true;
343        }
344    }
345
346    if let Some(threshold) = max_near_pct {
347        let actual = result.stats.near_duplicate_percent();
348        if actual > threshold {
349            writeln!(
350                writer,
351                "\nCheck FAILED: {actual:.1}% near duplicate lines (max: {threshold:.1}%)"
352            )?;
353            reporter.report_near(&result.near_groups, writer)?;
354            failed = true;
355        }
356    }
357
358    if failed {
359        Err(CliError::CheckFailed)
360    } else {
361        writeln!(writer, "\nCheck passed.")?;
362        Ok(())
363    }
364}
365
366/// Add a fingerprint to the ignore list.
367pub fn cmd_ignore(
368    root: &Path,
369    fingerprint: &str,
370    reason: Option<String>,
371    writer: &mut impl Write,
372) -> CliResult {
373    let fp = Fingerprint::from_hex(fingerprint)
374        .ok_or_else(|| CliError::InvalidFingerprint(fingerprint.to_string()))?;
375    let mut ignore_file = ignore::load_ignore_file(root);
376    ignore::add_ignore(&mut ignore_file, &fp, reason, vec![]);
377    ignore::save_ignore_file(root, &ignore_file)?;
378    writeln!(writer, "Added {fingerprint} to ignore list.")?;
379    Ok(())
380}
381
382/// List all ignored fingerprints.
383pub fn cmd_ignored(root: &Path, writer: &mut impl Write) -> CliResult {
384    let ignore_file = ignore::load_ignore_file(root);
385    if ignore_file.ignore.is_empty() {
386        writeln!(writer, "No ignored fingerprints.")?;
387    } else {
388        writeln!(writer, "Ignored fingerprints:")?;
389        for entry in &ignore_file.ignore {
390            write_ignore_entry(writer, entry)?;
391        }
392    }
393    Ok(())
394}
395
396/// Remove stale entries from the ignore file.
397pub fn cmd_cleanup(
398    root: &Path,
399    result: &AnalysisResult,
400    writer: &mut impl Write,
401    dry_run: bool,
402) -> CliResult {
403    let mut ignore_file = ignore::load_ignore_file(root);
404
405    if dry_run {
406        let stale = ignore::find_stale_entries(&ignore_file, &result.all_fingerprints);
407        if stale.is_empty() {
408            writeln!(writer, "No stale entries found.")?;
409        } else {
410            writeln!(writer, "Stale entries (dry run):")?;
411            for entry in &stale {
412                write_ignore_entry(writer, entry)?;
413            }
414            writeln!(writer, "\n{} stale entries would be removed.", stale.len())?;
415        }
416    } else {
417        let removed = ignore::remove_stale_entries(&mut ignore_file, &result.all_fingerprints);
418        if removed.is_empty() {
419            writeln!(writer, "No stale entries found.")?;
420        } else {
421            ignore::save_ignore_file(root, &ignore_file)?;
422            writeln!(writer, "Removed stale entries:")?;
423            for entry in &removed {
424                write_ignore_entry(writer, entry)?;
425            }
426            writeln!(writer, "\nRemoved {} stale entries.", removed.len())?;
427        }
428    }
429    Ok(())
430}
431
432// ---------------------------------------------------------------------------
433// Helpers
434// ---------------------------------------------------------------------------
435
436fn write_ignore_entry(writer: &mut impl Write, entry: &IgnoreEntry) -> io::Result<()> {
437    write!(writer, "  {}", entry.fingerprint)?;
438    if let Some(reason) = &entry.reason {
439        write!(writer, " (reason: {reason})")?;
440    }
441    if !entry.members.is_empty() {
442        write!(writer, " [{}]", entry.members.join(", "))?;
443    }
444    writeln!(writer)
445}