Skip to main content

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
384    #[test]
385    fn test_progress_info_edge_cases() {
386        // Test with zero total jobs
387        let info_empty = ProgressInfo {
388            total_jobs: 0,
389            completed_jobs: 0,
390            failed_jobs: 0,
391            running_jobs: 0,
392            start_time: Instant::now(),
393            estimated_remaining: None,
394            throughput: 0.0,
395        };
396
397        assert_eq!(info_empty.percentage(), 100.0); // Empty batch is 100% complete
398        assert!(info_empty.is_complete());
399        assert!(info_empty.calculate_eta().is_none());
400
401        // Test with zero throughput
402        let info_zero_throughput = ProgressInfo {
403            total_jobs: 10,
404            completed_jobs: 5,
405            failed_jobs: 0,
406            running_jobs: 1,
407            start_time: Instant::now(),
408            estimated_remaining: None,
409            throughput: 0.0,
410        };
411        assert!(info_zero_throughput.calculate_eta().is_none());
412
413        // Test with no processed jobs
414        let info_no_progress = ProgressInfo {
415            total_jobs: 10,
416            completed_jobs: 0,
417            failed_jobs: 0,
418            running_jobs: 2,
419            start_time: Instant::now(),
420            estimated_remaining: None,
421            throughput: 1.0,
422        };
423        assert!(info_no_progress.calculate_eta().is_none());
424    }
425
426    #[test]
427    fn test_progress_info_completion_states() {
428        let start_time = Instant::now();
429
430        // All completed
431        let info_all_done = ProgressInfo {
432            total_jobs: 10,
433            completed_jobs: 10,
434            failed_jobs: 0,
435            running_jobs: 0,
436            start_time,
437            estimated_remaining: None,
438            throughput: 2.0,
439        };
440        assert!(info_all_done.is_complete());
441        assert_eq!(info_all_done.percentage(), 100.0);
442
443        // Some failed, some completed
444        let info_mixed = ProgressInfo {
445            total_jobs: 10,
446            completed_jobs: 7,
447            failed_jobs: 3,
448            running_jobs: 0,
449            start_time,
450            estimated_remaining: None,
451            throughput: 1.5,
452        };
453        assert!(info_mixed.is_complete());
454        assert_eq!(info_mixed.percentage(), 70.0);
455
456        // In progress
457        let info_partial = ProgressInfo {
458            total_jobs: 10,
459            completed_jobs: 3,
460            failed_jobs: 1,
461            running_jobs: 2,
462            start_time,
463            estimated_remaining: None,
464            throughput: 1.0,
465        };
466        assert!(!info_partial.is_complete());
467        assert_eq!(info_partial.percentage(), 30.0);
468    }
469
470    #[test]
471    fn test_batch_progress_concurrent_operations() {
472        let progress = BatchProgress::new();
473
474        // Simulate concurrent operations
475        for _ in 0..10 {
476            progress.add_job();
477        }
478
479        let info_initial = progress.get_info();
480        assert_eq!(info_initial.total_jobs, 10);
481        assert_eq!(info_initial.completed_jobs, 0);
482        assert_eq!(info_initial.failed_jobs, 0);
483        assert_eq!(info_initial.running_jobs, 0);
484
485        // Start multiple jobs
486        progress.start_job();
487        progress.start_job();
488        progress.start_job();
489
490        let info_running = progress.get_info();
491        assert_eq!(info_running.running_jobs, 3);
492
493        // Complete and fail jobs
494        progress.complete_job();
495        progress.fail_job();
496        progress.complete_job();
497
498        let info_mixed = progress.get_info();
499        assert_eq!(info_mixed.completed_jobs, 2);
500        assert_eq!(info_mixed.failed_jobs, 1);
501        assert_eq!(info_mixed.running_jobs, 0);
502    }
503
504    #[test]
505    fn test_batch_progress_reset() {
506        let progress = BatchProgress::new();
507
508        // Add some jobs and progress
509        progress.add_job();
510        progress.add_job();
511        progress.start_job();
512        progress.complete_job();
513
514        let info_before = progress.get_info();
515        assert_eq!(info_before.total_jobs, 2);
516        assert_eq!(info_before.completed_jobs, 1);
517
518        // Reset and verify
519        progress.reset();
520        let info_after = progress.get_info();
521        assert_eq!(info_after.total_jobs, 0);
522        assert_eq!(info_after.completed_jobs, 0);
523        assert_eq!(info_after.failed_jobs, 0);
524        assert_eq!(info_after.running_jobs, 0);
525    }
526
527    #[test]
528    fn test_eta_formatting() {
529        // Test different time formats
530        let test_cases = vec![
531            (30, "30s"),
532            (90, "1m 30s"),
533            (3661, "1h 1m"),
534            (7200, "2h 0m"),
535        ];
536
537        for (seconds, expected) in test_cases {
538            let info = ProgressInfo {
539                total_jobs: 100,
540                completed_jobs: 50,
541                failed_jobs: 0,
542                running_jobs: 0,
543                start_time: Instant::now(),
544                estimated_remaining: Some(Duration::from_secs(seconds)),
545                throughput: 1.0,
546            };
547
548            assert_eq!(info.format_eta(), expected);
549        }
550
551        // Test None case
552        let info_none = ProgressInfo {
553            total_jobs: 100,
554            completed_jobs: 0,
555            failed_jobs: 0,
556            running_jobs: 1,
557            start_time: Instant::now(),
558            estimated_remaining: None,
559            throughput: 0.0,
560        };
561        assert_eq!(info_none.format_eta(), "calculating...");
562    }
563
564    #[test]
565    fn test_progress_bar_customization() {
566        let bar_narrow = ProgressBar::new(10);
567        let bar_wide = ProgressBar::new(100);
568
569        let info = ProgressInfo {
570            total_jobs: 100,
571            completed_jobs: 25,
572            failed_jobs: 0,
573            running_jobs: 1,
574            start_time: Instant::now(),
575            estimated_remaining: Some(Duration::from_secs(60)),
576            throughput: 1.5,
577        };
578
579        let rendered_narrow = bar_narrow.render(&info);
580        let rendered_wide = bar_wide.render(&info);
581
582        // Both should contain basic information
583        assert!(rendered_narrow.contains("25.0%"));
584        assert!(rendered_wide.contains("25.0%"));
585        assert!(rendered_narrow.contains("25/100"));
586        assert!(rendered_wide.contains("25/100"));
587
588        // Wide bar should have more filled characters
589        let narrow_equals = rendered_narrow.chars().filter(|&c| c == '=').count();
590        let wide_equals = rendered_wide.chars().filter(|&c| c == '=').count();
591        assert!(wide_equals > narrow_equals);
592    }
593
594    #[test]
595    fn test_progress_bar_zero_and_full() {
596        let bar = ProgressBar::new(20);
597
598        // Test 0% progress
599        let info_empty = ProgressInfo {
600            total_jobs: 100,
601            completed_jobs: 0,
602            failed_jobs: 0,
603            running_jobs: 1,
604            start_time: Instant::now(),
605            estimated_remaining: None,
606            throughput: 0.0,
607        };
608
609        let rendered_empty = bar.render(&info_empty);
610        assert!(rendered_empty.contains("[                    ] 0.0%"));
611
612        // Test 100% progress
613        let info_full = ProgressInfo {
614            total_jobs: 50,
615            completed_jobs: 50,
616            failed_jobs: 0,
617            running_jobs: 0,
618            start_time: Instant::now(),
619            estimated_remaining: Some(Duration::from_secs(0)),
620            throughput: 10.0,
621        };
622
623        let rendered_full = bar.render(&info_full);
624        assert!(rendered_full.contains("[====================] 100.0%"));
625        assert!(rendered_full.contains("50/50"));
626    }
627}