Skip to main content

provenant/
progress.rs

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}