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