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