Skip to main content

provenant/cli/
mod.rs

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