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