sqlmodel_console/renderables/
operation_progress.rs1use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21
22use crate::theme::Theme;
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ProgressState {
27 #[default]
29 Normal,
30 Complete,
32 Warning,
34 Error,
36}
37
38#[derive(Debug, Clone)]
60pub struct OperationProgress {
61 operation_name: String,
63 completed: u64,
65 total: u64,
67 started_at: Instant,
69 state: ProgressState,
71 theme: Option<Theme>,
73 width: Option<usize>,
75 show_eta: bool,
77 show_throughput: bool,
79 unit: String,
81}
82
83impl OperationProgress {
84 #[must_use]
90 pub fn new(operation_name: impl Into<String>, total: u64) -> Self {
91 Self {
92 operation_name: operation_name.into(),
93 completed: 0,
94 total,
95 started_at: Instant::now(),
96 state: ProgressState::Normal,
97 theme: None,
98 width: None,
99 show_eta: true,
100 show_throughput: true,
101 unit: String::new(),
102 }
103 }
104
105 #[must_use]
107 pub fn completed(mut self, completed: u64) -> Self {
108 self.completed = completed.min(self.total);
109 self.update_state();
110 self
111 }
112
113 pub fn set_completed(&mut self, completed: u64) {
115 self.completed = completed.min(self.total);
116 self.update_state();
117 }
118
119 pub fn increment(&mut self) {
121 if self.completed < self.total {
122 self.completed += 1;
123 self.update_state();
124 }
125 }
126
127 pub fn add(&mut self, count: u64) {
129 self.completed = self.completed.saturating_add(count).min(self.total);
130 self.update_state();
131 }
132
133 #[must_use]
135 pub fn theme(mut self, theme: Theme) -> Self {
136 self.theme = Some(theme);
137 self
138 }
139
140 #[must_use]
142 pub fn width(mut self, width: usize) -> Self {
143 self.width = Some(width);
144 self
145 }
146
147 #[must_use]
149 pub fn show_eta(mut self, show: bool) -> Self {
150 self.show_eta = show;
151 self
152 }
153
154 #[must_use]
156 pub fn show_throughput(mut self, show: bool) -> Self {
157 self.show_throughput = show;
158 self
159 }
160
161 #[must_use]
163 pub fn unit(mut self, unit: impl Into<String>) -> Self {
164 self.unit = unit.into();
165 self
166 }
167
168 #[must_use]
170 pub fn state(mut self, state: ProgressState) -> Self {
171 self.state = state;
172 self
173 }
174
175 pub fn reset_timer(&mut self) {
177 self.started_at = Instant::now();
178 }
179
180 #[must_use]
182 pub fn operation_name(&self) -> &str {
183 &self.operation_name
184 }
185
186 #[must_use]
188 pub fn completed_count(&self) -> u64 {
189 self.completed
190 }
191
192 #[must_use]
194 pub fn total_count(&self) -> u64 {
195 self.total
196 }
197
198 #[must_use]
200 pub fn current_state(&self) -> ProgressState {
201 self.state
202 }
203
204 #[must_use]
206 pub fn percentage(&self) -> f64 {
207 if self.total == 0 {
208 return 100.0;
209 }
210 (self.completed as f64 / self.total as f64) * 100.0
211 }
212
213 #[must_use]
215 pub fn elapsed_secs(&self) -> f64 {
216 self.started_at.elapsed().as_secs_f64()
217 }
218
219 #[must_use]
221 pub fn throughput(&self) -> f64 {
222 let elapsed = self.elapsed_secs();
223 if elapsed < 0.001 {
224 return 0.0;
225 }
226 self.completed as f64 / elapsed
227 }
228
229 #[must_use]
231 pub fn eta_secs(&self) -> Option<f64> {
232 let rate = self.throughput();
233 if rate < 0.001 {
234 return None;
235 }
236 let remaining = self.total.saturating_sub(self.completed);
237 Some(remaining as f64 / rate)
238 }
239
240 #[must_use]
242 pub fn is_complete(&self) -> bool {
243 self.completed >= self.total
244 }
245
246 fn update_state(&mut self) {
248 if self.completed >= self.total {
249 self.state = ProgressState::Complete;
250 }
251 }
253
254 #[must_use]
258 pub fn render_plain(&self) -> String {
259 let pct = self.percentage();
260 let mut parts = vec![format!(
261 "{}: {:.0}% ({}/{})",
262 self.operation_name, pct, self.completed, self.total
263 )];
264
265 if self.show_throughput && self.completed > 0 {
266 let rate = self.throughput();
267 let unit_label = if self.unit.is_empty() { "" } else { &self.unit };
268 parts.push(format!("{rate:.1}{unit_label}/s"));
269 }
270
271 if self.show_eta
272 && !self.is_complete()
273 && let Some(eta) = self.eta_secs()
274 {
275 parts.push(format!("ETA: {}", format_duration(eta)));
276 }
277
278 parts.join(" ")
279 }
280
281 #[must_use]
285 #[allow(clippy::cast_possible_truncation)] pub fn render_styled(&self) -> String {
287 let bar_width = self.width.unwrap_or(30);
288 let pct = self.percentage();
289 let filled = ((pct / 100.0) * bar_width as f64).round() as usize;
290 let empty = bar_width.saturating_sub(filled);
291
292 let theme = self.theme.clone().unwrap_or_default();
293
294 let (bar_color, text_color) = match self.state {
295 ProgressState::Normal => (theme.info.color_code(), theme.info.color_code()),
296 ProgressState::Complete => (theme.success.color_code(), theme.success.color_code()),
297 ProgressState::Warning => (theme.warning.color_code(), theme.warning.color_code()),
298 ProgressState::Error => (theme.error.color_code(), theme.error.color_code()),
299 };
300 let reset = "\x1b[0m";
301
302 let bar = format!(
304 "{bar_color}[{filled}{empty}]{reset}",
305 filled = "=".repeat(filled.saturating_sub(1)) + if filled > 0 { ">" } else { "" },
306 empty = " ".repeat(empty),
307 );
308
309 let mut parts = vec![
311 format!("{text_color}{}{reset}", self.operation_name),
312 bar,
313 format!("{pct:.0}%"),
314 format!("({}/{})", self.completed, self.total),
315 ];
316
317 if self.show_throughput && self.completed > 0 {
318 let rate = self.throughput();
319 let unit_label = if self.unit.is_empty() { "" } else { &self.unit };
320 parts.push(format!("{rate:.1}{unit_label}/s"));
321 }
322
323 if self.show_eta
324 && !self.is_complete()
325 && let Some(eta) = self.eta_secs()
326 {
327 parts.push(format!("ETA: {}", format_duration(eta)));
328 }
329
330 parts.join(" ")
331 }
332
333 #[must_use]
335 pub fn to_json(&self) -> String {
336 #[derive(Serialize)]
337 struct ProgressJson<'a> {
338 operation: &'a str,
339 completed: u64,
340 total: u64,
341 percentage: f64,
342 throughput: f64,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 eta_secs: Option<f64>,
345 elapsed_secs: f64,
346 is_complete: bool,
347 state: &'a str,
348 #[serde(skip_serializing_if = "str::is_empty")]
349 unit: &'a str,
350 }
351
352 let state_str = match self.state {
353 ProgressState::Normal => "normal",
354 ProgressState::Complete => "complete",
355 ProgressState::Warning => "warning",
356 ProgressState::Error => "error",
357 };
358
359 let json = ProgressJson {
360 operation: &self.operation_name,
361 completed: self.completed,
362 total: self.total,
363 percentage: self.percentage(),
364 throughput: self.throughput(),
365 eta_secs: self.eta_secs(),
366 elapsed_secs: self.elapsed_secs(),
367 is_complete: self.is_complete(),
368 state: state_str,
369 unit: &self.unit,
370 };
371
372 serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
373 }
374}
375
376fn format_duration(secs: f64) -> String {
378 if secs < 1.0 {
379 return "<1s".to_string();
380 }
381 if secs < 60.0 {
382 return format!("{:.0}s", secs);
383 }
384 if secs < 3600.0 {
385 let mins = (secs / 60.0).floor();
386 let remaining = secs % 60.0;
387 return format!("{:.0}m{:.0}s", mins, remaining);
388 }
389 let hours = (secs / 3600.0).floor();
390 let remaining_mins = ((secs % 3600.0) / 60.0).floor();
391 format!("{:.0}h{:.0}m", hours, remaining_mins)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn test_progress_creation() {
400 let progress = OperationProgress::new("Test", 100);
401 assert_eq!(progress.operation_name(), "Test");
402 assert_eq!(progress.completed_count(), 0);
403 assert_eq!(progress.total_count(), 100);
404 assert_eq!(progress.current_state(), ProgressState::Normal);
405 }
406
407 #[test]
408 fn test_progress_percentage_calculation_zero() {
409 let progress = OperationProgress::new("Test", 100).completed(0);
410 assert!((progress.percentage() - 0.0).abs() < f64::EPSILON);
411 }
412
413 #[test]
414 fn test_progress_percentage_calculation_half() {
415 let progress = OperationProgress::new("Test", 100).completed(50);
416 assert!((progress.percentage() - 50.0).abs() < f64::EPSILON);
417 }
418
419 #[test]
420 fn test_progress_percentage_calculation_full() {
421 let progress = OperationProgress::new("Test", 100).completed(100);
422 assert!((progress.percentage() - 100.0).abs() < f64::EPSILON);
423 }
424
425 #[test]
426 fn test_progress_percentage_zero_total() {
427 let progress = OperationProgress::new("Test", 0);
428 assert!((progress.percentage() - 100.0).abs() < f64::EPSILON);
429 }
430
431 #[test]
432 fn test_progress_increment() {
433 let mut progress = OperationProgress::new("Test", 100);
434 assert_eq!(progress.completed_count(), 0);
435 progress.increment();
436 assert_eq!(progress.completed_count(), 1);
437 progress.increment();
438 assert_eq!(progress.completed_count(), 2);
439 }
440
441 #[test]
442 fn test_progress_increment_at_max() {
443 let mut progress = OperationProgress::new("Test", 5).completed(5);
444 progress.increment();
445 assert_eq!(progress.completed_count(), 5); }
447
448 #[test]
449 fn test_progress_add_batch() {
450 let mut progress = OperationProgress::new("Test", 100);
451 progress.add(25);
452 assert_eq!(progress.completed_count(), 25);
453 progress.add(50);
454 assert_eq!(progress.completed_count(), 75);
455 }
456
457 #[test]
458 fn test_progress_add_exceeds_total() {
459 let mut progress = OperationProgress::new("Test", 100);
460 progress.add(150);
461 assert_eq!(progress.completed_count(), 100); }
463
464 #[test]
465 fn test_progress_is_complete() {
466 let progress = OperationProgress::new("Test", 100).completed(99);
467 assert!(!progress.is_complete());
468
469 let progress = OperationProgress::new("Test", 100).completed(100);
470 assert!(progress.is_complete());
471 }
472
473 #[test]
474 fn test_progress_state_updates() {
475 let progress = OperationProgress::new("Test", 100).completed(100);
476 assert_eq!(progress.current_state(), ProgressState::Complete);
477 }
478
479 #[test]
480 fn test_progress_manual_state() {
481 let progress = OperationProgress::new("Test", 100).state(ProgressState::Error);
482 assert_eq!(progress.current_state(), ProgressState::Error);
483 }
484
485 #[test]
486 fn test_progress_render_plain() {
487 let progress = OperationProgress::new("Processing", 1000)
488 .completed(500)
489 .show_throughput(false)
490 .show_eta(false);
491
492 let plain = progress.render_plain();
493 assert!(plain.contains("Processing:"));
494 assert!(plain.contains("50%"));
495 assert!(plain.contains("(500/1000)"));
496 }
497
498 #[test]
499 fn test_progress_render_plain_complete() {
500 let progress = OperationProgress::new("Done", 100)
501 .completed(100)
502 .show_throughput(false)
503 .show_eta(false);
504
505 let plain = progress.render_plain();
506 assert!(plain.contains("100%"));
507 }
508
509 #[test]
510 fn test_progress_render_styled_contains_bar() {
511 let progress = OperationProgress::new("Test", 100)
512 .completed(50)
513 .width(20)
514 .show_throughput(false)
515 .show_eta(false);
516
517 let styled = progress.render_styled();
518 assert!(styled.contains('['));
519 assert!(styled.contains(']'));
520 assert!(styled.contains("50%"));
521 }
522
523 #[test]
524 fn test_progress_json_output() {
525 let progress = OperationProgress::new("Test", 100).completed(42);
526 let json = progress.to_json();
527
528 assert!(json.contains("\"operation\":\"Test\""));
529 assert!(json.contains("\"completed\":42"));
530 assert!(json.contains("\"total\":100"));
531 assert!(json.contains("\"percentage\":42"));
532 assert!(json.contains("\"is_complete\":false"));
533 }
534
535 #[test]
536 fn test_progress_json_complete() {
537 let progress = OperationProgress::new("Test", 100).completed(100);
538 let json = progress.to_json();
539
540 assert!(json.contains("\"is_complete\":true"));
541 assert!(json.contains("\"state\":\"complete\""));
542 }
543
544 #[test]
545 fn test_progress_with_unit() {
546 let progress = OperationProgress::new("Transferring", 1000)
547 .completed(500)
548 .unit("KB")
549 .show_throughput(true)
550 .show_eta(false);
551
552 let plain = progress.render_plain();
553 assert!(plain.contains("KB/s") || plain.contains("(500/1000)"));
554 }
555
556 #[test]
557 fn test_progress_set_completed() {
558 let mut progress = OperationProgress::new("Test", 100);
559 progress.set_completed(75);
560 assert_eq!(progress.completed_count(), 75);
561 }
562
563 #[test]
564 fn test_progress_builder_chain() {
565 let progress = OperationProgress::new("Test", 100)
566 .completed(50)
567 .theme(Theme::default())
568 .width(40)
569 .show_eta(true)
570 .show_throughput(true)
571 .unit("items");
572
573 assert_eq!(progress.completed_count(), 50);
574 }
575
576 #[test]
577 fn test_format_duration_subsecond() {
578 assert_eq!(format_duration(0.5), "<1s");
579 }
580
581 #[test]
582 fn test_format_duration_seconds() {
583 assert_eq!(format_duration(45.0), "45s");
584 }
585
586 #[test]
587 fn test_format_duration_minutes() {
588 let result = format_duration(125.0);
589 assert!(result.contains('m'));
590 assert!(result.contains('s'));
591 }
592
593 #[test]
594 fn test_format_duration_hours() {
595 let result = format_duration(3700.0);
596 assert!(result.contains('h'));
597 assert!(result.contains('m'));
598 }
599
600 #[test]
601 fn test_progress_throughput_initial() {
602 let progress = OperationProgress::new("Test", 100);
604 assert!(progress.throughput() >= 0.0);
606 }
607
608 #[test]
609 fn test_progress_eta_no_progress() {
610 let progress = OperationProgress::new("Test", 100);
611 assert!(progress.eta_secs().is_none() || progress.eta_secs().unwrap_or(0.0) >= 0.0);
613 }
614}