Skip to main content

provenant/cli/run/
mod.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::app::request::ScanRequest;
5use crate::app::scan_pipeline::execute_request;
6use crate::cli::{Cli, Command, ScanArgs};
7use crate::compare::compare_json_files;
8use crate::license_detection::dataset::export_embedded_license_dataset;
9use crate::output::{OutputWriteConfig, write_output_file};
10use crate::progress::{ScanProgress, init_cli_logger};
11use crate::serve::run as run_serve_shell;
12use crate::time::format_scancode_timestamp;
13use anyhow::{Result, anyhow};
14use chrono::Utc;
15use std::path::Path;
16use std::sync::Arc;
17use std::time::Instant;
18
19pub fn run() -> Result<()> {
20    #[cfg(feature = "golden-tests")]
21    touch_license_golden_symbols();
22
23    let cli = Cli::parse();
24
25    // Every subcommand except `scan` installs a plain global logger here so its
26    // `log::*` diagnostics are actually emitted (respecting `-q`/`-v`). `scan`
27    // installs an indicatif-aware bridge later, once its progress system exists.
28    match &cli.command {
29        Command::Scan(_) => {}
30        Command::Serve(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
31        Command::Compare(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
32        Command::ExportLicenseDataset(args) => {
33            init_cli_logger(args.verbosity.verbosity().log_level())
34        }
35        Command::ShowAttribution => init_cli_logger(crate::cli::Verbosity::Normal.log_level()),
36    }
37
38    match &cli.command {
39        Command::ShowAttribution => {
40            print!("{}", include_str!("../../../NOTICE"));
41            return Ok(());
42        }
43        Command::Serve(args) => {
44            return run_serve_shell(args);
45        }
46        Command::Compare(args) => {
47            log::info!(
48                "Comparing ScanCode JSON {} against Provenant JSON {}...",
49                args.scancode_json.display(),
50                args.provenant_json.display()
51            );
52            let result = compare_json_files(
53                &args.scancode_json,
54                &args.provenant_json,
55                args.artifact_dir.as_deref(),
56            )?;
57            println!("Comparison status: {}", result.comparison_status);
58            println!("Artifacts:");
59            println!("  Artifact directory: {}", result.artifact_dir.display());
60            println!("  Run manifest:       {}", result.manifest_path.display());
61            println!("  Raw ScanCode JSON:  {}", result.scancode_json.display());
62            println!("  Raw Provenant JSON: {}", result.provenant_json.display());
63            println!("  Summary JSON:       {}", result.summary_json.display());
64            println!("  Summary TSV:        {}", result.summary_tsv.display());
65            println!("  Sample artifacts:   {}", result.samples_dir.display());
66            return Ok(());
67        }
68        Command::ExportLicenseDataset(args) => {
69            let dir = Path::new(&args.dir);
70            log::info!("Exporting built-in license dataset to {}...", dir.display());
71
72            let started = Instant::now();
73            let outcome = export_embedded_license_dataset(dir)?;
74
75            log::info!(
76                "Exported {} license dataset files to {} in {:.1}s (SPDX list {})",
77                outcome.files_written,
78                dir.display(),
79                started.elapsed().as_secs_f64(),
80                outcome.manifest.spdx_license_list_version
81            );
82            return Ok(());
83        }
84        Command::Scan(_) => {}
85    }
86
87    let cli = cli
88        .scan_args()
89        .expect("scan arguments should exist after command dispatch");
90
91    validate_scan_option_compatibility(cli)?;
92
93    let request = ScanRequest::from(cli);
94    let executed = execute_request(&request)?;
95    let output = executed.output;
96    let progress = executed.progress;
97    let start_time = executed.start_time;
98
99    let output_schema_output =
100        crate::output_schema::Output::from_with_compat_mode(&output, cli.compat_mode);
101    progress.start_output();
102    for target in &request.output_targets {
103        let output_config = OutputWriteConfig {
104            format: target.format,
105            custom_template: target.custom_template.clone(),
106            scanned_path: if request.input_paths.len() == 1 {
107                request.input_paths.first().cloned()
108            } else {
109                None
110            },
111        };
112
113        let timing_name = format!("output:{:?}", target.format).to_lowercase();
114        record_detail_timing(&progress, timing_name, || {
115            write_output_file(&target.file, &output_schema_output, &output_config)
116        })?;
117        progress.output_written(&format!(
118            "{:?} output written to {}",
119            target.format, target.file
120        ));
121    }
122    progress.record_final_counts(&output.files);
123    progress.record_final_header_counts(&output.headers);
124    progress.finish_output();
125
126    let summary_end = Utc::now();
127    progress.display_summary(
128        &format_scancode_timestamp(&start_time),
129        &format_scancode_timestamp(&summary_end),
130    );
131
132    Ok(())
133}
134
135#[cfg(feature = "golden-tests")]
136fn touch_license_golden_symbols() {
137    let _ = crate::license_detection::golden_utils::read_golden_input_content;
138    let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
139    let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
140    let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
141}
142
143fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
144    if cli.from_json
145        && (cli.package
146            || cli.system_package
147            || cli.package_in_compiled
148            || cli.package_only
149            || cli.copyright
150            || cli.email
151            || cli.url
152            || cli.generated)
153    {
154        return Err(anyhow!(
155            "When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
156        ));
157    }
158
159    if cli.from_json && !cli.paths_file.is_empty() {
160        return Err(anyhow!(
161            "--paths-file is only supported for native scan mode, not --from-json"
162        ));
163    }
164
165    if cli.from_json && cli.incremental {
166        return Err(anyhow!(
167            "--incremental is only supported for directory scan mode, not --from-json"
168        ));
169    }
170
171    if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
172        return Err(anyhow!(
173            "--paths-file requires exactly one positional scan root"
174        ));
175    }
176
177    if !cli.from_json && cli.dir_path.is_empty() {
178        return Err(anyhow!("Directory path is required for scan operations"));
179    }
180
181    if cli.tallies_by_facet && cli.facet.is_empty() {
182        return Err(anyhow!(
183            "--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
184        ));
185    }
186
187    if cli.mark_source && !cli.info {
188        return Err(anyhow!("--mark-source requires --info"));
189    }
190
191    Ok(())
192}
193
194fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
195where
196    F: FnOnce() -> T,
197{
198    let started = Instant::now();
199    let result = f();
200    progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
201    result
202}
203
204#[cfg(test)]
205mod tests;