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