Skip to main content

provenant/cli/
mod.rs

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