Skip to main content

rust_doctor/
cli.rs

1use clap::{Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4/// Exit-code reference shown after `--help` (clap `after_help`).
5///
6/// Mirrors the constants in `run.rs` (`EXIT_SCAN_ERROR` = 2,
7/// `EXIT_GATE_FAILURE` = 3) and the "Exit Codes" section of the README so CI
8/// authors can distinguish a quality-gate failure from a crash.
9const EXIT_CODES_HELP: &str = "\
10Exit codes:
11  0  Success — scan completed and all quality gates passed
12  1  Setup error — MCP server, setup wizard, or --install-deps failed
13  2  Scan error — project discovery/compile failure or output rendering failed
14  3  Quality gate failed — score below [score] fail_below, or --fail-on threshold reached
15
16CI gating example:
17  rust-doctor --fail-on error; if [ $? -eq 3 ]; then echo 'quality gate failed'; fi";
18
19/// Diagnose your Rust project's health with a single command.
20///
21/// rust-doctor scans Rust codebases for security, performance, correctness,
22/// architecture, and dependency issues, producing a 0-100 health score
23/// with actionable diagnostics.
24#[derive(Parser, Debug)]
25#[command(
26    version,
27    about,
28    long_about = None,
29    args_conflicts_with_subcommands = true,
30    after_help = EXIT_CODES_HELP,
31)]
32pub struct Cli {
33    #[command(subcommand)]
34    pub command: Option<Command>,
35
36    /// Directory to scan (defaults to current directory)
37    #[arg(default_value = ".")]
38    pub directory: PathBuf,
39
40    /// Show detailed file:line information per diagnostic
41    #[arg(long, short = 'v')]
42    pub verbose: bool,
43
44    /// Print only the bare integer score (for CI piping)
45    #[arg(long, conflicts_with = "json")]
46    pub score: bool,
47
48    /// Output full scan results as JSON
49    #[arg(long, conflicts_with_all = ["score", "sarif"])]
50    pub json: bool,
51
52    /// Output results in SARIF 2.1.0 format (for GitHub Code Scanning, GitLab SAST)
53    #[arg(long, conflicts_with_all = ["score", "json"])]
54    pub sarif: bool,
55
56    /// Scan only changed files vs a base branch
57    #[arg(long, num_args = 0..=1, default_missing_value = "auto", value_name = "BASE")]
58    pub diff: Option<String>,
59
60    /// Fail the quality gate (exit code 3) when this severity is reached
61    #[arg(long, value_enum)]
62    pub fail_on: Option<FailOn>,
63
64    /// Apply machine-applicable fixes from custom rules (modifies source files)
65    #[arg(long)]
66    pub fix: bool,
67
68    /// Show a prioritized remediation plan after scanning
69    #[arg(long)]
70    pub plan: bool,
71
72    /// Check and install missing external tools (cargo-deny, cargo-audit, etc.)
73    #[arg(long)]
74    pub install_deps: bool,
75
76    /// Skip network-dependent checks (cargo-audit advisory DB fetch, etc.)
77    #[arg(long)]
78    pub offline: bool,
79
80    /// Run as an MCP (Model Context Protocol) stdio server for AI tool integration
81    #[arg(long, conflicts_with_all = ["score", "json"])]
82    pub mcp: bool,
83
84    /// Ignore the project's rust-doctor.toml config file
85    #[arg(long)]
86    pub no_project_config: bool,
87
88    /// Scan only specific workspace members (comma-separated)
89    #[arg(long, value_delimiter = ',', value_name = "NAMES", value_parser = parse_non_empty)]
90    pub project: Vec<String>,
91}
92
93/// Reject empty project name segments (e.g. `--project ,api` or `--project core,`)
94fn parse_non_empty(s: &str) -> Result<String, String> {
95    if s.is_empty() {
96        Err("project name cannot be empty".to_string())
97    } else {
98        Ok(s.to_string())
99    }
100}
101
102/// When to exit with a non-zero status code
103#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
104pub enum FailOn {
105    /// Exit 3 if any errors found
106    Error,
107    /// Exit 3 if any errors or warnings found
108    Warning,
109    /// Exit 3 if any errors, warnings, or info findings found
110    Info,
111    /// Always exit 0 (unless rust-doctor itself crashes)
112    None,
113}
114
115impl std::fmt::Display for FailOn {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::Error => write!(f, "error"),
119            Self::Warning => write!(f, "warning"),
120            Self::Info => write!(f, "info"),
121            Self::None => write!(f, "none"),
122        }
123    }
124}
125
126/// Subcommands (optional — default behavior is scanning).
127#[derive(Subcommand, Debug, Clone)]
128pub enum Command {
129    /// Interactive setup wizard — configure rust-doctor for your AI coding agent
130    Setup,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use clap::{CommandFactory, Parser};
137
138    #[test]
139    fn test_default_directory() {
140        let cli = Cli::try_parse_from(["rust-doctor"]).unwrap();
141        assert_eq!(cli.directory, PathBuf::from("."));
142    }
143
144    #[test]
145    fn test_help_documents_exit_codes() {
146        // US-010: `--help` must carry the exit-code reference so CI authors can
147        // tell a quality-gate failure (3) from a crash (2) or setup error (1).
148        let help = Cli::command().render_long_help().to_string();
149        assert!(
150            help.contains("Exit codes:"),
151            "help is missing the exit-code section:\n{help}"
152        );
153        assert!(help.contains("Quality gate failed"));
154        for code in ["  0 ", "  1 ", "  2 ", "  3 "] {
155            assert!(
156                help.contains(code),
157                "help is missing exit code line `{code}`"
158            );
159        }
160    }
161
162    #[test]
163    fn test_custom_directory() {
164        let cli = Cli::try_parse_from(["rust-doctor", "/some/path"]).unwrap();
165        assert_eq!(cli.directory, PathBuf::from("/some/path"));
166    }
167
168    #[test]
169    fn test_score_flag() {
170        let cli = Cli::try_parse_from(["rust-doctor", "--score"]).unwrap();
171        assert!(cli.score);
172    }
173
174    #[test]
175    fn test_json_flag() {
176        let cli = Cli::try_parse_from(["rust-doctor", "--json"]).unwrap();
177        assert!(cli.json);
178    }
179
180    #[test]
181    fn test_score_and_json_conflict() {
182        let result = Cli::try_parse_from(["rust-doctor", "--score", "--json"]);
183        assert!(result.is_err());
184    }
185
186    #[test]
187    fn test_verbose_flag() {
188        let cli = Cli::try_parse_from(["rust-doctor", "--verbose"]).unwrap();
189        assert!(cli.verbose);
190    }
191
192    #[test]
193    fn test_offline_flag() {
194        let cli = Cli::try_parse_from(["rust-doctor", "--offline"]).unwrap();
195        assert!(cli.offline);
196    }
197
198    #[test]
199    fn test_fail_on_default() {
200        let cli = Cli::try_parse_from(["rust-doctor"]).unwrap();
201        assert_eq!(cli.fail_on, Option::None);
202    }
203
204    #[test]
205    fn test_fail_on_error() {
206        let cli = Cli::try_parse_from(["rust-doctor", "--fail-on", "error"]).unwrap();
207        assert_eq!(cli.fail_on, Some(FailOn::Error));
208    }
209
210    #[test]
211    fn test_fail_on_warning() {
212        let cli = Cli::try_parse_from(["rust-doctor", "--fail-on", "warning"]).unwrap();
213        assert_eq!(cli.fail_on, Some(FailOn::Warning));
214    }
215
216    #[test]
217    fn test_fail_on_none() {
218        let cli = Cli::try_parse_from(["rust-doctor", "--fail-on", "none"]).unwrap();
219        assert_eq!(cli.fail_on, Some(FailOn::None));
220    }
221
222    #[test]
223    fn test_fail_on_invalid() {
224        let result = Cli::try_parse_from(["rust-doctor", "--fail-on", "critical"]);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn test_diff_without_value() {
230        let cli = Cli::try_parse_from(["rust-doctor", "--diff"]).unwrap();
231        assert_eq!(cli.diff, Some("auto".to_string()));
232    }
233
234    #[test]
235    fn test_diff_with_value() {
236        let cli = Cli::try_parse_from(["rust-doctor", "--diff", "main"]).unwrap();
237        assert_eq!(cli.diff, Some("main".to_string()));
238    }
239
240    #[test]
241    fn test_diff_absent() {
242        let cli = Cli::try_parse_from(["rust-doctor"]).unwrap();
243        assert_eq!(cli.diff, Option::None);
244    }
245
246    #[test]
247    fn test_project_single() {
248        let cli = Cli::try_parse_from(["rust-doctor", "--project", "core"]).unwrap();
249        assert_eq!(cli.project, vec!["core"]);
250    }
251
252    #[test]
253    fn test_project_comma_separated() {
254        let cli = Cli::try_parse_from(["rust-doctor", "--project", "core,api,web"]).unwrap();
255        assert_eq!(cli.project, vec!["core", "api", "web"]);
256    }
257
258    #[test]
259    fn test_project_empty_by_default() {
260        let cli = Cli::try_parse_from(["rust-doctor"]).unwrap();
261        assert!(cli.project.is_empty());
262    }
263
264    #[test]
265    fn test_project_rejects_empty_name() {
266        let result = Cli::try_parse_from(["rust-doctor", "--project", ",api"]);
267        assert!(result.is_err());
268    }
269
270    #[test]
271    fn test_version_flag() {
272        let result = Cli::try_parse_from(["rust-doctor", "--version"]);
273        assert!(result.is_err());
274        let err = result.unwrap_err();
275        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
276    }
277
278    #[test]
279    fn test_help_flag() {
280        let result = Cli::try_parse_from(["rust-doctor", "--help"]);
281        assert!(result.is_err());
282        let err = result.unwrap_err();
283        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
284    }
285
286    #[test]
287    fn test_install_deps_flag() {
288        let cli = Cli::try_parse_from(["rust-doctor", "--install-deps"]).unwrap();
289        assert!(cli.install_deps);
290    }
291
292    #[test]
293    fn test_setup_subcommand() {
294        let cli = Cli::try_parse_from(["rust-doctor", "setup"]).unwrap();
295        assert!(matches!(cli.command, Some(Command::Setup)));
296    }
297
298    #[test]
299    fn test_all_flags_combined() {
300        let cli = Cli::try_parse_from([
301            "rust-doctor",
302            "/my/project",
303            "--verbose",
304            "--score",
305            "--diff",
306            "develop",
307            "--fail-on",
308            "warning",
309            "--offline",
310            "--project",
311            "core,api",
312        ])
313        .unwrap();
314
315        assert_eq!(cli.directory, PathBuf::from("/my/project"));
316        assert!(cli.verbose);
317        assert!(cli.score);
318        assert!(!cli.json);
319        assert_eq!(cli.diff, Some("develop".to_string()));
320        assert_eq!(cli.fail_on, Some(FailOn::Warning));
321        assert!(cli.offline);
322        assert_eq!(cli.project, vec!["core", "api"]);
323    }
324}