1use std::collections::HashMap;
5use std::env;
6use std::io::IsTerminal;
7use std::path::Path;
8use std::sync::Mutex;
9use std::time::Instant;
10
11use env_logger::Env;
12use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
13use indicatif_log_bridge::LogWrapper;
14use log::LevelFilter;
15
16use crate::cli::ProcessMode;
17use crate::models::{
18 DiagnosticSeverity, FileInfo, FileType, Header, ScanDiagnostic, is_legacy_warning_message,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum ProgressMode {
23 Quiet,
24 Default,
25 Verbose,
26}
27
28#[derive(Debug, Default, Clone)]
29pub struct ScanStats {
30 pub processes: ProcessMode,
31 pub scan_names: String,
32 pub initial_files: usize,
33 pub initial_dirs: usize,
34 pub initial_size: u64,
35 pub excluded_count: usize,
36 pub final_files: usize,
37 pub final_dirs: usize,
38 pub final_size: u64,
39 pub error_count: usize,
40 pub warning_count: usize,
41 pub total_bytes_scanned: u64,
42 pub packages_assembled: usize,
43 pub manifests_seen: usize,
44 pub top_level_timings: Vec<(String, f64)>,
45 pub detail_timings: Vec<(String, f64)>,
46 pub incremental_reused: usize,
47}
48
49pub struct ScanProgress {
50 mode: ProgressMode,
51 multi: MultiProgress,
52 scan_bar: ProgressBar,
53 stats: Mutex<ScanStats>,
54 phase_starts: Mutex<HashMap<&'static str, Instant>>,
55 phase_spinner: Mutex<Option<ProgressBar>>,
56 stderr_is_tty: bool,
57}
58
59impl ScanProgress {
60 pub fn new(mode: ProgressMode) -> Self {
61 let stderr_is_tty = std::io::stderr().is_terminal();
62 let multi = match mode {
63 ProgressMode::Quiet => MultiProgress::with_draw_target(ProgressDrawTarget::hidden()),
64 ProgressMode::Default if stderr_is_tty => {
65 MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(15))
66 }
67 ProgressMode::Default | ProgressMode::Verbose => {
68 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
69 }
70 };
71
72 let scan_bar = if mode == ProgressMode::Default && stderr_is_tty {
73 multi.add(ProgressBar::new(0))
74 } else {
75 ProgressBar::hidden()
76 };
77
78 scan_bar.set_style(
79 ProgressStyle::default_bar()
80 .template(
81 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} files ({per_sec}) ({eta})",
82 )
83 .expect("Failed to create progress bar style")
84 .progress_chars("#>-"),
85 );
86
87 Self {
88 mode,
89 multi,
90 scan_bar,
91 stats: Mutex::new(ScanStats::default()),
92 phase_starts: Mutex::new(HashMap::new()),
93 phase_spinner: Mutex::new(None),
94 stderr_is_tty,
95 }
96 }
97
98 pub fn start_setup(&self) {
99 self.start_phase("setup");
100 }
101
102 pub fn finish_setup(&self) {
103 self.finish_top_level_phase("setup");
104 }
105
106 pub fn set_processes(&self, processes: ProcessMode) {
107 let mut stats = self.stats.lock().expect("stats lock poisoned");
108 stats.processes = processes;
109 }
110
111 pub fn set_scan_names(&self, scan_names: String) {
112 let mut stats = self.stats.lock().expect("stats lock poisoned");
113 stats.scan_names = scan_names;
114 }
115
116 pub fn init_logging_bridge(&self) {
117 if self.mode == ProgressMode::Quiet {
118 return;
119 }
120
121 let logger = build_env_logger();
122 let level = logger.filter();
123 if LogWrapper::new(self.multi.clone(), logger)
124 .try_init()
125 .is_ok()
126 {
127 log::set_max_level(level);
128 }
129 }
130
131 pub fn start_discovery(&self) {
132 self.start_phase("inventory");
133 match self.mode {
134 ProgressMode::Quiet => {}
135 ProgressMode::Default => {
136 self.start_spinner("Collecting files...");
137 }
138 ProgressMode::Verbose => {
139 self.message("Collecting files...");
140 }
141 }
142 }
143
144 pub fn finish_discovery(&self, files: usize, dirs: usize, size: u64, excluded: usize) {
145 self.finish_spinner();
146 self.finish_top_level_phase("inventory");
147 let mut stats = self.stats.lock().expect("stats lock poisoned");
148 stats.initial_files = files;
149 stats.initial_dirs = dirs;
150 stats.initial_size = size;
151 stats.excluded_count = excluded;
152 }
153
154 pub fn start_license_detection_engine_creation(&self) {
155 self.start_phase("license_detection_engine_creation");
156 self.message("Loading SPDX data, this may take a while...");
157 }
158
159 pub fn finish_license_detection_engine_creation(&self, detail_name: impl Into<String>) {
160 self.finish_detail_phase(detail_name.into(), "license_detection_engine_creation");
161 }
162
163 pub fn start_scan(&self, total_files: usize) {
164 self.start_phase("scan");
165 self.scan_bar.set_length(total_files as u64);
166 self.scan_bar.set_position(0);
167
168 if matches!(self.mode, ProgressMode::Default | ProgressMode::Verbose) && !self.stderr_is_tty
169 {
170 self.message(&format!(
171 "Scanning {total_files} {}...",
172 pluralize_files(total_files)
173 ));
174 }
175 }
176
177 pub fn file_completed(&self, path: &Path, bytes: u64, scan_diagnostics: &[ScanDiagnostic]) {
178 self.scan_bar.inc(1);
179 let mut stats = self.stats.lock().expect("stats lock poisoned");
180 stats.total_bytes_scanned += bytes;
181
182 let (errors, warnings) = partition_scan_diagnostics(scan_diagnostics);
183
184 if !errors.is_empty() {
185 stats.error_count += 1;
186 } else if !warnings.is_empty() {
187 stats.warning_count += 1;
188 }
189 drop(stats);
190
191 match self.mode {
192 ProgressMode::Quiet => {}
193 ProgressMode::Default => {
194 if let Some(formatted) =
195 format_default_scan_error_from_diagnostics(path, scan_diagnostics)
196 {
197 self.error(&formatted);
198 } else if let Some(formatted) =
199 format_default_scan_warning_from_list(path, &warnings)
200 {
201 self.message(&format!("Warning: {formatted}"));
202 }
203 }
204 ProgressMode::Verbose => {
205 if self.stderr_is_tty || !errors.is_empty() || !warnings.is_empty() {
206 self.message(&path.to_string_lossy());
207 }
208 for err in &errors {
209 for line in err.lines() {
210 self.error(&format!(" {line}"));
211 }
212 }
213 for warning in &warnings {
214 for line in warning.lines() {
215 self.message(&format!(" warning: {line}"));
216 }
217 }
218 }
219 }
220 }
221
222 pub fn record_runtime_error(&self, path: &Path, err: &str) {
223 let mut stats = self.stats.lock().expect("stats lock poisoned");
224 stats.error_count += 1;
225 drop(stats);
226
227 match self.mode {
228 ProgressMode::Quiet => {}
229 ProgressMode::Default => self.error(&format_default_scan_error(path, err)),
230 ProgressMode::Verbose => {
231 self.error(&format!("Path: {}", path.to_string_lossy()));
232 for line in err.lines() {
233 self.error(&format!(" {line}"));
234 }
235 }
236 }
237 }
238
239 pub fn record_additional_error(&self, err: &str) {
240 let mut stats = self.stats.lock().expect("stats lock poisoned");
241 stats.error_count += 1;
242 drop(stats);
243
244 if self.mode != ProgressMode::Quiet {
245 self.error(err);
246 }
247 }
248
249 pub fn finish_scan(&self) {
250 self.finish_top_level_phase("scan");
251 if self.mode == ProgressMode::Default && self.stderr_is_tty {
252 self.scan_bar.finish_with_message("Scan complete!");
253 } else {
254 self.scan_bar.finish_and_clear();
255 if matches!(self.mode, ProgressMode::Default)
256 || (self.mode == ProgressMode::Verbose && !self.stderr_is_tty)
257 {
258 self.message("Scan complete.");
259 }
260 }
261 }
262
263 pub fn record_incremental_reused(&self, count: usize) {
264 let mut stats = self.stats.lock().expect("stats lock poisoned");
265 stats.incremental_reused += count;
266 }
267
268 pub fn start_assembly(&self) {
269 self.start_phase("assembly");
270 match self.mode {
271 ProgressMode::Quiet => {}
272 ProgressMode::Default => self.start_spinner("Assembling packages..."),
273 ProgressMode::Verbose => self.message("Assembling packages..."),
274 }
275 }
276
277 pub fn assembly_step(&self, step: &str) {
278 if self.mode == ProgressMode::Verbose {
279 self.message(&format!(" {step}"));
280 }
281 }
282
283 pub fn finish_assembly(&self, packages_assembled: usize, manifests_seen: usize) {
284 self.finish_spinner();
285 self.finish_top_level_phase("assembly");
286 let mut stats = self.stats.lock().expect("stats lock poisoned");
287 stats.packages_assembled = packages_assembled;
288 stats.manifests_seen = manifests_seen;
289 }
290
291 pub fn start_output(&self) {
292 self.start_phase("output");
293 match self.mode {
294 ProgressMode::Quiet => {}
295 ProgressMode::Default => self.start_spinner("Writing output..."),
296 ProgressMode::Verbose => self.message("Writing output..."),
297 }
298 }
299
300 pub fn output_written(&self, text: &str) {
301 self.message(text);
302 }
303
304 pub fn finish_output(&self) {
305 self.finish_spinner();
306 self.finish_top_level_phase("output");
307 }
308
309 pub fn start_post_scan(&self) {
310 self.start_phase("post-scan");
311 if self.mode == ProgressMode::Verbose {
312 self.message("Post-processing scan results...");
313 }
314 }
315
316 pub fn post_scan_step(&self, step: &str) {
317 if self.mode == ProgressMode::Verbose {
318 self.message(&format!(" {step}"));
319 }
320 }
321
322 pub fn finish_post_scan(&self) {
323 self.finish_top_level_phase("post-scan");
324 }
325
326 pub fn start_finalize(&self) {
327 self.start_phase("finalize");
328 if self.mode == ProgressMode::Verbose {
329 self.message("Finalizing scan results...");
330 }
331 }
332
333 pub fn finalize_step(&self, step: &str) {
334 if self.mode == ProgressMode::Verbose {
335 self.message(&format!(" {step}"));
336 }
337 }
338
339 pub fn finish_finalize(&self) {
340 self.finish_top_level_phase("finalize");
341 }
342
343 pub fn record_detail_timing(&self, name: impl Into<String>, duration: f64) {
344 let mut stats = self.stats.lock().expect("stats lock poisoned");
345 accumulate_timing(&mut stats.detail_timings, name.into(), duration);
346 }
347
348 pub fn record_final_counts(&self, files: &[FileInfo]) {
349 let mut stats = self.stats.lock().expect("stats lock poisoned");
350 stats.final_files = files
351 .iter()
352 .filter(|f| f.file_type == FileType::File)
353 .count();
354 stats.final_dirs = files
355 .iter()
356 .filter(|f| f.file_type == FileType::Directory)
357 .count();
358 stats.final_size = files
359 .iter()
360 .filter(|f| f.file_type == FileType::File)
361 .map(|f| f.size)
362 .sum();
363 }
364
365 pub fn record_final_header_counts(&self, headers: &[Header]) {
366 let mut stats = self.stats.lock().expect("stats lock poisoned");
367 let header_error_count: usize = headers.iter().map(|header| header.errors.len()).sum();
368 let header_warning_count: usize = headers.iter().map(|header| header.warnings.len()).sum();
369
370 stats.error_count = stats.error_count.max(header_error_count);
371 stats.warning_count = stats.warning_count.max(header_warning_count);
372 }
373
374 pub fn display_summary(&self, scan_start: &str, scan_end: &str) {
375 if self.mode == ProgressMode::Quiet {
376 return;
377 }
378
379 let stats = self.stats.lock().expect("stats lock poisoned");
380
381 if stats.error_count > 0 {
382 self.error("Some files failed to scan properly:");
383 } else if stats.warning_count > 0 {
384 self.message("Some files reported recoverable scan warnings:");
385 }
386 for line in build_summary_messages(&stats, scan_start, scan_end) {
387 self.message(&line);
388 }
389 if stats.incremental_reused > 0 {
390 self.message(&format!(
391 "Incremental: {} unchanged file(s) reused",
392 stats.incremental_reused
393 ));
394 }
395 }
396
397 fn message(&self, msg: &str) {
398 if self.mode == ProgressMode::Quiet {
399 return;
400 }
401
402 if self.mode == ProgressMode::Default && self.stderr_is_tty {
403 let _ = self.multi.println(msg);
404 } else {
405 eprintln!("{msg}");
406 }
407 }
408
409 fn error(&self, msg: &str) {
410 if self.mode == ProgressMode::Quiet {
411 return;
412 }
413
414 if supports_color(self.stderr_is_tty) {
415 self.message(&format!("\u{1b}[31m{msg}\u{1b}[0m"));
416 } else {
417 self.message(msg);
418 }
419 }
420
421 fn start_phase(&self, phase: &'static str) {
422 self.phase_starts
423 .lock()
424 .expect("phase lock poisoned")
425 .insert(phase, Instant::now());
426 }
427
428 fn finish_top_level_phase(&self, phase: &'static str) {
429 let start = self
430 .phase_starts
431 .lock()
432 .expect("phase lock poisoned")
433 .remove(phase);
434 if let Some(start) = start {
435 let mut stats = self.stats.lock().expect("stats lock poisoned");
436 accumulate_timing(
437 &mut stats.top_level_timings,
438 phase.to_string(),
439 start.elapsed().as_secs_f64(),
440 );
441 }
442 }
443
444 fn finish_detail_phase(&self, name: String, phase: &'static str) {
445 let start = self
446 .phase_starts
447 .lock()
448 .expect("phase lock poisoned")
449 .remove(phase);
450 if let Some(start) = start {
451 let mut stats = self.stats.lock().expect("stats lock poisoned");
452 accumulate_timing(
453 &mut stats.detail_timings,
454 name,
455 start.elapsed().as_secs_f64(),
456 );
457 }
458 }
459
460 fn start_spinner(&self, message: &str) {
461 if self.mode != ProgressMode::Default || !self.stderr_is_tty {
462 self.message(message);
463 return;
464 }
465
466 let spinner = self.multi.add(ProgressBar::new_spinner());
467 spinner.set_style(
468 ProgressStyle::default_spinner()
469 .template("{spinner:.green} {msg}")
470 .expect("Failed to create spinner style"),
471 );
472 spinner.enable_steady_tick(std::time::Duration::from_millis(80));
473 spinner.set_message(message.to_string());
474 *self
475 .phase_spinner
476 .lock()
477 .expect("phase spinner lock poisoned") = Some(spinner);
478 }
479
480 fn finish_spinner(&self) {
481 if let Some(spinner) = self
482 .phase_spinner
483 .lock()
484 .expect("phase spinner lock poisoned")
485 .take()
486 {
487 spinner.finish_and_clear();
488 }
489 }
490}
491
492fn build_env_logger() -> env_logger::Logger {
493 let mut builder = env_logger::Builder::from_env(Env::default().default_filter_or("warn"));
494 apply_default_log_filters(&mut builder);
495 builder.build()
496}
497
498fn apply_default_log_filters(builder: &mut env_logger::Builder) {
499 apply_default_log_filters_from(builder, env::var("RUST_LOG").ok().as_deref());
500}
501
502fn apply_default_log_filters_from(builder: &mut env_logger::Builder, rust_log: Option<&str>) {
503 if let Some(level) = pdf_oxide_default_log_filter_from(rust_log) {
504 builder.filter_module("pdf_oxide", level);
505 }
506}
507
508pub(crate) fn format_default_scan_error(path: &Path, err: &str) -> String {
509 let reason = concise_scan_error_reason(err);
510 format!("{reason}: {}", path.to_string_lossy())
511}
512
513pub(crate) fn format_default_scan_error_from_list(
514 path: &Path,
515 scan_errors: &[String],
516) -> Option<String> {
517 let is_timeout = |error: &str| {
522 error.starts_with("Timeout while ")
523 || error.starts_with("Timeout before ")
524 || error.starts_with("Timeout during ")
525 || error.starts_with("Processing interrupted due to timeout")
526 };
527 scan_errors
528 .iter()
529 .find(|error| is_timeout(error))
530 .or_else(|| scan_errors.first())
531 .map(|error| format_default_scan_error(path, error))
532}
533
534pub(crate) fn format_default_scan_error_from_diagnostics(
535 path: &Path,
536 scan_diagnostics: &[ScanDiagnostic],
537) -> Option<String> {
538 let errors: Vec<&ScanDiagnostic> = scan_diagnostics
539 .iter()
540 .filter(|d| d.severity == DiagnosticSeverity::Error)
541 .collect();
542
543 errors
544 .iter()
545 .find(|d| d.is_timeout)
546 .or_else(|| errors.first())
547 .map(|d| format_default_scan_error(path, &d.message))
548}
549
550pub(crate) fn format_default_scan_warning_from_list(
551 path: &Path,
552 scan_warnings: &[String],
553) -> Option<String> {
554 scan_warnings
555 .first()
556 .map(|warning| format_default_scan_error(path, warning))
557}
558
559pub(crate) fn partition_scan_diagnostics(
560 scan_diagnostics: &[ScanDiagnostic],
561) -> (Vec<String>, Vec<String>) {
562 let mut errors = Vec::new();
563 let mut warnings = Vec::new();
564
565 for diagnostic in scan_diagnostics {
566 match diagnostic.severity {
567 DiagnosticSeverity::Error => errors.push(diagnostic.message.clone()),
568 DiagnosticSeverity::Warning => warnings.push(diagnostic.message.clone()),
569 }
570 }
571
572 (errors, warnings)
573}
574
575fn concise_scan_error_reason(err: &str) -> String {
576 let first_line = err
577 .lines()
578 .find(|line| !line.trim().is_empty())
579 .map(str::trim)
580 .unwrap_or("Scan failed");
581
582 if let Some((prefix, _)) = first_line.split_once(" at ")
583 && is_structured_error_prefix(prefix)
584 {
585 return prefix.to_string();
586 }
587
588 if let Some((prefix, _)) = first_line.split_once(": ")
589 && is_structured_error_prefix(prefix)
590 {
591 return prefix.to_string();
592 }
593
594 first_line.to_string()
595}
596
597pub(crate) fn is_warning_scan_error(err: &str) -> bool {
598 is_legacy_warning_message(err)
599}
600
601fn is_structured_error_prefix(prefix: &str) -> bool {
602 let lowercase = prefix.to_ascii_lowercase();
603 lowercase.starts_with("failed to ")
604 || lowercase.ends_with(" failed")
605 || lowercase.starts_with("timeout ")
606 || lowercase.starts_with("processing interrupted")
607}
608
609fn pluralize_files(count: usize) -> &'static str {
610 if count == 1 { "file" } else { "files" }
611}
612
613fn pdf_oxide_default_log_filter_from(rust_log: Option<&str>) -> Option<LevelFilter> {
614 should_filter_pdf_oxide_default_warnings_from(rust_log).then_some(LevelFilter::Off)
615}
616
617fn should_filter_pdf_oxide_default_warnings_from(rust_log: Option<&str>) -> bool {
618 rust_log.is_none_or(|value| !value.contains("pdf_oxide"))
619}
620
621fn accumulate_timing(timings: &mut Vec<(String, f64)>, name: String, duration: f64) {
622 if let Some((_, existing)) = timings
623 .iter_mut()
624 .find(|(existing_name, _)| *existing_name == name)
625 {
626 *existing += duration;
627 } else {
628 timings.push((name, duration));
629 }
630}
631
632fn supports_color(stderr_is_tty: bool) -> bool {
633 if !stderr_is_tty {
634 return false;
635 }
636 if env::var_os("NO_COLOR").is_some() {
637 return false;
638 }
639 !matches!(env::var("TERM"), Ok(term) if term == "dumb")
640}
641
642fn build_summary_messages(stats: &ScanStats, scan_start: &str, scan_end: &str) -> Vec<String> {
643 let total = stats
644 .top_level_timings
645 .iter()
646 .map(|(_, value)| *value)
647 .sum::<f64>()
648 .max(0.0);
649 let scan_time = stats
650 .top_level_timings
651 .iter()
652 .find_map(|(name, value)| (name == "scan").then_some(*value))
653 .unwrap_or(0.0);
654
655 let speed_files = if scan_time > 0.0 {
656 stats.final_files as f64 / scan_time
657 } else {
658 0.0
659 };
660 let speed_bytes = if scan_time > 0.0 {
661 stats.total_bytes_scanned as f64 / scan_time
662 } else {
663 0.0
664 };
665
666 let scan_names = if stats.scan_names.is_empty() {
667 "scan".to_string()
668 } else {
669 stats.scan_names.clone()
670 };
671
672 let mut lines = vec![
673 format!(
674 "Summary: {scan_names} with {} process(es)",
675 stats.processes.to_i32()
676 ),
677 format!("Errors count: {}", stats.error_count),
678 format!("Warnings count: {}", stats.warning_count),
679 format!(
680 "Scan Speed: {speed_files:.2} files/sec. {}/sec.",
681 format_size(speed_bytes)
682 ),
683 format!(
684 "Initial counts: {} resource(s): {} file(s) and {} directorie(s) for {}",
685 stats.initial_files + stats.initial_dirs,
686 stats.initial_files,
687 stats.initial_dirs,
688 format_size(stats.initial_size as f64)
689 ),
690 format!(
691 "Final counts: {} resource(s): {} file(s) and {} directorie(s) for {}",
692 stats.final_files + stats.final_dirs,
693 stats.final_files,
694 stats.final_dirs,
695 format_size(stats.final_size as f64)
696 ),
697 format!("Excluded count: {}", stats.excluded_count),
698 format!(
699 "Packages: {} assembled from {} manifests",
700 stats.packages_assembled, stats.manifests_seen
701 ),
702 "Timings:".to_string(),
703 format!(" scan_start: {scan_start}"),
704 format!(" scan_end: {scan_end}"),
705 ];
706
707 for (name, value) in &stats.top_level_timings {
708 lines.push(format!(" {name}: {value:.2}s"));
709
710 let detail_timings = stats
711 .detail_timings
712 .iter()
713 .filter(|(detail_name, _)| detail_parent_phase(detail_name) == Some(name.as_str()));
714
715 if name == "scan" {
716 let scan_breakdown: Vec<_> = detail_timings.collect();
717 if !scan_breakdown.is_empty() {
718 lines.push(" scan breakdown (cumulative worker time):".to_string());
719 lines.extend(
720 scan_breakdown
721 .into_iter()
722 .map(|(detail_name, detail_value)| {
723 format!(" {detail_name}: {detail_value:.2}s")
724 }),
725 );
726 }
727 } else {
728 lines.extend(detail_timings.map(|(detail_name, detail_value)| {
729 format!(" {detail_name}: {detail_value:.2}s")
730 }));
731 }
732 }
733 lines.push(format!(" total: {total:.2}s"));
734
735 lines
736}
737
738fn detail_parent_phase(detail_name: &str) -> Option<&'static str> {
739 if detail_name.starts_with("setup:") || detail_name.starts_with("setup_scan:") {
740 Some("setup")
741 } else if detail_name.starts_with("scan:") {
742 Some("scan")
743 } else if detail_name.starts_with("post-scan:") || detail_name.starts_with("output-filter:") {
744 Some("post-scan")
745 } else if detail_name.starts_with("assembly:") {
746 Some("assembly")
747 } else if detail_name.starts_with("finalize:") {
748 Some("finalize")
749 } else if detail_name.starts_with("output:") {
750 Some("output")
751 } else {
752 None
753 }
754}
755
756pub fn format_size(bytes: f64) -> String {
757 if bytes < 1.0 {
758 return "0 Bytes".to_string();
759 }
760 if bytes == 1.0 {
761 return "1 Byte".to_string();
762 }
763
764 let mut size = bytes;
765 let units = ["Bytes", "KB", "MB", "GB", "TB"];
766 let mut idx = 0;
767 while size >= 1024.0 && idx < units.len() - 1 {
768 size /= 1024.0;
769 idx += 1;
770 }
771
772 if idx == 0 {
773 format!("{:.0} {}", size, units[idx])
774 } else {
775 format!("{size:.2} {}", units[idx])
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::{
782 ProgressMode, ScanProgress, ScanStats, apply_default_log_filters_from,
783 build_summary_messages, concise_scan_error_reason, format_default_scan_error,
784 format_default_scan_error_from_list, format_size, pdf_oxide_default_log_filter_from,
785 pluralize_files, should_filter_pdf_oxide_default_warnings_from,
786 };
787 use crate::cli::ProcessMode;
788 use crate::models::ScanDiagnostic;
789
790 use std::path::Path;
791
792 use log::{Level, LevelFilter, Log, MetadataBuilder};
793
794 #[test]
795 fn format_size_matches_expected_shape() {
796 assert_eq!(format_size(0.0), "0 Bytes");
797 assert_eq!(format_size(1.0), "1 Byte");
798 assert_eq!(format_size(1024.0), "1.00 KB");
799 assert_eq!(format_size(2_567_000.0), "2.45 MB");
800 }
801
802 #[test]
803 fn summary_messages_render_detail_timings_hierarchically() {
804 let stats = ScanStats {
805 processes: ProcessMode::Parallel(4),
806 scan_names: "licenses, packages".to_string(),
807 initial_files: 10,
808 initial_dirs: 2,
809 initial_size: 2_048,
810 excluded_count: 1,
811 final_files: 8,
812 final_dirs: 1,
813 final_size: 1_024,
814 error_count: 0,
815 warning_count: 0,
816 total_bytes_scanned: 800,
817 packages_assembled: 3,
818 manifests_seen: 5,
819 incremental_reused: 0,
820 top_level_timings: vec![
821 ("setup".to_string(), 1.0),
822 ("inventory".to_string(), 2.0),
823 ("scan".to_string(), 3.0),
824 ("post-scan".to_string(), 4.0),
825 ("assembly".to_string(), 5.0),
826 ("finalize".to_string(), 6.0),
827 ("output".to_string(), 7.0),
828 ],
829 detail_timings: vec![
830 ("setup_scan:licenses".to_string(), 0.5),
831 ("scan:packages".to_string(), 1.25),
832 ("output-filter:only-findings".to_string(), 1.5),
833 ("finalize:output-prepare".to_string(), 2.0),
834 ],
835 };
836
837 let lines = build_summary_messages(&stats, "start", "end");
838 let line_index = |needle: &str| {
839 lines
840 .iter()
841 .position(|line| line == needle)
842 .unwrap_or_else(|| panic!("missing line: {needle}"))
843 };
844
845 assert!(lines.contains(&" total: 28.00s".to_string()));
846 assert!(lines.contains(&" setup_scan:licenses: 0.50s".to_string()));
847 assert!(lines.contains(&" scan breakdown (cumulative worker time):".to_string()));
848 assert!(lines.contains(&" scan:packages: 1.25s".to_string()));
849 assert!(lines.contains(&" output-filter:only-findings: 1.50s".to_string()));
850 assert!(lines.contains(&" finalize:output-prepare: 2.00s".to_string()));
851 assert!(line_index(" setup: 1.00s") < line_index(" setup_scan:licenses: 0.50s"));
852 assert!(
853 line_index(" scan: 3.00s") < line_index(" scan breakdown (cumulative worker time):")
854 );
855 assert!(
856 line_index(" scan breakdown (cumulative worker time):")
857 < line_index(" scan:packages: 1.25s")
858 );
859 assert!(
860 line_index(" post-scan: 4.00s") < line_index(" output-filter:only-findings: 1.50s")
861 );
862 assert!(line_index(" finalize: 6.00s") < line_index(" finalize:output-prepare: 2.00s"));
863 }
864
865 #[test]
866 fn summary_messages_use_scan_time_for_scan_speed() {
867 let stats = ScanStats {
868 final_files: 20,
869 total_bytes_scanned: 2_048,
870 top_level_timings: vec![("scan".to_string(), 4.0)],
871 ..ScanStats::default()
872 };
873
874 let lines = build_summary_messages(&stats, "start", "end");
875
876 assert!(lines.contains(&"Scan Speed: 5.00 files/sec. 512 Bytes/sec.".to_string()));
877 }
878
879 #[test]
880 fn default_pdf_oxide_warnings_are_suppressed() {
881 assert_eq!(
882 pdf_oxide_default_log_filter_from(None),
883 Some(LevelFilter::Off)
884 );
885 assert!(should_filter_pdf_oxide_default_warnings_from(None));
886 }
887
888 #[test]
889 fn explicit_pdf_oxide_rust_log_override_disables_default_filter() {
890 assert!(!should_filter_pdf_oxide_default_warnings_from(Some(
891 "pdf_oxide::fonts::font_dict=warn"
892 )));
893 }
894
895 #[test]
896 fn default_pdf_oxide_filter_covers_unlisted_submodules() {
897 let mut builder = env_logger::Builder::new();
898 builder.filter_level(LevelFilter::Warn);
899 apply_default_log_filters_from(&mut builder, None);
900 let logger = builder.build();
901 let warn_metadata = MetadataBuilder::new()
902 .target("pdf_oxide::content::parser")
903 .level(Level::Warn)
904 .build();
905 let error_metadata = MetadataBuilder::new()
906 .target("pdf_oxide::content::parser")
907 .level(Level::Error)
908 .build();
909
910 assert!(!logger.enabled(&warn_metadata));
911 assert!(!logger.enabled(&error_metadata));
912 }
913
914 #[test]
915 fn concise_scan_error_reason_keeps_high_level_failure_context() {
916 assert_eq!(
917 concise_scan_error_reason(
918 "Failed to read or parse package.json at \"fixtures/package.json\": key must be a string at line 1 column 3"
919 ),
920 "Failed to read or parse package.json"
921 );
922 assert_eq!(
923 concise_scan_error_reason("License detection failed: missing query token"),
924 "License detection failed"
925 );
926 assert_eq!(
927 concise_scan_error_reason("Processing interrupted due to timeout after 2.00 seconds"),
928 "Processing interrupted due to timeout after 2.00 seconds"
929 );
930 }
931
932 #[test]
933 fn default_scan_error_format_includes_reason_and_path() {
934 let formatted = format_default_scan_error(
935 Path::new("fixtures/package.json"),
936 "Failed to read or parse package.json at \"fixtures/package.json\": key must be a string at line 1 column 3",
937 );
938
939 assert_eq!(
940 formatted,
941 "Failed to read or parse package.json: fixtures/package.json"
942 );
943 }
944
945 #[test]
946 fn default_scan_error_format_prefers_timeout_from_error_list() {
947 let formatted = format_default_scan_error_from_list(
948 Path::new("fixtures/package.json"),
949 &[
950 "Failed to read or parse package.json at \"fixtures/package.json\": expected value"
951 .to_string(),
952 "Timeout before license scan (> 120.00s)".to_string(),
953 ],
954 );
955
956 assert_eq!(
957 formatted.as_deref(),
958 Some("Timeout before license scan (> 120.00s): fixtures/package.json")
959 );
960 }
961
962 #[test]
963 fn pluralize_files_uses_expected_labels() {
964 assert_eq!(pluralize_files(1), "file");
965 assert_eq!(pluralize_files(2), "files");
966 }
967
968 #[test]
969 fn file_completed_counts_warning_diagnostics_without_prefix_heuristics() {
970 let progress = ScanProgress::new(ProgressMode::Quiet);
971
972 progress.file_completed(
973 Path::new("project/custom.txt"),
974 42,
975 &[ScanDiagnostic::warning("custom recoverable warning")],
976 );
977
978 let stats = progress.stats.lock().expect("stats lock poisoned");
979 assert_eq!(stats.warning_count, 1);
980 assert_eq!(stats.error_count, 0);
981 }
982
983 #[test]
984 fn final_header_counts_raise_summary_warning_count() {
985 let progress = ScanProgress::new(ProgressMode::Quiet);
986
987 progress.record_final_header_counts(&[crate::models::Header {
988 tool_name: "provenant".to_string(),
989 tool_version: "0.0.0-test".to_string(),
990 options: serde_json::Map::new(),
991 notice: "test".to_string(),
992 start_timestamp: "start".to_string(),
993 end_timestamp: "end".to_string(),
994 output_format_version: "4.1.0".to_string(),
995 duration: 0.0,
996 errors: vec![],
997 warnings: vec!["custom replay warning".to_string()],
998 extra_data: crate::models::ExtraData {
999 system_environment: crate::models::SystemEnvironment {
1000 operating_system: "linux".to_string(),
1001 cpu_architecture: "x86_64".to_string(),
1002 platform: "linux".to_string(),
1003 platform_version: "test".to_string(),
1004 rust_version: "1.0.0".to_string(),
1005 },
1006 spdx_license_list_version: "test".to_string(),
1007 files_count: 0,
1008 directories_count: 0,
1009 excluded_count: 0,
1010 license_index_provenance: None,
1011 },
1012 }]);
1013
1014 let stats = progress.stats.lock().expect("stats lock poisoned");
1015 assert_eq!(stats.warning_count, 1);
1016 assert_eq!(stats.error_count, 0);
1017 }
1018}