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