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}