1use clap::{ArgGroup, Parser};
2
3use crate::output::OutputFormat;
4
5#[derive(Parser, Debug)]
6#[command(
7 author,
8 version,
9 about,
10 long_about = None,
11 group(
12 ArgGroup::new("output")
13 .required(true)
14 .args([
15 "output_json",
16 "output_json_pp",
17 "output_json_lines",
18 "output_yaml",
19 "output_csv",
20 "output_html",
21 "output_html_app",
22 "output_spdx_tv",
23 "output_spdx_rdf",
24 "output_cyclonedx",
25 "output_cyclonedx_xml",
26 "custom_output"
27 ])
28 )
29)]
30pub struct Cli {
31 #[arg(required = true)]
33 pub dir_path: Vec<String>,
34
35 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
37 pub output_json: Option<String>,
38
39 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
41 pub output_json_pp: Option<String>,
42
43 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
45 pub output_json_lines: Option<String>,
46
47 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
49 pub output_yaml: Option<String>,
50
51 #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
53 pub output_csv: Option<String>,
54
55 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
57 pub output_html: Option<String>,
58
59 #[arg(
61 long = "html-app",
62 value_name = "FILE",
63 hide = true,
64 allow_hyphen_values = true
65 )]
66 pub output_html_app: Option<String>,
67
68 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
70 pub output_spdx_tv: Option<String>,
71
72 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
74 pub output_spdx_rdf: Option<String>,
75
76 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
78 pub output_cyclonedx: Option<String>,
79
80 #[arg(
82 long = "cyclonedx-xml",
83 value_name = "FILE",
84 allow_hyphen_values = true
85 )]
86 pub output_cyclonedx_xml: Option<String>,
87
88 #[arg(
90 long = "custom-output",
91 value_name = "FILE",
92 requires = "custom_template",
93 allow_hyphen_values = true
94 )]
95 pub custom_output: Option<String>,
96
97 #[arg(
99 long = "custom-template",
100 value_name = "FILE",
101 requires = "custom_output"
102 )]
103 pub custom_template: Option<String>,
104
105 #[arg(short, long, default_value = "0")]
107 pub max_depth: usize,
108
109 #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
110 pub processes: i32,
111
112 #[arg(long, default_value_t = 120.0)]
113 pub timeout: f64,
114
115 #[arg(short, long, conflicts_with = "verbose")]
116 pub quiet: bool,
117
118 #[arg(short, long, conflicts_with = "quiet")]
119 pub verbose: bool,
120
121 #[arg(long, conflicts_with = "full_root")]
122 pub strip_root: bool,
123
124 #[arg(long, conflicts_with = "strip_root")]
125 pub full_root: bool,
126
127 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
129 pub exclude: Vec<String>,
130
131 #[arg(long, value_delimiter = ',')]
132 pub include: Vec<String>,
133
134 #[arg(long = "cache-dir", value_name = "PATH")]
135 pub cache_dir: Option<String>,
136
137 #[arg(long = "cache-clear")]
138 pub cache_clear: bool,
139
140 #[arg(long = "max-in-memory", value_name = "INT")]
141 pub max_in_memory: Option<usize>,
142
143 #[arg(long)]
144 pub from_json: bool,
145
146 #[arg(long)]
148 pub no_assemble: bool,
149
150 #[arg(long)]
151 pub filter_clues: bool,
152
153 #[arg(long)]
154 pub only_findings: bool,
155
156 #[arg(long)]
157 pub mark_source: bool,
158
159 #[arg(short = 'c', long)]
160 pub copyright: bool,
161
162 #[arg(short = 'e', long)]
164 pub email: bool,
165
166 #[arg(long, default_value_t = 50, requires = "email")]
168 pub max_email: usize,
169
170 #[arg(short = 'u', long)]
172 pub url: bool,
173
174 #[arg(long, default_value_t = 50, requires = "url")]
176 pub max_url: usize,
177}
178
179fn default_processes() -> i32 {
180 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
181 if cpus > 1 { (cpus - 1) as i32 } else { 1 }
182}
183
184#[derive(Debug, Clone)]
185pub struct OutputTarget {
186 pub format: OutputFormat,
187 pub file: String,
188 pub custom_template: Option<String>,
189}
190
191impl Cli {
192 pub fn output_targets(&self) -> Vec<OutputTarget> {
193 let mut targets = Vec::new();
194
195 if let Some(file) = &self.output_json {
196 targets.push(OutputTarget {
197 format: OutputFormat::Json,
198 file: file.clone(),
199 custom_template: None,
200 });
201 }
202
203 if let Some(file) = &self.output_json_pp {
204 targets.push(OutputTarget {
205 format: OutputFormat::JsonPretty,
206 file: file.clone(),
207 custom_template: None,
208 });
209 }
210
211 if let Some(file) = &self.output_json_lines {
212 targets.push(OutputTarget {
213 format: OutputFormat::JsonLines,
214 file: file.clone(),
215 custom_template: None,
216 });
217 }
218
219 if let Some(file) = &self.output_yaml {
220 targets.push(OutputTarget {
221 format: OutputFormat::Yaml,
222 file: file.clone(),
223 custom_template: None,
224 });
225 }
226
227 if let Some(file) = &self.output_csv {
228 targets.push(OutputTarget {
229 format: OutputFormat::Csv,
230 file: file.clone(),
231 custom_template: None,
232 });
233 }
234
235 if let Some(file) = &self.output_html {
236 targets.push(OutputTarget {
237 format: OutputFormat::Html,
238 file: file.clone(),
239 custom_template: None,
240 });
241 }
242
243 if let Some(file) = &self.output_html_app {
244 targets.push(OutputTarget {
245 format: OutputFormat::HtmlApp,
246 file: file.clone(),
247 custom_template: None,
248 });
249 }
250
251 if let Some(file) = &self.output_spdx_tv {
252 targets.push(OutputTarget {
253 format: OutputFormat::SpdxTv,
254 file: file.clone(),
255 custom_template: None,
256 });
257 }
258
259 if let Some(file) = &self.output_spdx_rdf {
260 targets.push(OutputTarget {
261 format: OutputFormat::SpdxRdf,
262 file: file.clone(),
263 custom_template: None,
264 });
265 }
266
267 if let Some(file) = &self.output_cyclonedx {
268 targets.push(OutputTarget {
269 format: OutputFormat::CycloneDxJson,
270 file: file.clone(),
271 custom_template: None,
272 });
273 }
274
275 if let Some(file) = &self.output_cyclonedx_xml {
276 targets.push(OutputTarget {
277 format: OutputFormat::CycloneDxXml,
278 file: file.clone(),
279 custom_template: None,
280 });
281 }
282
283 if let Some(file) = &self.custom_output {
284 targets.push(OutputTarget {
285 format: OutputFormat::CustomTemplate,
286 file: file.clone(),
287 custom_template: self.custom_template.clone(),
288 });
289 }
290
291 targets
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_requires_at_least_one_output_option() {
301 let parsed = Cli::try_parse_from(["provenant", "samples"]);
302 assert!(parsed.is_err());
303 }
304
305 #[test]
306 fn test_parses_json_pretty_output_option() {
307 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
308 .expect("cli parse should succeed");
309
310 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
311 assert_eq!(parsed.output_targets().len(), 1);
312 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
313 }
314
315 #[test]
316 fn test_allows_stdout_dash_as_output_target() {
317 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
318 .expect("cli parse should allow stdout dash output target");
319
320 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
321 }
322
323 #[test]
324 fn test_custom_template_and_output_must_be_paired() {
325 let missing_template =
326 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
327 assert!(missing_template.is_err());
328
329 let missing_output =
330 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
331 assert!(missing_output.is_err());
332 }
333
334 #[test]
335 fn test_parses_processes_and_timeout_options() {
336 let parsed = Cli::try_parse_from([
337 "provenant",
338 "--json-pp",
339 "scan.json",
340 "-n",
341 "4",
342 "--timeout",
343 "30",
344 "samples",
345 ])
346 .expect("cli parse should succeed");
347
348 assert_eq!(parsed.processes, 4);
349 assert_eq!(parsed.timeout, 30.0);
350 }
351
352 #[test]
353 fn test_strip_root_conflicts_with_full_root() {
354 let parsed = Cli::try_parse_from([
355 "provenant",
356 "--json-pp",
357 "scan.json",
358 "--strip-root",
359 "--full-root",
360 "samples",
361 ]);
362 assert!(parsed.is_err());
363 }
364
365 #[test]
366 fn test_parses_include_and_only_findings_and_filter_clues() {
367 let parsed = Cli::try_parse_from([
368 "provenant",
369 "--json-pp",
370 "scan.json",
371 "--include",
372 "src/**,Cargo.toml",
373 "--only-findings",
374 "--filter-clues",
375 "samples",
376 ])
377 .expect("cli parse should succeed");
378
379 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
380 assert!(parsed.only_findings);
381 assert!(parsed.filter_clues);
382 }
383
384 #[test]
385 fn test_parses_ignore_alias_for_exclude_patterns() {
386 let parsed = Cli::try_parse_from([
387 "provenant",
388 "--json-pp",
389 "scan.json",
390 "--ignore",
391 "*.git*,target/*",
392 "samples",
393 ])
394 .expect("cli parse should accept --ignore alias");
395
396 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
397 }
398
399 #[test]
400 fn test_quiet_conflicts_with_verbose() {
401 let parsed = Cli::try_parse_from([
402 "provenant",
403 "--json-pp",
404 "scan.json",
405 "--quiet",
406 "--verbose",
407 "samples",
408 ]);
409 assert!(parsed.is_err());
410 }
411
412 #[test]
413 fn test_parses_from_json_and_mark_source() {
414 let parsed = Cli::try_parse_from([
415 "provenant",
416 "--json-pp",
417 "scan.json",
418 "--from-json",
419 "--mark-source",
420 "sample-scan.json",
421 ])
422 .expect("cli parse should succeed");
423
424 assert!(parsed.from_json);
425 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
426 assert!(parsed.mark_source);
427 }
428
429 #[test]
430 fn test_parses_copyright_flag() {
431 let parsed = Cli::try_parse_from([
432 "provenant",
433 "--json-pp",
434 "scan.json",
435 "--copyright",
436 "samples",
437 ])
438 .expect("cli parse should succeed");
439
440 assert!(parsed.copyright);
441 }
442
443 #[test]
444 fn test_parses_short_scan_flags() {
445 let parsed = Cli::try_parse_from([
446 "provenant",
447 "--json-pp",
448 "scan.json",
449 "-c",
450 "-e",
451 "-u",
452 "samples",
453 ])
454 .expect("cli parse should support short scan flags");
455
456 assert!(parsed.copyright);
457 assert!(parsed.email);
458 assert!(parsed.url);
459 }
460
461 #[test]
462 fn test_parses_processes_compat_values_zero_and_minus_one() {
463 let zero =
464 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
465 .expect("cli parse should accept processes=0");
466 assert_eq!(zero.processes, 0);
467
468 let parsed =
469 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
470 .expect("cli parse should accept processes=-1");
471 assert_eq!(parsed.processes, -1);
472 }
473
474 #[test]
475 fn test_parses_cache_flags() {
476 let parsed = Cli::try_parse_from([
477 "provenant",
478 "--json-pp",
479 "scan.json",
480 "--cache-dir",
481 "/tmp/sc-cache",
482 "--cache-clear",
483 "--max-in-memory",
484 "5000",
485 "samples",
486 ])
487 .expect("cli parse should accept cache flags");
488
489 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
490 assert!(parsed.cache_clear);
491 assert_eq!(parsed.max_in_memory, Some(5000));
492 }
493
494 #[test]
495 fn test_max_depth_default_matches_reference_behavior() {
496 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
497 .expect("cli parse should succeed");
498
499 assert_eq!(parsed.max_depth, 0);
500 }
501}