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