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