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    /// Force rebuild of the license index cache, ignoring any existing cache.
301    #[arg(long)]
302    pub reindex: bool,
303
304    /// Build the license index in memory for this run without reading or writing persistent cache files.
305    #[arg(long = "no-license-index-cache")]
306    pub no_license_index_cache: bool,
307
308    /// Include matched text in license detection output
309    #[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    /// Evaluate file license detections against a YAML license policy file.
372    #[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    /// Scan input for licenses
398    #[arg(short = 'l', long)]
399    pub license: bool,
400
401    #[arg(short = 'c', long)]
402    pub copyright: bool,
403
404    /// Scan input for email addresses
405    #[arg(short = 'e', long)]
406    pub email: bool,
407
408    /// Report only up to INT emails found in a file. Use 0 for no limit.
409    #[arg(long, default_value_t = 50, requires = "email")]
410    pub max_email: usize,
411
412    /// Scan input for URLs
413    #[arg(short = 'u', long)]
414    pub url: bool,
415
416    /// Report only up to INT URLs found in a file. Use 0 for no limit.
417    #[arg(long, default_value_t = 50, requires = "url")]
418    pub max_url: usize,
419
420    /// Show attribution notices for embedded license detection data
421    #[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}