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