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