1use clap::{ArgGroup, Parser};
2use std::fs;
3use std::path::Path;
4use yaml_serde::Value;
5
6use crate::cache::CacheKind;
7use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
8use crate::output::OutputFormat;
9
10fn parse_license_policy_arg(value: &str) -> Result<String, String> {
11 let policy_path = Path::new(value);
12 let metadata = fs::metadata(policy_path).map_err(|err| {
13 format!(
14 "Failed to read license policy file {:?}: {err}",
15 policy_path
16 )
17 })?;
18 if !metadata.is_file() {
19 return Err(format!(
20 "License policy path {:?} is not a regular file",
21 policy_path
22 ));
23 }
24
25 let policy_text = fs::read_to_string(policy_path).map_err(|err| {
26 format!(
27 "Failed to read license policy file {:?}: {err}",
28 policy_path
29 )
30 })?;
31 if policy_text.trim().is_empty() {
32 return Err(format!("License policy file {:?} is empty", policy_path));
33 }
34
35 let policy_value: Value = yaml_serde::from_str(&policy_text).map_err(|err| {
36 format!(
37 "Failed to parse license policy file {:?}: {err}",
38 policy_path
39 )
40 })?;
41 let has_license_policies = policy_value
42 .as_mapping()
43 .and_then(|mapping| mapping.get(Value::String("license_policies".to_string())))
44 .is_some();
45 if !has_license_policies {
46 return Err(format!(
47 "License policy file {:?} is missing a 'license_policies' attribute",
48 policy_path
49 ));
50 }
51
52 Ok(value.to_string())
53}
54
55#[derive(Parser, Debug)]
56#[command(
57 author,
58 version = env!("CARGO_PKG_VERSION"),
59 long_version = concat!(
60 env!("CARGO_PKG_VERSION"),
61 "\n",
62 "License detection uses data from ScanCode Toolkit (CC-BY-4.0). See NOTICE or --show_attribution."
63 ),
64 about,
65 long_about = None,
66 group(
67 ArgGroup::new("output")
68 .required(true)
69 .args([
70 "output_json",
71 "output_json_pp",
72 "output_json_lines",
73 "output_yaml",
74 "output_csv",
75 "output_debian",
76 "output_html",
77 "output_html_app",
78 "output_spdx_tv",
79 "output_spdx_rdf",
80 "output_cyclonedx",
81 "output_cyclonedx_xml",
82 "custom_output",
83 "show_attribution"
84 ])
85 )
86)]
87pub struct Cli {
88 #[arg(required = false)]
90 pub dir_path: Vec<String>,
91
92 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
94 pub output_json: Option<String>,
95
96 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
98 pub output_json_pp: Option<String>,
99
100 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
102 pub output_json_lines: Option<String>,
103
104 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
106 pub output_yaml: Option<String>,
107
108 #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
110 pub output_csv: Option<String>,
111
112 #[arg(
114 long = "debian",
115 value_name = "FILE",
116 allow_hyphen_values = true,
117 requires_all = ["copyright", "license", "license_text"]
118 )]
119 pub output_debian: Option<String>,
120
121 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
123 pub output_html: Option<String>,
124
125 #[arg(
127 long = "html-app",
128 value_name = "FILE",
129 hide = true,
130 allow_hyphen_values = true
131 )]
132 pub output_html_app: Option<String>,
133
134 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
136 pub output_spdx_tv: Option<String>,
137
138 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
140 pub output_spdx_rdf: Option<String>,
141
142 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
144 pub output_cyclonedx: Option<String>,
145
146 #[arg(
148 long = "cyclonedx-xml",
149 value_name = "FILE",
150 allow_hyphen_values = true
151 )]
152 pub output_cyclonedx_xml: Option<String>,
153
154 #[arg(
156 long = "custom-output",
157 value_name = "FILE",
158 requires = "custom_template",
159 allow_hyphen_values = true
160 )]
161 pub custom_output: Option<String>,
162
163 #[arg(
165 long = "custom-template",
166 value_name = "FILE",
167 requires = "custom_output"
168 )]
169 pub custom_template: Option<String>,
170
171 #[arg(short, long, default_value = "0")]
173 pub max_depth: usize,
174
175 #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
176 pub processes: i32,
177
178 #[arg(long, default_value_t = 120.0)]
179 pub timeout: f64,
180
181 #[arg(short, long, conflicts_with = "verbose")]
182 pub quiet: bool,
183
184 #[arg(short, long, conflicts_with = "quiet")]
185 pub verbose: bool,
186
187 #[arg(long, conflicts_with = "full_root")]
188 pub strip_root: bool,
189
190 #[arg(long, conflicts_with = "strip_root")]
191 pub full_root: bool,
192
193 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
195 pub exclude: Vec<String>,
196
197 #[arg(long, value_delimiter = ',')]
198 pub include: Vec<String>,
199
200 #[arg(long = "cache-dir", value_name = "PATH")]
201 pub cache_dir: Option<String>,
202
203 #[arg(
204 long = "cache",
205 value_name = "KIND",
206 value_enum,
207 value_delimiter = ',',
208 help = "Enable the persistent scan-results cache"
209 )]
210 pub cache: Vec<CacheKind>,
211
212 #[arg(long = "cache-clear")]
213 pub cache_clear: bool,
214
215 #[arg(
218 long = "max-in-memory",
219 value_name = "INT",
220 default_value_t = 10000,
221 value_parser = parse_max_in_memory,
222 allow_hyphen_values = true
223 )]
224 pub max_in_memory: i64,
225
226 #[arg(short = 'i', long)]
228 pub info: bool,
229
230 #[arg(long)]
232 pub from_json: bool,
233
234 #[arg(short = 'p', long)]
236 pub package: bool,
237
238 #[arg(long = "system-package")]
240 pub system_package: bool,
241
242 #[arg(long = "package-in-compiled")]
244 pub package_in_compiled: bool,
245
246 #[arg(
248 long = "package-only",
249 conflicts_with_all = ["license", "summary", "package", "system_package"]
250 )]
251 pub package_only: bool,
252
253 #[arg(long)]
255 pub no_assemble: bool,
256
257 #[arg(long, value_name = "PATH", requires = "license")]
260 pub license_rules_path: Option<String>,
261
262 #[arg(long = "license-text", requires = "license")]
264 pub license_text: bool,
265
266 #[arg(long = "license-text-diagnostics", requires = "license_text")]
267 pub license_text_diagnostics: bool,
268
269 #[arg(long = "license-diagnostics", requires = "license")]
270 pub license_diagnostics: bool,
271
272 #[arg(long = "unknown-licenses", requires = "license")]
273 pub unknown_licenses: bool,
274
275 #[arg(
276 long = "license-score",
277 default_value_t = 0,
278 requires = "license",
279 value_parser = clap::value_parser!(u8).range(0..=100)
280 )]
281 pub license_score: u8,
282
283 #[arg(
284 long = "license-url-template",
285 default_value = DEFAULT_LICENSEDB_URL_TEMPLATE,
286 requires = "license"
287 )]
288 pub license_url_template: String,
289
290 #[arg(long)]
291 pub filter_clues: bool,
292
293 #[arg(
294 long = "ignore-author",
295 value_name = "PATTERN",
296 help = "Ignore a file and all its findings if an author matches the regex PATTERN"
297 )]
298 pub ignore_author: Vec<String>,
299
300 #[arg(
301 long = "ignore-copyright-holder",
302 value_name = "PATTERN",
303 help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
304 )]
305 pub ignore_copyright_holder: Vec<String>,
306
307 #[arg(long)]
308 pub only_findings: bool,
309
310 #[arg(long, requires = "info")]
311 pub mark_source: bool,
312
313 #[arg(long)]
314 pub classify: bool,
315
316 #[arg(long, requires = "classify")]
317 pub summary: bool,
318
319 #[arg(long = "license-clarity-score", requires = "classify")]
320 pub license_clarity_score: bool,
321
322 #[arg(long = "license-references", requires = "license")]
323 pub license_references: bool,
324
325 #[arg(
327 long = "license-policy",
328 value_name = "FILE",
329 value_parser = parse_license_policy_arg
330 )]
331 pub license_policy: Option<String>,
332
333 #[arg(long)]
334 pub tallies: bool,
335
336 #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
337 pub tallies_key_files: bool,
338
339 #[arg(long = "tallies-with-details")]
340 pub tallies_with_details: bool,
341
342 #[arg(long = "facet", value_name = "<facet>=<pattern>")]
343 pub facet: Vec<String>,
344
345 #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
346 pub tallies_by_facet: bool,
347
348 #[arg(long)]
349 pub generated: bool,
350
351 #[arg(short = 'l', long)]
353 pub license: bool,
354
355 #[arg(short = 'c', long)]
356 pub copyright: bool,
357
358 #[arg(short = 'e', long)]
360 pub email: bool,
361
362 #[arg(long, default_value_t = 50, requires = "email")]
364 pub max_email: usize,
365
366 #[arg(short = 'u', long)]
368 pub url: bool,
369
370 #[arg(long, default_value_t = 50, requires = "url")]
372 pub max_url: usize,
373
374 #[arg(long)]
376 pub show_attribution: bool,
377}
378
379fn default_processes() -> i32 {
380 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
381 if cpus > 1 { (cpus - 1) as i32 } else { 1 }
382}
383
384fn parse_max_in_memory(value: &str) -> Result<i64, String> {
385 let parsed = value
386 .parse::<i64>()
387 .map_err(|_| format!("invalid integer value: {value}"))?;
388 if parsed < -1 {
389 return Err("--max-in-memory must be -1, 0, or a positive integer".to_string());
390 }
391 Ok(parsed)
392}
393
394#[derive(Debug, Clone)]
395pub struct OutputTarget {
396 pub format: OutputFormat,
397 pub file: String,
398 pub custom_template: Option<String>,
399}
400
401impl Cli {
402 pub fn output_targets(&self) -> Vec<OutputTarget> {
403 let mut targets = Vec::new();
404
405 if let Some(file) = &self.output_json {
406 targets.push(OutputTarget {
407 format: OutputFormat::Json,
408 file: file.clone(),
409 custom_template: None,
410 });
411 }
412
413 if let Some(file) = &self.output_json_pp {
414 targets.push(OutputTarget {
415 format: OutputFormat::JsonPretty,
416 file: file.clone(),
417 custom_template: None,
418 });
419 }
420
421 if let Some(file) = &self.output_json_lines {
422 targets.push(OutputTarget {
423 format: OutputFormat::JsonLines,
424 file: file.clone(),
425 custom_template: None,
426 });
427 }
428
429 if let Some(file) = &self.output_yaml {
430 targets.push(OutputTarget {
431 format: OutputFormat::Yaml,
432 file: file.clone(),
433 custom_template: None,
434 });
435 }
436
437 if let Some(file) = &self.output_csv {
438 targets.push(OutputTarget {
439 format: OutputFormat::Csv,
440 file: file.clone(),
441 custom_template: None,
442 });
443 }
444
445 if let Some(file) = &self.output_debian {
446 targets.push(OutputTarget {
447 format: OutputFormat::Debian,
448 file: file.clone(),
449 custom_template: None,
450 });
451 }
452
453 if let Some(file) = &self.output_html {
454 targets.push(OutputTarget {
455 format: OutputFormat::Html,
456 file: file.clone(),
457 custom_template: None,
458 });
459 }
460
461 if let Some(file) = &self.output_html_app {
462 targets.push(OutputTarget {
463 format: OutputFormat::HtmlApp,
464 file: file.clone(),
465 custom_template: None,
466 });
467 }
468
469 if let Some(file) = &self.output_spdx_tv {
470 targets.push(OutputTarget {
471 format: OutputFormat::SpdxTv,
472 file: file.clone(),
473 custom_template: None,
474 });
475 }
476
477 if let Some(file) = &self.output_spdx_rdf {
478 targets.push(OutputTarget {
479 format: OutputFormat::SpdxRdf,
480 file: file.clone(),
481 custom_template: None,
482 });
483 }
484
485 if let Some(file) = &self.output_cyclonedx {
486 targets.push(OutputTarget {
487 format: OutputFormat::CycloneDxJson,
488 file: file.clone(),
489 custom_template: None,
490 });
491 }
492
493 if let Some(file) = &self.output_cyclonedx_xml {
494 targets.push(OutputTarget {
495 format: OutputFormat::CycloneDxXml,
496 file: file.clone(),
497 custom_template: None,
498 });
499 }
500
501 if let Some(file) = &self.custom_output {
502 targets.push(OutputTarget {
503 format: OutputFormat::CustomTemplate,
504 file: file.clone(),
505 custom_template: self.custom_template.clone(),
506 });
507 }
508
509 targets
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use clap::CommandFactory;
517
518 #[test]
519 fn test_requires_at_least_one_output_option() {
520 let parsed = Cli::try_parse_from(["provenant", "samples"]);
521 assert!(parsed.is_err());
522 }
523
524 #[test]
525 fn test_parses_json_pretty_output_option() {
526 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
527 .expect("cli parse should succeed");
528
529 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
530 assert_eq!(parsed.output_targets().len(), 1);
531 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
532 }
533
534 #[test]
535 fn test_allows_stdout_dash_as_output_target() {
536 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
537 .expect("cli parse should allow stdout dash output target");
538
539 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
540 }
541
542 #[test]
543 fn test_debian_requires_license_copyright_and_license_text() {
544 let missing_license_text = Cli::try_parse_from([
545 "provenant",
546 "--debian",
547 "scan.copyright",
548 "--license",
549 "--copyright",
550 "samples",
551 ]);
552 assert!(missing_license_text.is_err());
553
554 let parsed = Cli::try_parse_from([
555 "provenant",
556 "--debian",
557 "scan.copyright",
558 "--license",
559 "--copyright",
560 "--license-text",
561 "samples",
562 ])
563 .expect("cli parse should accept debian output");
564
565 assert_eq!(parsed.output_targets().len(), 1);
566 assert_eq!(parsed.output_targets()[0].format, OutputFormat::Debian);
567 assert_eq!(parsed.output_debian.as_deref(), Some("scan.copyright"));
568 }
569
570 #[test]
571 fn test_debian_help_mentions_required_companion_flags() {
572 let command = Cli::command();
573 let debian_arg = command
574 .get_arguments()
575 .find(|arg| arg.get_long() == Some("debian"))
576 .expect("debian arg should exist");
577
578 let help = debian_arg
579 .get_help()
580 .expect("debian arg should have help text")
581 .to_string();
582
583 assert!(help.contains("requires --license, --copyright, and --license-text"));
584 }
585
586 #[test]
587 fn test_parses_license_policy_flag() {
588 let temp = tempfile::tempdir().expect("temp dir");
589 let policy_path = temp.path().join("policy.yml");
590 std::fs::write(&policy_path, "license_policies: []\n").expect("policy written");
591
592 let parsed = Cli::try_parse_from([
593 "provenant",
594 "--json-pp",
595 "scan.json",
596 "--license-policy",
597 policy_path.to_str().expect("utf8 path"),
598 "samples",
599 ])
600 .expect("cli parse should accept license-policy");
601
602 assert_eq!(
603 parsed.license_policy.as_deref(),
604 Some(policy_path.to_str().expect("utf8 path"))
605 );
606 }
607
608 #[test]
609 fn test_rejects_invalid_license_policy_flag_value() {
610 let temp = tempfile::tempdir().expect("temp dir");
611 let policy_path = temp.path().join("policy.yml");
612 std::fs::write(&policy_path, "not_license_policies: []\n").expect("policy written");
613
614 let parsed = Cli::try_parse_from([
615 "provenant",
616 "--json-pp",
617 "scan.json",
618 "--license-policy",
619 policy_path.to_str().expect("utf8 path"),
620 "samples",
621 ]);
622
623 assert!(parsed.is_err());
624 }
625
626 #[test]
627 fn test_custom_template_and_output_must_be_paired() {
628 let missing_template =
629 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
630 assert!(missing_template.is_err());
631
632 let missing_output =
633 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
634 assert!(missing_output.is_err());
635 }
636
637 #[test]
638 fn test_parses_processes_and_timeout_options() {
639 let parsed = Cli::try_parse_from([
640 "provenant",
641 "--json-pp",
642 "scan.json",
643 "-n",
644 "4",
645 "--timeout",
646 "30",
647 "samples",
648 ])
649 .expect("cli parse should succeed");
650
651 assert_eq!(parsed.processes, 4);
652 assert_eq!(parsed.timeout, 30.0);
653 }
654
655 #[test]
656 fn test_strip_root_conflicts_with_full_root() {
657 let parsed = Cli::try_parse_from([
658 "provenant",
659 "--json-pp",
660 "scan.json",
661 "--strip-root",
662 "--full-root",
663 "samples",
664 ]);
665 assert!(parsed.is_err());
666 }
667
668 #[test]
669 fn test_parses_include_and_only_findings_and_filter_clues() {
670 let parsed = Cli::try_parse_from([
671 "provenant",
672 "--json-pp",
673 "scan.json",
674 "--include",
675 "src/**,Cargo.toml",
676 "--only-findings",
677 "--filter-clues",
678 "samples",
679 ])
680 .expect("cli parse should succeed");
681
682 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
683 assert!(parsed.only_findings);
684 assert!(parsed.filter_clues);
685 }
686
687 #[test]
688 fn test_parses_ignore_author_and_holder_filters() {
689 let parsed = Cli::try_parse_from([
690 "provenant",
691 "--json-pp",
692 "scan.json",
693 "--ignore-author",
694 "Jane.*",
695 "--ignore-author",
696 ".*Bot$",
697 "--ignore-copyright-holder",
698 "Example Corp",
699 "samples",
700 ])
701 .expect("cli parse should succeed");
702
703 assert_eq!(parsed.ignore_author, vec!["Jane.*", ".*Bot$"]);
704 assert_eq!(parsed.ignore_copyright_holder, vec!["Example Corp"]);
705 }
706
707 #[test]
708 fn test_parses_ignore_alias_for_exclude_patterns() {
709 let parsed = Cli::try_parse_from([
710 "provenant",
711 "--json-pp",
712 "scan.json",
713 "--ignore",
714 "*.git*,target/*",
715 "samples",
716 ])
717 .expect("cli parse should accept --ignore alias");
718
719 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
720 }
721
722 #[test]
723 fn test_quiet_conflicts_with_verbose() {
724 let parsed = Cli::try_parse_from([
725 "provenant",
726 "--json-pp",
727 "scan.json",
728 "--quiet",
729 "--verbose",
730 "samples",
731 ]);
732 assert!(parsed.is_err());
733 }
734
735 #[test]
736 fn test_parses_from_json_and_mark_source() {
737 let parsed = Cli::try_parse_from([
738 "provenant",
739 "--json-pp",
740 "scan.json",
741 "--from-json",
742 "--info",
743 "--mark-source",
744 "sample-scan.json",
745 ])
746 .expect("cli parse should succeed");
747
748 assert!(parsed.from_json);
749 assert!(parsed.info);
750 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
751 assert!(parsed.mark_source);
752 }
753
754 #[test]
755 fn test_mark_source_requires_info() {
756 let parsed = Cli::try_parse_from([
757 "provenant",
758 "--json-pp",
759 "scan.json",
760 "--mark-source",
761 "samples",
762 ]);
763
764 assert!(parsed.is_err());
765 }
766
767 #[test]
768 fn test_parses_classify_facet_and_tallies_by_facet() {
769 let parsed = Cli::try_parse_from([
770 "provenant",
771 "--json-pp",
772 "scan.json",
773 "--classify",
774 "--tallies",
775 "--facet",
776 "dev=*.c",
777 "--facet",
778 "tests=*/tests/*",
779 "--tallies-by-facet",
780 "samples",
781 ])
782 .expect("cli parse should succeed");
783
784 assert!(parsed.classify);
785 assert!(parsed.tallies);
786 assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
787 assert!(parsed.tallies_by_facet);
788 }
789
790 #[test]
791 fn test_tallies_by_facet_requires_facet_definitions() {
792 let parsed = Cli::try_parse_from([
793 "provenant",
794 "--json-pp",
795 "scan.json",
796 "--tallies-by-facet",
797 "samples",
798 ]);
799
800 assert!(parsed.is_err());
801 }
802
803 #[test]
804 fn test_summary_requires_classify() {
805 let parsed = Cli::try_parse_from([
806 "provenant",
807 "--json-pp",
808 "scan.json",
809 "--summary",
810 "samples",
811 ]);
812
813 assert!(parsed.is_err());
814 }
815
816 #[test]
817 fn test_tallies_key_files_requires_tallies_and_classify() {
818 let parsed = Cli::try_parse_from([
819 "provenant",
820 "--json-pp",
821 "scan.json",
822 "--tallies-key-files",
823 "samples",
824 ]);
825
826 assert!(parsed.is_err());
827 }
828
829 #[test]
830 fn test_parses_summary_tallies_and_generated_flags() {
831 let parsed = Cli::try_parse_from([
832 "provenant",
833 "--json-pp",
834 "scan.json",
835 "--classify",
836 "--summary",
837 "--license-clarity-score",
838 "--tallies",
839 "--tallies-key-files",
840 "--tallies-with-details",
841 "--generated",
842 "samples",
843 ])
844 .expect("cli parse should succeed");
845
846 assert!(parsed.classify);
847 assert!(parsed.summary);
848 assert!(parsed.license_clarity_score);
849 assert!(parsed.tallies);
850 assert!(parsed.tallies_key_files);
851 assert!(parsed.tallies_with_details);
852 assert!(parsed.generated);
853 }
854
855 #[test]
856 fn test_parses_copyright_flag() {
857 let parsed = Cli::try_parse_from([
858 "provenant",
859 "--json-pp",
860 "scan.json",
861 "--copyright",
862 "samples",
863 ])
864 .expect("cli parse should succeed");
865
866 assert!(parsed.copyright);
867 }
868
869 #[test]
870 fn test_package_flag_defaults_to_disabled() {
871 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
872 .expect("cli parse should succeed");
873
874 assert!(!parsed.package);
875 }
876
877 #[test]
878 fn test_parses_system_package_flag() {
879 let parsed = Cli::try_parse_from([
880 "provenant",
881 "--json-pp",
882 "scan.json",
883 "--system-package",
884 "samples",
885 ])
886 .expect("cli parse should succeed");
887
888 assert!(parsed.system_package);
889 }
890
891 #[test]
892 fn test_parses_package_in_compiled_flag() {
893 let parsed = Cli::try_parse_from([
894 "provenant",
895 "--json-pp",
896 "scan.json",
897 "--package-in-compiled",
898 "samples",
899 ])
900 .expect("cli parse should succeed");
901
902 assert!(parsed.package_in_compiled);
903 }
904
905 #[test]
906 fn test_parses_package_only_flag() {
907 let parsed = Cli::try_parse_from([
908 "provenant",
909 "--json-pp",
910 "scan.json",
911 "--package-only",
912 "samples",
913 ])
914 .expect("cli parse should succeed");
915
916 assert!(parsed.package_only);
917 }
918
919 #[test]
920 fn test_package_only_conflicts_with_upstream_incompatible_flags() {
921 let with_license = Cli::try_parse_from([
922 "provenant",
923 "--json-pp",
924 "scan.json",
925 "--package-only",
926 "--license",
927 "samples",
928 ]);
929 assert!(with_license.is_err());
930
931 let with_package = Cli::try_parse_from([
932 "provenant",
933 "--json-pp",
934 "scan.json",
935 "--package-only",
936 "--package",
937 "samples",
938 ]);
939 assert!(with_package.is_err());
940 }
941
942 #[test]
943 fn test_parses_package_flag() {
944 let parsed = Cli::try_parse_from([
945 "provenant",
946 "--json-pp",
947 "scan.json",
948 "--package",
949 "samples",
950 ])
951 .expect("cli parse should succeed");
952
953 assert!(parsed.package);
954 }
955
956 #[test]
957 fn test_package_short_flag() {
958 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
959 .expect("cli parse should succeed");
960
961 assert!(parsed.package);
962 }
963
964 #[test]
965 fn test_parses_license_flag() {
966 let parsed = Cli::try_parse_from([
967 "provenant",
968 "--json-pp",
969 "scan.json",
970 "--license",
971 "samples",
972 ])
973 .expect("cli parse should succeed");
974
975 assert!(parsed.license);
976 }
977
978 #[test]
979 fn test_license_short_flag() {
980 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
981 .expect("cli parse should succeed");
982
983 assert!(parsed.license);
984 }
985
986 #[test]
987 fn test_license_text_requires_license() {
988 let result = Cli::try_parse_from([
989 "provenant",
990 "--json-pp",
991 "scan.json",
992 "--license-text",
993 "samples",
994 ]);
995 assert!(result.is_err());
996 }
997
998 #[test]
999 fn test_include_text_is_rejected() {
1000 let result = Cli::try_parse_from([
1001 "provenant",
1002 "--json-pp",
1003 "scan.json",
1004 "--license",
1005 "--include-text",
1006 "samples",
1007 ]);
1008
1009 assert!(result.is_err());
1010 }
1011
1012 #[test]
1013 fn test_license_text_diagnostics_requires_license_text() {
1014 let result = Cli::try_parse_from([
1015 "provenant",
1016 "--json-pp",
1017 "scan.json",
1018 "--license",
1019 "--license-text-diagnostics",
1020 "samples",
1021 ]);
1022
1023 assert!(result.is_err());
1024 }
1025
1026 #[test]
1027 fn test_parses_license_text_and_diagnostics_flags() {
1028 let parsed = Cli::try_parse_from([
1029 "provenant",
1030 "--json-pp",
1031 "scan.json",
1032 "--license",
1033 "--license-text",
1034 "--license-text-diagnostics",
1035 "--license-diagnostics",
1036 "--unknown-licenses",
1037 "samples",
1038 ])
1039 .expect("cli parse should succeed");
1040
1041 assert!(parsed.license_text);
1042 assert!(parsed.license_text_diagnostics);
1043 assert!(parsed.license_diagnostics);
1044 assert!(parsed.unknown_licenses);
1045 assert_eq!(parsed.license_score, 0);
1046 assert_eq!(parsed.license_url_template, DEFAULT_LICENSEDB_URL_TEMPLATE);
1047 }
1048
1049 #[test]
1050 fn test_license_score_requires_license() {
1051 let result = Cli::try_parse_from([
1052 "provenant",
1053 "--json-pp",
1054 "scan.json",
1055 "--license-score",
1056 "70",
1057 "samples",
1058 ]);
1059
1060 assert!(result.is_err());
1061 }
1062
1063 #[test]
1064 fn test_license_url_template_requires_license() {
1065 let result = Cli::try_parse_from([
1066 "provenant",
1067 "--json-pp",
1068 "scan.json",
1069 "--license-url-template",
1070 "https://example.com/licenses/{}/",
1071 "samples",
1072 ]);
1073
1074 assert!(result.is_err());
1075 }
1076
1077 #[test]
1078 fn test_parses_license_score_and_url_template_flags() {
1079 let parsed = Cli::try_parse_from([
1080 "provenant",
1081 "--json-pp",
1082 "scan.json",
1083 "--license",
1084 "--license-score",
1085 "70",
1086 "--license-url-template",
1087 "https://example.com/licenses/{}/",
1088 "samples",
1089 ])
1090 .expect("cli parse should succeed");
1091
1092 assert_eq!(parsed.license_score, 70);
1093 assert_eq!(
1094 parsed.license_url_template,
1095 "https://example.com/licenses/{}/"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_rejects_license_score_above_range() {
1101 let result = Cli::try_parse_from([
1102 "provenant",
1103 "--json-pp",
1104 "scan.json",
1105 "--license",
1106 "--license-score",
1107 "101",
1108 "samples",
1109 ]);
1110
1111 assert!(result.is_err());
1112 }
1113
1114 #[test]
1115 fn test_license_references_requires_license() {
1116 let result = Cli::try_parse_from([
1117 "provenant",
1118 "--json-pp",
1119 "scan.json",
1120 "--license-references",
1121 "samples",
1122 ]);
1123
1124 assert!(result.is_err());
1125 }
1126
1127 #[test]
1128 fn test_parses_license_references_flag() {
1129 let parsed = Cli::try_parse_from([
1130 "provenant",
1131 "--json-pp",
1132 "scan.json",
1133 "--license",
1134 "--license-references",
1135 "samples",
1136 ])
1137 .expect("cli parse should succeed");
1138
1139 assert!(parsed.license_references);
1140 }
1141
1142 #[test]
1143 fn test_include_text_alias_is_not_supported() {
1144 let result = Cli::try_parse_from([
1145 "provenant",
1146 "--json-pp",
1147 "scan.json",
1148 "--license",
1149 "--include-text",
1150 "samples",
1151 ]);
1152
1153 assert!(result.is_err());
1154 }
1155
1156 #[test]
1157 fn test_parses_short_scan_flags() {
1158 let parsed = Cli::try_parse_from([
1159 "provenant",
1160 "--json-pp",
1161 "scan.json",
1162 "-c",
1163 "-e",
1164 "-u",
1165 "samples",
1166 ])
1167 .expect("cli parse should support short scan flags");
1168
1169 assert!(parsed.copyright);
1170 assert!(parsed.email);
1171 assert!(parsed.url);
1172 }
1173
1174 #[test]
1175 fn test_parses_processes_compat_values_zero_and_minus_one() {
1176 let zero =
1177 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
1178 .expect("cli parse should accept processes=0");
1179 assert_eq!(zero.processes, 0);
1180
1181 let parsed =
1182 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
1183 .expect("cli parse should accept processes=-1");
1184 assert_eq!(parsed.processes, -1);
1185 }
1186
1187 #[test]
1188 fn test_parses_cache_flags() {
1189 let parsed = Cli::try_parse_from([
1190 "provenant",
1191 "--json-pp",
1192 "scan.json",
1193 "--cache",
1194 "scan-results",
1195 "--cache-dir",
1196 "/tmp/sc-cache",
1197 "--cache-clear",
1198 "--max-in-memory",
1199 "5000",
1200 "samples",
1201 ])
1202 .expect("cli parse should accept cache flags");
1203
1204 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
1205 assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
1206 assert!(parsed.cache_clear);
1207 assert_eq!(parsed.max_in_memory, 5000);
1208 }
1209
1210 #[test]
1211 fn test_max_in_memory_defaults_and_special_values() {
1212 let default_parsed =
1213 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1214 .expect("default max-in-memory should parse");
1215 assert_eq!(default_parsed.max_in_memory, 10000);
1216
1217 let disk_only = Cli::try_parse_from([
1218 "provenant",
1219 "--json-pp",
1220 "scan.json",
1221 "--max-in-memory",
1222 "-1",
1223 "samples",
1224 ])
1225 .expect("-1 should parse");
1226 assert_eq!(disk_only.max_in_memory, -1);
1227
1228 let unlimited = Cli::try_parse_from([
1229 "provenant",
1230 "--json-pp",
1231 "scan.json",
1232 "--max-in-memory",
1233 "0",
1234 "samples",
1235 ])
1236 .expect("0 should parse");
1237 assert_eq!(unlimited.max_in_memory, 0);
1238 }
1239
1240 #[test]
1241 fn test_max_in_memory_rejects_values_below_negative_one() {
1242 let result = Cli::try_parse_from([
1243 "provenant",
1244 "--json-pp",
1245 "scan.json",
1246 "--max-in-memory",
1247 "-2",
1248 "samples",
1249 ]);
1250
1251 assert!(result.is_err());
1252 }
1253
1254 #[test]
1255 fn test_parses_cache_alias_flag() {
1256 let parsed = Cli::try_parse_from([
1257 "provenant",
1258 "--json-pp",
1259 "scan.json",
1260 "--cache",
1261 "scan",
1262 "samples",
1263 ])
1264 .expect("cli parse should accept cache=scan alias");
1265
1266 assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
1267 }
1268
1269 #[test]
1270 fn test_max_depth_default_matches_reference_behavior() {
1271 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1272 .expect("cli parse should succeed");
1273
1274 assert_eq!(parsed.max_depth, 0);
1275 }
1276}