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