Skip to main content

provenant/
progress.rs

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