1use clap::{Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4const 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#[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 #[arg(default_value = ".")]
38 pub directory: PathBuf,
39
40 #[arg(long, short = 'v')]
42 pub verbose: bool,
43
44 #[arg(long, conflicts_with = "json")]
46 pub score: bool,
47
48 #[arg(long, conflicts_with_all = ["score", "sarif"])]
50 pub json: bool,
51
52 #[arg(long, conflicts_with_all = ["score", "json"])]
54 pub sarif: bool,
55
56 #[arg(long, num_args = 0..=1, default_missing_value = "auto", value_name = "BASE")]
58 pub diff: Option<String>,
59
60 #[arg(long, value_enum)]
62 pub fail_on: Option<FailOn>,
63
64 #[arg(long)]
66 pub fix: bool,
67
68 #[arg(long)]
70 pub plan: bool,
71
72 #[arg(long)]
74 pub install_deps: bool,
75
76 #[arg(long)]
78 pub offline: bool,
79
80 #[arg(long, conflicts_with_all = ["score", "json"])]
82 pub mcp: bool,
83
84 #[arg(long)]
86 pub no_project_config: bool,
87
88 #[arg(long, value_delimiter = ',', value_name = "NAMES", value_parser = parse_non_empty)]
90 pub project: Vec<String>,
91}
92
93fn 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#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
104pub enum FailOn {
105 Error,
107 Warning,
109 Info,
111 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#[derive(Subcommand, Debug, Clone)]
128pub enum Command {
129 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 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}