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