1use clap::{ArgGroup, Parser};
2
3use crate::cache::CacheKind;
4use crate::output::OutputFormat;
5
6#[derive(Parser, Debug)]
7#[command(
8 author,
9 version = env!("CARGO_PKG_VERSION"),
10 long_version = concat!(
11 env!("CARGO_PKG_VERSION"),
12 "\n",
13 "License detection uses data from ScanCode Toolkit (CC-BY-4.0). See NOTICE or --show_attribution."
14 ),
15 about,
16 long_about = None,
17 group(
18 ArgGroup::new("output")
19 .required(true)
20 .args([
21 "output_json",
22 "output_json_pp",
23 "output_json_lines",
24 "output_yaml",
25 "output_csv",
26 "output_html",
27 "output_html_app",
28 "output_spdx_tv",
29 "output_spdx_rdf",
30 "output_cyclonedx",
31 "output_cyclonedx_xml",
32 "custom_output",
33 "show_attribution"
34 ])
35 )
36)]
37pub struct Cli {
38 #[arg(required = false)]
40 pub dir_path: Vec<String>,
41
42 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
44 pub output_json: Option<String>,
45
46 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
48 pub output_json_pp: Option<String>,
49
50 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
52 pub output_json_lines: Option<String>,
53
54 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
56 pub output_yaml: Option<String>,
57
58 #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
60 pub output_csv: Option<String>,
61
62 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
64 pub output_html: Option<String>,
65
66 #[arg(
68 long = "html-app",
69 value_name = "FILE",
70 hide = true,
71 allow_hyphen_values = true
72 )]
73 pub output_html_app: Option<String>,
74
75 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
77 pub output_spdx_tv: Option<String>,
78
79 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
81 pub output_spdx_rdf: Option<String>,
82
83 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
85 pub output_cyclonedx: Option<String>,
86
87 #[arg(
89 long = "cyclonedx-xml",
90 value_name = "FILE",
91 allow_hyphen_values = true
92 )]
93 pub output_cyclonedx_xml: Option<String>,
94
95 #[arg(
97 long = "custom-output",
98 value_name = "FILE",
99 requires = "custom_template",
100 allow_hyphen_values = true
101 )]
102 pub custom_output: Option<String>,
103
104 #[arg(
106 long = "custom-template",
107 value_name = "FILE",
108 requires = "custom_output"
109 )]
110 pub custom_template: Option<String>,
111
112 #[arg(short, long, default_value = "0")]
114 pub max_depth: usize,
115
116 #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
117 pub processes: i32,
118
119 #[arg(long, default_value_t = 120.0)]
120 pub timeout: f64,
121
122 #[arg(short, long, conflicts_with = "verbose")]
123 pub quiet: bool,
124
125 #[arg(short, long, conflicts_with = "quiet")]
126 pub verbose: bool,
127
128 #[arg(long, conflicts_with = "full_root")]
129 pub strip_root: bool,
130
131 #[arg(long, conflicts_with = "strip_root")]
132 pub full_root: bool,
133
134 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
136 pub exclude: Vec<String>,
137
138 #[arg(long, value_delimiter = ',')]
139 pub include: Vec<String>,
140
141 #[arg(long = "cache-dir", value_name = "PATH")]
142 pub cache_dir: Option<String>,
143
144 #[arg(
145 long = "cache",
146 value_name = "KIND",
147 value_enum,
148 value_delimiter = ',',
149 help = "Enable the persistent scan-results cache"
150 )]
151 pub cache: Vec<CacheKind>,
152
153 #[arg(long = "cache-clear")]
154 pub cache_clear: bool,
155
156 #[arg(long = "max-in-memory", value_name = "INT")]
157 pub max_in_memory: Option<usize>,
158
159 #[arg(short = 'i', long)]
160 pub info: bool,
161
162 #[arg(long)]
163 pub from_json: bool,
164
165 #[arg(short = 'p', long)]
167 pub package: bool,
168
169 #[arg(long)]
171 pub no_assemble: bool,
172
173 #[arg(long, value_name = "PATH", requires = "license")]
176 pub license_rules_path: Option<String>,
177
178 #[arg(long = "license-text", alias = "include-text", requires = "license")]
180 pub license_text: bool,
181
182 #[arg(long = "license-text-diagnostics", requires = "license_text")]
183 pub license_text_diagnostics: bool,
184
185 #[arg(long = "license-diagnostics", requires = "license")]
186 pub license_diagnostics: bool,
187
188 #[arg(long = "unknown-licenses", requires = "license")]
189 pub unknown_licenses: bool,
190
191 #[arg(long)]
192 pub filter_clues: bool,
193
194 #[arg(
195 long = "ignore-author",
196 value_name = "PATTERN",
197 help = "Ignore a file and all its findings if an author matches the regex PATTERN"
198 )]
199 pub ignore_author: Vec<String>,
200
201 #[arg(
202 long = "ignore-copyright-holder",
203 value_name = "PATTERN",
204 help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
205 )]
206 pub ignore_copyright_holder: Vec<String>,
207
208 #[arg(long)]
209 pub only_findings: bool,
210
211 #[arg(long, requires = "info")]
212 pub mark_source: bool,
213
214 #[arg(long)]
215 pub classify: bool,
216
217 #[arg(long, requires = "classify")]
218 pub summary: bool,
219
220 #[arg(long = "license-clarity-score", requires = "classify")]
221 pub license_clarity_score: bool,
222
223 #[arg(long = "license-references", requires = "license")]
224 pub license_references: bool,
225
226 #[arg(long)]
227 pub tallies: bool,
228
229 #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
230 pub tallies_key_files: bool,
231
232 #[arg(long = "tallies-with-details")]
233 pub tallies_with_details: bool,
234
235 #[arg(long = "facet", value_name = "<facet>=<pattern>")]
236 pub facet: Vec<String>,
237
238 #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
239 pub tallies_by_facet: bool,
240
241 #[arg(long)]
242 pub generated: bool,
243
244 #[arg(short = 'l', long)]
246 pub license: bool,
247
248 #[arg(short = 'c', long)]
249 pub copyright: bool,
250
251 #[arg(short = 'e', long)]
253 pub email: bool,
254
255 #[arg(long, default_value_t = 50, requires = "email")]
257 pub max_email: usize,
258
259 #[arg(short = 'u', long)]
261 pub url: bool,
262
263 #[arg(long, default_value_t = 50, requires = "url")]
265 pub max_url: usize,
266
267 #[arg(long)]
269 pub show_attribution: bool,
270}
271
272fn default_processes() -> i32 {
273 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
274 if cpus > 1 { (cpus - 1) as i32 } else { 1 }
275}
276
277#[derive(Debug, Clone)]
278pub struct OutputTarget {
279 pub format: OutputFormat,
280 pub file: String,
281 pub custom_template: Option<String>,
282}
283
284impl Cli {
285 pub fn output_targets(&self) -> Vec<OutputTarget> {
286 let mut targets = Vec::new();
287
288 if let Some(file) = &self.output_json {
289 targets.push(OutputTarget {
290 format: OutputFormat::Json,
291 file: file.clone(),
292 custom_template: None,
293 });
294 }
295
296 if let Some(file) = &self.output_json_pp {
297 targets.push(OutputTarget {
298 format: OutputFormat::JsonPretty,
299 file: file.clone(),
300 custom_template: None,
301 });
302 }
303
304 if let Some(file) = &self.output_json_lines {
305 targets.push(OutputTarget {
306 format: OutputFormat::JsonLines,
307 file: file.clone(),
308 custom_template: None,
309 });
310 }
311
312 if let Some(file) = &self.output_yaml {
313 targets.push(OutputTarget {
314 format: OutputFormat::Yaml,
315 file: file.clone(),
316 custom_template: None,
317 });
318 }
319
320 if let Some(file) = &self.output_csv {
321 targets.push(OutputTarget {
322 format: OutputFormat::Csv,
323 file: file.clone(),
324 custom_template: None,
325 });
326 }
327
328 if let Some(file) = &self.output_html {
329 targets.push(OutputTarget {
330 format: OutputFormat::Html,
331 file: file.clone(),
332 custom_template: None,
333 });
334 }
335
336 if let Some(file) = &self.output_html_app {
337 targets.push(OutputTarget {
338 format: OutputFormat::HtmlApp,
339 file: file.clone(),
340 custom_template: None,
341 });
342 }
343
344 if let Some(file) = &self.output_spdx_tv {
345 targets.push(OutputTarget {
346 format: OutputFormat::SpdxTv,
347 file: file.clone(),
348 custom_template: None,
349 });
350 }
351
352 if let Some(file) = &self.output_spdx_rdf {
353 targets.push(OutputTarget {
354 format: OutputFormat::SpdxRdf,
355 file: file.clone(),
356 custom_template: None,
357 });
358 }
359
360 if let Some(file) = &self.output_cyclonedx {
361 targets.push(OutputTarget {
362 format: OutputFormat::CycloneDxJson,
363 file: file.clone(),
364 custom_template: None,
365 });
366 }
367
368 if let Some(file) = &self.output_cyclonedx_xml {
369 targets.push(OutputTarget {
370 format: OutputFormat::CycloneDxXml,
371 file: file.clone(),
372 custom_template: None,
373 });
374 }
375
376 if let Some(file) = &self.custom_output {
377 targets.push(OutputTarget {
378 format: OutputFormat::CustomTemplate,
379 file: file.clone(),
380 custom_template: self.custom_template.clone(),
381 });
382 }
383
384 targets
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_requires_at_least_one_output_option() {
394 let parsed = Cli::try_parse_from(["provenant", "samples"]);
395 assert!(parsed.is_err());
396 }
397
398 #[test]
399 fn test_parses_json_pretty_output_option() {
400 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
401 .expect("cli parse should succeed");
402
403 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
404 assert_eq!(parsed.output_targets().len(), 1);
405 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
406 }
407
408 #[test]
409 fn test_allows_stdout_dash_as_output_target() {
410 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
411 .expect("cli parse should allow stdout dash output target");
412
413 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
414 }
415
416 #[test]
417 fn test_custom_template_and_output_must_be_paired() {
418 let missing_template =
419 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
420 assert!(missing_template.is_err());
421
422 let missing_output =
423 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
424 assert!(missing_output.is_err());
425 }
426
427 #[test]
428 fn test_parses_processes_and_timeout_options() {
429 let parsed = Cli::try_parse_from([
430 "provenant",
431 "--json-pp",
432 "scan.json",
433 "-n",
434 "4",
435 "--timeout",
436 "30",
437 "samples",
438 ])
439 .expect("cli parse should succeed");
440
441 assert_eq!(parsed.processes, 4);
442 assert_eq!(parsed.timeout, 30.0);
443 }
444
445 #[test]
446 fn test_strip_root_conflicts_with_full_root() {
447 let parsed = Cli::try_parse_from([
448 "provenant",
449 "--json-pp",
450 "scan.json",
451 "--strip-root",
452 "--full-root",
453 "samples",
454 ]);
455 assert!(parsed.is_err());
456 }
457
458 #[test]
459 fn test_parses_include_and_only_findings_and_filter_clues() {
460 let parsed = Cli::try_parse_from([
461 "provenant",
462 "--json-pp",
463 "scan.json",
464 "--include",
465 "src/**,Cargo.toml",
466 "--only-findings",
467 "--filter-clues",
468 "samples",
469 ])
470 .expect("cli parse should succeed");
471
472 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
473 assert!(parsed.only_findings);
474 assert!(parsed.filter_clues);
475 }
476
477 #[test]
478 fn test_parses_ignore_author_and_holder_filters() {
479 let parsed = Cli::try_parse_from([
480 "provenant",
481 "--json-pp",
482 "scan.json",
483 "--ignore-author",
484 "Jane.*",
485 "--ignore-author",
486 ".*Bot$",
487 "--ignore-copyright-holder",
488 "Example Corp",
489 "samples",
490 ])
491 .expect("cli parse should succeed");
492
493 assert_eq!(parsed.ignore_author, vec!["Jane.*", ".*Bot$"]);
494 assert_eq!(parsed.ignore_copyright_holder, vec!["Example Corp"]);
495 }
496
497 #[test]
498 fn test_parses_ignore_alias_for_exclude_patterns() {
499 let parsed = Cli::try_parse_from([
500 "provenant",
501 "--json-pp",
502 "scan.json",
503 "--ignore",
504 "*.git*,target/*",
505 "samples",
506 ])
507 .expect("cli parse should accept --ignore alias");
508
509 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
510 }
511
512 #[test]
513 fn test_quiet_conflicts_with_verbose() {
514 let parsed = Cli::try_parse_from([
515 "provenant",
516 "--json-pp",
517 "scan.json",
518 "--quiet",
519 "--verbose",
520 "samples",
521 ]);
522 assert!(parsed.is_err());
523 }
524
525 #[test]
526 fn test_parses_from_json_and_mark_source() {
527 let parsed = Cli::try_parse_from([
528 "provenant",
529 "--json-pp",
530 "scan.json",
531 "--from-json",
532 "--info",
533 "--mark-source",
534 "sample-scan.json",
535 ])
536 .expect("cli parse should succeed");
537
538 assert!(parsed.from_json);
539 assert!(parsed.info);
540 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
541 assert!(parsed.mark_source);
542 }
543
544 #[test]
545 fn test_mark_source_requires_info() {
546 let parsed = Cli::try_parse_from([
547 "provenant",
548 "--json-pp",
549 "scan.json",
550 "--mark-source",
551 "samples",
552 ]);
553
554 assert!(parsed.is_err());
555 }
556
557 #[test]
558 fn test_parses_classify_facet_and_tallies_by_facet() {
559 let parsed = Cli::try_parse_from([
560 "provenant",
561 "--json-pp",
562 "scan.json",
563 "--classify",
564 "--tallies",
565 "--facet",
566 "dev=*.c",
567 "--facet",
568 "tests=*/tests/*",
569 "--tallies-by-facet",
570 "samples",
571 ])
572 .expect("cli parse should succeed");
573
574 assert!(parsed.classify);
575 assert!(parsed.tallies);
576 assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
577 assert!(parsed.tallies_by_facet);
578 }
579
580 #[test]
581 fn test_tallies_by_facet_requires_facet_definitions() {
582 let parsed = Cli::try_parse_from([
583 "provenant",
584 "--json-pp",
585 "scan.json",
586 "--tallies-by-facet",
587 "samples",
588 ]);
589
590 assert!(parsed.is_err());
591 }
592
593 #[test]
594 fn test_summary_requires_classify() {
595 let parsed = Cli::try_parse_from([
596 "provenant",
597 "--json-pp",
598 "scan.json",
599 "--summary",
600 "samples",
601 ]);
602
603 assert!(parsed.is_err());
604 }
605
606 #[test]
607 fn test_tallies_key_files_requires_tallies_and_classify() {
608 let parsed = Cli::try_parse_from([
609 "provenant",
610 "--json-pp",
611 "scan.json",
612 "--tallies-key-files",
613 "samples",
614 ]);
615
616 assert!(parsed.is_err());
617 }
618
619 #[test]
620 fn test_parses_summary_tallies_and_generated_flags() {
621 let parsed = Cli::try_parse_from([
622 "provenant",
623 "--json-pp",
624 "scan.json",
625 "--classify",
626 "--summary",
627 "--license-clarity-score",
628 "--tallies",
629 "--tallies-key-files",
630 "--tallies-with-details",
631 "--generated",
632 "samples",
633 ])
634 .expect("cli parse should succeed");
635
636 assert!(parsed.classify);
637 assert!(parsed.summary);
638 assert!(parsed.license_clarity_score);
639 assert!(parsed.tallies);
640 assert!(parsed.tallies_key_files);
641 assert!(parsed.tallies_with_details);
642 assert!(parsed.generated);
643 }
644
645 #[test]
646 fn test_parses_copyright_flag() {
647 let parsed = Cli::try_parse_from([
648 "provenant",
649 "--json-pp",
650 "scan.json",
651 "--copyright",
652 "samples",
653 ])
654 .expect("cli parse should succeed");
655
656 assert!(parsed.copyright);
657 }
658
659 #[test]
660 fn test_package_flag_defaults_to_disabled() {
661 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
662 .expect("cli parse should succeed");
663
664 assert!(!parsed.package);
665 }
666
667 #[test]
668 fn test_parses_package_flag() {
669 let parsed = Cli::try_parse_from([
670 "provenant",
671 "--json-pp",
672 "scan.json",
673 "--package",
674 "samples",
675 ])
676 .expect("cli parse should succeed");
677
678 assert!(parsed.package);
679 }
680
681 #[test]
682 fn test_package_short_flag() {
683 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
684 .expect("cli parse should succeed");
685
686 assert!(parsed.package);
687 }
688
689 #[test]
690 fn test_parses_license_flag() {
691 let parsed = Cli::try_parse_from([
692 "provenant",
693 "--json-pp",
694 "scan.json",
695 "--license",
696 "samples",
697 ])
698 .expect("cli parse should succeed");
699
700 assert!(parsed.license);
701 }
702
703 #[test]
704 fn test_license_short_flag() {
705 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
706 .expect("cli parse should succeed");
707
708 assert!(parsed.license);
709 }
710
711 #[test]
712 fn test_license_text_requires_license() {
713 let result = Cli::try_parse_from([
714 "provenant",
715 "--json-pp",
716 "scan.json",
717 "--license-text",
718 "samples",
719 ]);
720 assert!(result.is_err());
721 }
722
723 #[test]
724 fn test_license_text_diagnostics_requires_license_text() {
725 let result = Cli::try_parse_from([
726 "provenant",
727 "--json-pp",
728 "scan.json",
729 "--license",
730 "--license-text-diagnostics",
731 "samples",
732 ]);
733
734 assert!(result.is_err());
735 }
736
737 #[test]
738 fn test_parses_license_text_and_diagnostics_flags() {
739 let parsed = Cli::try_parse_from([
740 "provenant",
741 "--json-pp",
742 "scan.json",
743 "--license",
744 "--license-text",
745 "--license-text-diagnostics",
746 "--license-diagnostics",
747 "--unknown-licenses",
748 "samples",
749 ])
750 .expect("cli parse should succeed");
751
752 assert!(parsed.license_text);
753 assert!(parsed.license_text_diagnostics);
754 assert!(parsed.license_diagnostics);
755 assert!(parsed.unknown_licenses);
756 }
757
758 #[test]
759 fn test_license_references_requires_license() {
760 let result = Cli::try_parse_from([
761 "provenant",
762 "--json-pp",
763 "scan.json",
764 "--license-references",
765 "samples",
766 ]);
767
768 assert!(result.is_err());
769 }
770
771 #[test]
772 fn test_parses_license_references_flag() {
773 let parsed = Cli::try_parse_from([
774 "provenant",
775 "--json-pp",
776 "scan.json",
777 "--license",
778 "--license-references",
779 "samples",
780 ])
781 .expect("cli parse should succeed");
782
783 assert!(parsed.license_references);
784 }
785
786 #[test]
787 fn test_include_text_alias_still_parses_as_license_text() {
788 let parsed = Cli::try_parse_from([
789 "provenant",
790 "--json-pp",
791 "scan.json",
792 "--license",
793 "--include-text",
794 "samples",
795 ])
796 .expect("cli parse should accept include-text alias");
797
798 assert!(parsed.license_text);
799 }
800
801 #[test]
802 fn test_parses_short_scan_flags() {
803 let parsed = Cli::try_parse_from([
804 "provenant",
805 "--json-pp",
806 "scan.json",
807 "-c",
808 "-e",
809 "-u",
810 "samples",
811 ])
812 .expect("cli parse should support short scan flags");
813
814 assert!(parsed.copyright);
815 assert!(parsed.email);
816 assert!(parsed.url);
817 }
818
819 #[test]
820 fn test_parses_processes_compat_values_zero_and_minus_one() {
821 let zero =
822 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
823 .expect("cli parse should accept processes=0");
824 assert_eq!(zero.processes, 0);
825
826 let parsed =
827 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
828 .expect("cli parse should accept processes=-1");
829 assert_eq!(parsed.processes, -1);
830 }
831
832 #[test]
833 fn test_parses_cache_flags() {
834 let parsed = Cli::try_parse_from([
835 "provenant",
836 "--json-pp",
837 "scan.json",
838 "--cache",
839 "scan-results",
840 "--cache-dir",
841 "/tmp/sc-cache",
842 "--cache-clear",
843 "--max-in-memory",
844 "5000",
845 "samples",
846 ])
847 .expect("cli parse should accept cache flags");
848
849 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
850 assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
851 assert!(parsed.cache_clear);
852 assert_eq!(parsed.max_in_memory, Some(5000));
853 }
854
855 #[test]
856 fn test_parses_cache_alias_flag() {
857 let parsed = Cli::try_parse_from([
858 "provenant",
859 "--json-pp",
860 "scan.json",
861 "--cache",
862 "scan",
863 "samples",
864 ])
865 .expect("cli parse should accept cache=scan alias");
866
867 assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
868 }
869
870 #[test]
871 fn test_max_depth_default_matches_reference_behavior() {
872 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
873 .expect("cli parse should succeed");
874
875 assert_eq!(parsed.max_depth, 0);
876 }
877}