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 && !self.is_complete() {
272 if let Some(eta) = self.eta_secs() {
273 parts.push(format!("ETA: {}", format_duration(eta)));
274 }
275 }
276
277 parts.join(" ")
278 }
279
280 #[must_use]
284 #[allow(clippy::cast_possible_truncation)] pub fn render_styled(&self) -> String {
286 let bar_width = self.width.unwrap_or(30);
287 let pct = self.percentage();
288 let filled = ((pct / 100.0) * bar_width as f64).round() as usize;
289 let empty = bar_width.saturating_sub(filled);
290
291 let theme = self.theme.clone().unwrap_or_default();
292
293 let (bar_color, text_color) = match self.state {
294 ProgressState::Normal => (theme.info.color_code(), theme.info.color_code()),
295 ProgressState::Complete => (theme.success.color_code(), theme.success.color_code()),
296 ProgressState::Warning => (theme.warning.color_code(), theme.warning.color_code()),
297 ProgressState::Error => (theme.error.color_code(), theme.error.color_code()),
298 };
299 let reset = "\x1b[0m";
300
301 let bar = format!(
303 "{bar_color}[{filled}{empty}]{reset}",
304 filled = "=".repeat(filled.saturating_sub(1)) + if filled > 0 { ">" } else { "" },
305 empty = " ".repeat(empty),
306 );
307
308 let mut parts = vec![
310 format!("{text_color}{}{reset}", self.operation_name),
311 bar,
312 format!("{pct:.0}%"),
313 format!("({}/{})", self.completed, self.total),
314 ];
315
316 if self.show_throughput && self.completed > 0 {
317 let rate = self.throughput();
318 let unit_label = if self.unit.is_empty() { "" } else { &self.unit };
319 parts.push(format!("{rate:.1}{unit_label}/s"));
320 }
321
322 if self.show_eta && !self.is_complete() {
323 if let Some(eta) = self.eta_secs() {
324 parts.push(format!("ETA: {}", format_duration(eta)));
325 }
326 }
327
328 parts.join(" ")
329 }
330
331 #[must_use]
333 pub fn to_json(&self) -> String {
334 #[derive(Serialize)]
335 struct ProgressJson<'a> {
336 operation: &'a str,
337 completed: u64,
338 total: u64,
339 percentage: f64,
340 throughput: f64,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 eta_secs: Option<f64>,
343 elapsed_secs: f64,
344 is_complete: bool,
345 state: &'a str,
346 #[serde(skip_serializing_if = "str::is_empty")]
347 unit: &'a str,
348 }
349
350 let state_str = match self.state {
351 ProgressState::Normal => "normal",
352 ProgressState::Complete => "complete",
353 ProgressState::Warning => "warning",
354 ProgressState::Error => "error",
355 };
356
357 let json = ProgressJson {
358 operation: &self.operation_name,
359 completed: self.completed,
360 total: self.total,
361 percentage: self.percentage(),
362 throughput: self.throughput(),
363 eta_secs: self.eta_secs(),
364 elapsed_secs: self.elapsed_secs(),
365 is_complete: self.is_complete(),
366 state: state_str,
367 unit: &self.unit,
368 };
369
370 serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
371 }
372}
373
374fn format_duration(secs: f64) -> String {
376 if secs < 1.0 {
377 return "<1s".to_string();
378 }
379 if secs < 60.0 {
380 return format!("{:.0}s", secs);
381 }
382 if secs < 3600.0 {
383 let mins = (secs / 60.0).floor();
384 let remaining = secs % 60.0;
385 return format!("{:.0}m{:.0}s", mins, remaining);
386 }
387 let hours = (secs / 3600.0).floor();
388 let remaining_mins = ((secs % 3600.0) / 60.0).floor();
389 format!("{:.0}h{:.0}m", hours, remaining_mins)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_progress_creation() {
398 let progress = OperationProgress::new("Test", 100);
399 assert_eq!(progress.operation_name(), "Test");
400 assert_eq!(progress.completed_count(), 0);
401 assert_eq!(progress.total_count(), 100);
402 assert_eq!(progress.current_state(), ProgressState::Normal);
403 }
404
405 #[test]
406 fn test_progress_percentage_calculation_zero() {
407 let progress = OperationProgress::new("Test", 100).completed(0);
408 assert!((progress.percentage() - 0.0).abs() < f64::EPSILON);
409 }
410
411 #[test]
412 fn test_progress_percentage_calculation_half() {
413 let progress = OperationProgress::new("Test", 100).completed(50);
414 assert!((progress.percentage() - 50.0).abs() < f64::EPSILON);
415 }
416
417 #[test]
418 fn test_progress_percentage_calculation_full() {
419 let progress = OperationProgress::new("Test", 100).completed(100);
420 assert!((progress.percentage() - 100.0).abs() < f64::EPSILON);
421 }
422
423 #[test]
424 fn test_progress_percentage_zero_total() {
425 let progress = OperationProgress::new("Test", 0);
426 assert!((progress.percentage() - 100.0).abs() < f64::EPSILON);
427 }
428
429 #[test]
430 fn test_progress_increment() {
431 let mut progress = OperationProgress::new("Test", 100);
432 assert_eq!(progress.completed_count(), 0);
433 progress.increment();
434 assert_eq!(progress.completed_count(), 1);
435 progress.increment();
436 assert_eq!(progress.completed_count(), 2);
437 }
438
439 #[test]
440 fn test_progress_increment_at_max() {
441 let mut progress = OperationProgress::new("Test", 5).completed(5);
442 progress.increment();
443 assert_eq!(progress.completed_count(), 5); }
445
446 #[test]
447 fn test_progress_add_batch() {
448 let mut progress = OperationProgress::new("Test", 100);
449 progress.add(25);
450 assert_eq!(progress.completed_count(), 25);
451 progress.add(50);
452 assert_eq!(progress.completed_count(), 75);
453 }
454
455 #[test]
456 fn test_progress_add_exceeds_total() {
457 let mut progress = OperationProgress::new("Test", 100);
458 progress.add(150);
459 assert_eq!(progress.completed_count(), 100); }
461
462 #[test]
463 fn test_progress_is_complete() {
464 let progress = OperationProgress::new("Test", 100).completed(99);
465 assert!(!progress.is_complete());
466
467 let progress = OperationProgress::new("Test", 100).completed(100);
468 assert!(progress.is_complete());
469 }
470
471 #[test]
472 fn test_progress_state_updates() {
473 let progress = OperationProgress::new("Test", 100).completed(100);
474 assert_eq!(progress.current_state(), ProgressState::Complete);
475 }
476
477 #[test]
478 fn test_progress_manual_state() {
479 let progress = OperationProgress::new("Test", 100).state(ProgressState::Error);
480 assert_eq!(progress.current_state(), ProgressState::Error);
481 }
482
483 #[test]
484 fn test_progress_render_plain() {
485 let progress = OperationProgress::new("Processing", 1000)
486 .completed(500)
487 .show_throughput(false)
488 .show_eta(false);
489
490 let plain = progress.render_plain();
491 assert!(plain.contains("Processing:"));
492 assert!(plain.contains("50%"));
493 assert!(plain.contains("(500/1000)"));
494 }
495
496 #[test]
497 fn test_progress_render_plain_complete() {
498 let progress = OperationProgress::new("Done", 100)
499 .completed(100)
500 .show_throughput(false)
501 .show_eta(false);
502
503 let plain = progress.render_plain();
504 assert!(plain.contains("100%"));
505 }
506
507 #[test]
508 fn test_progress_render_styled_contains_bar() {
509 let progress = OperationProgress::new("Test", 100)
510 .completed(50)
511 .width(20)
512 .show_throughput(false)
513 .show_eta(false);
514
515 let styled = progress.render_styled();
516 assert!(styled.contains('['));
517 assert!(styled.contains(']'));
518 assert!(styled.contains("50%"));
519 }
520
521 #[test]
522 fn test_progress_json_output() {
523 let progress = OperationProgress::new("Test", 100).completed(42);
524 let json = progress.to_json();
525
526 assert!(json.contains("\"operation\":\"Test\""));
527 assert!(json.contains("\"completed\":42"));
528 assert!(json.contains("\"total\":100"));
529 assert!(json.contains("\"percentage\":42"));
530 assert!(json.contains("\"is_complete\":false"));
531 }
532
533 #[test]
534 fn test_progress_json_complete() {
535 let progress = OperationProgress::new("Test", 100).completed(100);
536 let json = progress.to_json();
537
538 assert!(json.contains("\"is_complete\":true"));
539 assert!(json.contains("\"state\":\"complete\""));
540 }
541
542 #[test]
543 fn test_progress_with_unit() {
544 let progress = OperationProgress::new("Transferring", 1000)
545 .completed(500)
546 .unit("KB")
547 .show_throughput(true)
548 .show_eta(false);
549
550 let plain = progress.render_plain();
551 assert!(plain.contains("KB/s") || plain.contains("(500/1000)"));
552 }
553
554 #[test]
555 fn test_progress_set_completed() {
556 let mut progress = OperationProgress::new("Test", 100);
557 progress.set_completed(75);
558 assert_eq!(progress.completed_count(), 75);
559 }
560
561 #[test]
562 fn test_progress_builder_chain() {
563 let progress = OperationProgress::new("Test", 100)
564 .completed(50)
565 .theme(Theme::default())
566 .width(40)
567 .show_eta(true)
568 .show_throughput(true)
569 .unit("items");
570
571 assert_eq!(progress.completed_count(), 50);
572 }
573
574 #[test]
575 fn test_format_duration_subsecond() {
576 assert_eq!(format_duration(0.5), "<1s");
577 }
578
579 #[test]
580 fn test_format_duration_seconds() {
581 assert_eq!(format_duration(45.0), "45s");
582 }
583
584 #[test]
585 fn test_format_duration_minutes() {
586 let result = format_duration(125.0);
587 assert!(result.contains('m'));
588 assert!(result.contains('s'));
589 }
590
591 #[test]
592 fn test_format_duration_hours() {
593 let result = format_duration(3700.0);
594 assert!(result.contains('h'));
595 assert!(result.contains('m'));
596 }
597
598 #[test]
599 fn test_progress_throughput_initial() {
600 let progress = OperationProgress::new("Test", 100);
602 assert!(progress.throughput() >= 0.0);
604 }
605
606 #[test]
607 fn test_progress_eta_no_progress() {
608 let progress = OperationProgress::new("Test", 100);
609 assert!(progress.eta_secs().is_none() || progress.eta_secs().unwrap_or(0.0) >= 0.0);
611 }
612}