zesven/
progress.rs

1//! Enhanced progress reporting for archive operations.
2//!
3//! This module provides extended progress callbacks with support for:
4//! - Cancellation signaling (return false to abort)
5//! - Compression ratio tracking
6//! - ETA calculation
7//! - Rate limiting callbacks to reduce overhead
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use zesven::progress::{ProgressReporter, StatisticsProgress};
13//! use zesven::{Archive, ExtractOptions};
14//!
15//! let progress = StatisticsProgress::new();
16//! let options = ExtractOptions::new().progress(progress);
17//! archive.extract("./output", (), &options)?;
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24/// IEC byte unit: 1 KiB = 1024 bytes.
25pub const BYTES_KIB: u64 = 1024;
26/// IEC byte unit: 1 MiB = 1024 KiB.
27pub const BYTES_MIB: u64 = 1024 * BYTES_KIB;
28/// IEC byte unit: 1 GiB = 1024 MiB.
29pub const BYTES_GIB: u64 = 1024 * BYTES_MIB;
30
31// Floating point versions for formatting calculations
32const BYTES_KB: f64 = 1024.0;
33const BYTES_MB: f64 = BYTES_KB * 1024.0;
34const BYTES_GB: f64 = BYTES_MB * 1024.0;
35
36/// Progress reporting trait for archive operations.
37///
38/// This trait supports:
39/// - Cancellation by returning `false` from `on_progress`
40/// - Compression ratio reporting
41/// - Archive-level totals
42/// - Entry-level progress tracking
43pub trait ProgressReporter: Send {
44    /// Called at the start with the total bytes to process.
45    ///
46    /// This is called once before extraction begins.
47    fn on_total(&mut self, total_bytes: u64) {
48        let _ = total_bytes;
49    }
50
51    /// Called periodically during operation.
52    ///
53    /// Returns `true` to continue or `false` to request cancellation.
54    fn on_progress(&mut self, bytes_processed: u64, total_bytes: u64) -> bool {
55        let _ = (bytes_processed, total_bytes);
56        true
57    }
58
59    /// Called when compression/decompression ratio changes significantly.
60    ///
61    /// Useful for displaying compression efficiency.
62    fn on_ratio(&mut self, input_bytes: u64, output_bytes: u64) {
63        let _ = (input_bytes, output_bytes);
64    }
65
66    /// Called when starting to process a new entry.
67    ///
68    /// Note: Archives contain "entries" which may be files or directories.
69    fn on_entry_start(&mut self, entry_name: &str, size: u64) {
70        let _ = (entry_name, size);
71    }
72
73    /// Called when entry processing completes.
74    ///
75    /// Note: Archives contain "entries" which may be files or directories.
76    fn on_entry_complete(&mut self, entry_name: &str, success: bool) {
77        let _ = (entry_name, success);
78    }
79
80    /// Called when a password is needed.
81    ///
82    /// Return `Some(password)` to provide a password, or `None` to abort.
83    fn on_password_needed(&mut self) -> Option<String> {
84        None
85    }
86
87    /// Called on any warning during processing.
88    fn on_warning(&mut self, message: &str) {
89        let _ = message;
90    }
91
92    /// Checks if cancellation has been requested.
93    ///
94    /// This is called before processing each entry to allow early termination
95    /// without waiting for the next `on_progress` callback.
96    ///
97    /// Default implementation returns `false` (no cancellation).
98    fn should_cancel(&self) -> bool {
99        false
100    }
101}
102
103/// Progress state with timing and rate calculation.
104#[derive(Debug, Clone)]
105pub struct ProgressState {
106    /// Total bytes to process.
107    pub total_bytes: u64,
108    /// Bytes processed so far.
109    pub processed_bytes: u64,
110    /// Compressed/packed bytes (for ratio calculation).
111    pub packed_bytes: u64,
112    /// Current entry being processed (may be a file or directory).
113    pub current_entry: Option<String>,
114    /// Number of entries processed.
115    pub entries_processed: usize,
116    /// Total number of entries.
117    pub entries_total: usize,
118    /// Processing start time.
119    pub start_time: Instant,
120    /// Time of last update.
121    pub last_update: Instant,
122}
123
124impl Default for ProgressState {
125    fn default() -> Self {
126        let now = Instant::now();
127        Self {
128            total_bytes: 0,
129            processed_bytes: 0,
130            packed_bytes: 0,
131            current_entry: None,
132            entries_processed: 0,
133            entries_total: 0,
134            start_time: now,
135            last_update: now,
136        }
137    }
138}
139
140impl ProgressState {
141    /// Creates a new progress state.
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Returns the completion percentage (0.0 - 100.0).
147    pub fn percentage(&self) -> f64 {
148        if self.total_bytes == 0 {
149            0.0
150        } else {
151            (self.processed_bytes as f64 / self.total_bytes as f64) * 100.0
152        }
153    }
154
155    /// Returns the compression ratio (packed / unpacked).
156    pub fn compression_ratio(&self) -> f64 {
157        if self.processed_bytes == 0 {
158            1.0
159        } else {
160            self.packed_bytes as f64 / self.processed_bytes as f64
161        }
162    }
163
164    /// Returns elapsed time since start.
165    pub fn elapsed(&self) -> Duration {
166        self.start_time.elapsed()
167    }
168
169    /// Returns the processing rate in bytes per second.
170    pub fn bytes_per_second(&self) -> f64 {
171        let elapsed = self.elapsed().as_secs_f64();
172        if elapsed < 0.001 {
173            0.0
174        } else {
175            self.processed_bytes as f64 / elapsed
176        }
177    }
178
179    /// Returns estimated time remaining.
180    pub fn eta(&self) -> Option<Duration> {
181        let rate = self.bytes_per_second();
182        if rate < 1.0 || self.processed_bytes >= self.total_bytes {
183            return None;
184        }
185        let remaining = self.total_bytes - self.processed_bytes;
186        let seconds = remaining as f64 / rate;
187        Some(Duration::from_secs_f64(seconds))
188    }
189
190    /// Formats the rate as a human-readable string using IEC units.
191    pub fn format_rate(&self) -> String {
192        let rate = self.bytes_per_second();
193        format_bytes_per_second_iec(rate)
194    }
195
196    /// Formats the ETA as a human-readable string.
197    pub fn format_eta(&self) -> String {
198        match self.eta() {
199            Some(duration) => format_duration(duration),
200            None => "unknown".to_string(),
201        }
202    }
203}
204
205/// A progress reporter that does nothing (null object pattern).
206#[derive(Debug, Default, Clone)]
207pub struct NoProgress;
208
209impl ProgressReporter for NoProgress {}
210
211/// A progress reporter that collects statistics.
212#[derive(Debug, Default, Clone)]
213pub struct StatisticsProgress {
214    /// The progress state.
215    pub state: ProgressState,
216    /// Whether cancellation was requested.
217    pub cancelled: bool,
218    /// Warnings collected.
219    pub warnings: Vec<String>,
220}
221
222impl StatisticsProgress {
223    /// Creates a new statistics progress reporter.
224    pub fn new() -> Self {
225        Self::default()
226    }
227
228    /// Returns the collected state.
229    pub fn state(&self) -> &ProgressState {
230        &self.state
231    }
232}
233
234impl ProgressReporter for StatisticsProgress {
235    fn on_total(&mut self, total_bytes: u64) {
236        self.state.total_bytes = total_bytes;
237    }
238
239    fn on_progress(&mut self, bytes_processed: u64, _total_bytes: u64) -> bool {
240        self.state.processed_bytes = bytes_processed;
241        self.state.last_update = Instant::now();
242        !self.cancelled
243    }
244
245    fn on_ratio(&mut self, _input_bytes: u64, output_bytes: u64) {
246        self.state.packed_bytes = output_bytes;
247    }
248
249    fn on_entry_start(&mut self, entry_name: &str, _size: u64) {
250        self.state.current_entry = Some(entry_name.to_string());
251    }
252
253    fn on_entry_complete(&mut self, _entry_name: &str, _success: bool) {
254        self.state.entries_processed += 1;
255        self.state.current_entry = None;
256    }
257
258    fn on_warning(&mut self, message: &str) {
259        self.warnings.push(message.to_string());
260    }
261
262    fn should_cancel(&self) -> bool {
263        self.cancelled
264    }
265}
266
267/// A progress reporter that rate-limits callbacks.
268///
269/// Useful for reducing overhead when progress is reported very frequently.
270pub struct ThrottledProgress<P> {
271    inner: P,
272    min_interval: Duration,
273    last_callback: Instant,
274    last_bytes: u64,
275}
276
277impl<P: ProgressReporter> ThrottledProgress<P> {
278    /// Creates a new throttled progress reporter.
279    ///
280    /// `min_interval` is the minimum time between progress callbacks.
281    pub fn new(inner: P, min_interval: Duration) -> Self {
282        Self {
283            inner,
284            min_interval,
285            last_callback: Instant::now(),
286            last_bytes: 0,
287        }
288    }
289
290    /// Creates with default 100ms interval.
291    pub fn default_interval(inner: P) -> Self {
292        Self::new(inner, Duration::from_millis(100))
293    }
294
295    /// Returns the inner reporter.
296    pub fn into_inner(self) -> P {
297        self.inner
298    }
299}
300
301impl<P: ProgressReporter> ProgressReporter for ThrottledProgress<P> {
302    fn on_total(&mut self, total_bytes: u64) {
303        self.inner.on_total(total_bytes);
304    }
305
306    fn on_progress(&mut self, bytes_processed: u64, total_bytes: u64) -> bool {
307        let now = Instant::now();
308        let elapsed = now.duration_since(self.last_callback);
309
310        // Always call on completion
311        if bytes_processed >= total_bytes || elapsed >= self.min_interval {
312            self.last_callback = now;
313            self.last_bytes = bytes_processed;
314            self.inner.on_progress(bytes_processed, total_bytes)
315        } else {
316            true
317        }
318    }
319
320    fn on_ratio(&mut self, input_bytes: u64, output_bytes: u64) {
321        self.inner.on_ratio(input_bytes, output_bytes);
322    }
323
324    fn on_entry_start(&mut self, entry_name: &str, size: u64) {
325        self.inner.on_entry_start(entry_name, size);
326    }
327
328    fn on_entry_complete(&mut self, entry_name: &str, success: bool) {
329        self.inner.on_entry_complete(entry_name, success);
330    }
331
332    fn on_password_needed(&mut self) -> Option<String> {
333        self.inner.on_password_needed()
334    }
335
336    fn on_warning(&mut self, message: &str) {
337        self.inner.on_warning(message);
338    }
339
340    fn should_cancel(&self) -> bool {
341        self.inner.should_cancel()
342    }
343}
344
345/// A thread-safe progress reporter using atomics.
346///
347/// Allows progress to be monitored from another thread.
348#[derive(Debug)]
349pub struct AtomicProgress {
350    total_bytes: AtomicU64,
351    processed_bytes: AtomicU64,
352    packed_bytes: AtomicU64,
353    cancelled: AtomicBool,
354    start_time: Instant,
355}
356
357impl Default for AtomicProgress {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363impl AtomicProgress {
364    /// Creates a new atomic progress reporter.
365    pub fn new() -> Self {
366        Self {
367            total_bytes: AtomicU64::new(0),
368            processed_bytes: AtomicU64::new(0),
369            packed_bytes: AtomicU64::new(0),
370            cancelled: AtomicBool::new(false),
371            start_time: Instant::now(),
372        }
373    }
374
375    /// Creates a shared atomic progress reporter.
376    pub fn shared() -> Arc<Self> {
377        Arc::new(Self::new())
378    }
379
380    /// Returns total bytes to process.
381    pub fn total_bytes(&self) -> u64 {
382        self.total_bytes.load(Ordering::Relaxed)
383    }
384
385    /// Returns processed bytes.
386    pub fn processed_bytes(&self) -> u64 {
387        self.processed_bytes.load(Ordering::Relaxed)
388    }
389
390    /// Returns packed/compressed bytes.
391    pub fn packed_bytes(&self) -> u64 {
392        self.packed_bytes.load(Ordering::Relaxed)
393    }
394
395    /// Returns whether cancellation was requested.
396    pub fn is_cancelled(&self) -> bool {
397        self.cancelled.load(Ordering::Relaxed)
398    }
399
400    /// Requests cancellation.
401    pub fn cancel(&self) {
402        self.cancelled.store(true, Ordering::Relaxed);
403    }
404
405    /// Returns completion percentage (0.0 - 100.0).
406    pub fn percentage(&self) -> f64 {
407        let total = self.total_bytes();
408        if total == 0 {
409            0.0
410        } else {
411            (self.processed_bytes() as f64 / total as f64) * 100.0
412        }
413    }
414
415    /// Returns elapsed time since creation.
416    pub fn elapsed(&self) -> Duration {
417        self.start_time.elapsed()
418    }
419
420    /// Returns processing rate in bytes per second.
421    pub fn bytes_per_second(&self) -> f64 {
422        let elapsed = self.elapsed().as_secs_f64();
423        if elapsed < 0.001 {
424            0.0
425        } else {
426            self.processed_bytes() as f64 / elapsed
427        }
428    }
429}
430
431impl ProgressReporter for AtomicProgress {
432    fn on_total(&mut self, total_bytes: u64) {
433        self.total_bytes.store(total_bytes, Ordering::Relaxed);
434    }
435
436    fn on_progress(&mut self, bytes_processed: u64, _total_bytes: u64) -> bool {
437        self.processed_bytes
438            .store(bytes_processed, Ordering::Relaxed);
439        !self.is_cancelled()
440    }
441
442    fn on_ratio(&mut self, _input_bytes: u64, output_bytes: u64) {
443        self.packed_bytes.store(output_bytes, Ordering::Relaxed);
444    }
445
446    fn should_cancel(&self) -> bool {
447        self.is_cancelled()
448    }
449}
450
451/// Progress reporter for shared `Arc<AtomicProgress>`.
452impl ProgressReporter for Arc<AtomicProgress> {
453    fn on_total(&mut self, total_bytes: u64) {
454        self.total_bytes.store(total_bytes, Ordering::Relaxed);
455    }
456
457    fn on_progress(&mut self, bytes_processed: u64, _total_bytes: u64) -> bool {
458        self.processed_bytes
459            .store(bytes_processed, Ordering::Relaxed);
460        !self.is_cancelled()
461    }
462
463    fn on_ratio(&mut self, _input_bytes: u64, output_bytes: u64) {
464        self.packed_bytes.store(output_bytes, Ordering::Relaxed);
465    }
466
467    fn should_cancel(&self) -> bool {
468        self.is_cancelled()
469    }
470}
471
472/// A progress reporter that calls a closure.
473pub struct ClosureProgress<F> {
474    callback: F,
475}
476
477impl<F> ClosureProgress<F>
478where
479    F: FnMut(u64, u64) -> bool + Send,
480{
481    /// Creates a progress reporter from a closure.
482    ///
483    /// The closure receives (bytes_processed, total_bytes) and returns
484    /// `true` to continue or `false` to cancel.
485    pub fn new(callback: F) -> Self {
486        Self { callback }
487    }
488}
489
490impl<F> ProgressReporter for ClosureProgress<F>
491where
492    F: FnMut(u64, u64) -> bool + Send,
493{
494    fn on_progress(&mut self, bytes_processed: u64, total_bytes: u64) -> bool {
495        (self.callback)(bytes_processed, total_bytes)
496    }
497}
498
499/// Creates a closure-based progress reporter.
500pub fn progress_fn<F>(f: F) -> ClosureProgress<F>
501where
502    F: FnMut(u64, u64) -> bool + Send,
503{
504    ClosureProgress::new(f)
505}
506
507/// Formats bytes per second as a human-readable string using IEC units.
508///
509/// Uses 1024-based calculation with correct IEC labels (KiB/s, MiB/s, GiB/s).
510pub fn format_bytes_per_second_iec(rate: f64) -> String {
511    if rate < BYTES_KB {
512        format!("{:.0} B/s", rate)
513    } else if rate < BYTES_MB {
514        format!("{:.1} KiB/s", rate / BYTES_KB)
515    } else if rate < BYTES_GB {
516        format!("{:.1} MiB/s", rate / BYTES_MB)
517    } else {
518        format!("{:.1} GiB/s", rate / BYTES_GB)
519    }
520}
521
522/// Formats a duration as a human-readable string.
523pub fn format_duration(duration: Duration) -> String {
524    let secs = duration.as_secs();
525    if secs < 60 {
526        format!("{}s", secs)
527    } else if secs < 3600 {
528        format!("{}m {}s", secs / 60, secs % 60)
529    } else {
530        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
531    }
532}
533
534/// Formats bytes as a human-readable string using IEC units (KiB, MiB, GiB).
535///
536/// This version uses the technically correct IEC binary prefixes that explicitly
537/// indicate binary (1024-based) measurements.
538///
539/// # Examples
540///
541/// ```rust
542/// use zesven::progress::format_bytes_iec;
543///
544/// assert_eq!(format_bytes_iec(0), "0 B");
545/// assert_eq!(format_bytes_iec(512), "512 B");
546/// assert_eq!(format_bytes_iec(1024), "1.0 KiB");
547/// assert_eq!(format_bytes_iec(1536), "1.5 KiB");
548/// assert_eq!(format_bytes_iec(1048576), "1.0 MiB");
549/// ```
550pub fn format_bytes_iec(bytes: u64) -> String {
551    let bytes_f64 = bytes as f64;
552    if bytes_f64 < BYTES_KB {
553        format!("{} B", bytes)
554    } else if bytes_f64 < BYTES_MB {
555        format!("{:.1} KiB", bytes_f64 / BYTES_KB)
556    } else if bytes_f64 < BYTES_GB {
557        format!("{:.1} MiB", bytes_f64 / BYTES_MB)
558    } else {
559        format!("{:.1} GiB", bytes_f64 / BYTES_GB)
560    }
561}
562
563/// Formats bytes as a human-readable string using IEC units.
564///
565/// This version accepts `usize` for convenience when working with memory sizes.
566/// See [`format_bytes_iec`] for the `u64` version.
567pub fn format_bytes_iec_usize(bytes: usize) -> String {
568    format_bytes_iec(bytes as u64)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_progress_state_percentage() {
577        let mut state = ProgressState::new();
578        state.total_bytes = 100;
579        state.processed_bytes = 25;
580        assert!((state.percentage() - 25.0).abs() < 0.001);
581    }
582
583    #[test]
584    fn test_progress_state_compression_ratio() {
585        let mut state = ProgressState::new();
586        state.processed_bytes = 1000;
587        state.packed_bytes = 500;
588        assert!((state.compression_ratio() - 0.5).abs() < 0.001);
589    }
590
591    #[test]
592    fn test_no_progress() {
593        let mut progress = NoProgress;
594        assert!(progress.on_progress(50, 100));
595    }
596
597    #[test]
598    fn test_statistics_progress() {
599        let mut progress = StatisticsProgress::new();
600        progress.on_total(1000);
601        progress.on_entry_start("test.txt", 500);
602        progress.on_progress(250, 1000);
603        progress.on_entry_complete("test.txt", true);
604
605        assert_eq!(progress.state().total_bytes, 1000);
606        assert_eq!(progress.state().processed_bytes, 250);
607        assert_eq!(progress.state().entries_processed, 1);
608    }
609
610    #[test]
611    fn test_throttled_progress() {
612        let inner = StatisticsProgress::new();
613        let mut throttled = ThrottledProgress::new(inner, Duration::from_millis(10));
614
615        throttled.on_total(100);
616        assert!(throttled.on_progress(10, 100));
617
618        // Should be throttled
619        assert!(throttled.on_progress(20, 100));
620
621        // Wait and should pass through
622        std::thread::sleep(Duration::from_millis(15));
623        assert!(throttled.on_progress(30, 100));
624    }
625
626    #[test]
627    fn test_atomic_progress() {
628        let progress = AtomicProgress::shared();
629        let mut reporter: Arc<AtomicProgress> = Arc::clone(&progress);
630
631        reporter.on_total(1000);
632        reporter.on_progress(500, 1000);
633
634        assert_eq!(progress.total_bytes(), 1000);
635        assert_eq!(progress.processed_bytes(), 500);
636        assert!((progress.percentage() - 50.0).abs() < 0.001);
637
638        progress.cancel();
639        assert!(!reporter.on_progress(600, 1000));
640    }
641
642    #[test]
643    fn test_closure_progress() {
644        let mut count = 0;
645        let mut progress = progress_fn(|bytes, total| {
646            count += 1;
647            bytes < total
648        });
649
650        assert!(progress.on_progress(50, 100));
651        assert!(progress.on_progress(99, 100));
652        assert!(!progress.on_progress(100, 100));
653        assert_eq!(count, 3);
654    }
655
656    #[test]
657    fn test_format_bytes_per_second() {
658        // Test the IEC version (correct labels)
659        assert_eq!(format_bytes_per_second_iec(500.0), "500 B/s");
660        assert_eq!(format_bytes_per_second_iec(1500.0), "1.5 KiB/s");
661        assert_eq!(format_bytes_per_second_iec(1500.0 * 1024.0), "1.5 MiB/s");
662        assert_eq!(
663            format_bytes_per_second_iec(1500.0 * 1024.0 * 1024.0),
664            "1.5 GiB/s"
665        );
666    }
667
668    #[test]
669    fn test_format_duration() {
670        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
671        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
672        assert_eq!(format_duration(Duration::from_secs(3700)), "1h 1m");
673    }
674
675    #[test]
676    fn test_format_bytes() {
677        // Test the IEC version (correct labels)
678        assert_eq!(format_bytes_iec(500), "500 B");
679        assert_eq!(format_bytes_iec(1500), "1.5 KiB");
680        assert_eq!(format_bytes_iec(1500 * 1024), "1.5 MiB");
681        assert_eq!(format_bytes_iec(1500 * 1024 * 1024), "1.5 GiB");
682    }
683
684    #[test]
685    fn test_progress_state_empty() {
686        let state = ProgressState::new();
687        assert_eq!(state.percentage(), 0.0);
688        assert_eq!(state.compression_ratio(), 1.0);
689    }
690
691    #[test]
692    fn test_statistics_cancellation() {
693        let mut progress = StatisticsProgress::new();
694        assert!(progress.on_progress(50, 100));
695
696        progress.cancelled = true;
697        assert!(!progress.on_progress(75, 100));
698    }
699}