Skip to main content

provenant/cli/
mod.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7mod run;
8
9pub use run::run;
10
11use clap::{ArgGroup, Args, Parser, Subcommand};
12use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
13use std::ffi::OsString;
14use std::fs;
15#[cfg(test)]
16use std::ops::Deref;
17use std::path::{Path, PathBuf};
18use yaml_serde::Value as YamlValue;
19
20use crate::app::request::{InputMode, OutputTarget, ScanRequest};
21use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
22use crate::output::OutputFormat;
23use crate::scanner::MemoryMode;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ProcessMode {
27    Parallel(usize),
28    SequentialWithTimeouts,
29    SequentialWithoutTimeouts,
30}
31
32impl Default for ProcessMode {
33    fn default() -> Self {
34        let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
35        if cpus > 1 {
36            ProcessMode::Parallel(cpus - 1)
37        } else {
38            ProcessMode::Parallel(1)
39        }
40    }
41}
42
43impl ProcessMode {
44    fn default_value() -> Self {
45        let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
46        if cpus > 1 {
47            ProcessMode::Parallel(cpus - 1)
48        } else {
49            ProcessMode::Parallel(1)
50        }
51    }
52
53    pub fn to_i32(self) -> i32 {
54        match self {
55            ProcessMode::Parallel(n) => n as i32,
56            ProcessMode::SequentialWithTimeouts => 0,
57            ProcessMode::SequentialWithoutTimeouts => -1,
58        }
59    }
60}
61
62impl std::fmt::Display for ProcessMode {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{}", self.to_i32())
65    }
66}
67
68fn parse_processes(value: &str) -> Result<ProcessMode, String> {
69    let parsed: i32 = value
70        .parse()
71        .map_err(|e| format!("invalid integer for --processes: {e}"))?;
72    if parsed > 0 {
73        Ok(ProcessMode::Parallel(
74            u32::try_from(parsed).unwrap() as usize
75        ))
76    } else if parsed == 0 {
77        Ok(ProcessMode::SequentialWithTimeouts)
78    } else {
79        Ok(ProcessMode::SequentialWithoutTimeouts)
80    }
81}
82
83const 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).";
84const CLI_ABOUT: &str = "Rust scanner for ScanCode-compatible workflows. Not affiliated with, endorsed by, or sponsored by ScanCode Toolkit, AboutCode, or nexB Inc.";
85const 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.";
86
87fn parse_license_policy_arg(value: &str) -> Result<String, String> {
88    let policy_path = Path::new(value);
89    let metadata = fs::metadata(policy_path).map_err(|err| {
90        format!(
91            "Failed to read license policy file {:?}: {err}",
92            policy_path
93        )
94    })?;
95    if !metadata.is_file() {
96        return Err(format!(
97            "License policy path {:?} is not a regular file",
98            policy_path
99        ));
100    }
101
102    let policy_text = fs::read_to_string(policy_path).map_err(|err| {
103        format!(
104            "Failed to read license policy file {:?}: {err}",
105            policy_path
106        )
107    })?;
108    if policy_text.trim().is_empty() {
109        return Err(format!("License policy file {:?} is empty", policy_path));
110    }
111
112    let policy_value: YamlValue = yaml_serde::from_str(&policy_text).map_err(|err| {
113        format!(
114            "Failed to parse license policy file {:?}: {err}",
115            policy_path
116        )
117    })?;
118    let has_license_policies = policy_value
119        .as_mapping()
120        .and_then(|mapping| mapping.get(YamlValue::String("license_policies".to_string())))
121        .is_some();
122    if !has_license_policies {
123        return Err(format!(
124            "License policy file {:?} is missing a 'license_policies' attribute",
125            policy_path
126        ));
127    }
128
129    Ok(value.to_string())
130}
131
132#[derive(Parser, Debug)]
133#[command(
134    author = "The Provenant contributors",
135    version = crate::version::BUILD_VERSION,
136    long_version = crate::version::build_long_version(),
137    after_help = PDF_OXIDE_LOG_HELP,
138    about = CLI_ABOUT,
139    long_about = CLI_LONG_ABOUT,
140    arg_required_else_help = true,
141    subcommand_required = true
142)]
143pub struct Cli {
144    #[command(subcommand)]
145    pub command: Command,
146}
147
148#[derive(Subcommand, Debug, Clone)]
149pub enum Command {
150    /// Scan files or existing ScanCode-style JSON inputs.
151    Scan(Box<ScanArgs>),
152    /// Run the long-lived HTTP service.
153    Serve(ServeArgs),
154    /// Compare ScanCode and Provenant JSON outputs to review migration-confidence deltas.
155    Compare(CompareArgs),
156    /// Show attribution notices for embedded license detection data.
157    ShowAttribution,
158    /// Export the effective built-in license dataset to DIR and exit.
159    ExportLicenseDataset(ExportLicenseDatasetArgs),
160}
161
162/// Requested output verbosity for a subcommand.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub enum Verbosity {
165    /// Warnings and errors only.
166    Quiet,
167    /// Progress and informational output (the default).
168    #[default]
169    Normal,
170    /// Everything, including debug-level diagnostics.
171    Verbose,
172}
173
174impl Verbosity {
175    /// Default global log level for this verbosity. `RUST_LOG` still overrides.
176    pub fn log_level(self) -> log::LevelFilter {
177        match self {
178            Self::Quiet => log::LevelFilter::Warn,
179            Self::Normal => log::LevelFilter::Info,
180            Self::Verbose => log::LevelFilter::Debug,
181        }
182    }
183}
184
185/// Reusable `-q/-v` verbosity flags, flattened into subcommands other than
186/// `scan` (which has its own richer progress modes tied to its output header).
187#[derive(Args, Debug, Clone, Default)]
188pub struct VerbosityFlags {
189    /// Suppress progress and informational output; show warnings and errors only.
190    #[arg(short = 'q', long = "quiet", conflicts_with = "verbose")]
191    pub quiet: bool,
192
193    /// Emit verbose diagnostics, including debug-level logging.
194    #[arg(short = 'v', long = "verbose", conflicts_with = "quiet")]
195    pub verbose: bool,
196}
197
198impl VerbosityFlags {
199    pub fn verbosity(&self) -> Verbosity {
200        if self.quiet {
201            Verbosity::Quiet
202        } else if self.verbose {
203            Verbosity::Verbose
204        } else {
205            Verbosity::Normal
206        }
207    }
208}
209
210#[derive(Args, Debug, Clone)]
211pub struct CompareArgs {
212    /// Path to an existing ScanCode JSON output file.
213    #[arg(long = "scancode-json", value_name = "PATH")]
214    pub scancode_json: PathBuf,
215
216    /// Path to an existing Provenant JSON output file.
217    #[arg(long = "provenant-json", value_name = "PATH")]
218    pub provenant_json: PathBuf,
219
220    /// Directory where comparison artifacts should be written. Defaults to a timestamped directory in the current working directory.
221    #[arg(long = "artifact-dir", value_name = "DIR")]
222    pub artifact_dir: Option<PathBuf>,
223
224    #[command(flatten)]
225    pub verbosity: VerbosityFlags,
226}
227
228#[derive(Args, Debug, Clone)]
229pub struct ExportLicenseDatasetArgs {
230    #[arg(value_name = "DIR")]
231    pub dir: String,
232
233    #[command(flatten)]
234    pub verbosity: VerbosityFlags,
235}
236
237/// Minimum license-policy compliance severity that fails the build (`--fail-on`).
238#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
239pub enum FailOn {
240    Warning,
241    Error,
242}
243
244impl FailOn {
245    /// Lowest `ComplianceAlert` severity that should trip the gate.
246    pub fn threshold(self) -> crate::models::ComplianceAlert {
247        match self {
248            Self::Warning => crate::models::ComplianceAlert::Warning,
249            Self::Error => crate::models::ComplianceAlert::Error,
250        }
251    }
252}
253
254#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum CompatibilityMode {
256    #[default]
257    Native,
258    Scancode,
259}
260
261impl CompatibilityMode {
262    fn as_str(self) -> &'static str {
263        match self {
264            Self::Native => "native",
265            Self::Scancode => "scancode",
266        }
267    }
268}
269
270#[derive(Args, Debug, Clone)]
271pub struct ServeArgs {
272    /// Bind the service shell to HOST:PORT.
273    #[arg(long = "bind", value_name = "ADDR", default_value = "127.0.0.1:8080")]
274    pub bind: String,
275
276    /// Allow paths, URL, and repository inputs when bound beyond localhost.
277    #[arg(long = "allow-privileged-inputs")]
278    pub allow_privileged_inputs: bool,
279
280    #[command(flatten)]
281    pub verbosity: VerbosityFlags,
282}
283
284#[derive(Args, Debug, Clone)]
285#[command(
286    group(
287        ArgGroup::new("output")
288            .required(true)
289            .multiple(true)
290            .args([
291                "output_json",
292                "output_json_pp",
293                "output_json_lines",
294                "output_yaml",
295                "output_debian",
296                "output_html",
297                "output_spdx_tv",
298                "output_spdx_rdf",
299                "output_cyclonedx",
300                "output_cyclonedx_xml",
301                "custom_output"
302            ])
303    ),
304    after_help = PDF_OXIDE_LOG_HELP
305)]
306pub struct ScanArgs {
307    /// File or directory paths to scan
308    #[arg(required = false)]
309    pub dir_path: Vec<String>,
310
311    /// Write scan output as compact JSON to FILE
312    #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
313    pub output_json: Option<String>,
314
315    /// Write scan output as pretty-printed JSON to FILE
316    #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
317    pub output_json_pp: Option<String>,
318
319    /// Write scan output as JSON Lines to FILE
320    #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
321    pub output_json_lines: Option<String>,
322
323    /// Write scan output as YAML to FILE
324    #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
325    pub output_yaml: Option<String>,
326
327    /// Write scan output in machine-readable Debian copyright format to FILE (requires --license, --copyright, and --license-text)
328    #[arg(
329        long = "debian",
330        value_name = "FILE",
331        allow_hyphen_values = true,
332        requires_all = ["copyright", "license", "license_text"]
333    )]
334    pub output_debian: Option<String>,
335
336    /// Write scan output as HTML report to FILE
337    #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
338    pub output_html: Option<String>,
339
340    /// Write scan output as SPDX tag/value to FILE
341    #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
342    pub output_spdx_tv: Option<String>,
343
344    /// Write scan output as SPDX RDF/XML to FILE
345    #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
346    pub output_spdx_rdf: Option<String>,
347
348    /// Write scan output as CycloneDX JSON to FILE
349    #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
350    pub output_cyclonedx: Option<String>,
351
352    /// Write scan output as CycloneDX XML to FILE
353    #[arg(
354        long = "cyclonedx-xml",
355        value_name = "FILE",
356        allow_hyphen_values = true
357    )]
358    pub output_cyclonedx_xml: Option<String>,
359
360    /// Write license-policy violations as SARIF 2.1.0 to FILE (needs --license-policy)
361    #[arg(long = "sarif", value_name = "FILE", allow_hyphen_values = true)]
362    pub output_sarif: Option<String>,
363
364    /// Write scan output to FILE formatted with the custom template
365    #[arg(
366        long = "custom-output",
367        value_name = "FILE",
368        requires = "custom_template",
369        allow_hyphen_values = true
370    )]
371    pub custom_output: Option<String>,
372
373    /// Use this template FILE with --custom-output
374    #[arg(
375        long = "custom-template",
376        value_name = "FILE",
377        requires = "custom_output"
378    )]
379    pub custom_template: Option<String>,
380
381    /// Maximum recursion depth (0 means no depth limit)
382    #[arg(short, long, default_value = "0")]
383    pub max_depth: usize,
384
385    #[arg(short = 'n', long, default_value_t = ProcessMode::default_value(), value_parser = parse_processes, allow_hyphen_values = true)]
386    pub processes: ProcessMode,
387
388    #[arg(long, default_value_t = 120.0)]
389    pub timeout: f64,
390
391    #[arg(short, long, conflicts_with = "verbose")]
392    pub quiet: bool,
393
394    /// Emit verbose diagnostics, including the per-phase timing breakdown and, on a TTY, per-file scan detail.
395    #[arg(short, long, conflicts_with = "quiet")]
396    pub verbose: bool,
397
398    #[arg(long, conflicts_with = "full_root")]
399    pub strip_root: bool,
400
401    #[arg(long, conflicts_with = "strip_root")]
402    pub full_root: bool,
403
404    /// Exclude patterns (ScanCode-compatible alias: --ignore)
405    #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
406    pub exclude: Vec<String>,
407
408    /// Include files matching PATTERN. Use `**` when you want recursion across directories.
409    #[arg(long, value_delimiter = ',')]
410    pub include: Vec<String>,
411
412    /// Read selected scan paths from FILE (or '-' for stdin), relative to the explicit scan root.
413    #[arg(long = "paths-file", value_name = "FILE", allow_hyphen_values = true)]
414    pub paths_file: Vec<String>,
415
416    #[arg(long = "cache-dir", value_name = "PATH")]
417    pub cache_dir: Option<String>,
418
419    #[arg(long = "cache-clear")]
420    pub cache_clear: bool,
421
422    #[arg(long = "incremental")]
423    pub incremental: bool,
424
425    /// Trust size + mtime for incremental reuse, skipping the content re-hash of
426    /// unchanged files. Speeds up warm incremental re-scans at the cost of
427    /// missing the rare edit that keeps the same size and mtime tick. Such a miss
428    /// is not permanent: the next scan without this flag re-hashes and detects it.
429    /// Default off keeps the paranoid full-hash check so scans stay reproducible.
430    #[arg(long = "cache-trust-mtime", requires = "incremental")]
431    pub cache_trust_mtime: bool,
432
433    /// Maximum number of file and directory scan details kept in memory during
434    /// the in-scan file-processing window before the rest spill to disk.
435    /// Use 0 for unlimited memory or -1 for disk-only spill during the scan.
436    /// This bounds only the in-scan working set, NOT total or peak process
437    /// memory: assembly, summary, and output reconstitute the full result set
438    /// after the scan, so peak RSS is not bounded by this flag.
439    #[arg(
440        long = "max-in-memory",
441        value_name = "INT",
442        default_value_t = MemoryMode::Limit(10000),
443        value_parser = parse_max_in_memory,
444        allow_hyphen_values = true
445    )]
446    pub max_in_memory: MemoryMode,
447
448    /// Collect file information such as checksums, type hints, and source/script flags.
449    #[arg(short = 'i', long)]
450    pub info: bool,
451
452    /// Load one or more existing ScanCode-style JSON scans instead of rescanning inputs.
453    #[arg(long)]
454    pub from_json: bool,
455
456    /// Scan input for application package and dependency manifests, lockfiles and related data
457    #[arg(short = 'p', long)]
458    pub package: bool,
459
460    /// Select a compatibility bundle for intentional Provenant-vs-ScanCode behavior differences.
461    #[arg(
462        long = "compat-mode",
463        visible_alias = "compat",
464        value_enum,
465        default_value_t = CompatibilityMode::Native
466    )]
467    pub compat_mode: CompatibilityMode,
468
469    /// Scan input for installed system package databases (RPM, dpkg, apk, etc.)
470    #[arg(long = "system-package")]
471    pub system_package: bool,
472
473    /// Scan supported compiled Go and Rust binaries for embedded package metadata.
474    #[arg(long = "package-in-compiled")]
475    pub package_in_compiled: bool,
476
477    /// Scan for system and application package data and skip license/copyright detection and top-level package creation.
478    #[arg(
479        long = "package-only",
480        conflicts_with_all = ["license", "summary", "package", "system_package"]
481    )]
482    pub package_only: bool,
483
484    /// Disable package assembly (merging related manifest/lockfiles into packages)
485    #[arg(long)]
486    pub no_assemble: bool,
487
488    /// Path to a custom license dataset root containing manifest.json, rules/, and licenses/.
489    /// If not specified, uses the built-in embedded license index.
490    #[arg(
491        long = "license-dataset-path",
492        value_name = "PATH",
493        requires = "license"
494    )]
495    pub license_dataset_path: Option<String>,
496
497    /// Force rebuild of the license index cache, ignoring any existing cache.
498    #[arg(long)]
499    pub reindex: bool,
500
501    /// Build the license index in memory for this run without reading or writing persistent cache files.
502    #[arg(long = "no-license-index-cache")]
503    pub no_license_index_cache: bool,
504
505    /// Include matched text in license detection output
506    #[arg(long = "license-text", requires = "license")]
507    pub license_text: bool,
508
509    #[arg(long = "license-text-diagnostics", requires = "license_text")]
510    pub license_text_diagnostics: bool,
511
512    #[arg(long = "license-diagnostics", requires = "license")]
513    pub license_diagnostics: bool,
514
515    #[arg(long = "unknown-licenses", requires = "license")]
516    pub unknown_licenses: bool,
517
518    /// Disable approximate sequence matching during `--license` detection.
519    #[arg(long = "no-sequence-matching", requires = "license")]
520    pub no_sequence_matching: bool,
521
522    #[arg(
523        long = "license-score",
524        default_value_t = 0,
525        requires = "license",
526        value_parser = clap::value_parser!(u8).range(0..=100)
527    )]
528    pub license_score: u8,
529
530    #[arg(
531        long = "license-url-template",
532        default_value = DEFAULT_LICENSEDB_URL_TEMPLATE,
533        requires = "license"
534    )]
535    pub license_url_template: String,
536
537    #[arg(long)]
538    pub filter_clues: bool,
539
540    #[arg(
541        long = "ignore-author",
542        value_name = "PATTERN",
543        help = "Ignore a file and all its findings if an author matches the regex PATTERN"
544    )]
545    pub ignore_author: Vec<String>,
546
547    #[arg(
548        long = "ignore-copyright-holder",
549        value_name = "PATTERN",
550        help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
551    )]
552    pub ignore_copyright_holder: Vec<String>,
553
554    #[arg(long)]
555    pub only_findings: bool,
556
557    #[arg(long, requires = "info")]
558    pub mark_source: bool,
559
560    #[arg(long)]
561    pub classify: bool,
562
563    #[arg(long, requires = "classify")]
564    pub summary: bool,
565
566    #[arg(long = "license-clarity-score", requires = "classify")]
567    pub license_clarity_score: bool,
568
569    #[arg(long = "license-references", requires = "license")]
570    pub license_references: bool,
571
572    /// Evaluate file license detections against a YAML license policy file.
573    #[arg(
574        long = "license-policy",
575        value_name = "FILE",
576        value_parser = parse_license_policy_arg
577    )]
578    pub license_policy: Option<String>,
579
580    /// Exit non-zero (code 3) when a scanned file matches a license policy whose
581    /// compliance_alert is at or above this level. Requires --license-policy.
582    #[arg(long = "fail-on", value_enum, requires = "license_policy")]
583    pub fail_on: Option<FailOn>,
584
585    #[arg(long)]
586    pub tallies: bool,
587
588    #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
589    pub tallies_key_files: bool,
590
591    #[arg(long = "tallies-with-details")]
592    pub tallies_with_details: bool,
593
594    #[arg(long = "facet", value_name = "<facet>=<pattern>")]
595    pub facet: Vec<String>,
596
597    #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
598    pub tallies_by_facet: bool,
599
600    #[arg(long)]
601    pub generated: bool,
602
603    /// Scan input for licenses
604    #[arg(short = 'l', long)]
605    pub license: bool,
606
607    #[arg(short = 'c', long)]
608    pub copyright: bool,
609
610    /// Scan input for email addresses
611    #[arg(short = 'e', long)]
612    pub email: bool,
613
614    /// Report only up to INT emails found in a file. Use 0 for no limit.
615    #[arg(long, default_value_t = 50, requires = "email")]
616    pub max_email: usize,
617
618    /// Scan input for URLs
619    #[arg(short = 'u', long)]
620    pub url: bool,
621
622    /// Report only up to INT URLs found in a file. Use 0 for no limit.
623    #[arg(long, default_value_t = 50, requires = "url")]
624    pub max_url: usize,
625}
626
627impl Cli {
628    pub fn parse() -> Self {
629        <Self as Parser>::parse_from(rewrite_args_for_default_scan(std::env::args_os()))
630    }
631
632    pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
633    where
634        I: IntoIterator<Item = T>,
635        T: Into<OsString>,
636    {
637        <Self as Parser>::try_parse_from(rewrite_args_for_default_scan(itr))
638    }
639
640    pub(crate) fn scan_args(&self) -> Option<&ScanArgs> {
641        match &self.command {
642            Command::Scan(scan_args) => Some(scan_args.as_ref()),
643            Command::Serve(_)
644            | Command::Compare(_)
645            | Command::ShowAttribution
646            | Command::ExportLicenseDataset(_) => None,
647        }
648    }
649}
650
651#[cfg(test)]
652impl Deref for Cli {
653    type Target = ScanArgs;
654
655    fn deref(&self) -> &Self::Target {
656        self.scan_args()
657            .expect("scan arguments are only available for the scan command")
658    }
659}
660
661fn rewrite_args_for_default_scan<I, T>(itr: I) -> Vec<OsString>
662where
663    I: IntoIterator<Item = T>,
664    T: Into<OsString>,
665{
666    let mut args: Vec<OsString> = itr.into_iter().map(Into::into).collect();
667    if args.len() <= 1 {
668        return args;
669    }
670
671    let first = args[1].to_string_lossy();
672    if matches!(
673        first.as_ref(),
674        "scan"
675            | "serve"
676            | "compare"
677            | "show-attribution"
678            | "export-license-dataset"
679            | "help"
680            | "-h"
681            | "--help"
682            | "-V"
683            | "--version"
684    ) {
685        return args;
686    }
687
688    if first.starts_with('-') || Path::new(first.as_ref()).exists() {
689        args.insert(1, OsString::from("scan"));
690    }
691
692    args
693}
694
695fn parse_max_in_memory(value: &str) -> Result<MemoryMode, String> {
696    let parsed = value
697        .parse::<i64>()
698        .map_err(|_| format!("invalid integer value: {value}"))?;
699    if parsed < -1 {
700        return Err("--max-in-memory must be -1, 0, or a positive integer".to_string());
701    }
702    match parsed {
703        -1 => Ok(MemoryMode::StreamUnlimited),
704        0 => Ok(MemoryMode::CollectFirst),
705        n if n > 0 => Ok(MemoryMode::Limit(usize::try_from(n).unwrap_or(usize::MAX))),
706        _ => Ok(MemoryMode::CollectFirst),
707    }
708}
709
710impl ScanArgs {
711    pub(crate) fn output_targets(&self) -> Vec<OutputTarget> {
712        let mut targets = Vec::new();
713
714        if let Some(file) = &self.output_json {
715            targets.push(OutputTarget {
716                format: OutputFormat::Json,
717                file: file.clone(),
718                custom_template: None,
719            });
720        }
721
722        if let Some(file) = &self.output_json_pp {
723            targets.push(OutputTarget {
724                format: OutputFormat::JsonPretty,
725                file: file.clone(),
726                custom_template: None,
727            });
728        }
729
730        if let Some(file) = &self.output_json_lines {
731            targets.push(OutputTarget {
732                format: OutputFormat::JsonLines,
733                file: file.clone(),
734                custom_template: None,
735            });
736        }
737
738        if let Some(file) = &self.output_yaml {
739            targets.push(OutputTarget {
740                format: OutputFormat::Yaml,
741                file: file.clone(),
742                custom_template: None,
743            });
744        }
745
746        if let Some(file) = &self.output_debian {
747            targets.push(OutputTarget {
748                format: OutputFormat::Debian,
749                file: file.clone(),
750                custom_template: None,
751            });
752        }
753
754        if let Some(file) = &self.output_html {
755            targets.push(OutputTarget {
756                format: OutputFormat::Html,
757                file: file.clone(),
758                custom_template: None,
759            });
760        }
761
762        if let Some(file) = &self.output_spdx_tv {
763            targets.push(OutputTarget {
764                format: OutputFormat::SpdxTv,
765                file: file.clone(),
766                custom_template: None,
767            });
768        }
769
770        if let Some(file) = &self.output_spdx_rdf {
771            targets.push(OutputTarget {
772                format: OutputFormat::SpdxRdf,
773                file: file.clone(),
774                custom_template: None,
775            });
776        }
777
778        if let Some(file) = &self.output_cyclonedx {
779            targets.push(OutputTarget {
780                format: OutputFormat::CycloneDxJson,
781                file: file.clone(),
782                custom_template: None,
783            });
784        }
785
786        if let Some(file) = &self.output_cyclonedx_xml {
787            targets.push(OutputTarget {
788                format: OutputFormat::CycloneDxXml,
789                file: file.clone(),
790                custom_template: None,
791            });
792        }
793
794        if let Some(file) = &self.output_sarif {
795            targets.push(OutputTarget {
796                format: OutputFormat::Sarif,
797                file: file.clone(),
798                custom_template: None,
799            });
800        }
801
802        if let Some(file) = &self.custom_output {
803            targets.push(OutputTarget {
804                format: OutputFormat::CustomTemplate,
805                file: file.clone(),
806                custom_template: self.custom_template.clone(),
807            });
808        }
809
810        targets
811    }
812
813    pub(crate) fn output_header_options(&self) -> JsonMap<String, JsonValue> {
814        let mut options = JsonMap::new();
815        if !self.dir_path.is_empty() {
816            options.insert(
817                "input".to_string(),
818                JsonValue::Array(
819                    self.dir_path
820                        .iter()
821                        .cloned()
822                        .map(JsonValue::String)
823                        .collect(),
824                ),
825            );
826        }
827
828        let mut flags = Vec::new();
829
830        push_string_option(&mut flags, "--cache-dir", self.cache_dir.as_ref());
831        push_bool_option(&mut flags, "--cache-clear", self.cache_clear);
832        push_bool_option(&mut flags, "--cache-trust-mtime", self.cache_trust_mtime);
833        push_bool_option(&mut flags, "--classify", self.classify);
834        push_string_option(&mut flags, "--custom-output", self.custom_output.as_ref());
835        push_string_option(
836            &mut flags,
837            "--custom-template",
838            self.custom_template.as_ref(),
839        );
840        push_bool_option(&mut flags, "--copyright", self.copyright);
841        if self.compat_mode != CompatibilityMode::Native {
842            flags.push((
843                "--compat-mode".to_string(),
844                JsonValue::String(self.compat_mode.as_str().to_string()),
845            ));
846        }
847        push_string_option(&mut flags, "--cyclonedx", self.output_cyclonedx.as_ref());
848        push_string_option(
849            &mut flags,
850            "--cyclonedx-xml",
851            self.output_cyclonedx_xml.as_ref(),
852        );
853        push_string_option(&mut flags, "--debian", self.output_debian.as_ref());
854        push_bool_option(&mut flags, "--email", self.email);
855        push_array_option(&mut flags, "--facet", &self.facet);
856        push_bool_option(&mut flags, "--filter-clues", self.filter_clues);
857        push_bool_option(&mut flags, "--from-json", self.from_json);
858        push_bool_option(&mut flags, "--full-root", self.full_root);
859        push_bool_option(&mut flags, "--generated", self.generated);
860        push_string_option(&mut flags, "--html", self.output_html.as_ref());
861        push_array_option(&mut flags, "--ignore", &self.exclude);
862        push_array_option(&mut flags, "--ignore-author", &self.ignore_author);
863        push_array_option(
864            &mut flags,
865            "--ignore-copyright-holder",
866            &self.ignore_copyright_holder,
867        );
868        push_bool_option(&mut flags, "--incremental", self.incremental);
869        push_array_option(&mut flags, "--include", &self.include);
870        push_bool_option(&mut flags, "--info", self.info);
871        push_string_option(&mut flags, "--json", self.output_json.as_ref());
872        push_string_option(&mut flags, "--json-lines", self.output_json_lines.as_ref());
873        push_string_option(&mut flags, "--json-pp", self.output_json_pp.as_ref());
874        push_bool_option(&mut flags, "--license", self.license);
875        push_bool_option(
876            &mut flags,
877            "--license-clarity-score",
878            self.license_clarity_score,
879        );
880        push_bool_option(
881            &mut flags,
882            "--license-diagnostics",
883            self.license_diagnostics,
884        );
885        push_string_option(
886            &mut flags,
887            "--license-dataset-path",
888            self.license_dataset_path.as_ref(),
889        );
890        push_string_option(&mut flags, "--license-policy", self.license_policy.as_ref());
891        push_bool_option(
892            &mut flags,
893            "--no-license-index-cache",
894            self.no_license_index_cache,
895        );
896        push_bool_option(&mut flags, "--license-references", self.license_references);
897        push_bool_option(&mut flags, "--reindex", self.reindex);
898        push_non_default_u8_option(&mut flags, "--license-score", self.license_score, 0);
899        push_bool_option(&mut flags, "--license-text", self.license_text);
900        push_bool_option(
901            &mut flags,
902            "--license-text-diagnostics",
903            self.license_text_diagnostics,
904        );
905        push_non_default_string_option(
906            &mut flags,
907            "--license-url-template",
908            &self.license_url_template,
909            DEFAULT_LICENSEDB_URL_TEMPLATE,
910        );
911        push_non_default_usize_option(&mut flags, "--max-depth", self.max_depth, 0);
912        match self.max_in_memory {
913            MemoryMode::Limit(10000) => {}
914            MemoryMode::CollectFirst => {
915                flags.push(("--max-in-memory".to_string(), JsonValue::Number(0.into())));
916            }
917            MemoryMode::StreamUnlimited => {
918                flags.push((
919                    "--max-in-memory".to_string(),
920                    JsonValue::Number((-1i64).into()),
921                ));
922            }
923            MemoryMode::Limit(n) => {
924                flags.push(("--max-in-memory".to_string(), JsonValue::Number(n.into())));
925            }
926        }
927        if self.email {
928            push_non_default_usize_option(&mut flags, "--max-email", self.max_email, 50);
929        }
930        if self.url {
931            push_non_default_usize_option(&mut flags, "--max-url", self.max_url, 50);
932        }
933        push_bool_option(&mut flags, "--mark-source", self.mark_source);
934        push_bool_option(&mut flags, "--no-assemble", self.no_assemble);
935        push_bool_option(&mut flags, "--only-findings", self.only_findings);
936        push_bool_option(&mut flags, "--package", self.package);
937        push_bool_option(
938            &mut flags,
939            "--package-in-compiled",
940            self.package_in_compiled,
941        );
942        push_bool_option(&mut flags, "--package-only", self.package_only);
943        push_array_option(&mut flags, "--paths-file", &self.paths_file);
944        push_non_default_process_mode_option(
945            &mut flags,
946            "--processes",
947            self.processes,
948            ProcessMode::default_value(),
949        );
950        push_bool_option(&mut flags, "--quiet", self.quiet);
951        push_string_option(&mut flags, "--spdx-rdf", self.output_spdx_rdf.as_ref());
952        push_string_option(&mut flags, "--spdx-tv", self.output_spdx_tv.as_ref());
953        push_bool_option(&mut flags, "--strip-root", self.strip_root);
954        push_bool_option(&mut flags, "--summary", self.summary);
955        push_bool_option(&mut flags, "--system-package", self.system_package);
956        push_bool_option(&mut flags, "--tallies", self.tallies);
957        push_bool_option(&mut flags, "--tallies-by-facet", self.tallies_by_facet);
958        push_bool_option(&mut flags, "--tallies-key-files", self.tallies_key_files);
959        push_bool_option(
960            &mut flags,
961            "--tallies-with-details",
962            self.tallies_with_details,
963        );
964        push_non_default_f64_option(&mut flags, "--timeout", self.timeout, 120.0);
965        push_bool_option(&mut flags, "--unknown-licenses", self.unknown_licenses);
966        push_bool_option(
967            &mut flags,
968            "--no-sequence-matching",
969            self.no_sequence_matching,
970        );
971        push_bool_option(&mut flags, "--url", self.url);
972        push_bool_option(&mut flags, "--verbose", self.verbose);
973        push_string_option(&mut flags, "--yaml", self.output_yaml.as_ref());
974
975        flags.sort_by(|left, right| left.0.cmp(&right.0));
976        for (key, value) in flags {
977            options.insert(key, value);
978        }
979
980        options
981    }
982}
983
984impl From<&ScanArgs> for ScanRequest {
985    fn from(cli: &ScanArgs) -> Self {
986        Self {
987            input_paths: cli.dir_path.clone(),
988            input_mode: if cli.from_json {
989                InputMode::FromJson
990            } else {
991                InputMode::Native
992            },
993            output_targets: cli.output_targets(),
994            output_header_options: cli.output_header_options(),
995            progress_mode: if cli.quiet {
996                crate::progress::ProgressMode::Quiet
997            } else if cli.verbose {
998                crate::progress::ProgressMode::Verbose
999            } else {
1000                crate::progress::ProgressMode::Default
1001            },
1002            process_mode: cli.processes,
1003            timeout_seconds: cli.timeout,
1004            quiet: cli.quiet,
1005            verbose: cli.verbose,
1006            strip_root: cli.strip_root,
1007            full_root: cli.full_root,
1008            exclude: cli.exclude.clone(),
1009            include: cli.include.clone(),
1010            paths_files: cli.paths_file.clone(),
1011            respect_process_cache_env: true,
1012            cache_dir: cli.cache_dir.clone(),
1013            cache_clear: cli.cache_clear,
1014            incremental: cli.incremental,
1015            cache_trust_mtime: cli.cache_trust_mtime,
1016            max_depth: cli.max_depth,
1017            max_in_memory: cli.max_in_memory,
1018            info: cli.info,
1019            package: cli.package,
1020            system_package: cli.system_package,
1021            package_in_compiled: cli.package_in_compiled,
1022            package_only: cli.package_only,
1023            no_assemble: cli.no_assemble,
1024            license_dataset_path: cli.license_dataset_path.clone(),
1025            reindex: cli.reindex,
1026            no_license_index_cache: cli.no_license_index_cache,
1027            license_text: cli.license_text,
1028            license_text_diagnostics: cli.license_text_diagnostics,
1029            license_diagnostics: cli.license_diagnostics,
1030            unknown_licenses: cli.unknown_licenses,
1031            no_sequence_matching: cli.no_sequence_matching,
1032            license_score: cli.license_score,
1033            license_url_template: cli.license_url_template.clone(),
1034            filter_clues: cli.filter_clues,
1035            ignore_author: cli.ignore_author.clone(),
1036            ignore_copyright_holder: cli.ignore_copyright_holder.clone(),
1037            only_findings: cli.only_findings,
1038            mark_source: cli.mark_source,
1039            classify: cli.classify,
1040            summary: cli.summary,
1041            license_clarity_score: cli.license_clarity_score,
1042            license_references: cli.license_references,
1043            license_policy: cli.license_policy.clone(),
1044            fail_on: cli.fail_on.map(FailOn::threshold),
1045            tallies: cli.tallies,
1046            tallies_key_files: cli.tallies_key_files,
1047            tallies_with_details: cli.tallies_with_details,
1048            facet: cli.facet.clone(),
1049            tallies_by_facet: cli.tallies_by_facet,
1050            generated: cli.generated,
1051            license: cli.license,
1052            copyright: cli.copyright,
1053            email: cli.email,
1054            max_email: cli.max_email,
1055            url: cli.url,
1056            max_url: cli.max_url,
1057            scan_bounds: crate::app::request::ScanBounds::default(),
1058        }
1059    }
1060}
1061
1062fn push_bool_option(options: &mut Vec<(String, JsonValue)>, key: &str, enabled: bool) {
1063    if enabled {
1064        options.push((key.to_string(), JsonValue::Bool(true)));
1065    }
1066}
1067
1068fn push_string_option(options: &mut Vec<(String, JsonValue)>, key: &str, value: Option<&String>) {
1069    if let Some(value) = value {
1070        options.push((key.to_string(), JsonValue::String(value.clone())));
1071    }
1072}
1073
1074fn push_non_default_string_option(
1075    options: &mut Vec<(String, JsonValue)>,
1076    key: &str,
1077    value: &str,
1078    default: &str,
1079) {
1080    if value != default {
1081        options.push((key.to_string(), JsonValue::String(value.to_string())));
1082    }
1083}
1084
1085fn push_array_option(options: &mut Vec<(String, JsonValue)>, key: &str, values: &[String]) {
1086    if !values.is_empty() {
1087        options.push((
1088            key.to_string(),
1089            JsonValue::Array(values.iter().cloned().map(JsonValue::String).collect()),
1090        ));
1091    }
1092}
1093
1094fn push_non_default_usize_option(
1095    options: &mut Vec<(String, JsonValue)>,
1096    key: &str,
1097    value: usize,
1098    default: usize,
1099) {
1100    if value != default {
1101        options.push((key.to_string(), JsonValue::Number(value.into())));
1102    }
1103}
1104
1105fn push_non_default_u8_option(
1106    options: &mut Vec<(String, JsonValue)>,
1107    key: &str,
1108    value: u8,
1109    default: u8,
1110) {
1111    if value != default {
1112        options.push((key.to_string(), JsonValue::Number(value.into())));
1113    }
1114}
1115
1116fn push_non_default_process_mode_option(
1117    options: &mut Vec<(String, JsonValue)>,
1118    key: &str,
1119    value: ProcessMode,
1120    default: ProcessMode,
1121) {
1122    if value != default {
1123        options.push((key.to_string(), JsonValue::Number(value.to_i32().into())));
1124    }
1125}
1126
1127fn push_non_default_f64_option(
1128    options: &mut Vec<(String, JsonValue)>,
1129    key: &str,
1130    value: f64,
1131    default: f64,
1132) {
1133    if (value - default).abs() > f64::EPSILON
1134        && let Some(number) = JsonNumber::from_f64(value)
1135    {
1136        options.push((key.to_string(), JsonValue::Number(number)));
1137    }
1138}
1139
1140#[cfg(test)]
1141mod tests;