Skip to main content

dry4rust/cli/
entry_point.rs

1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::env::current_dir;
7use std::io;
8use std::path::PathBuf;
9use std::process::ExitCode;
10
11use clap::Error as ClapError;
12use clap::Parser;
13
14use crate::cli::cli_error::CliError;
15use crate::cli::cli_overrides::CliOverrides;
16use crate::cli::command::Command;
17use crate::cli::output_format::OutputFormat;
18use crate::command_dispatcher::CommandDispatcher;
19use crate::rust::rust_analyzer::RustAnalyzer;
20
21/// The command line, and what it means.
22///
23/// It lives in the library rather than in `main` so the mapping from arguments
24/// to a run -- which root, which command, which overrides -- is something a
25/// test can call. `main` is left with nothing but an exit code to return.
26#[derive(Parser)]
27// `long_about = None` keeps the doc comment above for a reader of the source
28// without clap promoting it into `--help`, where it would stand in for the
29// one-line description.
30#[command(
31    name = "cargo-dry4rust",
32    version,
33    about = "Detect duplicate code in Rust codebases",
34    long_about = None
35)]
36pub struct EntryPoint {
37    /// When invoked as `cargo dry4rust`, cargo passes "dry4rust" as the first arg.
38    #[arg(hide = true, default_value = "")]
39    _cargo_subcommand: String,
40
41    #[command(subcommand)]
42    command: Option<Command>,
43
44    /// Path to analyze (defaults to current directory).
45    #[arg(short, long, global = true)]
46    path: Option<PathBuf>,
47
48    /// Minimum AST node count for analysis.
49    #[arg(long, global = true)]
50    min_nodes: Option<usize>,
51
52    /// Minimum source line count for analysis.
53    #[arg(long, global = true)]
54    min_lines: Option<usize>,
55
56    /// Similarity threshold (0.0-1.0).
57    #[arg(long, global = true)]
58    threshold: Option<f64>,
59
60    /// Output format.
61    #[arg(long, global = true, default_value = "text")]
62    format: OutputFormat,
63
64    /// Exclude patterns (can be repeated).
65    #[arg(long, global = true)]
66    exclude: Vec<String>,
67
68    /// Exclude test code (#[test] functions and #[cfg(test)] modules).
69    #[arg(long, global = true)]
70    exclude_tests: bool,
71
72    /// Enable sub-function duplicate detection (if branches, match arms, loop bodies).
73    #[arg(long, short = 's', global = true)]
74    sub_function: bool,
75
76    /// Minimum AST node count for sub-function units.
77    #[arg(long, global = true)]
78    min_sub_nodes: Option<usize>,
79
80    /// Baseline of inherited duplication: groups it records are not reported
81    /// and do not fail a check. Record one with the `baseline` subcommand.
82    #[arg(long, global = true)]
83    baseline: Option<PathBuf>,
84}
85
86impl EntryPoint {
87    /// Parse `args`, run what they ask for, and report the outcome.
88    ///
89    /// Taking the arguments rather than reading `argv` is what lets a test
90    /// call this at all; `main` passes the real ones.
91    #[must_use]
92    pub fn run(args: Vec<String>) -> ExitCode {
93        match Self::try_parse_from(args) {
94            Ok(entry) => entry.execute(),
95            Err(error) => Self::parsing_failed(&error),
96        }
97    }
98
99    fn execute(self) -> ExitCode {
100        let analyzer = RustAnalyzer::new();
101        let root = self.root();
102        let dispatcher = CommandDispatcher::new(&analyzer, &root, self.format, self.overrides());
103        let stdout = io::stdout();
104        let mut writer = stdout.lock();
105        match dispatcher.dispatch(&self.command(), &mut writer) {
106            Ok(()) => ExitCode::SUCCESS,
107            Err(e) => Self::reported(&e),
108        }
109    }
110
111    /// `--help` and `--version` are not failures; clap reports them the same
112    /// way it reports a typo, and only `use_stderr` tells the two apart.
113    fn parsing_failed(error: &ClapError) -> ExitCode {
114        let _ = error.print();
115        if error.use_stderr() {
116            ExitCode::from(2)
117        } else {
118            ExitCode::SUCCESS
119        }
120    }
121
122    /// The root to analyse: what `--path` said, or where the shell is.
123    #[must_use]
124    pub fn root(&self) -> PathBuf {
125        self.path
126            .clone()
127            .unwrap_or_else(|| current_dir().unwrap_or_else(|_| PathBuf::from(".")))
128    }
129
130    /// The command to run. Naming none means `report`.
131    #[must_use]
132    pub fn command(&self) -> Command {
133        self.command.clone().unwrap_or(Command::Report)
134    }
135
136    /// What the flags override in the loaded configuration.
137    ///
138    /// The three booleans become `Some(true)` or `None` rather than
139    /// `Some(false)`: a flag that was not passed has no opinion, and a config
140    /// file that turned the behaviour on must not be switched off by its
141    /// absence.
142    #[must_use]
143    pub fn overrides(&self) -> CliOverrides {
144        CliOverrides {
145            min_nodes: self.min_nodes,
146            min_lines: self.min_lines,
147            threshold: self.threshold,
148            exclude: self.exclude.clone(),
149            exclude_tests: self.exclude_tests.then_some(true),
150            sub_function: self.sub_function.then_some(true),
151            min_sub_nodes: self.min_sub_nodes,
152            baseline: self.baseline.clone(),
153        }
154    }
155
156    /// The output format asked for.
157    #[must_use]
158    pub const fn format(&self) -> OutputFormat {
159        self.format
160    }
161
162    fn reported(error: &CliError) -> ExitCode {
163        if !matches!(error, CliError::CheckFailed) {
164            eprintln!("Error: {error}");
165        }
166        ExitCode::from(error.exit_code() as u8)
167    }
168}