Skip to main content

provenant/
progress.rs

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