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