1use serde_json::{Map as JsonMap, Value as JsonValue};
5use std::path::{Path, PathBuf};
6
7use crate::app::request::{InputMode, ScanRequest};
8use crate::app::scan_pipeline::execute_request;
9use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
10use crate::progress::ProgressMode;
11use crate::scanner::MemoryMode;
12use crate::{Output, ProcessMode};
13
14#[derive(Debug, thiserror::Error)]
15pub enum WorkflowError {
16 #[error("{0}")]
17 InvalidOptions(String),
18 #[error(transparent)]
19 Pipeline(#[from] anyhow::Error),
20}
21
22#[derive(Debug, Clone)]
24pub enum LicenseSource {
25 Disabled,
27 Embedded,
29 Directory(PathBuf),
31}
32
33#[derive(Debug, Clone)]
39pub struct ScanOptions {
40 pub progress_mode: ProgressMode,
41 pub process_mode: ProcessMode,
42 pub timeout_seconds: f64,
43 pub max_depth: usize,
44 pub max_in_memory: MemoryMode,
45 pub collect_info: bool,
46 pub detect_license: LicenseSource,
47 pub detect_packages: bool,
48 pub detect_system_packages: bool,
49 pub detect_packages_in_compiled: bool,
50 pub package_only: bool,
51 pub no_assemble: bool,
52 pub detect_copyrights: bool,
53 pub detect_emails: bool,
54 pub detect_urls: bool,
55 pub detect_generated: bool,
56 pub max_emails: usize,
57 pub max_urls: usize,
58 pub include: Vec<String>,
59 pub exclude: Vec<String>,
60 pub include_input_header: bool,
61 pub cache_dir: Option<PathBuf>,
62 pub cache_clear: bool,
63 pub incremental: bool,
64 pub reindex: bool,
65 pub no_license_index_cache: bool,
66 pub license_text: bool,
67 pub license_text_diagnostics: bool,
68 pub license_diagnostics: bool,
69 pub unknown_licenses: bool,
70 pub no_sequence_matching: bool,
71 pub license_score: u8,
72 pub filter_clues: bool,
73 pub ignore_author_patterns: Vec<String>,
74 pub ignore_copyright_holder_patterns: Vec<String>,
75 pub only_findings: bool,
76 pub mark_source: bool,
77 pub classify: bool,
78 pub summary: bool,
79 pub license_clarity_score: bool,
80 pub license_references: bool,
81 pub license_url_template: String,
82 pub license_policy: Option<PathBuf>,
83 pub tallies: bool,
84 pub tallies_key_files: bool,
85 pub tallies_with_details: bool,
86 pub facets: Vec<String>,
87 pub tallies_by_facet: bool,
88 pub strip_root: bool,
89 pub full_root: bool,
90 pub header_options: JsonMap<String, JsonValue>,
91}
92
93impl Default for ScanOptions {
94 fn default() -> Self {
95 Self {
96 progress_mode: ProgressMode::Quiet,
97 process_mode: ProcessMode::default(),
98 timeout_seconds: 120.0,
99 max_depth: 0,
100 max_in_memory: MemoryMode::Limit(10_000),
101 collect_info: false,
102 detect_license: LicenseSource::Disabled,
103 detect_packages: false,
104 detect_system_packages: false,
105 detect_packages_in_compiled: false,
106 package_only: false,
107 no_assemble: false,
108 detect_copyrights: false,
109 detect_emails: false,
110 detect_urls: false,
111 detect_generated: false,
112 max_emails: 50,
113 max_urls: 50,
114 include: Vec::new(),
115 exclude: Vec::new(),
116 include_input_header: false,
117 cache_dir: None,
118 cache_clear: false,
119 incremental: false,
120 reindex: false,
121 no_license_index_cache: false,
122 license_text: false,
123 license_text_diagnostics: false,
124 license_diagnostics: false,
125 unknown_licenses: false,
126 no_sequence_matching: false,
127 license_score: 0,
128 filter_clues: false,
129 ignore_author_patterns: Vec::new(),
130 ignore_copyright_holder_patterns: Vec::new(),
131 only_findings: false,
132 mark_source: false,
133 classify: false,
134 summary: false,
135 license_clarity_score: false,
136 license_references: false,
137 license_url_template: DEFAULT_LICENSEDB_URL_TEMPLATE.to_string(),
138 license_policy: None,
139 tallies: false,
140 tallies_key_files: false,
141 tallies_with_details: false,
142 facets: Vec::new(),
143 tallies_by_facet: false,
144 strip_root: false,
145 full_root: false,
146 header_options: JsonMap::new(),
147 }
148 }
149}
150
151pub fn scan_path(path: impl AsRef<Path>, options: &ScanOptions) -> Result<Output, WorkflowError> {
169 scan_paths([path.as_ref()], options)
170}
171
172pub fn scan_paths<'a>(
198 paths: impl IntoIterator<Item = &'a Path>,
199 options: &ScanOptions,
200) -> Result<Output, WorkflowError> {
201 let input_paths: Vec<String> = paths
202 .into_iter()
203 .map(|path| path.to_string_lossy().to_string())
204 .collect();
205
206 if input_paths.is_empty() {
207 return Err(WorkflowError::InvalidOptions(
208 "At least one input path is required".to_string(),
209 ));
210 }
211
212 let request = request_for_native_paths(input_paths, options);
213 validate_workflow_request(&request)?;
214
215 execute_request(&request)
216 .map(|executed| executed.output)
217 .map_err(WorkflowError::Pipeline)
218}
219
220fn request_for_native_paths(input_paths: Vec<String>, options: &ScanOptions) -> ScanRequest {
221 let mut header_options = options.header_options.clone();
222 if options.include_input_header {
223 header_options.insert(
224 "input".to_string(),
225 JsonValue::Array(input_paths.iter().cloned().map(JsonValue::String).collect()),
226 );
227 }
228
229 let (license, license_dataset_path) = match &options.detect_license {
230 LicenseSource::Disabled => (false, None),
231 LicenseSource::Embedded => (true, None),
232 LicenseSource::Directory(path) => (true, Some(path.to_string_lossy().to_string())),
233 };
234
235 ScanRequest {
236 input_paths,
237 input_mode: InputMode::Native,
238 output_targets: Vec::new(),
239 output_header_options: header_options,
240 progress_mode: options.progress_mode,
241 process_mode: options.process_mode,
242 timeout_seconds: options.timeout_seconds,
243 quiet: matches!(options.progress_mode, ProgressMode::Quiet),
244 verbose: matches!(options.progress_mode, ProgressMode::Verbose),
245 strip_root: options.strip_root,
246 full_root: options.full_root,
247 include: options.include.clone(),
248 exclude: options.exclude.clone(),
249 paths_files: Vec::new(),
250 respect_process_cache_env: false,
251 cache_dir: options
252 .cache_dir
253 .as_ref()
254 .map(|path| path.to_string_lossy().to_string()),
255 cache_clear: options.cache_clear,
256 incremental: options.incremental,
257 max_depth: options.max_depth,
258 max_in_memory: options.max_in_memory,
259 info: options.collect_info,
260 package: options.detect_packages,
261 system_package: options.detect_system_packages,
262 package_in_compiled: options.detect_packages_in_compiled,
263 package_only: options.package_only,
264 no_assemble: options.no_assemble,
265 license_dataset_path,
266 reindex: options.reindex,
267 no_license_index_cache: options.no_license_index_cache,
268 license_text: options.license_text,
269 license_text_diagnostics: options.license_text_diagnostics,
270 license_diagnostics: options.license_diagnostics,
271 unknown_licenses: options.unknown_licenses,
272 no_sequence_matching: options.no_sequence_matching,
273 license_score: options.license_score,
274 license_url_template: options.license_url_template.clone(),
275 filter_clues: options.filter_clues,
276 ignore_author: options.ignore_author_patterns.clone(),
277 ignore_copyright_holder: options.ignore_copyright_holder_patterns.clone(),
278 only_findings: options.only_findings,
279 mark_source: options.mark_source,
280 classify: options.classify,
281 summary: options.summary,
282 license_clarity_score: options.license_clarity_score,
283 license_references: options.license_references,
284 license_policy: options
285 .license_policy
286 .as_ref()
287 .map(|path| path.to_string_lossy().to_string()),
288 tallies: options.tallies,
289 tallies_key_files: options.tallies_key_files,
290 tallies_with_details: options.tallies_with_details,
291 facet: options.facets.clone(),
292 tallies_by_facet: options.tallies_by_facet,
293 generated: options.detect_generated,
294 license,
295 copyright: options.detect_copyrights,
296 email: options.detect_emails,
297 max_email: options.max_emails,
298 url: options.detect_urls,
299 max_url: options.max_urls,
300 }
301}
302
303fn validate_workflow_request(request: &ScanRequest) -> Result<(), WorkflowError> {
304 let license_enabled = request.license;
305
306 if request.strip_root && request.full_root {
307 return Err(WorkflowError::InvalidOptions(
308 "strip_root and full_root are mutually exclusive".to_string(),
309 ));
310 }
311
312 if request.license_text && !license_enabled {
313 return Err(WorkflowError::InvalidOptions(
314 "license_text requires detect_license".to_string(),
315 ));
316 }
317
318 if request.license_text_diagnostics && !request.license_text {
319 return Err(WorkflowError::InvalidOptions(
320 "license_text_diagnostics requires license_text".to_string(),
321 ));
322 }
323
324 if request.license_diagnostics && !license_enabled {
325 return Err(WorkflowError::InvalidOptions(
326 "license_diagnostics requires detect_license".to_string(),
327 ));
328 }
329
330 if request.unknown_licenses && !license_enabled {
331 return Err(WorkflowError::InvalidOptions(
332 "unknown_licenses requires detect_license".to_string(),
333 ));
334 }
335
336 if request.license_references && !license_enabled {
337 return Err(WorkflowError::InvalidOptions(
338 "license_references requires detect_license".to_string(),
339 ));
340 }
341
342 if request.license_url_template != DEFAULT_LICENSEDB_URL_TEMPLATE && !license_enabled {
343 return Err(WorkflowError::InvalidOptions(
344 "license_url_template requires detect_license".to_string(),
345 ));
346 }
347
348 if request.package_only && license_enabled {
349 return Err(WorkflowError::InvalidOptions(
350 "package_only cannot be combined with detect_license".to_string(),
351 ));
352 }
353
354 if request.package_only && request.summary {
355 return Err(WorkflowError::InvalidOptions(
356 "package_only cannot be combined with summary".to_string(),
357 ));
358 }
359
360 if request.package_only && request.package {
361 return Err(WorkflowError::InvalidOptions(
362 "package_only cannot be combined with detect_packages".to_string(),
363 ));
364 }
365
366 if request.package_only && request.system_package {
367 return Err(WorkflowError::InvalidOptions(
368 "package_only cannot be combined with detect_system_packages".to_string(),
369 ));
370 }
371
372 if request.summary && !request.classify {
373 return Err(WorkflowError::InvalidOptions(
374 "summary requires classify".to_string(),
375 ));
376 }
377
378 if request.license_clarity_score && !request.classify {
379 return Err(WorkflowError::InvalidOptions(
380 "license_clarity_score requires classify".to_string(),
381 ));
382 }
383
384 if request.tallies_key_files && !(request.tallies && request.classify) {
385 return Err(WorkflowError::InvalidOptions(
386 "tallies_key_files requires tallies and classify".to_string(),
387 ));
388 }
389
390 if request.tallies_by_facet && request.facet.is_empty() {
391 return Err(WorkflowError::InvalidOptions(
392 "tallies_by_facet requires at least one facet definition".to_string(),
393 ));
394 }
395
396 if request.tallies_by_facet && !request.tallies {
397 return Err(WorkflowError::InvalidOptions(
398 "tallies_by_facet requires tallies".to_string(),
399 ));
400 }
401
402 if request.mark_source && !request.info {
403 return Err(WorkflowError::InvalidOptions(
404 "mark_source requires collect_info".to_string(),
405 ));
406 }
407
408 if request.license_score > 100 {
409 return Err(WorkflowError::InvalidOptions(
410 "license_score must be between 0 and 100".to_string(),
411 ));
412 }
413
414 Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::fs;
421
422 #[test]
423 fn scan_path_requires_at_least_one_input() {
424 let result = scan_paths(std::iter::empty::<&Path>(), &ScanOptions::default());
425 assert!(result.is_err());
426 }
427
428 #[test]
429 fn workflow_request_populates_input_header() {
430 let options = ScanOptions {
431 include_input_header: true,
432 ..ScanOptions::default()
433 };
434 let request = request_for_native_paths(vec!["src".to_string()], &options);
435 assert!(request.output_header_options.contains_key("input"));
436 }
437
438 #[test]
439 fn workflow_validation_rejects_license_dependent_flags_without_license() {
440 let options = ScanOptions {
441 license_references: true,
442 ..ScanOptions::default()
443 };
444
445 let request = request_for_native_paths(vec!["src".to_string()], &options);
446 let error = validate_workflow_request(&request).expect_err("validation should fail");
447 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
448 assert!(
449 error
450 .to_string()
451 .contains("license_references requires detect_license")
452 );
453 }
454
455 #[test]
456 fn workflow_validation_rejects_package_only_with_regular_package_modes() {
457 let options = ScanOptions {
458 package_only: true,
459 detect_packages: true,
460 ..ScanOptions::default()
461 };
462
463 let request = request_for_native_paths(vec!["src".to_string()], &options);
464 let error = validate_workflow_request(&request).expect_err("validation should fail");
465 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
466 assert!(
467 error
468 .to_string()
469 .contains("package_only cannot be combined with detect_packages")
470 );
471 }
472
473 #[test]
474 fn workflow_validation_rejects_classify_dependent_flags_without_classify() {
475 let options = ScanOptions {
476 summary: true,
477 ..ScanOptions::default()
478 };
479
480 let request = request_for_native_paths(vec!["src".to_string()], &options);
481 let error = validate_workflow_request(&request).expect_err("validation should fail");
482 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
483 assert!(error.to_string().contains("summary requires classify"));
484 }
485
486 #[test]
487 fn scan_path_runs_a_basic_in_process_scan() {
488 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
489 fs::write(
490 temp_dir.path().join("README.txt"),
491 "hello from workflow facade\n",
492 )
493 .expect("write fixture file");
494
495 let options = ScanOptions {
496 collect_info: true,
497 include_input_header: true,
498 ..ScanOptions::default()
499 };
500
501 let output = scan_path(temp_dir.path(), &options).expect("workflow scan should succeed");
502
503 assert_eq!(output.headers.len(), 1);
504 assert!(!output.files.is_empty());
505 assert!(output.headers[0].options.contains_key("input"));
506 }
507
508 #[test]
509 fn scan_paths_supports_multiple_absolute_inputs() {
510 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
511 let left = temp_dir.path().join("left");
512 let right = temp_dir.path().join("right");
513 fs::create_dir_all(&left).expect("create left dir");
514 fs::create_dir_all(&right).expect("create right dir");
515 fs::write(left.join("one.txt"), "left\n").expect("write left fixture");
516 fs::write(right.join("two.txt"), "right\n").expect("write right fixture");
517
518 let output = scan_paths([left.as_path(), right.as_path()], &ScanOptions::default())
519 .expect("workflow scan should succeed for multiple absolute inputs");
520
521 assert!(
522 output
523 .files
524 .iter()
525 .any(|file| file.path.ends_with("one.txt"))
526 );
527 assert!(
528 output
529 .files
530 .iter()
531 .any(|file| file.path.ends_with("two.txt"))
532 );
533 }
534}