Skip to main content

sqlmodel_console/renderables/
operation_progress.rs

1//! Operation progress bar for determinate operations.
2//!
3//! `OperationProgress` displays a progress bar with completion percentage,
4//! throughput rate, and estimated time remaining.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::OperationProgress;
10//!
11//! let progress = OperationProgress::new("Inserting rows", 1000)
12//!     .completed(420);
13//!
14//! // Plain text: "Inserting rows: 42% (420/1000)"
15//! println!("{}", progress.render_plain());
16//! ```
17
18use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21
22use crate::theme::Theme;
23
24/// Progress state for styling.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ProgressState {
27    /// Normal progress (default)
28    #[default]
29    Normal,
30    /// Completed successfully
31    Complete,
32    /// Progress is slow/stalled
33    Warning,
34    /// Progress has errored
35    Error,
36}
37
38/// A progress bar for operations with known total count.
39///
40/// Tracks completion percentage, calculates throughput rate, and estimates
41/// time remaining based on current progress speed.
42///
43/// # Rendering Modes
44///
45/// - **Rich mode**: Colored progress bar with percentage, counter, throughput, ETA
46/// - **Plain mode**: Text format suitable for agents: `Name: 42% (420/1000) 50.2/s ETA: 12s`
47/// - **JSON mode**: Structured data for programmatic consumption
48///
49/// # Example
50///
51/// ```rust
52/// use sqlmodel_console::renderables::OperationProgress;
53///
54/// let mut progress = OperationProgress::new("Processing", 100);
55/// progress.set_completed(50);
56///
57/// assert_eq!(progress.percentage(), 50.0);
58/// ```
59#[derive(Debug, Clone)]
60pub struct OperationProgress {
61    /// Name of the operation being tracked
62    operation_name: String,
63    /// Number of items completed
64    completed: u64,
65    /// Total number of items
66    total: u64,
67    /// When the operation started (for rate calculation)
68    started_at: Instant,
69    /// Current state for styling
70    state: ProgressState,
71    /// Optional theme for styling
72    theme: Option<Theme>,
73    /// Optional fixed width for rendering
74    width: Option<usize>,
75    /// Whether to show ETA
76    show_eta: bool,
77    /// Whether to show throughput
78    show_throughput: bool,
79    /// Unit label for items (e.g., "rows", "bytes")
80    unit: String,
81}
82
83impl OperationProgress {
84    /// Create a new progress tracker.
85    ///
86    /// # Arguments
87    /// - `operation_name`: Human-readable name for the operation
88    /// - `total`: Total number of items to process
89    #[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    /// Set the number of completed items.
106    #[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    /// Set the number of completed items (mutable version).
114    pub fn set_completed(&mut self, completed: u64) {
115        self.completed = completed.min(self.total);
116        self.update_state();
117    }
118
119    /// Increment the completed count by one.
120    pub fn increment(&mut self) {
121        if self.completed < self.total {
122            self.completed += 1;
123            self.update_state();
124        }
125    }
126
127    /// Add to the completed count.
128    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    /// Set the theme for styled output.
134    #[must_use]
135    pub fn theme(mut self, theme: Theme) -> Self {
136        self.theme = Some(theme);
137        self
138    }
139
140    /// Set the rendering width.
141    #[must_use]
142    pub fn width(mut self, width: usize) -> Self {
143        self.width = Some(width);
144        self
145    }
146
147    /// Set whether to show ETA.
148    #[must_use]
149    pub fn show_eta(mut self, show: bool) -> Self {
150        self.show_eta = show;
151        self
152    }
153
154    /// Set whether to show throughput.
155    #[must_use]
156    pub fn show_throughput(mut self, show: bool) -> Self {
157        self.show_throughput = show;
158        self
159    }
160
161    /// Set the unit label for items.
162    #[must_use]
163    pub fn unit(mut self, unit: impl Into<String>) -> Self {
164        self.unit = unit.into();
165        self
166    }
167
168    /// Set the state manually (e.g., for error indication).
169    #[must_use]
170    pub fn state(mut self, state: ProgressState) -> Self {
171        self.state = state;
172        self
173    }
174
175    /// Reset the start time (useful when reusing a progress tracker).
176    pub fn reset_timer(&mut self) {
177        self.started_at = Instant::now();
178    }
179
180    /// Get the operation name.
181    #[must_use]
182    pub fn operation_name(&self) -> &str {
183        &self.operation_name
184    }
185
186    /// Get the completed count.
187    #[must_use]
188    pub fn completed_count(&self) -> u64 {
189        self.completed
190    }
191
192    /// Get the total count.
193    #[must_use]
194    pub fn total_count(&self) -> u64 {
195        self.total
196    }
197
198    /// Get the current state.
199    #[must_use]
200    pub fn current_state(&self) -> ProgressState {
201        self.state
202    }
203
204    /// Calculate the completion percentage.
205    #[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    /// Calculate the elapsed time in seconds.
214    #[must_use]
215    pub fn elapsed_secs(&self) -> f64 {
216        self.started_at.elapsed().as_secs_f64()
217    }
218
219    /// Calculate the throughput (items per second).
220    #[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    /// Calculate the estimated time remaining in seconds.
230    #[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    /// Check if the operation is complete.
241    #[must_use]
242    pub fn is_complete(&self) -> bool {
243        self.completed >= self.total
244    }
245
246    /// Update state based on current progress.
247    fn update_state(&mut self) {
248        if self.completed >= self.total {
249            self.state = ProgressState::Complete;
250        }
251        // Could add warning state detection for slow operations
252    }
253
254    /// Render as plain text for agents.
255    ///
256    /// Format: `Name: 42% (420/1000) 50.2/s ETA: 12s`
257    #[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    /// Render with ANSI styling.
281    ///
282    /// Shows a visual progress bar with colors based on state.
283    #[must_use]
284    #[allow(clippy::cast_possible_truncation)] // bar_width is bounded
285    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        // Build the progress bar
302        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        // Build the status line
309        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    /// Render as JSON for structured output.
332    #[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
374/// Format a duration in seconds to human-readable form.
375fn 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); // Should not exceed total
444    }
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); // Capped at total
460    }
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        // Initially, throughput should be 0 or very low
601        let progress = OperationProgress::new("Test", 100);
602        // With 0 completed and near-zero elapsed time
603        assert!(progress.throughput() >= 0.0);
604    }
605
606    #[test]
607    fn test_progress_eta_no_progress() {
608        let progress = OperationProgress::new("Test", 100);
609        // With no progress, ETA should be None (rate is 0)
610        assert!(progress.eta_secs().is_none() || progress.eta_secs().unwrap_or(0.0) >= 0.0);
611    }
612}