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