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