Skip to main content

provenant/cli/
mod.rs

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