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::process::ExitCode;
17use std::sync::Arc;
18use std::time::Instant;
19
20/// Exit code returned when the `--fail-on` license-policy gate trips, distinct from
21/// the code used for scan/runtime errors (see ADR 0011).
22const POLICY_GATE_EXIT_CODE: u8 = 3;
23
24pub fn run() -> Result<ExitCode> {
25    #[cfg(feature = "golden-tests")]
26    touch_license_golden_symbols();
27
28    let cli = Cli::parse();
29
30    // Every subcommand except `scan` installs a plain global logger here so its
31    // `log::*` diagnostics are actually emitted (respecting `-q`/`-v`). `scan`
32    // installs an indicatif-aware bridge later, once its progress system exists.
33    match &cli.command {
34        Command::Scan(_) => {}
35        Command::Serve(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
36        Command::Compare(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
37        Command::ExportLicenseDataset(args) => {
38            init_cli_logger(args.verbosity.verbosity().log_level())
39        }
40        Command::ShowAttribution => init_cli_logger(crate::cli::Verbosity::Normal.log_level()),
41    }
42
43    match &cli.command {
44        Command::ShowAttribution => {
45            print!("{}", include_str!("../../../NOTICE"));
46            return Ok(ExitCode::SUCCESS);
47        }
48        Command::Serve(args) => {
49            return run_serve_shell(args).map(|()| ExitCode::SUCCESS);
50        }
51        Command::Compare(args) => {
52            log::info!(
53                "Comparing ScanCode JSON {} against Provenant JSON {}...",
54                args.scancode_json.display(),
55                args.provenant_json.display()
56            );
57            let result = compare_json_files(
58                &args.scancode_json,
59                &args.provenant_json,
60                args.artifact_dir.as_deref(),
61            )?;
62            println!("Comparison status: {}", result.comparison_status);
63            println!("Artifacts:");
64            println!("  Artifact directory: {}", result.artifact_dir.display());
65            println!("  Run manifest:       {}", result.manifest_path.display());
66            println!("  Raw ScanCode JSON:  {}", result.scancode_json.display());
67            println!("  Raw Provenant JSON: {}", result.provenant_json.display());
68            println!("  Summary JSON:       {}", result.summary_json.display());
69            println!("  Summary TSV:        {}", result.summary_tsv.display());
70            println!("  Sample artifacts:   {}", result.samples_dir.display());
71            return Ok(ExitCode::SUCCESS);
72        }
73        Command::ExportLicenseDataset(args) => {
74            let dir = Path::new(&args.dir);
75            log::info!("Exporting built-in license dataset to {}...", dir.display());
76
77            let started = Instant::now();
78            let outcome = export_embedded_license_dataset(dir)?;
79
80            log::info!(
81                "Exported {} license dataset files to {} in {:.1}s (SPDX list {})",
82                outcome.files_written,
83                dir.display(),
84                started.elapsed().as_secs_f64(),
85                outcome.manifest.spdx_license_list_version
86            );
87            return Ok(ExitCode::SUCCESS);
88        }
89        Command::Scan(_) => {}
90    }
91
92    let cli = cli
93        .scan_args()
94        .expect("scan arguments should exist after command dispatch");
95
96    validate_scan_option_compatibility(cli)?;
97
98    let request = ScanRequest::from(cli);
99    let executed = execute_request(&request)?;
100    let output = executed.output;
101    let progress = executed.progress;
102    let start_time = executed.start_time;
103
104    let output_schema_output =
105        crate::output_schema::Output::from_with_compat_mode(&output, cli.compat_mode);
106    progress.start_output();
107    for target in &request.output_targets {
108        let output_config = OutputWriteConfig {
109            format: target.format,
110            custom_template: target.custom_template.clone(),
111            scanned_path: if request.input_paths.len() == 1 {
112                request.input_paths.first().cloned()
113            } else {
114                None
115            },
116        };
117
118        let timing_name = format!("output:{:?}", target.format).to_lowercase();
119        record_detail_timing(&progress, timing_name, || {
120            write_output_file(&target.file, &output_schema_output, &output_config)
121        })?;
122        progress.output_written(&format!(
123            "{:?} output written to {}",
124            target.format, target.file
125        ));
126    }
127    progress.record_final_counts(&output.files);
128    progress.record_final_header_counts(&output.headers);
129    progress.finish_output();
130
131    let summary_end = Utc::now();
132    progress.display_summary(
133        &format_scancode_timestamp(&start_time),
134        &format_scancode_timestamp(&summary_end),
135    );
136
137    // License-policy gate: evaluated after the report is written so the artifact is
138    // never lost to a failing gate (ADR 0011).
139    if let Some(threshold) = request.fail_on {
140        let violations = count_policy_violations(&output.files, threshold);
141        if violations > 0 {
142            log::error!(
143                "License policy gate: {violations} file(s) match a policy at or above `{threshold:?}` severity; failing with exit code {POLICY_GATE_EXIT_CODE}."
144            );
145            return Ok(ExitCode::from(POLICY_GATE_EXIT_CODE));
146        }
147    }
148
149    Ok(ExitCode::SUCCESS)
150}
151
152/// Count files whose license policy carries a `compliance_alert` at or above `threshold`.
153fn count_policy_violations(
154    files: &[crate::models::FileInfo],
155    threshold: crate::models::ComplianceAlert,
156) -> usize {
157    files
158        .iter()
159        .filter(|file| {
160            file.license_policy.as_ref().is_some_and(|entries| {
161                entries.iter().any(|entry| {
162                    entry
163                        .compliance_alert
164                        .is_some_and(|alert| alert >= threshold)
165                })
166            })
167        })
168        .count()
169}
170
171#[cfg(feature = "golden-tests")]
172fn touch_license_golden_symbols() {
173    let _ = crate::license_detection::golden_utils::read_golden_input_content;
174    let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
175    let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
176    let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
177}
178
179fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
180    if cli.from_json
181        && (cli.package
182            || cli.system_package
183            || cli.package_in_compiled
184            || cli.package_only
185            || cli.copyright
186            || cli.email
187            || cli.url
188            || cli.generated)
189    {
190        return Err(anyhow!(
191            "When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
192        ));
193    }
194
195    if cli.from_json && !cli.paths_file.is_empty() {
196        return Err(anyhow!(
197            "--paths-file is only supported for native scan mode, not --from-json"
198        ));
199    }
200
201    if cli.from_json && cli.incremental {
202        return Err(anyhow!(
203            "--incremental is only supported for directory scan mode, not --from-json"
204        ));
205    }
206
207    if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
208        return Err(anyhow!(
209            "--paths-file requires exactly one positional scan root"
210        ));
211    }
212
213    if !cli.from_json && cli.dir_path.is_empty() {
214        return Err(anyhow!("Directory path is required for scan operations"));
215    }
216
217    if cli.tallies_by_facet && cli.facet.is_empty() {
218        return Err(anyhow!(
219            "--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
220        ));
221    }
222
223    if cli.mark_source && !cli.info {
224        return Err(anyhow!("--mark-source requires --info"));
225    }
226
227    Ok(())
228}
229
230fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
231where
232    F: FnOnce() -> T,
233{
234    let started = Instant::now();
235    let result = f();
236    progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
237    result
238}
239
240#[cfg(test)]
241mod tests;