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