Skip to main content

provenant/
cli.rs

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