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 cache_trust_mtime: bool,
70 pub reindex: bool,
71 pub no_license_index_cache: bool,
72 pub license_text: bool,
73 pub license_text_diagnostics: bool,
74 pub license_diagnostics: bool,
75 pub unknown_licenses: bool,
76 pub no_sequence_matching: bool,
77 pub license_score: u8,
78 pub filter_clues: bool,
79 pub ignore_author_patterns: Vec<String>,
80 pub ignore_copyright_holder_patterns: Vec<String>,
81 pub only_findings: bool,
82 pub mark_source: bool,
83 pub classify: bool,
84 pub summary: bool,
85 pub license_clarity_score: bool,
86 pub license_references: bool,
87 pub license_url_template: String,
88 pub license_policy: Option<PathBuf>,
89 pub tallies: bool,
90 pub tallies_key_files: bool,
91 pub tallies_with_details: bool,
92 pub facets: Vec<String>,
93 pub tallies_by_facet: bool,
94 pub strip_root: bool,
95 pub full_root: bool,
96 pub header_options: JsonMap<String, JsonValue>,
97 pub max_files: Option<usize>,
102 pub max_total_bytes: Option<u64>,
104 pub scan_deadline_seconds: Option<f64>,
106 pub restrict_out_of_tree_symlinks: bool,
109}
110
111impl Default for ScanOptions {
112 fn default() -> Self {
113 Self {
114 progress_mode: ProgressMode::Quiet,
115 process_mode: ProcessMode::default(),
116 timeout_seconds: 120.0,
117 max_depth: 0,
118 max_in_memory: MemoryMode::Limit(10_000),
119 collect_info: false,
120 detect_license: LicenseSource::Disabled,
121 detect_packages: false,
122 detect_system_packages: false,
123 detect_packages_in_compiled: false,
124 package_only: false,
125 no_assemble: false,
126 detect_copyrights: false,
127 detect_emails: false,
128 detect_urls: false,
129 detect_generated: false,
130 max_emails: 50,
131 max_urls: 50,
132 include: Vec::new(),
133 exclude: Vec::new(),
134 include_input_header: false,
135 cache_dir: None,
136 cache_clear: false,
137 incremental: false,
138 cache_trust_mtime: false,
139 reindex: false,
140 no_license_index_cache: false,
141 license_text: false,
142 license_text_diagnostics: false,
143 license_diagnostics: false,
144 unknown_licenses: false,
145 no_sequence_matching: false,
146 license_score: 0,
147 filter_clues: false,
148 ignore_author_patterns: Vec::new(),
149 ignore_copyright_holder_patterns: Vec::new(),
150 only_findings: false,
151 mark_source: false,
152 classify: false,
153 summary: false,
154 license_clarity_score: false,
155 license_references: false,
156 license_url_template: DEFAULT_LICENSEDB_URL_TEMPLATE.to_string(),
157 license_policy: None,
158 tallies: false,
159 tallies_key_files: false,
160 tallies_with_details: false,
161 facets: Vec::new(),
162 tallies_by_facet: false,
163 strip_root: false,
164 full_root: false,
165 header_options: JsonMap::new(),
166 max_files: None,
167 max_total_bytes: None,
168 scan_deadline_seconds: None,
169 restrict_out_of_tree_symlinks: false,
170 }
171 }
172}
173
174pub fn scan_path(path: impl AsRef<Path>, options: &ScanOptions) -> Result<Output, WorkflowError> {
192 scan_paths([path.as_ref()], options)
193}
194
195pub fn scan_paths<'a>(
221 paths: impl IntoIterator<Item = &'a Path>,
222 options: &ScanOptions,
223) -> Result<Output, WorkflowError> {
224 let input_paths: Vec<String> = paths
225 .into_iter()
226 .map(|path| path.to_string_lossy().to_string())
227 .collect();
228
229 if input_paths.is_empty() {
230 return Err(WorkflowError::InvalidOptions(
231 "At least one input path is required".to_string(),
232 ));
233 }
234
235 let request = request_for_native_paths(input_paths, options);
236 validate_workflow_request(&request)?;
237
238 execute_request(&request)
239 .map(|executed| executed.output)
240 .map_err(WorkflowError::Pipeline)
241}
242
243fn request_for_native_paths(input_paths: Vec<String>, options: &ScanOptions) -> ScanRequest {
244 let mut header_options = options.header_options.clone();
245 if options.include_input_header {
246 header_options.insert(
247 "input".to_string(),
248 JsonValue::Array(input_paths.iter().cloned().map(JsonValue::String).collect()),
249 );
250 }
251
252 let (license, license_dataset_path) = match &options.detect_license {
253 LicenseSource::Disabled => (false, None),
254 LicenseSource::Embedded => (true, None),
255 LicenseSource::Directory(path) => (true, Some(path.to_string_lossy().to_string())),
256 };
257
258 ScanRequest {
259 input_paths,
260 input_mode: InputMode::Native,
261 output_targets: Vec::new(),
262 output_header_options: header_options,
263 progress_mode: options.progress_mode,
264 process_mode: options.process_mode,
265 timeout_seconds: options.timeout_seconds,
266 quiet: matches!(options.progress_mode, ProgressMode::Quiet),
267 verbose: matches!(options.progress_mode, ProgressMode::Verbose),
268 strip_root: options.strip_root,
269 full_root: options.full_root,
270 include: options.include.clone(),
271 exclude: options.exclude.clone(),
272 paths_files: Vec::new(),
273 respect_process_cache_env: false,
274 cache_dir: options
275 .cache_dir
276 .as_ref()
277 .map(|path| path.to_string_lossy().to_string()),
278 cache_clear: options.cache_clear,
279 incremental: options.incremental,
280 cache_trust_mtime: options.cache_trust_mtime,
281 max_depth: options.max_depth,
282 max_in_memory: options.max_in_memory,
283 info: options.collect_info,
284 package: options.detect_packages,
285 system_package: options.detect_system_packages,
286 package_in_compiled: options.detect_packages_in_compiled,
287 package_only: options.package_only,
288 no_assemble: options.no_assemble,
289 license_dataset_path,
290 reindex: options.reindex,
291 no_license_index_cache: options.no_license_index_cache,
292 license_text: options.license_text,
293 license_text_diagnostics: options.license_text_diagnostics,
294 license_diagnostics: options.license_diagnostics,
295 unknown_licenses: options.unknown_licenses,
296 no_sequence_matching: options.no_sequence_matching,
297 license_score: options.license_score,
298 license_url_template: options.license_url_template.clone(),
299 filter_clues: options.filter_clues,
300 ignore_author: options.ignore_author_patterns.clone(),
301 ignore_copyright_holder: options.ignore_copyright_holder_patterns.clone(),
302 only_findings: options.only_findings,
303 mark_source: options.mark_source,
304 classify: options.classify,
305 summary: options.summary,
306 license_clarity_score: options.license_clarity_score,
307 license_references: options.license_references,
308 license_policy: options
309 .license_policy
310 .as_ref()
311 .map(|path| path.to_string_lossy().to_string()),
312 fail_on: None,
313 tallies: options.tallies,
314 tallies_key_files: options.tallies_key_files,
315 tallies_with_details: options.tallies_with_details,
316 facet: options.facets.clone(),
317 tallies_by_facet: options.tallies_by_facet,
318 generated: options.detect_generated,
319 license,
320 copyright: options.detect_copyrights,
321 email: options.detect_emails,
322 max_email: options.max_emails,
323 url: options.detect_urls,
324 max_url: options.max_urls,
325 scan_bounds: crate::app::request::ScanBounds {
326 max_files: options.max_files,
327 max_total_bytes: options.max_total_bytes,
328 deadline_seconds: options.scan_deadline_seconds,
329 restrict_out_of_tree_symlinks: options.restrict_out_of_tree_symlinks,
330 },
331 }
332}
333
334fn validate_workflow_request(request: &ScanRequest) -> Result<(), WorkflowError> {
335 let license_enabled = request.license;
336
337 if request.strip_root && request.full_root {
338 return Err(WorkflowError::InvalidOptions(
339 "strip_root and full_root are mutually exclusive".to_string(),
340 ));
341 }
342
343 if request.license_text && !license_enabled {
344 return Err(WorkflowError::InvalidOptions(
345 "license_text requires detect_license".to_string(),
346 ));
347 }
348
349 if request.license_text_diagnostics && !request.license_text {
350 return Err(WorkflowError::InvalidOptions(
351 "license_text_diagnostics requires license_text".to_string(),
352 ));
353 }
354
355 if request.license_diagnostics && !license_enabled {
356 return Err(WorkflowError::InvalidOptions(
357 "license_diagnostics requires detect_license".to_string(),
358 ));
359 }
360
361 if request.unknown_licenses && !license_enabled {
362 return Err(WorkflowError::InvalidOptions(
363 "unknown_licenses requires detect_license".to_string(),
364 ));
365 }
366
367 if request.license_references && !license_enabled {
368 return Err(WorkflowError::InvalidOptions(
369 "license_references requires detect_license".to_string(),
370 ));
371 }
372
373 if request.license_url_template != DEFAULT_LICENSEDB_URL_TEMPLATE && !license_enabled {
374 return Err(WorkflowError::InvalidOptions(
375 "license_url_template requires detect_license".to_string(),
376 ));
377 }
378
379 if request.package_only && license_enabled {
380 return Err(WorkflowError::InvalidOptions(
381 "package_only cannot be combined with detect_license".to_string(),
382 ));
383 }
384
385 if request.package_only && request.summary {
386 return Err(WorkflowError::InvalidOptions(
387 "package_only cannot be combined with summary".to_string(),
388 ));
389 }
390
391 if request.package_only && request.package {
392 return Err(WorkflowError::InvalidOptions(
393 "package_only cannot be combined with detect_packages".to_string(),
394 ));
395 }
396
397 if request.package_only && request.system_package {
398 return Err(WorkflowError::InvalidOptions(
399 "package_only cannot be combined with detect_system_packages".to_string(),
400 ));
401 }
402
403 if request.summary && !request.classify {
404 return Err(WorkflowError::InvalidOptions(
405 "summary requires classify".to_string(),
406 ));
407 }
408
409 if request.license_clarity_score && !request.classify {
410 return Err(WorkflowError::InvalidOptions(
411 "license_clarity_score requires classify".to_string(),
412 ));
413 }
414
415 if request.tallies_key_files && !(request.tallies && request.classify) {
416 return Err(WorkflowError::InvalidOptions(
417 "tallies_key_files requires tallies and classify".to_string(),
418 ));
419 }
420
421 if request.tallies_by_facet && request.facet.is_empty() {
422 return Err(WorkflowError::InvalidOptions(
423 "tallies_by_facet requires at least one facet definition".to_string(),
424 ));
425 }
426
427 if request.tallies_by_facet && !request.tallies {
428 return Err(WorkflowError::InvalidOptions(
429 "tallies_by_facet requires tallies".to_string(),
430 ));
431 }
432
433 if request.mark_source && !request.info {
434 return Err(WorkflowError::InvalidOptions(
435 "mark_source requires collect_info".to_string(),
436 ));
437 }
438
439 if request.license_score > 100 {
440 return Err(WorkflowError::InvalidOptions(
441 "license_score must be between 0 and 100".to_string(),
442 ));
443 }
444
445 Ok(())
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::fs;
452
453 #[test]
454 fn scan_path_requires_at_least_one_input() {
455 let result = scan_paths(std::iter::empty::<&Path>(), &ScanOptions::default());
456 assert!(result.is_err());
457 }
458
459 #[test]
460 fn workflow_request_populates_input_header() {
461 let options = ScanOptions {
462 include_input_header: true,
463 ..ScanOptions::default()
464 };
465 let request = request_for_native_paths(vec!["src".to_string()], &options);
466 assert!(request.output_header_options.contains_key("input"));
467 }
468
469 #[test]
470 fn workflow_validation_rejects_license_dependent_flags_without_license() {
471 let options = ScanOptions {
472 license_references: true,
473 ..ScanOptions::default()
474 };
475
476 let request = request_for_native_paths(vec!["src".to_string()], &options);
477 let error = validate_workflow_request(&request).expect_err("validation should fail");
478 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
479 assert!(
480 error
481 .to_string()
482 .contains("license_references requires detect_license")
483 );
484 }
485
486 #[test]
487 fn workflow_validation_rejects_package_only_with_regular_package_modes() {
488 let options = ScanOptions {
489 package_only: true,
490 detect_packages: true,
491 ..ScanOptions::default()
492 };
493
494 let request = request_for_native_paths(vec!["src".to_string()], &options);
495 let error = validate_workflow_request(&request).expect_err("validation should fail");
496 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
497 assert!(
498 error
499 .to_string()
500 .contains("package_only cannot be combined with detect_packages")
501 );
502 }
503
504 #[test]
505 fn workflow_validation_rejects_classify_dependent_flags_without_classify() {
506 let options = ScanOptions {
507 summary: true,
508 ..ScanOptions::default()
509 };
510
511 let request = request_for_native_paths(vec!["src".to_string()], &options);
512 let error = validate_workflow_request(&request).expect_err("validation should fail");
513 assert!(matches!(error, WorkflowError::InvalidOptions(_)));
514 assert!(error.to_string().contains("summary requires classify"));
515 }
516
517 #[test]
518 fn scan_path_runs_a_basic_in_process_scan() {
519 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
520 fs::write(
521 temp_dir.path().join("README.txt"),
522 "hello from workflow facade\n",
523 )
524 .expect("write fixture file");
525
526 let options = ScanOptions {
527 collect_info: true,
528 include_input_header: true,
529 ..ScanOptions::default()
530 };
531
532 let output = scan_path(temp_dir.path(), &options).expect("workflow scan should succeed");
533
534 assert_eq!(output.headers.len(), 1);
535 assert!(!output.files.is_empty());
536 assert!(output.headers[0].options.contains_key("input"));
537 }
538
539 #[test]
540 fn scan_paths_supports_multiple_absolute_inputs() {
541 let temp_dir = tempfile::TempDir::new().expect("create temp dir");
542 let left = temp_dir.path().join("left");
543 let right = temp_dir.path().join("right");
544 fs::create_dir_all(&left).expect("create left dir");
545 fs::create_dir_all(&right).expect("create right dir");
546 fs::write(left.join("one.txt"), "left\n").expect("write left fixture");
547 fs::write(right.join("two.txt"), "right\n").expect("write right fixture");
548
549 let output = scan_paths([left.as_path(), right.as_path()], &ScanOptions::default())
550 .expect("workflow scan should succeed for multiple absolute inputs");
551
552 assert!(
553 output
554 .files
555 .iter()
556 .any(|file| file.path.ends_with("one.txt"))
557 );
558 assert!(
559 output
560 .files
561 .iter()
562 .any(|file| file.path.ends_with("two.txt"))
563 );
564 }
565}