1use clap::{ArgGroup, Parser};
2
3use crate::output::OutputFormat;
4
5#[derive(Parser, Debug)]
6#[command(
7 author,
8 version = env!("CARGO_PKG_VERSION"),
9 long_version = concat!(
10 env!("CARGO_PKG_VERSION"),
11 "\n",
12 "License detection uses data from ScanCode Toolkit (CC-BY-4.0). See NOTICE or --show_attribution."
13 ),
14 about,
15 long_about = None,
16 group(
17 ArgGroup::new("output")
18 .required(true)
19 .args([
20 "output_json",
21 "output_json_pp",
22 "output_json_lines",
23 "output_yaml",
24 "output_csv",
25 "output_html",
26 "output_html_app",
27 "output_spdx_tv",
28 "output_spdx_rdf",
29 "output_cyclonedx",
30 "output_cyclonedx_xml",
31 "custom_output",
32 "show_attribution"
33 ])
34 )
35)]
36pub struct Cli {
37 #[arg(required = false)]
39 pub dir_path: Vec<String>,
40
41 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
43 pub output_json: Option<String>,
44
45 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
47 pub output_json_pp: Option<String>,
48
49 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
51 pub output_json_lines: Option<String>,
52
53 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
55 pub output_yaml: Option<String>,
56
57 #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
59 pub output_csv: Option<String>,
60
61 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
63 pub output_html: Option<String>,
64
65 #[arg(
67 long = "html-app",
68 value_name = "FILE",
69 hide = true,
70 allow_hyphen_values = true
71 )]
72 pub output_html_app: Option<String>,
73
74 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
76 pub output_spdx_tv: Option<String>,
77
78 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
80 pub output_spdx_rdf: Option<String>,
81
82 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
84 pub output_cyclonedx: Option<String>,
85
86 #[arg(
88 long = "cyclonedx-xml",
89 value_name = "FILE",
90 allow_hyphen_values = true
91 )]
92 pub output_cyclonedx_xml: Option<String>,
93
94 #[arg(
96 long = "custom-output",
97 value_name = "FILE",
98 requires = "custom_template",
99 allow_hyphen_values = true
100 )]
101 pub custom_output: Option<String>,
102
103 #[arg(
105 long = "custom-template",
106 value_name = "FILE",
107 requires = "custom_output"
108 )]
109 pub custom_template: Option<String>,
110
111 #[arg(short, long, default_value = "0")]
113 pub max_depth: usize,
114
115 #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
116 pub processes: i32,
117
118 #[arg(long, default_value_t = 120.0)]
119 pub timeout: f64,
120
121 #[arg(short, long, conflicts_with = "verbose")]
122 pub quiet: bool,
123
124 #[arg(short, long, conflicts_with = "quiet")]
125 pub verbose: bool,
126
127 #[arg(long, conflicts_with = "full_root")]
128 pub strip_root: bool,
129
130 #[arg(long, conflicts_with = "strip_root")]
131 pub full_root: bool,
132
133 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
135 pub exclude: Vec<String>,
136
137 #[arg(long, value_delimiter = ',')]
138 pub include: Vec<String>,
139
140 #[arg(long = "cache-dir", value_name = "PATH")]
141 pub cache_dir: Option<String>,
142
143 #[arg(long = "cache-clear")]
144 pub cache_clear: bool,
145
146 #[arg(long = "max-in-memory", value_name = "INT")]
147 pub max_in_memory: Option<usize>,
148
149 #[arg(long)]
150 pub from_json: bool,
151
152 #[arg(short = 'p', long)]
154 pub package: bool,
155
156 #[arg(long)]
158 pub no_assemble: bool,
159
160 #[arg(long, value_name = "PATH", requires = "license")]
163 pub license_rules_path: Option<String>,
164
165 #[arg(long, requires = "license")]
167 pub include_text: bool,
168
169 #[arg(long)]
170 pub filter_clues: bool,
171
172 #[arg(long)]
173 pub only_findings: bool,
174
175 #[arg(long)]
176 pub mark_source: bool,
177
178 #[arg(long)]
179 pub classify: bool,
180
181 #[arg(long, requires = "classify")]
182 pub summary: bool,
183
184 #[arg(long = "license-clarity-score", requires = "classify")]
185 pub license_clarity_score: bool,
186
187 #[arg(long)]
188 pub tallies: bool,
189
190 #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
191 pub tallies_key_files: bool,
192
193 #[arg(long = "tallies-with-details")]
194 pub tallies_with_details: bool,
195
196 #[arg(long = "facet", value_name = "<facet>=<pattern>")]
197 pub facet: Vec<String>,
198
199 #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
200 pub tallies_by_facet: bool,
201
202 #[arg(long)]
203 pub generated: bool,
204
205 #[arg(short = 'l', long)]
207 pub license: bool,
208
209 #[arg(short = 'c', long)]
210 pub copyright: bool,
211
212 #[arg(short = 'e', long)]
214 pub email: bool,
215
216 #[arg(long, default_value_t = 50, requires = "email")]
218 pub max_email: usize,
219
220 #[arg(short = 'u', long)]
222 pub url: bool,
223
224 #[arg(long, default_value_t = 50, requires = "url")]
226 pub max_url: usize,
227
228 #[arg(long)]
230 pub show_attribution: bool,
231}
232
233fn default_processes() -> i32 {
234 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
235 if cpus > 1 { (cpus - 1) as i32 } else { 1 }
236}
237
238#[derive(Debug, Clone)]
239pub struct OutputTarget {
240 pub format: OutputFormat,
241 pub file: String,
242 pub custom_template: Option<String>,
243}
244
245impl Cli {
246 pub fn output_targets(&self) -> Vec<OutputTarget> {
247 let mut targets = Vec::new();
248
249 if let Some(file) = &self.output_json {
250 targets.push(OutputTarget {
251 format: OutputFormat::Json,
252 file: file.clone(),
253 custom_template: None,
254 });
255 }
256
257 if let Some(file) = &self.output_json_pp {
258 targets.push(OutputTarget {
259 format: OutputFormat::JsonPretty,
260 file: file.clone(),
261 custom_template: None,
262 });
263 }
264
265 if let Some(file) = &self.output_json_lines {
266 targets.push(OutputTarget {
267 format: OutputFormat::JsonLines,
268 file: file.clone(),
269 custom_template: None,
270 });
271 }
272
273 if let Some(file) = &self.output_yaml {
274 targets.push(OutputTarget {
275 format: OutputFormat::Yaml,
276 file: file.clone(),
277 custom_template: None,
278 });
279 }
280
281 if let Some(file) = &self.output_csv {
282 targets.push(OutputTarget {
283 format: OutputFormat::Csv,
284 file: file.clone(),
285 custom_template: None,
286 });
287 }
288
289 if let Some(file) = &self.output_html {
290 targets.push(OutputTarget {
291 format: OutputFormat::Html,
292 file: file.clone(),
293 custom_template: None,
294 });
295 }
296
297 if let Some(file) = &self.output_html_app {
298 targets.push(OutputTarget {
299 format: OutputFormat::HtmlApp,
300 file: file.clone(),
301 custom_template: None,
302 });
303 }
304
305 if let Some(file) = &self.output_spdx_tv {
306 targets.push(OutputTarget {
307 format: OutputFormat::SpdxTv,
308 file: file.clone(),
309 custom_template: None,
310 });
311 }
312
313 if let Some(file) = &self.output_spdx_rdf {
314 targets.push(OutputTarget {
315 format: OutputFormat::SpdxRdf,
316 file: file.clone(),
317 custom_template: None,
318 });
319 }
320
321 if let Some(file) = &self.output_cyclonedx {
322 targets.push(OutputTarget {
323 format: OutputFormat::CycloneDxJson,
324 file: file.clone(),
325 custom_template: None,
326 });
327 }
328
329 if let Some(file) = &self.output_cyclonedx_xml {
330 targets.push(OutputTarget {
331 format: OutputFormat::CycloneDxXml,
332 file: file.clone(),
333 custom_template: None,
334 });
335 }
336
337 if let Some(file) = &self.custom_output {
338 targets.push(OutputTarget {
339 format: OutputFormat::CustomTemplate,
340 file: file.clone(),
341 custom_template: self.custom_template.clone(),
342 });
343 }
344
345 targets
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_requires_at_least_one_output_option() {
355 let parsed = Cli::try_parse_from(["provenant", "samples"]);
356 assert!(parsed.is_err());
357 }
358
359 #[test]
360 fn test_parses_json_pretty_output_option() {
361 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
362 .expect("cli parse should succeed");
363
364 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
365 assert_eq!(parsed.output_targets().len(), 1);
366 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
367 }
368
369 #[test]
370 fn test_allows_stdout_dash_as_output_target() {
371 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
372 .expect("cli parse should allow stdout dash output target");
373
374 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
375 }
376
377 #[test]
378 fn test_custom_template_and_output_must_be_paired() {
379 let missing_template =
380 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
381 assert!(missing_template.is_err());
382
383 let missing_output =
384 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
385 assert!(missing_output.is_err());
386 }
387
388 #[test]
389 fn test_parses_processes_and_timeout_options() {
390 let parsed = Cli::try_parse_from([
391 "provenant",
392 "--json-pp",
393 "scan.json",
394 "-n",
395 "4",
396 "--timeout",
397 "30",
398 "samples",
399 ])
400 .expect("cli parse should succeed");
401
402 assert_eq!(parsed.processes, 4);
403 assert_eq!(parsed.timeout, 30.0);
404 }
405
406 #[test]
407 fn test_strip_root_conflicts_with_full_root() {
408 let parsed = Cli::try_parse_from([
409 "provenant",
410 "--json-pp",
411 "scan.json",
412 "--strip-root",
413 "--full-root",
414 "samples",
415 ]);
416 assert!(parsed.is_err());
417 }
418
419 #[test]
420 fn test_parses_include_and_only_findings_and_filter_clues() {
421 let parsed = Cli::try_parse_from([
422 "provenant",
423 "--json-pp",
424 "scan.json",
425 "--include",
426 "src/**,Cargo.toml",
427 "--only-findings",
428 "--filter-clues",
429 "samples",
430 ])
431 .expect("cli parse should succeed");
432
433 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
434 assert!(parsed.only_findings);
435 assert!(parsed.filter_clues);
436 }
437
438 #[test]
439 fn test_parses_ignore_alias_for_exclude_patterns() {
440 let parsed = Cli::try_parse_from([
441 "provenant",
442 "--json-pp",
443 "scan.json",
444 "--ignore",
445 "*.git*,target/*",
446 "samples",
447 ])
448 .expect("cli parse should accept --ignore alias");
449
450 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
451 }
452
453 #[test]
454 fn test_quiet_conflicts_with_verbose() {
455 let parsed = Cli::try_parse_from([
456 "provenant",
457 "--json-pp",
458 "scan.json",
459 "--quiet",
460 "--verbose",
461 "samples",
462 ]);
463 assert!(parsed.is_err());
464 }
465
466 #[test]
467 fn test_parses_from_json_and_mark_source() {
468 let parsed = Cli::try_parse_from([
469 "provenant",
470 "--json-pp",
471 "scan.json",
472 "--from-json",
473 "--mark-source",
474 "sample-scan.json",
475 ])
476 .expect("cli parse should succeed");
477
478 assert!(parsed.from_json);
479 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
480 assert!(parsed.mark_source);
481 }
482
483 #[test]
484 fn test_parses_classify_facet_and_tallies_by_facet() {
485 let parsed = Cli::try_parse_from([
486 "provenant",
487 "--json-pp",
488 "scan.json",
489 "--classify",
490 "--tallies",
491 "--facet",
492 "dev=*.c",
493 "--facet",
494 "tests=*/tests/*",
495 "--tallies-by-facet",
496 "samples",
497 ])
498 .expect("cli parse should succeed");
499
500 assert!(parsed.classify);
501 assert!(parsed.tallies);
502 assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
503 assert!(parsed.tallies_by_facet);
504 }
505
506 #[test]
507 fn test_tallies_by_facet_requires_facet_definitions() {
508 let parsed = Cli::try_parse_from([
509 "provenant",
510 "--json-pp",
511 "scan.json",
512 "--tallies-by-facet",
513 "samples",
514 ]);
515
516 assert!(parsed.is_err());
517 }
518
519 #[test]
520 fn test_summary_requires_classify() {
521 let parsed = Cli::try_parse_from([
522 "provenant",
523 "--json-pp",
524 "scan.json",
525 "--summary",
526 "samples",
527 ]);
528
529 assert!(parsed.is_err());
530 }
531
532 #[test]
533 fn test_tallies_key_files_requires_tallies_and_classify() {
534 let parsed = Cli::try_parse_from([
535 "provenant",
536 "--json-pp",
537 "scan.json",
538 "--tallies-key-files",
539 "samples",
540 ]);
541
542 assert!(parsed.is_err());
543 }
544
545 #[test]
546 fn test_parses_summary_tallies_and_generated_flags() {
547 let parsed = Cli::try_parse_from([
548 "provenant",
549 "--json-pp",
550 "scan.json",
551 "--classify",
552 "--summary",
553 "--license-clarity-score",
554 "--tallies",
555 "--tallies-key-files",
556 "--tallies-with-details",
557 "--generated",
558 "samples",
559 ])
560 .expect("cli parse should succeed");
561
562 assert!(parsed.classify);
563 assert!(parsed.summary);
564 assert!(parsed.license_clarity_score);
565 assert!(parsed.tallies);
566 assert!(parsed.tallies_key_files);
567 assert!(parsed.tallies_with_details);
568 assert!(parsed.generated);
569 }
570
571 #[test]
572 fn test_parses_copyright_flag() {
573 let parsed = Cli::try_parse_from([
574 "provenant",
575 "--json-pp",
576 "scan.json",
577 "--copyright",
578 "samples",
579 ])
580 .expect("cli parse should succeed");
581
582 assert!(parsed.copyright);
583 }
584
585 #[test]
586 fn test_package_flag_defaults_to_disabled() {
587 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
588 .expect("cli parse should succeed");
589
590 assert!(!parsed.package);
591 }
592
593 #[test]
594 fn test_parses_package_flag() {
595 let parsed = Cli::try_parse_from([
596 "provenant",
597 "--json-pp",
598 "scan.json",
599 "--package",
600 "samples",
601 ])
602 .expect("cli parse should succeed");
603
604 assert!(parsed.package);
605 }
606
607 #[test]
608 fn test_package_short_flag() {
609 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
610 .expect("cli parse should succeed");
611
612 assert!(parsed.package);
613 }
614
615 #[test]
616 fn test_parses_license_flag() {
617 let parsed = Cli::try_parse_from([
618 "provenant",
619 "--json-pp",
620 "scan.json",
621 "--license",
622 "samples",
623 ])
624 .expect("cli parse should succeed");
625
626 assert!(parsed.license);
627 }
628
629 #[test]
630 fn test_license_short_flag() {
631 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
632 .expect("cli parse should succeed");
633
634 assert!(parsed.license);
635 }
636
637 #[test]
638 fn test_include_text_requires_license() {
639 let result = Cli::try_parse_from([
640 "provenant",
641 "--json-pp",
642 "scan.json",
643 "--include-text",
644 "samples",
645 ]);
646 assert!(result.is_err());
647 }
648
649 #[test]
650 fn test_parses_short_scan_flags() {
651 let parsed = Cli::try_parse_from([
652 "provenant",
653 "--json-pp",
654 "scan.json",
655 "-c",
656 "-e",
657 "-u",
658 "samples",
659 ])
660 .expect("cli parse should support short scan flags");
661
662 assert!(parsed.copyright);
663 assert!(parsed.email);
664 assert!(parsed.url);
665 }
666
667 #[test]
668 fn test_parses_processes_compat_values_zero_and_minus_one() {
669 let zero =
670 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
671 .expect("cli parse should accept processes=0");
672 assert_eq!(zero.processes, 0);
673
674 let parsed =
675 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
676 .expect("cli parse should accept processes=-1");
677 assert_eq!(parsed.processes, -1);
678 }
679
680 #[test]
681 fn test_parses_cache_flags() {
682 let parsed = Cli::try_parse_from([
683 "provenant",
684 "--json-pp",
685 "scan.json",
686 "--cache-dir",
687 "/tmp/sc-cache",
688 "--cache-clear",
689 "--max-in-memory",
690 "5000",
691 "samples",
692 ])
693 .expect("cli parse should accept cache flags");
694
695 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
696 assert!(parsed.cache_clear);
697 assert_eq!(parsed.max_in_memory, Some(5000));
698 }
699
700 #[test]
701 fn test_max_depth_default_matches_reference_behavior() {
702 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
703 .expect("cli parse should succeed");
704
705 assert_eq!(parsed.max_depth, 0);
706 }
707}