1mod run;
5
6pub use run::run;
7
8use clap::{ArgGroup, Args, Parser, Subcommand};
9use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
10use std::ffi::OsString;
11use std::fs;
12#[cfg(test)]
13use std::ops::Deref;
14use std::path::{Path, PathBuf};
15use yaml_serde::Value as YamlValue;
16
17use crate::app::request::{InputMode, OutputTarget, ScanRequest};
18use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
19use crate::output::OutputFormat;
20use crate::scanner::MemoryMode;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ProcessMode {
24 Parallel(usize),
25 SequentialWithTimeouts,
26 SequentialWithoutTimeouts,
27}
28
29impl Default for ProcessMode {
30 fn default() -> Self {
31 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
32 if cpus > 1 {
33 ProcessMode::Parallel(cpus - 1)
34 } else {
35 ProcessMode::Parallel(1)
36 }
37 }
38}
39
40impl ProcessMode {
41 fn default_value() -> Self {
42 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
43 if cpus > 1 {
44 ProcessMode::Parallel(cpus - 1)
45 } else {
46 ProcessMode::Parallel(1)
47 }
48 }
49
50 pub fn to_i32(self) -> i32 {
51 match self {
52 ProcessMode::Parallel(n) => n as i32,
53 ProcessMode::SequentialWithTimeouts => 0,
54 ProcessMode::SequentialWithoutTimeouts => -1,
55 }
56 }
57}
58
59impl std::fmt::Display for ProcessMode {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "{}", self.to_i32())
62 }
63}
64
65fn parse_processes(value: &str) -> Result<ProcessMode, String> {
66 let parsed: i32 = value
67 .parse()
68 .map_err(|e| format!("invalid integer for --processes: {e}"))?;
69 if parsed > 0 {
70 Ok(ProcessMode::Parallel(
71 u32::try_from(parsed).unwrap() as usize
72 ))
73 } else if parsed == 0 {
74 Ok(ProcessMode::SequentialWithTimeouts)
75 } else {
76 Ok(ProcessMode::SequentialWithoutTimeouts)
77 }
78}
79
80const 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).";
81const CLI_ABOUT: &str = "Rust scanner for ScanCode-compatible workflows. Not affiliated with, endorsed by, or sponsored by ScanCode Toolkit, AboutCode, or nexB Inc.";
82const CLI_LONG_ABOUT: &str = "Rust scanner for ScanCode-compatible workflows.\n\nNot affiliated with, endorsed by, or sponsored by ScanCode Toolkit, AboutCode, or nexB Inc.";
83
84fn parse_license_policy_arg(value: &str) -> Result<String, String> {
85 let policy_path = Path::new(value);
86 let metadata = fs::metadata(policy_path).map_err(|err| {
87 format!(
88 "Failed to read license policy file {:?}: {err}",
89 policy_path
90 )
91 })?;
92 if !metadata.is_file() {
93 return Err(format!(
94 "License policy path {:?} is not a regular file",
95 policy_path
96 ));
97 }
98
99 let policy_text = fs::read_to_string(policy_path).map_err(|err| {
100 format!(
101 "Failed to read license policy file {:?}: {err}",
102 policy_path
103 )
104 })?;
105 if policy_text.trim().is_empty() {
106 return Err(format!("License policy file {:?} is empty", policy_path));
107 }
108
109 let policy_value: YamlValue = yaml_serde::from_str(&policy_text).map_err(|err| {
110 format!(
111 "Failed to parse license policy file {:?}: {err}",
112 policy_path
113 )
114 })?;
115 let has_license_policies = policy_value
116 .as_mapping()
117 .and_then(|mapping| mapping.get(YamlValue::String("license_policies".to_string())))
118 .is_some();
119 if !has_license_policies {
120 return Err(format!(
121 "License policy file {:?} is missing a 'license_policies' attribute",
122 policy_path
123 ));
124 }
125
126 Ok(value.to_string())
127}
128
129#[derive(Parser, Debug)]
130#[command(
131 author = "The Provenant contributors",
132 version = crate::version::BUILD_VERSION,
133 long_version = crate::version::build_long_version(),
134 after_help = PDF_OXIDE_LOG_HELP,
135 about = CLI_ABOUT,
136 long_about = CLI_LONG_ABOUT,
137 arg_required_else_help = true,
138 subcommand_required = true
139)]
140pub struct Cli {
141 #[command(subcommand)]
142 pub command: Command,
143}
144
145#[derive(Subcommand, Debug, Clone)]
146pub enum Command {
147 Scan(Box<ScanArgs>),
149 Serve(ServeArgs),
151 Compare(CompareArgs),
153 ShowAttribution,
155 ExportLicenseDataset(ExportLicenseDatasetArgs),
157}
158
159#[derive(Args, Debug, Clone)]
160pub struct CompareArgs {
161 #[arg(long = "scancode-json", value_name = "PATH")]
163 pub scancode_json: PathBuf,
164
165 #[arg(long = "provenant-json", value_name = "PATH")]
167 pub provenant_json: PathBuf,
168
169 #[arg(long = "artifact-dir", value_name = "DIR")]
171 pub artifact_dir: Option<PathBuf>,
172}
173
174#[derive(Args, Debug, Clone)]
175pub struct ExportLicenseDatasetArgs {
176 #[arg(value_name = "DIR")]
177 pub dir: String,
178}
179
180#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum CompatibilityMode {
182 #[default]
183 Native,
184 Scancode,
185}
186
187impl CompatibilityMode {
188 fn as_str(self) -> &'static str {
189 match self {
190 Self::Native => "native",
191 Self::Scancode => "scancode",
192 }
193 }
194}
195
196#[derive(Args, Debug, Clone)]
197pub struct ServeArgs {
198 #[arg(long = "bind", value_name = "ADDR", default_value = "127.0.0.1:8080")]
200 pub bind: String,
201}
202
203#[derive(Args, Debug, Clone)]
204#[command(
205 group(
206 ArgGroup::new("output")
207 .required(true)
208 .multiple(true)
209 .args([
210 "output_json",
211 "output_json_pp",
212 "output_json_lines",
213 "output_yaml",
214 "output_debian",
215 "output_html",
216 "output_spdx_tv",
217 "output_spdx_rdf",
218 "output_cyclonedx",
219 "output_cyclonedx_xml",
220 "custom_output"
221 ])
222 ),
223 after_help = PDF_OXIDE_LOG_HELP
224)]
225pub struct ScanArgs {
226 #[arg(required = false)]
228 pub dir_path: Vec<String>,
229
230 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
232 pub output_json: Option<String>,
233
234 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
236 pub output_json_pp: Option<String>,
237
238 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
240 pub output_json_lines: Option<String>,
241
242 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
244 pub output_yaml: Option<String>,
245
246 #[arg(
248 long = "debian",
249 value_name = "FILE",
250 allow_hyphen_values = true,
251 requires_all = ["copyright", "license", "license_text"]
252 )]
253 pub output_debian: Option<String>,
254
255 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
257 pub output_html: Option<String>,
258
259 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
261 pub output_spdx_tv: Option<String>,
262
263 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
265 pub output_spdx_rdf: Option<String>,
266
267 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
269 pub output_cyclonedx: Option<String>,
270
271 #[arg(
273 long = "cyclonedx-xml",
274 value_name = "FILE",
275 allow_hyphen_values = true
276 )]
277 pub output_cyclonedx_xml: Option<String>,
278
279 #[arg(
281 long = "custom-output",
282 value_name = "FILE",
283 requires = "custom_template",
284 allow_hyphen_values = true
285 )]
286 pub custom_output: Option<String>,
287
288 #[arg(
290 long = "custom-template",
291 value_name = "FILE",
292 requires = "custom_output"
293 )]
294 pub custom_template: Option<String>,
295
296 #[arg(short, long, default_value = "0")]
298 pub max_depth: usize,
299
300 #[arg(short = 'n', long, default_value_t = ProcessMode::default_value(), value_parser = parse_processes, allow_hyphen_values = true)]
301 pub processes: ProcessMode,
302
303 #[arg(long, default_value_t = 120.0)]
304 pub timeout: f64,
305
306 #[arg(short, long, conflicts_with = "verbose")]
307 pub quiet: bool,
308
309 #[arg(short, long, conflicts_with = "quiet")]
310 pub verbose: bool,
311
312 #[arg(long, conflicts_with = "full_root")]
313 pub strip_root: bool,
314
315 #[arg(long, conflicts_with = "strip_root")]
316 pub full_root: bool,
317
318 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
320 pub exclude: Vec<String>,
321
322 #[arg(long, value_delimiter = ',')]
324 pub include: Vec<String>,
325
326 #[arg(long = "paths-file", value_name = "FILE", allow_hyphen_values = true)]
328 pub paths_file: Vec<String>,
329
330 #[arg(long = "cache-dir", value_name = "PATH")]
331 pub cache_dir: Option<String>,
332
333 #[arg(long = "cache-clear")]
334 pub cache_clear: bool,
335
336 #[arg(long = "incremental")]
337 pub incremental: bool,
338
339 #[arg(
342 long = "max-in-memory",
343 value_name = "INT",
344 default_value_t = MemoryMode::Limit(10000),
345 value_parser = parse_max_in_memory,
346 allow_hyphen_values = true
347 )]
348 pub max_in_memory: MemoryMode,
349
350 #[arg(short = 'i', long)]
352 pub info: bool,
353
354 #[arg(long)]
356 pub from_json: bool,
357
358 #[arg(short = 'p', long)]
360 pub package: bool,
361
362 #[arg(
364 long = "compat-mode",
365 visible_alias = "compat",
366 value_enum,
367 default_value_t = CompatibilityMode::Native
368 )]
369 pub compat_mode: CompatibilityMode,
370
371 #[arg(long = "system-package")]
373 pub system_package: bool,
374
375 #[arg(long = "package-in-compiled")]
377 pub package_in_compiled: bool,
378
379 #[arg(
381 long = "package-only",
382 conflicts_with_all = ["license", "summary", "package", "system_package"]
383 )]
384 pub package_only: bool,
385
386 #[arg(long)]
388 pub no_assemble: bool,
389
390 #[arg(
393 long = "license-dataset-path",
394 value_name = "PATH",
395 requires = "license"
396 )]
397 pub license_dataset_path: Option<String>,
398
399 #[arg(long)]
401 pub reindex: bool,
402
403 #[arg(long = "no-license-index-cache")]
405 pub no_license_index_cache: bool,
406
407 #[arg(long = "license-text", requires = "license")]
409 pub license_text: bool,
410
411 #[arg(long = "license-text-diagnostics", requires = "license_text")]
412 pub license_text_diagnostics: bool,
413
414 #[arg(long = "license-diagnostics", requires = "license")]
415 pub license_diagnostics: bool,
416
417 #[arg(long = "unknown-licenses", requires = "license")]
418 pub unknown_licenses: bool,
419
420 #[arg(long = "no-sequence-matching", requires = "license")]
422 pub no_sequence_matching: bool,
423
424 #[arg(
425 long = "license-score",
426 default_value_t = 0,
427 requires = "license",
428 value_parser = clap::value_parser!(u8).range(0..=100)
429 )]
430 pub license_score: u8,
431
432 #[arg(
433 long = "license-url-template",
434 default_value = DEFAULT_LICENSEDB_URL_TEMPLATE,
435 requires = "license"
436 )]
437 pub license_url_template: String,
438
439 #[arg(long)]
440 pub filter_clues: bool,
441
442 #[arg(
443 long = "ignore-author",
444 value_name = "PATTERN",
445 help = "Ignore a file and all its findings if an author matches the regex PATTERN"
446 )]
447 pub ignore_author: Vec<String>,
448
449 #[arg(
450 long = "ignore-copyright-holder",
451 value_name = "PATTERN",
452 help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
453 )]
454 pub ignore_copyright_holder: Vec<String>,
455
456 #[arg(long)]
457 pub only_findings: bool,
458
459 #[arg(long, requires = "info")]
460 pub mark_source: bool,
461
462 #[arg(long)]
463 pub classify: bool,
464
465 #[arg(long, requires = "classify")]
466 pub summary: bool,
467
468 #[arg(long = "license-clarity-score", requires = "classify")]
469 pub license_clarity_score: bool,
470
471 #[arg(long = "license-references", requires = "license")]
472 pub license_references: bool,
473
474 #[arg(
476 long = "license-policy",
477 value_name = "FILE",
478 value_parser = parse_license_policy_arg
479 )]
480 pub license_policy: Option<String>,
481
482 #[arg(long)]
483 pub tallies: bool,
484
485 #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
486 pub tallies_key_files: bool,
487
488 #[arg(long = "tallies-with-details")]
489 pub tallies_with_details: bool,
490
491 #[arg(long = "facet", value_name = "<facet>=<pattern>")]
492 pub facet: Vec<String>,
493
494 #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
495 pub tallies_by_facet: bool,
496
497 #[arg(long)]
498 pub generated: bool,
499
500 #[arg(short = 'l', long)]
502 pub license: bool,
503
504 #[arg(short = 'c', long)]
505 pub copyright: bool,
506
507 #[arg(short = 'e', long)]
509 pub email: bool,
510
511 #[arg(long, default_value_t = 50, requires = "email")]
513 pub max_email: usize,
514
515 #[arg(short = 'u', long)]
517 pub url: bool,
518
519 #[arg(long, default_value_t = 50, requires = "url")]
521 pub max_url: usize,
522}
523
524impl Cli {
525 pub fn parse() -> Self {
526 <Self as Parser>::parse_from(rewrite_args_for_default_scan(std::env::args_os()))
527 }
528
529 pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
530 where
531 I: IntoIterator<Item = T>,
532 T: Into<OsString>,
533 {
534 <Self as Parser>::try_parse_from(rewrite_args_for_default_scan(itr))
535 }
536
537 pub(crate) fn scan_args(&self) -> Option<&ScanArgs> {
538 match &self.command {
539 Command::Scan(scan_args) => Some(scan_args.as_ref()),
540 Command::Serve(_)
541 | Command::Compare(_)
542 | Command::ShowAttribution
543 | Command::ExportLicenseDataset(_) => None,
544 }
545 }
546}
547
548#[cfg(test)]
549impl Deref for Cli {
550 type Target = ScanArgs;
551
552 fn deref(&self) -> &Self::Target {
553 self.scan_args()
554 .expect("scan arguments are only available for the scan command")
555 }
556}
557
558fn rewrite_args_for_default_scan<I, T>(itr: I) -> Vec<OsString>
559where
560 I: IntoIterator<Item = T>,
561 T: Into<OsString>,
562{
563 let mut args: Vec<OsString> = itr.into_iter().map(Into::into).collect();
564 if args.len() <= 1 {
565 return args;
566 }
567
568 let first = args[1].to_string_lossy();
569 if matches!(
570 first.as_ref(),
571 "scan"
572 | "serve"
573 | "compare"
574 | "show-attribution"
575 | "export-license-dataset"
576 | "help"
577 | "-h"
578 | "--help"
579 | "-V"
580 | "--version"
581 ) {
582 return args;
583 }
584
585 if first.starts_with('-') || Path::new(first.as_ref()).exists() {
586 args.insert(1, OsString::from("scan"));
587 }
588
589 args
590}
591
592fn parse_max_in_memory(value: &str) -> Result<MemoryMode, String> {
593 let parsed = value
594 .parse::<i64>()
595 .map_err(|_| format!("invalid integer value: {value}"))?;
596 if parsed < -1 {
597 return Err("--max-in-memory must be -1, 0, or a positive integer".to_string());
598 }
599 match parsed {
600 -1 => Ok(MemoryMode::StreamUnlimited),
601 0 => Ok(MemoryMode::CollectFirst),
602 n if n > 0 => Ok(MemoryMode::Limit(usize::try_from(n).unwrap_or(usize::MAX))),
603 _ => Ok(MemoryMode::CollectFirst),
604 }
605}
606
607impl ScanArgs {
608 pub(crate) fn output_targets(&self) -> Vec<OutputTarget> {
609 let mut targets = Vec::new();
610
611 if let Some(file) = &self.output_json {
612 targets.push(OutputTarget {
613 format: OutputFormat::Json,
614 file: file.clone(),
615 custom_template: None,
616 });
617 }
618
619 if let Some(file) = &self.output_json_pp {
620 targets.push(OutputTarget {
621 format: OutputFormat::JsonPretty,
622 file: file.clone(),
623 custom_template: None,
624 });
625 }
626
627 if let Some(file) = &self.output_json_lines {
628 targets.push(OutputTarget {
629 format: OutputFormat::JsonLines,
630 file: file.clone(),
631 custom_template: None,
632 });
633 }
634
635 if let Some(file) = &self.output_yaml {
636 targets.push(OutputTarget {
637 format: OutputFormat::Yaml,
638 file: file.clone(),
639 custom_template: None,
640 });
641 }
642
643 if let Some(file) = &self.output_debian {
644 targets.push(OutputTarget {
645 format: OutputFormat::Debian,
646 file: file.clone(),
647 custom_template: None,
648 });
649 }
650
651 if let Some(file) = &self.output_html {
652 targets.push(OutputTarget {
653 format: OutputFormat::Html,
654 file: file.clone(),
655 custom_template: None,
656 });
657 }
658
659 if let Some(file) = &self.output_spdx_tv {
660 targets.push(OutputTarget {
661 format: OutputFormat::SpdxTv,
662 file: file.clone(),
663 custom_template: None,
664 });
665 }
666
667 if let Some(file) = &self.output_spdx_rdf {
668 targets.push(OutputTarget {
669 format: OutputFormat::SpdxRdf,
670 file: file.clone(),
671 custom_template: None,
672 });
673 }
674
675 if let Some(file) = &self.output_cyclonedx {
676 targets.push(OutputTarget {
677 format: OutputFormat::CycloneDxJson,
678 file: file.clone(),
679 custom_template: None,
680 });
681 }
682
683 if let Some(file) = &self.output_cyclonedx_xml {
684 targets.push(OutputTarget {
685 format: OutputFormat::CycloneDxXml,
686 file: file.clone(),
687 custom_template: None,
688 });
689 }
690
691 if let Some(file) = &self.custom_output {
692 targets.push(OutputTarget {
693 format: OutputFormat::CustomTemplate,
694 file: file.clone(),
695 custom_template: self.custom_template.clone(),
696 });
697 }
698
699 targets
700 }
701
702 pub(crate) fn output_header_options(&self) -> JsonMap<String, JsonValue> {
703 let mut options = JsonMap::new();
704 if !self.dir_path.is_empty() {
705 options.insert(
706 "input".to_string(),
707 JsonValue::Array(
708 self.dir_path
709 .iter()
710 .cloned()
711 .map(JsonValue::String)
712 .collect(),
713 ),
714 );
715 }
716
717 let mut flags = Vec::new();
718
719 push_string_option(&mut flags, "--cache-dir", self.cache_dir.as_ref());
720 push_bool_option(&mut flags, "--cache-clear", self.cache_clear);
721 push_bool_option(&mut flags, "--classify", self.classify);
722 push_string_option(&mut flags, "--custom-output", self.custom_output.as_ref());
723 push_string_option(
724 &mut flags,
725 "--custom-template",
726 self.custom_template.as_ref(),
727 );
728 push_bool_option(&mut flags, "--copyright", self.copyright);
729 if self.compat_mode != CompatibilityMode::Native {
730 flags.push((
731 "--compat-mode".to_string(),
732 JsonValue::String(self.compat_mode.as_str().to_string()),
733 ));
734 }
735 push_string_option(&mut flags, "--cyclonedx", self.output_cyclonedx.as_ref());
736 push_string_option(
737 &mut flags,
738 "--cyclonedx-xml",
739 self.output_cyclonedx_xml.as_ref(),
740 );
741 push_string_option(&mut flags, "--debian", self.output_debian.as_ref());
742 push_bool_option(&mut flags, "--email", self.email);
743 push_array_option(&mut flags, "--facet", &self.facet);
744 push_bool_option(&mut flags, "--filter-clues", self.filter_clues);
745 push_bool_option(&mut flags, "--from-json", self.from_json);
746 push_bool_option(&mut flags, "--full-root", self.full_root);
747 push_bool_option(&mut flags, "--generated", self.generated);
748 push_string_option(&mut flags, "--html", self.output_html.as_ref());
749 push_array_option(&mut flags, "--ignore", &self.exclude);
750 push_array_option(&mut flags, "--ignore-author", &self.ignore_author);
751 push_array_option(
752 &mut flags,
753 "--ignore-copyright-holder",
754 &self.ignore_copyright_holder,
755 );
756 push_bool_option(&mut flags, "--incremental", self.incremental);
757 push_array_option(&mut flags, "--include", &self.include);
758 push_bool_option(&mut flags, "--info", self.info);
759 push_string_option(&mut flags, "--json", self.output_json.as_ref());
760 push_string_option(&mut flags, "--json-lines", self.output_json_lines.as_ref());
761 push_string_option(&mut flags, "--json-pp", self.output_json_pp.as_ref());
762 push_bool_option(&mut flags, "--license", self.license);
763 push_bool_option(
764 &mut flags,
765 "--license-clarity-score",
766 self.license_clarity_score,
767 );
768 push_bool_option(
769 &mut flags,
770 "--license-diagnostics",
771 self.license_diagnostics,
772 );
773 push_string_option(
774 &mut flags,
775 "--license-dataset-path",
776 self.license_dataset_path.as_ref(),
777 );
778 push_string_option(&mut flags, "--license-policy", self.license_policy.as_ref());
779 push_bool_option(
780 &mut flags,
781 "--no-license-index-cache",
782 self.no_license_index_cache,
783 );
784 push_bool_option(&mut flags, "--license-references", self.license_references);
785 push_bool_option(&mut flags, "--reindex", self.reindex);
786 push_non_default_u8_option(&mut flags, "--license-score", self.license_score, 0);
787 push_bool_option(&mut flags, "--license-text", self.license_text);
788 push_bool_option(
789 &mut flags,
790 "--license-text-diagnostics",
791 self.license_text_diagnostics,
792 );
793 push_non_default_string_option(
794 &mut flags,
795 "--license-url-template",
796 &self.license_url_template,
797 DEFAULT_LICENSEDB_URL_TEMPLATE,
798 );
799 push_non_default_usize_option(&mut flags, "--max-depth", self.max_depth, 0);
800 match self.max_in_memory {
801 MemoryMode::Limit(10000) => {}
802 MemoryMode::CollectFirst => {
803 flags.push(("--max-in-memory".to_string(), JsonValue::Number(0.into())));
804 }
805 MemoryMode::StreamUnlimited => {
806 flags.push((
807 "--max-in-memory".to_string(),
808 JsonValue::Number((-1i64).into()),
809 ));
810 }
811 MemoryMode::Limit(n) => {
812 flags.push(("--max-in-memory".to_string(), JsonValue::Number(n.into())));
813 }
814 }
815 if self.email {
816 push_non_default_usize_option(&mut flags, "--max-email", self.max_email, 50);
817 }
818 if self.url {
819 push_non_default_usize_option(&mut flags, "--max-url", self.max_url, 50);
820 }
821 push_bool_option(&mut flags, "--mark-source", self.mark_source);
822 push_bool_option(&mut flags, "--no-assemble", self.no_assemble);
823 push_bool_option(&mut flags, "--only-findings", self.only_findings);
824 push_bool_option(&mut flags, "--package", self.package);
825 push_bool_option(
826 &mut flags,
827 "--package-in-compiled",
828 self.package_in_compiled,
829 );
830 push_bool_option(&mut flags, "--package-only", self.package_only);
831 push_array_option(&mut flags, "--paths-file", &self.paths_file);
832 push_non_default_process_mode_option(
833 &mut flags,
834 "--processes",
835 self.processes,
836 ProcessMode::default_value(),
837 );
838 push_bool_option(&mut flags, "--quiet", self.quiet);
839 push_string_option(&mut flags, "--spdx-rdf", self.output_spdx_rdf.as_ref());
840 push_string_option(&mut flags, "--spdx-tv", self.output_spdx_tv.as_ref());
841 push_bool_option(&mut flags, "--strip-root", self.strip_root);
842 push_bool_option(&mut flags, "--summary", self.summary);
843 push_bool_option(&mut flags, "--system-package", self.system_package);
844 push_bool_option(&mut flags, "--tallies", self.tallies);
845 push_bool_option(&mut flags, "--tallies-by-facet", self.tallies_by_facet);
846 push_bool_option(&mut flags, "--tallies-key-files", self.tallies_key_files);
847 push_bool_option(
848 &mut flags,
849 "--tallies-with-details",
850 self.tallies_with_details,
851 );
852 push_non_default_f64_option(&mut flags, "--timeout", self.timeout, 120.0);
853 push_bool_option(&mut flags, "--unknown-licenses", self.unknown_licenses);
854 push_bool_option(
855 &mut flags,
856 "--no-sequence-matching",
857 self.no_sequence_matching,
858 );
859 push_bool_option(&mut flags, "--url", self.url);
860 push_bool_option(&mut flags, "--verbose", self.verbose);
861 push_string_option(&mut flags, "--yaml", self.output_yaml.as_ref());
862
863 flags.sort_by(|left, right| left.0.cmp(&right.0));
864 for (key, value) in flags {
865 options.insert(key, value);
866 }
867
868 options
869 }
870}
871
872impl From<&ScanArgs> for ScanRequest {
873 fn from(cli: &ScanArgs) -> Self {
874 Self {
875 input_paths: cli.dir_path.clone(),
876 input_mode: if cli.from_json {
877 InputMode::FromJson
878 } else {
879 InputMode::Native
880 },
881 output_targets: cli.output_targets(),
882 output_header_options: cli.output_header_options(),
883 progress_mode: if cli.quiet {
884 crate::progress::ProgressMode::Quiet
885 } else if cli.verbose {
886 crate::progress::ProgressMode::Verbose
887 } else {
888 crate::progress::ProgressMode::Default
889 },
890 process_mode: cli.processes,
891 timeout_seconds: cli.timeout,
892 quiet: cli.quiet,
893 verbose: cli.verbose,
894 strip_root: cli.strip_root,
895 full_root: cli.full_root,
896 exclude: cli.exclude.clone(),
897 include: cli.include.clone(),
898 paths_files: cli.paths_file.clone(),
899 respect_process_cache_env: true,
900 cache_dir: cli.cache_dir.clone(),
901 cache_clear: cli.cache_clear,
902 incremental: cli.incremental,
903 max_depth: cli.max_depth,
904 max_in_memory: cli.max_in_memory,
905 info: cli.info,
906 package: cli.package,
907 system_package: cli.system_package,
908 package_in_compiled: cli.package_in_compiled,
909 package_only: cli.package_only,
910 no_assemble: cli.no_assemble,
911 license_dataset_path: cli.license_dataset_path.clone(),
912 reindex: cli.reindex,
913 no_license_index_cache: cli.no_license_index_cache,
914 license_text: cli.license_text,
915 license_text_diagnostics: cli.license_text_diagnostics,
916 license_diagnostics: cli.license_diagnostics,
917 unknown_licenses: cli.unknown_licenses,
918 no_sequence_matching: cli.no_sequence_matching,
919 license_score: cli.license_score,
920 license_url_template: cli.license_url_template.clone(),
921 filter_clues: cli.filter_clues,
922 ignore_author: cli.ignore_author.clone(),
923 ignore_copyright_holder: cli.ignore_copyright_holder.clone(),
924 only_findings: cli.only_findings,
925 mark_source: cli.mark_source,
926 classify: cli.classify,
927 summary: cli.summary,
928 license_clarity_score: cli.license_clarity_score,
929 license_references: cli.license_references,
930 license_policy: cli.license_policy.clone(),
931 tallies: cli.tallies,
932 tallies_key_files: cli.tallies_key_files,
933 tallies_with_details: cli.tallies_with_details,
934 facet: cli.facet.clone(),
935 tallies_by_facet: cli.tallies_by_facet,
936 generated: cli.generated,
937 license: cli.license,
938 copyright: cli.copyright,
939 email: cli.email,
940 max_email: cli.max_email,
941 url: cli.url,
942 max_url: cli.max_url,
943 }
944 }
945}
946
947fn push_bool_option(options: &mut Vec<(String, JsonValue)>, key: &str, enabled: bool) {
948 if enabled {
949 options.push((key.to_string(), JsonValue::Bool(true)));
950 }
951}
952
953fn push_string_option(options: &mut Vec<(String, JsonValue)>, key: &str, value: Option<&String>) {
954 if let Some(value) = value {
955 options.push((key.to_string(), JsonValue::String(value.clone())));
956 }
957}
958
959fn push_non_default_string_option(
960 options: &mut Vec<(String, JsonValue)>,
961 key: &str,
962 value: &str,
963 default: &str,
964) {
965 if value != default {
966 options.push((key.to_string(), JsonValue::String(value.to_string())));
967 }
968}
969
970fn push_array_option(options: &mut Vec<(String, JsonValue)>, key: &str, values: &[String]) {
971 if !values.is_empty() {
972 options.push((
973 key.to_string(),
974 JsonValue::Array(values.iter().cloned().map(JsonValue::String).collect()),
975 ));
976 }
977}
978
979fn push_non_default_usize_option(
980 options: &mut Vec<(String, JsonValue)>,
981 key: &str,
982 value: usize,
983 default: usize,
984) {
985 if value != default {
986 options.push((key.to_string(), JsonValue::Number(value.into())));
987 }
988}
989
990fn push_non_default_u8_option(
991 options: &mut Vec<(String, JsonValue)>,
992 key: &str,
993 value: u8,
994 default: u8,
995) {
996 if value != default {
997 options.push((key.to_string(), JsonValue::Number(value.into())));
998 }
999}
1000
1001fn push_non_default_process_mode_option(
1002 options: &mut Vec<(String, JsonValue)>,
1003 key: &str,
1004 value: ProcessMode,
1005 default: ProcessMode,
1006) {
1007 if value != default {
1008 options.push((key.to_string(), JsonValue::Number(value.to_i32().into())));
1009 }
1010}
1011
1012fn push_non_default_f64_option(
1013 options: &mut Vec<(String, JsonValue)>,
1014 key: &str,
1015 value: f64,
1016 default: f64,
1017) {
1018 if (value - default).abs() > f64::EPSILON
1019 && let Some(number) = JsonNumber::from_f64(value)
1020 {
1021 options.push((key.to_string(), JsonValue::Number(number)));
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use clap::CommandFactory;
1029
1030 fn scan_command() -> clap::Command {
1031 Cli::command()
1032 .find_subcommand("scan")
1033 .expect("scan subcommand should exist")
1034 .clone()
1035 }
1036
1037 #[test]
1038 fn test_requires_at_least_one_output_option() {
1039 let parsed = Cli::try_parse_from(["provenant", "samples"]);
1040 assert!(parsed.is_err());
1041 }
1042
1043 #[test]
1044 fn test_parses_json_pretty_output_option() {
1045 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1046 .expect("cli parse should succeed");
1047
1048 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
1049 assert_eq!(parsed.output_targets().len(), 1);
1050 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
1051 }
1052
1053 #[test]
1054 fn test_explicit_scan_subcommand_parses_scan_flags() {
1055 let parsed = Cli::try_parse_from([
1056 "provenant",
1057 "scan",
1058 "--json-pp",
1059 "scan.json",
1060 "--license",
1061 "samples",
1062 ])
1063 .expect("explicit scan subcommand should parse");
1064
1065 assert!(matches!(parsed.command, Command::Scan(_)));
1066 let scan = parsed.scan_args().expect("scan args should be present");
1067 assert_eq!(scan.output_json_pp.as_deref(), Some("scan.json"));
1068 assert!(scan.license);
1069 assert_eq!(scan.dir_path, vec!["samples"]);
1070 }
1071
1072 #[test]
1073 fn test_parses_compare_subcommand() {
1074 let parsed = Cli::try_parse_from([
1075 "provenant",
1076 "compare",
1077 "--scancode-json",
1078 "scan-a.json",
1079 "--provenant-json",
1080 "scan-b.json",
1081 "--artifact-dir",
1082 "compare-out",
1083 ])
1084 .expect("compare subcommand should parse");
1085
1086 match parsed.command {
1087 Command::Compare(args) => {
1088 assert_eq!(args.scancode_json, PathBuf::from("scan-a.json"));
1089 assert_eq!(args.provenant_json, PathBuf::from("scan-b.json"));
1090 assert_eq!(args.artifact_dir, Some(PathBuf::from("compare-out")));
1091 }
1092 other => panic!("expected compare subcommand, got {other:?}"),
1093 }
1094 }
1095
1096 #[test]
1097 fn test_parses_serve_subcommand() {
1098 let parsed = Cli::try_parse_from(["provenant", "serve", "--bind", "127.0.0.1:9090"])
1099 .expect("serve subcommand should parse");
1100
1101 match parsed.command {
1102 Command::Serve(args) => assert_eq!(args.bind, "127.0.0.1:9090"),
1103 other => panic!("expected serve subcommand, got {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_compare_subcommand_allows_default_artifact_dir() {
1109 let parsed = Cli::try_parse_from([
1110 "provenant",
1111 "compare",
1112 "--scancode-json",
1113 "scan-a.json",
1114 "--provenant-json",
1115 "scan-b.json",
1116 ])
1117 .expect("compare subcommand should allow default artifact dir");
1118
1119 match parsed.command {
1120 Command::Compare(args) => {
1121 assert_eq!(args.scancode_json, PathBuf::from("scan-a.json"));
1122 assert_eq!(args.provenant_json, PathBuf::from("scan-b.json"));
1123 assert!(args.artifact_dir.is_none());
1124 }
1125 other => panic!("expected compare subcommand, got {other:?}"),
1126 }
1127 }
1128
1129 #[test]
1130 fn test_unknown_command_like_token_is_not_rewritten_to_scan() {
1131 let parsed = Cli::try_parse_from([
1132 "provenant",
1133 "future-command",
1134 "--json-pp",
1135 "scan.json",
1136 "samples",
1137 ]);
1138
1139 let error = parsed.expect_err("unknown command-like token should fail");
1140 assert!(
1141 error
1142 .to_string()
1143 .contains("unrecognized subcommand 'future-command'")
1144 );
1145 }
1146
1147 #[test]
1148 fn test_allows_multiple_output_options_in_one_run() {
1149 let parsed = Cli::try_parse_from([
1150 "provenant",
1151 "--json",
1152 "scan.json",
1153 "--html",
1154 "report.html",
1155 "samples",
1156 ])
1157 .expect("cli parse should allow multiple outputs");
1158
1159 assert_eq!(parsed.output_targets().len(), 2);
1160 assert_eq!(parsed.output_targets()[0].format, OutputFormat::Json);
1161 assert_eq!(parsed.output_targets()[1].format, OutputFormat::Html);
1162 }
1163
1164 #[test]
1165 fn test_parses_show_attribution_subcommand() {
1166 let parsed = Cli::try_parse_from(["provenant", "show-attribution"])
1167 .expect("show-attribution subcommand should parse");
1168
1169 assert!(matches!(parsed.command, Command::ShowAttribution));
1170 }
1171
1172 #[test]
1173 fn test_legacy_show_attribution_flag_is_rejected() {
1174 let parsed = Cli::try_parse_from(["provenant", "--show-attribution"]);
1175 assert!(parsed.is_err());
1176 }
1177
1178 #[test]
1179 fn test_export_license_dataset_allows_mode_without_output_file() {
1180 let parsed = Cli::try_parse_from(["provenant", "export-license-dataset", "dataset-out"])
1181 .expect("cli parse should allow export mode without output flags");
1182
1183 match parsed.command {
1184 Command::ExportLicenseDataset(args) => assert_eq!(args.dir, "dataset-out"),
1185 other => panic!("expected export subcommand, got {other:?}"),
1186 }
1187 }
1188
1189 #[test]
1190 fn test_legacy_export_license_dataset_flag_is_rejected() {
1191 let parsed = Cli::try_parse_from(["provenant", "--export-license-dataset", "dataset-out"]);
1192 assert!(parsed.is_err());
1193 }
1194
1195 #[test]
1196 fn test_license_dataset_path_parses_for_license_scans() {
1197 let parsed = Cli::try_parse_from([
1198 "provenant",
1199 "--json-pp",
1200 "scan.json",
1201 "--license",
1202 "--license-dataset-path",
1203 "dataset-root",
1204 "samples",
1205 ])
1206 .expect("cli parse should accept custom license dataset flag");
1207
1208 assert_eq!(parsed.license_dataset_path.as_deref(), Some("dataset-root"));
1209 }
1210
1211 #[test]
1212 fn test_output_header_options_use_scancode_style_keys() {
1213 let parsed = Cli::try_parse_from([
1214 "provenant",
1215 "--json-pp",
1216 "scan.json",
1217 "--license",
1218 "--package",
1219 "--strip-root",
1220 "--paths-file",
1221 "changed-files.txt",
1222 "--ignore",
1223 "*.git*",
1224 "--ignore",
1225 "target/*",
1226 "samples",
1227 ])
1228 .expect("cli parse should succeed");
1229
1230 let options = parsed.output_header_options();
1231
1232 assert_eq!(
1233 options.get("input"),
1234 Some(&JsonValue::Array(vec![JsonValue::String(
1235 "samples".to_string()
1236 )]))
1237 );
1238 assert_eq!(
1239 options.get("--json-pp"),
1240 Some(&JsonValue::String("scan.json".to_string()))
1241 );
1242 assert_eq!(options.get("--license"), Some(&JsonValue::Bool(true)));
1243 assert_eq!(options.get("--package"), Some(&JsonValue::Bool(true)));
1244 assert_eq!(
1245 options.get("--paths-file"),
1246 Some(&JsonValue::Array(vec![JsonValue::String(
1247 "changed-files.txt".to_string()
1248 )]))
1249 );
1250 assert_eq!(options.get("--strip-root"), Some(&JsonValue::Bool(true)));
1251 assert_eq!(
1252 options.get("--ignore"),
1253 Some(&JsonValue::Array(vec![
1254 JsonValue::String("*.git*".to_string()),
1255 JsonValue::String("target/*".to_string()),
1256 ]))
1257 );
1258 assert!(!options.contains_key("--compat-mode"));
1259 }
1260
1261 #[test]
1262 fn test_compat_mode_parses_and_is_recorded_when_non_default() {
1263 let parsed = Cli::try_parse_from([
1264 "provenant",
1265 "--json-pp",
1266 "scan.json",
1267 "--copyright",
1268 "--compat-mode",
1269 "scancode",
1270 "samples",
1271 ])
1272 .expect("cli parse should succeed");
1273
1274 assert_eq!(parsed.compat_mode, CompatibilityMode::Scancode);
1275 let options = parsed.output_header_options();
1276 assert_eq!(
1277 options.get("--compat-mode"),
1278 Some(&JsonValue::String("scancode".to_string()))
1279 );
1280 }
1281
1282 #[test]
1283 fn test_output_header_options_include_license_dataset_path_when_set() {
1284 let parsed = Cli::try_parse_from([
1285 "provenant",
1286 "--json-pp",
1287 "scan.json",
1288 "--license",
1289 "--license-dataset-path",
1290 "dataset-root",
1291 "samples",
1292 ])
1293 .expect("cli parse should accept custom license dataset flag");
1294
1295 let options = parsed.output_header_options();
1296 assert_eq!(
1297 options.get("--license-dataset-path"),
1298 Some(&JsonValue::String("dataset-root".to_string()))
1299 );
1300 }
1301
1302 #[test]
1303 fn test_output_header_options_skip_defaults_and_include_non_defaults() {
1304 let default_options =
1305 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1306 .expect("default cli parse should succeed")
1307 .output_header_options();
1308 assert!(!default_options.contains_key("--timeout"));
1309 assert!(!default_options.contains_key("--processes"));
1310
1311 let custom_options = Cli::try_parse_from([
1312 "provenant",
1313 "--json-pp",
1314 "scan.json",
1315 "--timeout",
1316 "30",
1317 "--processes",
1318 "4",
1319 "samples",
1320 ])
1321 .expect("custom cli parse should succeed")
1322 .output_header_options();
1323
1324 assert_eq!(
1325 custom_options.get("--timeout"),
1326 Some(&JsonValue::Number(
1327 JsonNumber::from_f64(30.0).expect("valid number")
1328 ))
1329 );
1330 assert_eq!(
1331 custom_options.get("--processes"),
1332 Some(&JsonValue::Number(4.into()))
1333 );
1334 }
1335
1336 #[test]
1337 fn test_allows_stdout_dash_as_output_target() {
1338 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
1339 .expect("cli parse should allow stdout dash output target");
1340
1341 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
1342 }
1343
1344 #[test]
1345 fn test_debian_requires_license_copyright_and_license_text() {
1346 let missing_license_text = Cli::try_parse_from([
1347 "provenant",
1348 "--debian",
1349 "scan.copyright",
1350 "--license",
1351 "--copyright",
1352 "samples",
1353 ]);
1354 assert!(missing_license_text.is_err());
1355
1356 let parsed = Cli::try_parse_from([
1357 "provenant",
1358 "--debian",
1359 "scan.copyright",
1360 "--license",
1361 "--copyright",
1362 "--license-text",
1363 "samples",
1364 ])
1365 .expect("cli parse should accept debian output");
1366
1367 assert_eq!(parsed.output_targets().len(), 1);
1368 assert_eq!(parsed.output_targets()[0].format, OutputFormat::Debian);
1369 assert_eq!(parsed.output_debian.as_deref(), Some("scan.copyright"));
1370 }
1371
1372 #[test]
1373 fn test_debian_help_mentions_required_companion_flags() {
1374 let command = scan_command();
1375 let debian_arg = command
1376 .get_arguments()
1377 .find(|arg| arg.get_long() == Some("debian"))
1378 .expect("debian arg should exist");
1379
1380 let help = debian_arg
1381 .get_help()
1382 .expect("debian arg should have help text")
1383 .to_string();
1384
1385 assert!(help.contains("requires --license, --copyright, and --license-text"));
1386 }
1387
1388 #[test]
1389 fn test_scan_help_mentions_pdf_oxide_rust_log_escape_hatch() {
1390 let help = scan_command().render_help().to_string();
1391
1392 assert!(help.contains("RUST_LOG=pdf_oxide=warn"));
1393 assert!(help.contains("suppresses noisy pdf_oxide logs by default"));
1394 }
1395
1396 #[test]
1397 fn test_root_help_mentions_subcommands() {
1398 let help = Cli::command().render_help().to_string();
1399
1400 assert!(help.contains("scan"));
1401 assert!(help.contains("serve"));
1402 assert!(help.contains("compare"));
1403 assert!(help.contains("show-attribution"));
1404 assert!(help.contains("export-license-dataset"));
1405 }
1406
1407 #[test]
1408 fn test_root_help_mentions_non_affiliation() {
1409 let help = Cli::command().render_help().to_string();
1410
1411 assert!(help.contains("Not affiliated with, endorsed by, or sponsored by"));
1412 assert!(help.contains("ScanCode Toolkit"));
1413 }
1414
1415 #[test]
1416 fn test_parses_license_policy_flag() {
1417 let temp = tempfile::tempdir().expect("temp dir");
1418 let policy_path = temp.path().join("policy.yml");
1419 std::fs::write(&policy_path, "license_policies: []\n").expect("policy written");
1420
1421 let parsed = Cli::try_parse_from([
1422 "provenant",
1423 "--json-pp",
1424 "scan.json",
1425 "--license-policy",
1426 policy_path.to_str().expect("utf8 path"),
1427 "samples",
1428 ])
1429 .expect("cli parse should accept license-policy");
1430
1431 assert_eq!(
1432 parsed.license_policy.as_deref(),
1433 Some(policy_path.to_str().expect("utf8 path"))
1434 );
1435 }
1436
1437 #[test]
1438 fn test_rejects_invalid_license_policy_flag_value() {
1439 let temp = tempfile::tempdir().expect("temp dir");
1440 let policy_path = temp.path().join("policy.yml");
1441 std::fs::write(&policy_path, "not_license_policies: []\n").expect("policy written");
1442
1443 let parsed = Cli::try_parse_from([
1444 "provenant",
1445 "--json-pp",
1446 "scan.json",
1447 "--license-policy",
1448 policy_path.to_str().expect("utf8 path"),
1449 "samples",
1450 ]);
1451
1452 assert!(parsed.is_err());
1453 }
1454
1455 #[test]
1456 fn test_custom_template_and_output_must_be_paired() {
1457 let missing_template =
1458 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
1459 assert!(missing_template.is_err());
1460
1461 let missing_output =
1462 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
1463 assert!(missing_output.is_err());
1464 }
1465
1466 #[test]
1467 fn test_parses_processes_and_timeout_options() {
1468 let parsed = Cli::try_parse_from([
1469 "provenant",
1470 "--json-pp",
1471 "scan.json",
1472 "-n",
1473 "4",
1474 "--timeout",
1475 "30",
1476 "samples",
1477 ])
1478 .expect("cli parse should succeed");
1479
1480 assert_eq!(parsed.processes, ProcessMode::Parallel(4));
1481 assert_eq!(parsed.timeout, 30.0);
1482 }
1483
1484 #[test]
1485 fn test_strip_root_conflicts_with_full_root() {
1486 let parsed = Cli::try_parse_from([
1487 "provenant",
1488 "--json-pp",
1489 "scan.json",
1490 "--strip-root",
1491 "--full-root",
1492 "samples",
1493 ]);
1494 assert!(parsed.is_err());
1495 }
1496
1497 #[test]
1498 fn test_parses_include_and_only_findings_and_filter_clues() {
1499 let parsed = Cli::try_parse_from([
1500 "provenant",
1501 "--json-pp",
1502 "scan.json",
1503 "--include",
1504 "src/**,Cargo.toml",
1505 "--only-findings",
1506 "--filter-clues",
1507 "samples",
1508 ])
1509 .expect("cli parse should succeed");
1510
1511 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
1512 assert!(parsed.only_findings);
1513 assert!(parsed.filter_clues);
1514 }
1515
1516 #[test]
1517 fn test_parses_repeated_paths_file_flags_including_stdin_dash() {
1518 let parsed = Cli::try_parse_from([
1519 "provenant",
1520 "--json-pp",
1521 "scan.json",
1522 "--paths-file",
1523 "changed-files.txt",
1524 "--paths-file",
1525 "-",
1526 "samples",
1527 ])
1528 .expect("cli parse should accept repeated --paths-file flags");
1529
1530 assert_eq!(parsed.paths_file, vec!["changed-files.txt", "-"]);
1531 }
1532
1533 #[test]
1534 fn test_parses_ignore_author_and_holder_filters() {
1535 let parsed = Cli::try_parse_from([
1536 "provenant",
1537 "--json-pp",
1538 "scan.json",
1539 "--ignore-author",
1540 "Jane.*",
1541 "--ignore-author",
1542 ".*Bot$",
1543 "--ignore-copyright-holder",
1544 "Example Corp",
1545 "samples",
1546 ])
1547 .expect("cli parse should succeed");
1548
1549 assert_eq!(parsed.ignore_author, vec!["Jane.*", ".*Bot$"]);
1550 assert_eq!(parsed.ignore_copyright_holder, vec!["Example Corp"]);
1551 }
1552
1553 #[test]
1554 fn test_parses_ignore_alias_for_exclude_patterns() {
1555 let parsed = Cli::try_parse_from([
1556 "provenant",
1557 "--json-pp",
1558 "scan.json",
1559 "--ignore",
1560 "*.git*,target/*",
1561 "samples",
1562 ])
1563 .expect("cli parse should accept --ignore alias");
1564
1565 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
1566 }
1567
1568 #[test]
1569 fn test_quiet_conflicts_with_verbose() {
1570 let parsed = Cli::try_parse_from([
1571 "provenant",
1572 "--json-pp",
1573 "scan.json",
1574 "--quiet",
1575 "--verbose",
1576 "samples",
1577 ]);
1578 assert!(parsed.is_err());
1579 }
1580
1581 #[test]
1582 fn test_parses_from_json_and_mark_source() {
1583 let parsed = Cli::try_parse_from([
1584 "provenant",
1585 "--json-pp",
1586 "scan.json",
1587 "--from-json",
1588 "--info",
1589 "--mark-source",
1590 "sample-scan.json",
1591 ])
1592 .expect("cli parse should succeed");
1593
1594 assert!(parsed.from_json);
1595 assert!(parsed.info);
1596 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
1597 assert!(parsed.mark_source);
1598 }
1599
1600 #[test]
1601 fn test_mark_source_requires_info() {
1602 let parsed = Cli::try_parse_from([
1603 "provenant",
1604 "--json-pp",
1605 "scan.json",
1606 "--mark-source",
1607 "samples",
1608 ]);
1609
1610 assert!(parsed.is_err());
1611 }
1612
1613 #[test]
1614 fn test_parses_classify_facet_and_tallies_by_facet() {
1615 let parsed = Cli::try_parse_from([
1616 "provenant",
1617 "--json-pp",
1618 "scan.json",
1619 "--classify",
1620 "--tallies",
1621 "--facet",
1622 "dev=*.c",
1623 "--facet",
1624 "tests=*/tests/*",
1625 "--tallies-by-facet",
1626 "samples",
1627 ])
1628 .expect("cli parse should succeed");
1629
1630 assert!(parsed.classify);
1631 assert!(parsed.tallies);
1632 assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
1633 assert!(parsed.tallies_by_facet);
1634 }
1635
1636 #[test]
1637 fn test_tallies_by_facet_requires_facet_definitions() {
1638 let parsed = Cli::try_parse_from([
1639 "provenant",
1640 "--json-pp",
1641 "scan.json",
1642 "--tallies-by-facet",
1643 "samples",
1644 ]);
1645
1646 assert!(parsed.is_err());
1647 }
1648
1649 #[test]
1650 fn test_summary_requires_classify() {
1651 let parsed = Cli::try_parse_from([
1652 "provenant",
1653 "--json-pp",
1654 "scan.json",
1655 "--summary",
1656 "samples",
1657 ]);
1658
1659 assert!(parsed.is_err());
1660 }
1661
1662 #[test]
1663 fn test_tallies_key_files_requires_tallies_and_classify() {
1664 let parsed = Cli::try_parse_from([
1665 "provenant",
1666 "--json-pp",
1667 "scan.json",
1668 "--tallies-key-files",
1669 "samples",
1670 ]);
1671
1672 assert!(parsed.is_err());
1673 }
1674
1675 #[test]
1676 fn test_parses_summary_tallies_and_generated_flags() {
1677 let parsed = Cli::try_parse_from([
1678 "provenant",
1679 "--json-pp",
1680 "scan.json",
1681 "--classify",
1682 "--summary",
1683 "--license-clarity-score",
1684 "--tallies",
1685 "--tallies-key-files",
1686 "--tallies-with-details",
1687 "--generated",
1688 "samples",
1689 ])
1690 .expect("cli parse should succeed");
1691
1692 assert!(parsed.classify);
1693 assert!(parsed.summary);
1694 assert!(parsed.license_clarity_score);
1695 assert!(parsed.tallies);
1696 assert!(parsed.tallies_key_files);
1697 assert!(parsed.tallies_with_details);
1698 assert!(parsed.generated);
1699 }
1700
1701 #[test]
1702 fn test_parses_copyright_flag() {
1703 let parsed = Cli::try_parse_from([
1704 "provenant",
1705 "--json-pp",
1706 "scan.json",
1707 "--copyright",
1708 "samples",
1709 ])
1710 .expect("cli parse should succeed");
1711
1712 assert!(parsed.copyright);
1713 }
1714
1715 #[test]
1716 fn test_package_flag_defaults_to_disabled() {
1717 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1718 .expect("cli parse should succeed");
1719
1720 assert!(!parsed.package);
1721 }
1722
1723 #[test]
1724 fn test_parses_system_package_flag() {
1725 let parsed = Cli::try_parse_from([
1726 "provenant",
1727 "--json-pp",
1728 "scan.json",
1729 "--system-package",
1730 "samples",
1731 ])
1732 .expect("cli parse should succeed");
1733
1734 assert!(parsed.system_package);
1735 }
1736
1737 #[test]
1738 fn test_parses_package_in_compiled_flag() {
1739 let parsed = Cli::try_parse_from([
1740 "provenant",
1741 "--json-pp",
1742 "scan.json",
1743 "--package-in-compiled",
1744 "samples",
1745 ])
1746 .expect("cli parse should succeed");
1747
1748 assert!(parsed.package_in_compiled);
1749 }
1750
1751 #[test]
1752 fn test_parses_package_only_flag() {
1753 let parsed = Cli::try_parse_from([
1754 "provenant",
1755 "--json-pp",
1756 "scan.json",
1757 "--package-only",
1758 "samples",
1759 ])
1760 .expect("cli parse should succeed");
1761
1762 assert!(parsed.package_only);
1763 }
1764
1765 #[test]
1766 fn test_package_only_conflicts_with_upstream_incompatible_flags() {
1767 let with_license = Cli::try_parse_from([
1768 "provenant",
1769 "--json-pp",
1770 "scan.json",
1771 "--package-only",
1772 "--license",
1773 "samples",
1774 ]);
1775 assert!(with_license.is_err());
1776
1777 let with_package = Cli::try_parse_from([
1778 "provenant",
1779 "--json-pp",
1780 "scan.json",
1781 "--package-only",
1782 "--package",
1783 "samples",
1784 ]);
1785 assert!(with_package.is_err());
1786 }
1787
1788 #[test]
1789 fn test_parses_package_flag() {
1790 let parsed = Cli::try_parse_from([
1791 "provenant",
1792 "--json-pp",
1793 "scan.json",
1794 "--package",
1795 "samples",
1796 ])
1797 .expect("cli parse should succeed");
1798
1799 assert!(parsed.package);
1800 }
1801
1802 #[test]
1803 fn test_package_short_flag() {
1804 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
1805 .expect("cli parse should succeed");
1806
1807 assert!(parsed.package);
1808 }
1809
1810 #[test]
1811 fn test_parses_license_flag() {
1812 let parsed = Cli::try_parse_from([
1813 "provenant",
1814 "--json-pp",
1815 "scan.json",
1816 "--license",
1817 "samples",
1818 ])
1819 .expect("cli parse should succeed");
1820
1821 assert!(parsed.license);
1822 }
1823
1824 #[test]
1825 fn test_license_short_flag() {
1826 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
1827 .expect("cli parse should succeed");
1828
1829 assert!(parsed.license);
1830 }
1831
1832 #[test]
1833 fn test_license_text_requires_license() {
1834 let result = Cli::try_parse_from([
1835 "provenant",
1836 "--json-pp",
1837 "scan.json",
1838 "--license-text",
1839 "samples",
1840 ]);
1841 assert!(result.is_err());
1842 }
1843
1844 #[test]
1845 fn test_include_text_is_rejected() {
1846 let result = Cli::try_parse_from([
1847 "provenant",
1848 "--json-pp",
1849 "scan.json",
1850 "--license",
1851 "--include-text",
1852 "samples",
1853 ]);
1854
1855 assert!(result.is_err());
1856 }
1857
1858 #[test]
1859 fn test_license_text_diagnostics_requires_license_text() {
1860 let result = Cli::try_parse_from([
1861 "provenant",
1862 "--json-pp",
1863 "scan.json",
1864 "--license",
1865 "--license-text-diagnostics",
1866 "samples",
1867 ]);
1868
1869 assert!(result.is_err());
1870 }
1871
1872 #[test]
1873 fn test_parses_license_text_and_diagnostics_flags() {
1874 let parsed = Cli::try_parse_from([
1875 "provenant",
1876 "--json-pp",
1877 "scan.json",
1878 "--license",
1879 "--license-text",
1880 "--license-text-diagnostics",
1881 "--license-diagnostics",
1882 "--unknown-licenses",
1883 "samples",
1884 ])
1885 .expect("cli parse should succeed");
1886
1887 assert!(parsed.license_text);
1888 assert!(parsed.license_text_diagnostics);
1889 assert!(parsed.license_diagnostics);
1890 assert!(parsed.unknown_licenses);
1891 assert_eq!(parsed.license_score, 0);
1892 assert_eq!(parsed.license_url_template, DEFAULT_LICENSEDB_URL_TEMPLATE);
1893 }
1894
1895 #[test]
1896 fn test_parses_no_sequence_matching_flag() {
1897 let parsed = Cli::try_parse_from([
1898 "provenant",
1899 "--json-pp",
1900 "scan.json",
1901 "--license",
1902 "--no-sequence-matching",
1903 "samples",
1904 ])
1905 .expect("cli parse should succeed");
1906
1907 assert!(parsed.no_sequence_matching);
1908 }
1909
1910 #[test]
1911 fn test_license_score_requires_license() {
1912 let result = Cli::try_parse_from([
1913 "provenant",
1914 "--json-pp",
1915 "scan.json",
1916 "--license-score",
1917 "70",
1918 "samples",
1919 ]);
1920
1921 assert!(result.is_err());
1922 }
1923
1924 #[test]
1925 fn test_license_url_template_requires_license() {
1926 let result = Cli::try_parse_from([
1927 "provenant",
1928 "--json-pp",
1929 "scan.json",
1930 "--license-url-template",
1931 "https://example.com/licenses/{}/",
1932 "samples",
1933 ]);
1934
1935 assert!(result.is_err());
1936 }
1937
1938 #[test]
1939 fn test_parses_license_score_and_url_template_flags() {
1940 let parsed = Cli::try_parse_from([
1941 "provenant",
1942 "--json-pp",
1943 "scan.json",
1944 "--license",
1945 "--license-score",
1946 "70",
1947 "--license-url-template",
1948 "https://example.com/licenses/{}/",
1949 "samples",
1950 ])
1951 .expect("cli parse should succeed");
1952
1953 assert_eq!(parsed.license_score, 70);
1954 assert_eq!(
1955 parsed.license_url_template,
1956 "https://example.com/licenses/{}/"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_rejects_license_score_above_range() {
1962 let result = Cli::try_parse_from([
1963 "provenant",
1964 "--json-pp",
1965 "scan.json",
1966 "--license",
1967 "--license-score",
1968 "101",
1969 "samples",
1970 ]);
1971
1972 assert!(result.is_err());
1973 }
1974
1975 #[test]
1976 fn test_license_references_requires_license() {
1977 let result = Cli::try_parse_from([
1978 "provenant",
1979 "--json-pp",
1980 "scan.json",
1981 "--license-references",
1982 "samples",
1983 ]);
1984
1985 assert!(result.is_err());
1986 }
1987
1988 #[test]
1989 fn test_parses_license_references_flag() {
1990 let parsed = Cli::try_parse_from([
1991 "provenant",
1992 "--json-pp",
1993 "scan.json",
1994 "--license",
1995 "--license-references",
1996 "samples",
1997 ])
1998 .expect("cli parse should succeed");
1999
2000 assert!(parsed.license_references);
2001 }
2002
2003 #[test]
2004 fn test_include_text_alias_is_not_supported() {
2005 let result = Cli::try_parse_from([
2006 "provenant",
2007 "--json-pp",
2008 "scan.json",
2009 "--license",
2010 "--include-text",
2011 "samples",
2012 ]);
2013
2014 assert!(result.is_err());
2015 }
2016
2017 #[test]
2018 fn test_parses_short_scan_flags() {
2019 let parsed = Cli::try_parse_from([
2020 "provenant",
2021 "--json-pp",
2022 "scan.json",
2023 "-c",
2024 "-e",
2025 "-u",
2026 "samples",
2027 ])
2028 .expect("cli parse should support short scan flags");
2029
2030 assert!(parsed.copyright);
2031 assert!(parsed.email);
2032 assert!(parsed.url);
2033 }
2034
2035 #[test]
2036 fn test_parses_processes_compat_values_zero_and_minus_one() {
2037 let zero =
2038 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
2039 .expect("cli parse should accept processes=0");
2040 assert_eq!(zero.processes, ProcessMode::SequentialWithTimeouts);
2041
2042 let parsed =
2043 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
2044 .expect("cli parse should accept processes=-1");
2045 assert_eq!(parsed.processes, ProcessMode::SequentialWithoutTimeouts);
2046 }
2047
2048 #[test]
2049 fn test_parses_cache_flags() {
2050 let parsed = Cli::try_parse_from([
2051 "provenant",
2052 "--json-pp",
2053 "scan.json",
2054 "--cache-dir",
2055 "/tmp/sc-cache",
2056 "--cache-clear",
2057 "--max-in-memory",
2058 "5000",
2059 "samples",
2060 ])
2061 .expect("cli parse should accept cache flags");
2062
2063 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
2064 assert!(parsed.cache_clear);
2065 assert!(!parsed.incremental);
2066 assert_eq!(parsed.max_in_memory, MemoryMode::Limit(5000));
2067 }
2068
2069 #[test]
2070 fn test_parses_incremental_flag() {
2071 let parsed = Cli::try_parse_from([
2072 "provenant",
2073 "--json-pp",
2074 "scan.json",
2075 "--incremental",
2076 "samples",
2077 ])
2078 .expect("cli parse should accept incremental flag");
2079
2080 assert!(parsed.incremental);
2081 }
2082
2083 #[test]
2084 fn test_parses_license_cache_control_flags() {
2085 let parsed = Cli::try_parse_from([
2086 "provenant",
2087 "--json-pp",
2088 "scan.json",
2089 "--license",
2090 "--reindex",
2091 "--no-license-index-cache",
2092 "samples",
2093 ])
2094 .expect("cli parse should accept license cache flags");
2095
2096 assert!(parsed.license);
2097 assert!(parsed.reindex);
2098 assert!(parsed.no_license_index_cache);
2099 }
2100
2101 #[test]
2102 fn test_max_in_memory_defaults_and_special_values() {
2103 let default_parsed =
2104 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
2105 .expect("default max-in-memory should parse");
2106 assert_eq!(default_parsed.max_in_memory, MemoryMode::Limit(10000));
2107
2108 let disk_only = Cli::try_parse_from([
2109 "provenant",
2110 "--json-pp",
2111 "scan.json",
2112 "--max-in-memory",
2113 "-1",
2114 "samples",
2115 ])
2116 .expect("-1 should parse");
2117 assert_eq!(disk_only.max_in_memory, MemoryMode::StreamUnlimited);
2118
2119 let unlimited = Cli::try_parse_from([
2120 "provenant",
2121 "--json-pp",
2122 "scan.json",
2123 "--max-in-memory",
2124 "0",
2125 "samples",
2126 ])
2127 .expect("0 should parse");
2128 assert_eq!(unlimited.max_in_memory, MemoryMode::CollectFirst);
2129 }
2130
2131 #[test]
2132 fn test_max_in_memory_rejects_values_below_negative_one() {
2133 let result = Cli::try_parse_from([
2134 "provenant",
2135 "--json-pp",
2136 "scan.json",
2137 "--max-in-memory",
2138 "-2",
2139 "samples",
2140 ]);
2141
2142 assert!(result.is_err());
2143 }
2144
2145 #[test]
2146 fn test_max_depth_default_matches_reference_behavior() {
2147 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
2148 .expect("cli parse should succeed");
2149
2150 assert_eq!(parsed.max_depth, 0);
2151 }
2152}