Skip to main content

provenant/
progress.rs

1use std::collections::HashMap;
2use std::env;
3use std::io::IsTerminal;
4use std::path::Path;
5use std::sync::Mutex;
6use std::time::Instant;
7
8use env_logger::Env;
9use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
10use indicatif_log_bridge::LogWrapper;
11
12use crate::models::{FileInfo, FileType};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum ProgressMode {
16    Quiet,
17    Default,
18    Verbose,
19}
20
21#[derive(Debug, Default, Clone)]
22pub struct ScanStats {
23    pub processes: usize,
24    pub scan_names: String,
25    pub initial_files: usize,
26    pub initial_dirs: usize,
27    pub initial_size: u64,
28    pub excluded_count: usize,
29    pub final_files: usize,
30    pub final_dirs: usize,
31    pub final_size: u64,
32    pub error_count: usize,
33    pub total_bytes_scanned: u64,
34    pub packages_assembled: usize,
35    pub manifests_seen: usize,
36    pub phase_timings: Vec<(String, f64)>,
37}
38
39pub struct ScanProgress {
40    mode: ProgressMode,
41    multi: MultiProgress,
42    scan_bar: ProgressBar,
43    stats: Mutex<ScanStats>,
44    phase_starts: Mutex<HashMap<&'static str, Instant>>,
45    phase_spinner: Mutex<Option<ProgressBar>>,
46    started_at: Instant,
47    stderr_is_tty: bool,
48}
49
50impl ScanProgress {
51    pub fn new(mode: ProgressMode) -> Self {
52        let stderr_is_tty = std::io::stderr().is_terminal();
53        let multi = match mode {
54            ProgressMode::Quiet => MultiProgress::with_draw_target(ProgressDrawTarget::hidden()),
55            ProgressMode::Default if stderr_is_tty => {
56                MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(15))
57            }
58            ProgressMode::Default | ProgressMode::Verbose => {
59                MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
60            }
61        };
62
63        let scan_bar = if mode == ProgressMode::Default && stderr_is_tty {
64            multi.add(ProgressBar::new(0))
65        } else {
66            ProgressBar::hidden()
67        };
68
69        scan_bar.set_style(
70            ProgressStyle::default_bar()
71                .template(
72                    "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} files ({per_sec}) ({eta})",
73                )
74                .expect("Failed to create progress bar style")
75                .progress_chars("#>-"),
76        );
77
78        Self {
79            mode,
80            multi,
81            scan_bar,
82            stats: Mutex::new(ScanStats::default()),
83            phase_starts: Mutex::new(HashMap::new()),
84            phase_spinner: Mutex::new(None),
85            started_at: Instant::now(),
86            stderr_is_tty,
87        }
88    }
89
90    pub fn set_processes(&self, processes: usize) {
91        let mut stats = self.stats.lock().expect("stats lock poisoned");
92        stats.processes = processes;
93    }
94
95    pub fn set_scan_names(&self, scan_names: String) {
96        let mut stats = self.stats.lock().expect("stats lock poisoned");
97        stats.scan_names = scan_names;
98    }
99
100    pub fn init_logging_bridge(&self) {
101        if self.mode == ProgressMode::Quiet {
102            return;
103        }
104
105        let logger =
106            env_logger::Builder::from_env(Env::default().default_filter_or("warn")).build();
107        let level = logger.filter();
108        if LogWrapper::new(self.multi.clone(), logger)
109            .try_init()
110            .is_ok()
111        {
112            log::set_max_level(level);
113        }
114    }
115
116    pub fn start_discovery(&self) {
117        self.start_phase("discovery");
118        match self.mode {
119            ProgressMode::Quiet => {}
120            ProgressMode::Default => {
121                self.start_spinner("Collecting files...");
122            }
123            ProgressMode::Verbose => {
124                self.message("Collecting files...");
125            }
126        }
127    }
128
129    pub fn finish_discovery(&self, files: usize, dirs: usize, size: u64, excluded: usize) {
130        self.finish_spinner();
131        self.finish_phase("discovery");
132        let mut stats = self.stats.lock().expect("stats lock poisoned");
133        stats.initial_files = files;
134        stats.initial_dirs = dirs;
135        stats.initial_size = size;
136        stats.excluded_count = excluded;
137    }
138
139    pub fn start_spdx_load(&self) {
140        self.start_phase("spdx_load");
141        self.message("Loading SPDX data, this may take a while...");
142    }
143
144    pub fn finish_spdx_load(&self) {
145        self.finish_phase("spdx_load");
146    }
147
148    pub fn start_scan(&self, total_files: usize) {
149        self.start_phase("scan");
150        self.scan_bar.set_length(total_files as u64);
151        self.scan_bar.set_position(0);
152    }
153
154    pub fn file_completed(&self, path: &Path, bytes: u64, scan_errors: &[String]) {
155        self.scan_bar.inc(1);
156        let mut stats = self.stats.lock().expect("stats lock poisoned");
157        stats.total_bytes_scanned += bytes;
158
159        let has_error = !scan_errors.is_empty();
160        if has_error {
161            stats.error_count += 1;
162        }
163        drop(stats);
164
165        match self.mode {
166            ProgressMode::Quiet => {}
167            ProgressMode::Default => {
168                if has_error {
169                    self.error(&format!("Path: {}", path.to_string_lossy()));
170                }
171            }
172            ProgressMode::Verbose => {
173                self.message(&path.to_string_lossy());
174                for err in scan_errors {
175                    for line in err.lines() {
176                        self.error(&format!("  {line}"));
177                    }
178                }
179            }
180        }
181    }
182
183    pub fn record_runtime_error(&self, path: &Path, err: &str) {
184        let mut stats = self.stats.lock().expect("stats lock poisoned");
185        stats.error_count += 1;
186        drop(stats);
187
188        match self.mode {
189            ProgressMode::Quiet => {}
190            ProgressMode::Default => self.error(&format!("Path: {}", path.to_string_lossy())),
191            ProgressMode::Verbose => {
192                self.error(&format!("Path: {}", path.to_string_lossy()));
193                for line in err.lines() {
194                    self.error(&format!("  {line}"));
195                }
196            }
197        }
198    }
199
200    pub fn finish_scan(&self) {
201        self.finish_phase("scan");
202        if self.mode == ProgressMode::Default && self.stderr_is_tty {
203            self.scan_bar.finish_with_message("Scan complete!");
204        } else {
205            self.scan_bar.finish_and_clear();
206        }
207    }
208
209    pub fn start_assembly(&self) {
210        self.start_phase("assembly");
211        match self.mode {
212            ProgressMode::Quiet => {}
213            ProgressMode::Default => self.start_spinner("Assembling packages..."),
214            ProgressMode::Verbose => self.message("Assembling packages..."),
215        }
216    }
217
218    pub fn finish_assembly(&self, packages_assembled: usize, manifests_seen: usize) {
219        self.finish_spinner();
220        self.finish_phase("assembly");
221        let mut stats = self.stats.lock().expect("stats lock poisoned");
222        stats.packages_assembled = packages_assembled;
223        stats.manifests_seen = manifests_seen;
224    }
225
226    pub fn start_output(&self) {
227        self.start_phase("output");
228        match self.mode {
229            ProgressMode::Quiet => {}
230            ProgressMode::Default => self.start_spinner("Writing output..."),
231            ProgressMode::Verbose => self.message("Writing output..."),
232        }
233    }
234
235    pub fn output_written(&self, text: &str) {
236        self.message(text);
237    }
238
239    pub fn finish_output(&self) {
240        self.finish_spinner();
241        self.finish_phase("output");
242    }
243
244    pub fn record_final_counts(&self, files: &[FileInfo]) {
245        let mut stats = self.stats.lock().expect("stats lock poisoned");
246        stats.final_files = files
247            .iter()
248            .filter(|f| f.file_type == FileType::File)
249            .count();
250        stats.final_dirs = files
251            .iter()
252            .filter(|f| f.file_type == FileType::Directory)
253            .count();
254        stats.final_size = files
255            .iter()
256            .filter(|f| f.file_type == FileType::File)
257            .map(|f| f.size)
258            .sum();
259    }
260
261    pub fn display_summary(&self, scan_start: &str, scan_end: &str) {
262        if self.mode == ProgressMode::Quiet {
263            return;
264        }
265
266        let mut stats = self.stats.lock().expect("stats lock poisoned");
267        let total = self.started_at.elapsed().as_secs_f64();
268        stats
269            .phase_timings
270            .push(("total".to_string(), total.max(0.0)));
271
272        if stats.error_count > 0 {
273            self.error("Some files failed to scan properly:");
274        }
275
276        let speed_files = if total > 0.0 {
277            stats.final_files as f64 / total
278        } else {
279            0.0
280        };
281        let speed_bytes = if total > 0.0 {
282            stats.total_bytes_scanned as f64 / total
283        } else {
284            0.0
285        };
286
287        self.message("Scanning done.");
288        let processes = if stats.processes > 0 {
289            stats.processes
290        } else {
291            num_cpus_for_display()
292        };
293        let scan_names = if stats.scan_names.is_empty() {
294            "scan".to_string()
295        } else {
296            stats.scan_names.clone()
297        };
298        self.message(&format!(
299            "Summary:        {scan_names} with {processes} process(es)"
300        ));
301        self.message(&format!("Errors count:   {}", stats.error_count));
302        self.message(&format!(
303            "Scan Speed:     {speed_files:.2} files/sec. {}/sec.",
304            format_size(speed_bytes as u64)
305        ));
306        self.message(&format!(
307            "Initial counts: {} resource(s): {} file(s) and {} directorie(s) for {}",
308            stats.initial_files + stats.initial_dirs,
309            stats.initial_files,
310            stats.initial_dirs,
311            format_size(stats.initial_size)
312        ));
313        self.message(&format!(
314            "Final counts:   {} resource(s): {} file(s) and {} directorie(s) for {}",
315            stats.final_files + stats.final_dirs,
316            stats.final_files,
317            stats.final_dirs,
318            format_size(stats.final_size)
319        ));
320        self.message(&format!("Excluded count: {}", stats.excluded_count));
321        self.message(&format!(
322            "Packages:       {} assembled from {} manifests",
323            stats.packages_assembled, stats.manifests_seen
324        ));
325        self.message("Timings:");
326        self.message(&format!("  scan_start: {scan_start}"));
327        self.message(&format!("  scan_end:   {scan_end}"));
328        for (name, value) in &stats.phase_timings {
329            self.message(&format!("  {name}: {value:.2}s"));
330        }
331    }
332
333    fn message(&self, msg: &str) {
334        if self.mode == ProgressMode::Quiet {
335            return;
336        }
337
338        if self.mode == ProgressMode::Default && self.stderr_is_tty {
339            let _ = self.multi.println(msg);
340        } else {
341            eprintln!("{msg}");
342        }
343    }
344
345    fn error(&self, msg: &str) {
346        if self.mode == ProgressMode::Quiet {
347            return;
348        }
349
350        if supports_color(self.stderr_is_tty) {
351            self.message(&format!("\u{1b}[31m{msg}\u{1b}[0m"));
352        } else {
353            self.message(msg);
354        }
355    }
356
357    fn start_phase(&self, phase: &'static str) {
358        self.phase_starts
359            .lock()
360            .expect("phase lock poisoned")
361            .insert(phase, Instant::now());
362    }
363
364    fn finish_phase(&self, phase: &'static str) {
365        let start = self
366            .phase_starts
367            .lock()
368            .expect("phase lock poisoned")
369            .remove(phase);
370        if let Some(start) = start {
371            let mut stats = self.stats.lock().expect("stats lock poisoned");
372            stats
373                .phase_timings
374                .push((phase.to_string(), start.elapsed().as_secs_f64()));
375        }
376    }
377
378    fn start_spinner(&self, message: &str) {
379        if self.mode != ProgressMode::Default || !self.stderr_is_tty {
380            self.message(message);
381            return;
382        }
383
384        let spinner = self.multi.add(ProgressBar::new_spinner());
385        spinner.set_style(
386            ProgressStyle::default_spinner()
387                .template("{spinner:.green} {msg}")
388                .expect("Failed to create spinner style"),
389        );
390        spinner.enable_steady_tick(std::time::Duration::from_millis(80));
391        spinner.set_message(message.to_string());
392        *self
393            .phase_spinner
394            .lock()
395            .expect("phase spinner lock poisoned") = Some(spinner);
396    }
397
398    fn finish_spinner(&self) {
399        if let Some(spinner) = self
400            .phase_spinner
401            .lock()
402            .expect("phase spinner lock poisoned")
403            .take()
404        {
405            spinner.finish_and_clear();
406        }
407    }
408}
409
410fn supports_color(stderr_is_tty: bool) -> bool {
411    if !stderr_is_tty {
412        return false;
413    }
414    if env::var_os("NO_COLOR").is_some() {
415        return false;
416    }
417    !matches!(env::var("TERM"), Ok(term) if term == "dumb")
418}
419
420pub fn format_size(bytes: u64) -> String {
421    if bytes == 0 {
422        return "0 Bytes".to_string();
423    }
424    if bytes == 1 {
425        return "1 Byte".to_string();
426    }
427
428    let mut size = bytes as f64;
429    let units = ["Bytes", "KB", "MB", "GB", "TB"];
430    let mut idx = 0;
431    while size >= 1024.0 && idx < units.len() - 1 {
432        size /= 1024.0;
433        idx += 1;
434    }
435
436    if idx == 0 {
437        format!("{} {}", bytes, units[idx])
438    } else {
439        format!("{size:.2} {}", units[idx])
440    }
441}
442
443fn num_cpus_for_display() -> usize {
444    let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
445    if cpus > 1 { cpus - 1 } else { 1 }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::format_size;
451
452    #[test]
453    fn format_size_matches_expected_shape() {
454        assert_eq!(format_size(0), "0 Bytes");
455        assert_eq!(format_size(1), "1 Byte");
456        assert_eq!(format_size(1024), "1.00 KB");
457        assert_eq!(format_size(2_567_000), "2.45 MB");
458    }
459}