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 {
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
152fn 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;