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). Covers file-level detections plus
139    // package/dependency declared licenses.
140    if let Some(threshold) = request.fail_on {
141        let file_violations = count_policy_violations(&output.files, threshold);
142        // Fail closed: propagate a policy re-evaluation failure instead of treating
143        // it as zero package/dependency violations.
144        let declared_violations = match request.license_policy.as_deref() {
145            Some(policy_path) => crate::post_processing::count_declared_license_policy_violations(
146                Path::new(policy_path),
147                &output.packages,
148                &output.dependencies,
149                threshold,
150            )?,
151            None => 0,
152        };
153        let violations = file_violations + declared_violations;
154        if violations > 0 {
155            log::error!(
156                "License policy gate: {file_violations} file(s) and {declared_violations} package/dependency license(s) match a policy at or above `{threshold:?}` severity; failing with exit code {POLICY_GATE_EXIT_CODE}."
157            );
158            return Ok(ExitCode::from(POLICY_GATE_EXIT_CODE));
159        }
160    }
161
162    Ok(ExitCode::SUCCESS)
163}
164
165/// Count files whose license policy carries a `compliance_alert` at or above `threshold`.
166fn count_policy_violations(
167    files: &[crate::models::FileInfo],
168    threshold: crate::models::ComplianceAlert,
169) -> usize {
170    files
171        .iter()
172        .filter(|file| {
173            file.license_policy.as_ref().is_some_and(|entries| {
174                entries.iter().any(|entry| {
175                    entry
176                        .compliance_alert
177                        .is_some_and(|alert| alert >= threshold)
178                })
179            })
180        })
181        .count()
182}
183
184#[cfg(feature = "golden-tests")]
185fn touch_license_golden_symbols() {
186    let _ = crate::license_detection::golden_utils::read_golden_input_content;
187    let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
188    let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
189    let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
190}
191
192fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
193    if cli.from_json
194        && (cli.package
195            || cli.system_package
196            || cli.package_in_compiled
197            || cli.package_only
198            || cli.copyright
199            || cli.email
200            || cli.url
201            || cli.generated)
202    {
203        return Err(anyhow!(
204            "When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
205        ));
206    }
207
208    if cli.from_json && !cli.paths_file.is_empty() {
209        return Err(anyhow!(
210            "--paths-file is only supported for native scan mode, not --from-json"
211        ));
212    }
213
214    if cli.from_json && cli.incremental {
215        return Err(anyhow!(
216            "--incremental is only supported for directory scan mode, not --from-json"
217        ));
218    }
219
220    if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
221        return Err(anyhow!(
222            "--paths-file requires exactly one positional scan root"
223        ));
224    }
225
226    if !cli.from_json && cli.dir_path.is_empty() {
227        return Err(anyhow!("Directory path is required for scan operations"));
228    }
229
230    if cli.tallies_by_facet && cli.facet.is_empty() {
231        return Err(anyhow!(
232            "--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
233        ));
234    }
235
236    if cli.mark_source && !cli.info {
237        return Err(anyhow!("--mark-source requires --info"));
238    }
239
240    Ok(())
241}
242
243fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
244where
245    F: FnOnce() -> T,
246{
247    let started = Instant::now();
248    let result = f();
249    progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
250    result
251}
252
253#[cfg(test)]
254mod tests;