1use std::sync::atomic::{AtomicUsize, Ordering};
4use std::time::{Duration, Instant};
5
6#[derive(Debug, Clone)]
8pub struct ProgressInfo {
9 pub total_jobs: usize,
11 pub completed_jobs: usize,
13 pub failed_jobs: usize,
15 pub running_jobs: usize,
17 pub start_time: Instant,
19 pub estimated_remaining: Option<Duration>,
21 pub throughput: f64,
23}
24
25impl ProgressInfo {
26 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 pub fn is_complete(&self) -> bool {
37 self.completed_jobs + self.failed_jobs >= self.total_jobs
38 }
39
40 pub fn elapsed(&self) -> Duration {
42 self.start_time.elapsed()
43 }
44
45 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 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 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
87pub 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 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 pub fn add_job(&self) {
116 self.total_jobs.fetch_add(1, Ordering::SeqCst);
117 }
118
119 pub fn start_job(&self) {
121 self.running_jobs.fetch_add(1, Ordering::SeqCst);
122 }
123
124 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 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 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 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
174pub trait ProgressCallback: Send + Sync {
176 fn on_progress(&self, info: &ProgressInfo);
178}
179
180impl<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
190pub 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 pub fn new(width: usize) -> Self {
210 Self {
211 width,
212 ..Default::default()
213 }
214 }
215
216 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); }
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 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 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 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, };
376
377 let eta = info.calculate_eta();
378 assert!(eta.is_some());
379
380 assert_eq!(eta.unwrap().as_secs(), 15);
382 }
383
384 #[test]
385 fn test_progress_info_edge_cases() {
386 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); assert!(info_empty.is_complete());
399 assert!(info_empty.calculate_eta().is_none());
400
401 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}