oxidize_pdf/batch/
progress.rs

1//! Progress tracking for batch operations
2
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::time::{Duration, Instant};
5
6/// Progress information for a batch operation
7#[derive(Debug, Clone)]
8pub struct ProgressInfo {
9    /// Total number of jobs
10    pub total_jobs: usize,
11    /// Number of completed jobs
12    pub completed_jobs: usize,
13    /// Number of failed jobs
14    pub failed_jobs: usize,
15    /// Number of jobs currently running
16    pub running_jobs: usize,
17    /// Start time of the batch
18    pub start_time: Instant,
19    /// Estimated time remaining
20    pub estimated_remaining: Option<Duration>,
21    /// Current throughput (jobs per second)
22    pub throughput: f64,
23}
24
25impl ProgressInfo {
26    /// Get progress percentage (0.0 - 100.0)
27    pub fn percentage(&self) -> f64 {
28        if self.total_jobs == 0 {
29            100.0
30        } else {
31            (self.completed_jobs as f64 / self.total_jobs as f64) * 100.0
32        }
33    }
34
35    /// Check if batch is complete
36    pub fn is_complete(&self) -> bool {
37        self.completed_jobs + self.failed_jobs >= self.total_jobs
38    }
39
40    /// Get elapsed time
41    pub fn elapsed(&self) -> Duration {
42        self.start_time.elapsed()
43    }
44
45    /// Calculate estimated time remaining
46    pub fn calculate_eta(&self) -> Option<Duration> {
47        let processed = self.completed_jobs + self.failed_jobs;
48        if processed == 0 || self.throughput <= 0.0 {
49            return None;
50        }
51
52        let remaining = self.total_jobs.saturating_sub(processed);
53        let seconds_remaining = remaining as f64 / self.throughput;
54        Some(Duration::from_secs_f64(seconds_remaining))
55    }
56
57    /// Format progress as a string
58    pub fn format_progress(&self) -> String {
59        format!(
60            "{}/{} ({:.1}%) - {} running, {} failed",
61            self.completed_jobs,
62            self.total_jobs,
63            self.percentage(),
64            self.running_jobs,
65            self.failed_jobs
66        )
67    }
68
69    /// Format ETA as a string
70    pub fn format_eta(&self) -> String {
71        match self.estimated_remaining {
72            Some(duration) => {
73                let secs = duration.as_secs();
74                if secs < 60 {
75                    format!("{secs}s")
76                } else if secs < 3600 {
77                    format!("{}m {}s", secs / 60, secs % 60)
78                } else {
79                    format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
80                }
81            }
82            None => "calculating...".to_string(),
83        }
84    }
85}
86
87/// Progress tracker for batch operations
88pub struct BatchProgress {
89    total_jobs: AtomicUsize,
90    completed_jobs: AtomicUsize,
91    failed_jobs: AtomicUsize,
92    running_jobs: AtomicUsize,
93    start_time: Instant,
94}
95
96impl Default for BatchProgress {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl BatchProgress {
103    /// Create a new progress tracker
104    pub fn new() -> Self {
105        Self {
106            total_jobs: AtomicUsize::new(0),
107            completed_jobs: AtomicUsize::new(0),
108            failed_jobs: AtomicUsize::new(0),
109            running_jobs: AtomicUsize::new(0),
110            start_time: Instant::now(),
111        }
112    }
113
114    /// Add a job to the total count
115    pub fn add_job(&self) {
116        self.total_jobs.fetch_add(1, Ordering::SeqCst);
117    }
118
119    /// Mark a job as started
120    pub fn start_job(&self) {
121        self.running_jobs.fetch_add(1, Ordering::SeqCst);
122    }
123
124    /// Mark a job as completed successfully
125    pub fn complete_job(&self) {
126        self.running_jobs.fetch_sub(1, Ordering::SeqCst);
127        self.completed_jobs.fetch_add(1, Ordering::SeqCst);
128    }
129
130    /// Mark a job as failed
131    pub fn fail_job(&self) {
132        self.running_jobs.fetch_sub(1, Ordering::SeqCst);
133        self.failed_jobs.fetch_add(1, Ordering::SeqCst);
134    }
135
136    /// Get current progress information
137    pub fn get_info(&self) -> ProgressInfo {
138        let total = self.total_jobs.load(Ordering::SeqCst);
139        let completed = self.completed_jobs.load(Ordering::SeqCst);
140        let failed = self.failed_jobs.load(Ordering::SeqCst);
141        let running = self.running_jobs.load(Ordering::SeqCst);
142
143        let elapsed = self.start_time.elapsed();
144        let processed = completed + failed;
145        let throughput = if elapsed.as_secs_f64() > 0.0 {
146            processed as f64 / elapsed.as_secs_f64()
147        } else {
148            0.0
149        };
150
151        let mut info = ProgressInfo {
152            total_jobs: total,
153            completed_jobs: completed,
154            failed_jobs: failed,
155            running_jobs: running,
156            start_time: self.start_time,
157            estimated_remaining: None,
158            throughput,
159        };
160
161        info.estimated_remaining = info.calculate_eta();
162        info
163    }
164
165    /// Reset the progress tracker
166    pub fn reset(&self) {
167        self.total_jobs.store(0, Ordering::SeqCst);
168        self.completed_jobs.store(0, Ordering::SeqCst);
169        self.failed_jobs.store(0, Ordering::SeqCst);
170        self.running_jobs.store(0, Ordering::SeqCst);
171    }
172}
173
174/// Trait for progress callbacks
175pub trait ProgressCallback: Send + Sync {
176    /// Called when progress is updated
177    fn on_progress(&self, info: &ProgressInfo);
178}
179
180/// Implementation of ProgressCallback for closures
181impl<F> ProgressCallback for F
182where
183    F: Fn(&ProgressInfo) + Send + Sync,
184{
185    fn on_progress(&self, info: &ProgressInfo) {
186        self(info)
187    }
188}
189
190/// Progress bar renderer for terminal output
191pub struct ProgressBar {
192    width: usize,
193    show_eta: bool,
194    show_throughput: bool,
195}
196
197impl Default for ProgressBar {
198    fn default() -> Self {
199        Self {
200            width: 50,
201            show_eta: true,
202            show_throughput: true,
203        }
204    }
205}
206
207impl ProgressBar {
208    /// Create a new progress bar
209    pub fn new(width: usize) -> Self {
210        Self {
211            width,
212            ..Default::default()
213        }
214    }
215
216    /// Render the progress bar
217    pub fn render(&self, info: &ProgressInfo) -> String {
218        let percentage = info.percentage();
219        let filled = (percentage / 100.0 * self.width as f64) as usize;
220        let empty = self.width.saturating_sub(filled);
221
222        let bar = format!(
223            "[{}{}] {:.1}%",
224            "=".repeat(filled),
225            " ".repeat(empty),
226            percentage
227        );
228
229        let mut parts = vec![bar];
230
231        parts.push(format!("{}/{}", info.completed_jobs, info.total_jobs));
232
233        if self.show_throughput && info.throughput > 0.0 {
234            parts.push(format!("{:.1} jobs/s", info.throughput));
235        }
236
237        if self.show_eta {
238            parts.push(format!("ETA: {}", info.format_eta()));
239        }
240
241        parts.join(" | ")
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_progress_info() {
251        let info = ProgressInfo {
252            total_jobs: 100,
253            completed_jobs: 25,
254            failed_jobs: 5,
255            running_jobs: 2,
256            start_time: Instant::now(),
257            estimated_remaining: Some(Duration::from_secs(60)),
258            throughput: 2.5,
259        };
260
261        assert_eq!(info.percentage(), 25.0);
262        assert!(!info.is_complete());
263        assert!(info.elapsed().as_millis() < u128::MAX); // Just check it's valid
264    }
265
266    #[test]
267    fn test_progress_info_formatting() {
268        let info = ProgressInfo {
269            total_jobs: 100,
270            completed_jobs: 50,
271            failed_jobs: 10,
272            running_jobs: 5,
273            start_time: Instant::now(),
274            estimated_remaining: Some(Duration::from_secs(125)),
275            throughput: 1.0,
276        };
277
278        let progress_str = info.format_progress();
279        assert!(progress_str.contains("50/100"));
280        assert!(progress_str.contains("50.0%"));
281        assert!(progress_str.contains("5 running"));
282        assert!(progress_str.contains("10 failed"));
283
284        let eta_str = info.format_eta();
285        assert!(eta_str.contains("2m"));
286    }
287
288    #[test]
289    fn test_batch_progress() {
290        let progress = BatchProgress::new();
291
292        // Add jobs
293        progress.add_job();
294        progress.add_job();
295        progress.add_job();
296
297        let info = progress.get_info();
298        assert_eq!(info.total_jobs, 3);
299        assert_eq!(info.completed_jobs, 0);
300
301        // Start and complete jobs
302        progress.start_job();
303        progress.complete_job();
304
305        let info = progress.get_info();
306        assert_eq!(info.completed_jobs, 1);
307        assert_eq!(info.running_jobs, 0);
308
309        // Fail a job
310        progress.start_job();
311        progress.fail_job();
312
313        let info = progress.get_info();
314        assert_eq!(info.failed_jobs, 1);
315    }
316
317    #[test]
318    fn test_progress_bar() {
319        let bar = ProgressBar::new(20);
320
321        let info = ProgressInfo {
322            total_jobs: 100,
323            completed_jobs: 50,
324            failed_jobs: 0,
325            running_jobs: 0,
326            start_time: Instant::now(),
327            estimated_remaining: Some(Duration::from_secs(60)),
328            throughput: 2.0,
329        };
330
331        let rendered = bar.render(&info);
332        assert!(rendered.contains("[=========="));
333        assert!(rendered.contains("50.0%"));
334        assert!(rendered.contains("50/100"));
335        assert!(rendered.contains("2.0 jobs/s"));
336        assert!(rendered.contains("ETA:"));
337    }
338
339    #[test]
340    fn test_progress_callback() {
341        use std::sync::atomic::AtomicBool;
342        use std::sync::Arc;
343
344        let called = Arc::new(AtomicBool::new(false));
345        let called_clone = Arc::clone(&called);
346
347        let callback = move |_info: &ProgressInfo| {
348            called_clone.store(true, Ordering::SeqCst);
349        };
350
351        let info = ProgressInfo {
352            total_jobs: 1,
353            completed_jobs: 1,
354            failed_jobs: 0,
355            running_jobs: 0,
356            start_time: Instant::now(),
357            estimated_remaining: None,
358            throughput: 1.0,
359        };
360
361        callback.on_progress(&info);
362        assert!(called.load(Ordering::SeqCst));
363    }
364
365    #[test]
366    fn test_eta_calculation() {
367        let info = ProgressInfo {
368            total_jobs: 100,
369            completed_jobs: 25,
370            failed_jobs: 0,
371            running_jobs: 0,
372            start_time: Instant::now(),
373            estimated_remaining: None,
374            throughput: 5.0, // 5 jobs per second
375        };
376
377        let eta = info.calculate_eta();
378        assert!(eta.is_some());
379
380        // 75 remaining jobs at 5 jobs/sec = 15 seconds
381        assert_eq!(eta.unwrap().as_secs(), 15);
382    }
383}