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