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