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