1use 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
20const 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 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 if let Some(threshold) = request.fail_on {
141 let file_violations = count_policy_violations(&output.files, threshold);
142 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
165fn 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;