Skip to main content

rusty_rich/
progress.rs

1//! Progress bars and task tracking. Equivalent to Rich's `progress.py`
2//! and `progress_bar.py`.
3//!
4//! # Overview
5//!
6//! [`Progress`] manages multiple concurrent tasks, each with its own
7//! description, total, and completed count. The display is built from
8//! configurable column types (see [`crate::progress_columns`]).
9//!
10//! # Quick Example
11//!
12//! ```rust
13//! use rusty_rich::Progress;
14//!
15//! let mut progress = Progress::new();
16//! let task = progress.add_task("Downloading...", Some(100.0));
17//! progress.update(task, 50.0);
18//! println!("{}", progress.render(80));
19//! ```
20//!
21//! # Tracking Iterables
22//!
23//! ```rust
24//! use rusty_rich::{Progress, TrackIterator};
25//!
26//! let mut progress = Progress::new();
27//! let items: Vec<i32> = (0..100).collect();
28//! let tracker = progress.track(items, "Processing", None);
29//! for item in tracker {
30//!     // item is yielded, progress auto-advances
31//! }
32//! ```
33//!
34//! # File Progress
35//!
36//! [`ProgressFile`] wraps a `std::io::Read` and tracks read progress via a
37//! [`Progress`] task. Use [`Progress::wrap_file`] to create one.
38
39use std::collections::HashMap;
40use std::time::{Duration, Instant};
41
42use crate::console::{ConsoleOptions, DynRenderable, Renderable};
43use crate::progress_columns::{
44    BarColumn, ProgressColumn, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn,
45};
46use crate::style::Style;
47use crate::table::{Cell, Table};
48
49// ---------------------------------------------------------------------------
50// ProgressBar
51// ---------------------------------------------------------------------------
52
53/// A single progress bar.
54#[derive(Debug, Clone)]
55pub struct ProgressBar {
56    /// Total steps (None = indeterminate).
57    pub total: Option<f64>,
58    /// Completed steps.
59    pub completed: f64,
60    /// Width in characters.
61    pub width: Option<usize>,
62    /// Characters for completed portion.
63    pub complete_char: char,
64    /// Characters for remaining portion.
65    pub remaining_char: char,
66    /// Optional pulse style (for indeterminate).
67    pub pulse: bool,
68    /// Style for completed portion.
69    pub complete_style: Style,
70    /// Style for remaining portion.
71    pub remaining_style: Style,
72    /// Style for the pulse cursor.
73    pub pulse_style: Style,
74}
75
76impl ProgressBar {
77    /// Create a new `ProgressBar` with default values (total=100, completed=0).
78    pub fn new() -> Self {
79        Self {
80            total: Some(100.0),
81            completed: 0.0,
82            width: None,
83            complete_char: '█',
84            remaining_char: '░',
85            pulse: false,
86            complete_style: Style::new(),
87            remaining_style: Style::new(),
88            pulse_style: Style::new(),
89        }
90    }
91
92    /// Set total.
93    pub fn total(mut self, total: f64) -> Self {
94        self.total = Some(total);
95        self
96    }
97
98    /// Set completed.
99    pub fn completed(mut self, completed: f64) -> Self {
100        self.completed = completed;
101        self
102    }
103
104    /// Set width.
105    pub fn width(mut self, width: usize) -> Self {
106        self.width = Some(width);
107        self
108    }
109
110    /// Set complete style.
111    pub fn complete_style(mut self, style: Style) -> Self {
112        self.complete_style = style;
113        self
114    }
115
116    /// Set remaining style.
117    pub fn remaining_style(mut self, style: Style) -> Self {
118        self.remaining_style = style;
119        self
120    }
121
122    /// Get progress as a fraction (0.0–1.0).
123    pub fn percentage(&self) -> f64 {
124        if let Some(total) = self.total {
125            if total > 0.0 {
126                (self.completed / total).clamp(0.0, 1.0)
127            } else {
128                0.0
129            }
130        } else {
131            0.0
132        }
133    }
134
135    /// Render the bar to a string.
136    pub fn render(&self, width: usize) -> String {
137        let w = self.width.unwrap_or(width).saturating_sub(2); // leave room for brackets
138        if w < 3 {
139            return "[]".to_string();
140        }
141
142        if self.pulse || self.total.is_none() {
143            // Indeterminate: pulsing animation
144            let pos = ((self.completed as usize / 8) % w.max(1)).min(w.saturating_sub(1));
145            let left = " ".repeat(pos);
146            let right = " ".repeat(w.saturating_sub(pos + 1));
147            format!("[{left}⣿{right}]")
148        } else {
149            let pct = self.percentage();
150            let filled = (w as f64 * pct) as usize;
151            let empty = w - filled;
152            let complete_ansi = self.complete_style.to_ansi();
153            let complete_reset = if complete_ansi.is_empty() {
154                ""
155            } else {
156                "\x1b[0m"
157            };
158            format!(
159                "[{complete_ansi}{}{complete_reset}{}]",
160                self.complete_char.to_string().repeat(filled),
161                self.remaining_char.to_string().repeat(empty)
162            )
163        }
164    }
165}
166
167impl Default for ProgressBar {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Task
175// ---------------------------------------------------------------------------
176
177/// A tracked task within a Progress display.
178#[derive(Debug, Clone)]
179pub struct Task {
180    pub id: usize,
181    pub description: String,
182    pub total: Option<f64>,
183    pub completed: f64,
184    pub visible: bool,
185    pub start_time: Instant,
186    pub stop_time: Option<Instant>,
187    pub fields: HashMap<String, String>,
188    /// Optional custom renderable associated with this task.
189    pub renderable: Option<DynRenderable>,
190}
191
192impl Task {
193    /// Create a new `Task` with the given id, description, and optional total.
194    pub fn new(id: usize, description: impl Into<String>, total: Option<f64>) -> Self {
195        Self {
196            id,
197            description: description.into(),
198            total,
199            completed: 0.0,
200            visible: true,
201            start_time: Instant::now(),
202            stop_time: None,
203            fields: HashMap::new(),
204            renderable: None,
205        }
206    }
207
208    /// Return the progress fraction (0.0–1.0), or 0.0 if no total is set.
209    pub fn progress(&self) -> f64 {
210        if let Some(t) = self.total {
211            if t > 0.0 {
212                (self.completed / t).clamp(0.0, 1.0)
213            } else {
214                0.0
215            }
216        } else {
217            0.0
218        }
219    }
220
221    /// Return the [`Duration`] since this task was created.
222    pub fn elapsed(&self) -> Duration {
223        self.start_time.elapsed()
224    }
225
226    /// Estimate the remaining [`Duration`] based on current progress, or
227    /// [`None`] if progress is zero or no total is set.
228    pub fn time_remaining(&self) -> Option<Duration> {
229        let pct = self.progress();
230        if pct > 0.0 {
231            let elapsed = self.elapsed();
232            let total = elapsed.div_f64(pct);
233            Some(total.saturating_sub(elapsed))
234        } else {
235            None
236        }
237    }
238
239    /// Check if the task is finished (completed >= total).
240    pub fn is_finished(&self) -> bool {
241        if let Some(t) = self.total {
242            self.completed >= t
243        } else {
244            false
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// RenderableColumn — a ProgressColumn that renders a custom renderable
251// ---------------------------------------------------------------------------
252
253/// A column that renders a custom renderable per task.
254pub struct RenderableColumn {
255    pub format: Box<dyn Fn(&Task) -> DynRenderable + Send + Sync>,
256}
257
258impl RenderableColumn {
259    /// Create a new `RenderableColumn` from a renderable-producing closure.
260    pub fn new<F: Fn(&Task) -> DynRenderable + Send + Sync + 'static>(format: F) -> Self {
261        Self {
262            format: Box::new(format),
263        }
264    }
265}
266
267impl std::fmt::Debug for RenderableColumn {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("RenderableColumn").finish()
270    }
271}
272
273impl ProgressColumn for RenderableColumn {
274    fn render(&self, task: &Task, _width: usize, _elapsed: Duration) -> String {
275        let renderable = (self.format)(task);
276        renderable.render(&ConsoleOptions::default()).to_ansi()
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Progress
282// ---------------------------------------------------------------------------
283
284/// A multi-task progress display.
285///
286/// Tasks are stored in a [`HashMap`] keyed by task ID for O(1) lookup.
287/// `task_order` maintains insertion order for rendering.
288#[derive(Debug)]
289pub struct Progress {
290    pub tasks: HashMap<usize, Task>,
291    /// Insertion order of task IDs for stable iteration during rendering.
292    pub task_order: Vec<usize>,
293    pub auto_refresh: bool,
294    pub refresh_per_second: f64,
295    pub transient: bool,
296    /// Columns to render for each task (if None, uses default columns).
297    pub columns: Option<Vec<Box<dyn crate::progress_columns::ProgressColumn>>>,
298    next_id: usize,
299}
300
301impl Progress {
302    /// Create a new `Progress` instance with no tasks.
303    pub fn new() -> Self {
304        Self {
305            tasks: HashMap::new(),
306            task_order: Vec::new(),
307            auto_refresh: true,
308            refresh_per_second: 10.0,
309            transient: false,
310            columns: None,
311            next_id: 1,
312        }
313    }
314
315    /// Replace the default columns with a custom list of [`ProgressColumn`]s.
316    ///
317    /// Each task is rendered as one row using the provided columns.
318    pub fn with_columns(
319        mut self,
320        columns: Vec<Box<dyn crate::progress_columns::ProgressColumn>>,
321    ) -> Self {
322        self.columns = Some(columns);
323        self
324    }
325
326    /// Register a new task and return its numeric ID (used by `advance`, `update`, etc.).
327    pub fn add_task(&mut self, description: impl Into<String>, total: Option<f64>) -> usize {
328        let id = self.next_id;
329        self.next_id += 1;
330        self.tasks.insert(id, Task::new(id, description, total));
331        self.task_order.push(id);
332        id
333    }
334
335    /// Increase a task's completed count by `delta`.
336    pub fn advance(&mut self, task_id: usize, delta: f64) {
337        if let Some(task) = self.tasks.get_mut(&task_id) {
338            task.completed += delta;
339            if let Some(total) = task.total {
340                if task.completed > total {
341                    task.completed = total;
342                }
343            }
344        }
345    }
346
347    /// Set a task's completed count directly (overwrites current value).
348    pub fn update(&mut self, task_id: usize, completed: f64) {
349        if let Some(task) = self.tasks.get_mut(&task_id) {
350            // Clamp to [0, total] to prevent NaN/Inf from escaping
351            task.completed = if let Some(total) = task.total {
352                completed.clamp(0.0, total)
353            } else {
354                completed.max(0.0)
355            };
356        }
357    }
358
359    /// Remove a task by its ID. No-op if the task does not exist.
360    pub fn remove_task(&mut self, task_id: usize) {
361        self.tasks.remove(&task_id);
362        self.task_order.retain(|id| *id != task_id);
363    }
364
365    /// Force a refresh/render of the progress display.
366    /// In a live display context this triggers a re-render.
367    pub fn refresh(&mut self) {
368        // Force refresh — in a live display this triggers a redraw.
369        // Stateless rendering: this is a no-op placeholder.
370    }
371
372    /// Mark a task as started (reset its start_time to now).
373    pub fn start_task(&mut self, task_id: usize) {
374        if let Some(task) = self.tasks.get_mut(&task_id) {
375            task.start_time = Instant::now();
376        }
377    }
378
379    /// Mark a task as stopped (set its stop_time to now).
380    pub fn stop_task(&mut self, task_id: usize) {
381        if let Some(task) = self.tasks.get_mut(&task_id) {
382            task.stop_time = Some(Instant::now());
383        }
384    }
385
386    /// Reset a task's completed count.
387    ///
388    /// If `total` is `Some`, also updates the task's total.
389    pub fn reset(&mut self, task_id: usize, total: Option<f64>) {
390        if let Some(task) = self.tasks.get_mut(&task_id) {
391            task.completed = 0.0;
392            if let Some(t) = total {
393                task.total = Some(t);
394            }
395        }
396    }
397
398    /// Check if all tasks are finished.
399    pub fn finished(&self) -> bool {
400        self.tasks.values().all(|t| t.is_finished())
401    }
402
403    /// Get the default column set for rendering.
404    ///
405    /// Returns: description, spinner, bar, percentage, elapsed.
406    pub fn get_default_columns(&self) -> Vec<Box<dyn ProgressColumn>> {
407        vec![
408            Box::new(TextColumn::new("description")),
409            Box::new(SpinnerColumn::new()),
410            Box::new(BarColumn::new()),
411            Box::new(TaskProgressColumn::new()),
412            Box::new(TimeElapsedColumn::new()),
413        ]
414    }
415
416    /// Get the renderable for a specific task, if any.
417    pub fn get_renderable(&self, task_id: usize) -> Option<&dyn Renderable> {
418        self.tasks
419            .get(&task_id)
420            .and_then(|t| t.renderable.as_ref())
421            .map(|dr| dr as &dyn Renderable)
422    }
423
424    /// Get all task renderables.
425    pub fn get_renderables(&self) -> Vec<&dyn Renderable> {
426        self.tasks
427            .values()
428            .filter_map(|t| t.renderable.as_ref())
429            .map(|dr| dr as &dyn Renderable)
430            .collect()
431    }
432
433    /// Build a [`Table`] from tasks and progress columns.
434    ///
435    /// Each visible task becomes a row, each column becomes a cell rendered
436    /// by the corresponding `ProgressColumn`.
437    pub fn make_tasks_table(&self, columns: &[Box<dyn ProgressColumn>]) -> Table {
438        let now = Instant::now();
439        let mut table = Table::new();
440        table.show_header = false;
441        table.show_edge = false;
442        table.padding = (0, 1, 0, 0);
443
444        // Add a table column for each progress column
445        for (i, _col) in columns.iter().enumerate() {
446            table.add_column(crate::table::Column::new(format!("Col {}", i)));
447        }
448
449        for id in &self.task_order {
450            if let Some(task) = self.tasks.get(id) {
451                if !task.visible {
452                    continue;
453                }
454                let elapsed = now.duration_since(task.start_time);
455                let cells: Vec<Cell> = columns
456                    .iter()
457                    .map(|col| Cell::new(col.render(task, 20, elapsed)))
458                    .collect();
459                table.add_row(cells);
460            }
461        }
462
463        table
464    }
465
466    /// Render all visible tasks to a multi-line string at the given terminal width.
467    pub fn render(&self, width: usize) -> String {
468        if let Some(ref columns) = self.columns {
469            self.render_with_columns(width, columns)
470        } else {
471            self.render_default(width)
472        }
473    }
474
475    /// Render using custom columns.
476    fn render_with_columns(
477        &self,
478        _width: usize,
479        columns: &[Box<dyn crate::progress_columns::ProgressColumn>],
480    ) -> String {
481        let mut out = String::new();
482        let now = std::time::Instant::now();
483        for id in &self.task_order {
484            if let Some(task) = self.tasks.get(id) {
485                if !task.visible {
486                    continue;
487                }
488                let elapsed = now.duration_since(task.start_time);
489                let mut line = String::new();
490                for (i, col) in columns.iter().enumerate() {
491                    if i > 0 {
492                        line.push(' ');
493                    }
494                    line.push_str(&col.render(task, 20, elapsed));
495                }
496                out.push_str(&line);
497                out.push('\n');
498            }
499        }
500        out
501    }
502
503    /// Default render (no columns).
504    fn render_default(&self, width: usize) -> String {
505        let mut out = String::new();
506        for id in &self.task_order {
507            if let Some(task) = self.tasks.get(id) {
508                if !task.visible {
509                    continue;
510                }
511                let bar_width = width.saturating_sub(30).max(10);
512                let bar = self.render_task_bar(task, bar_width);
513                let pct = (task.progress() * 100.0) as usize;
514                let elapsed = format_duration(&task.elapsed());
515                let remaining = task
516                    .time_remaining()
517                    .map(|d| format_duration(&d))
518                    .unwrap_or_else(|| "?".to_string());
519
520                out.push_str(&format!(
521                    "{desc:<20} {pct:>3}% {bar} {elapsed}<{remaining}\n",
522                    desc = task.description.chars().take(20).collect::<String>(),
523                ));
524            }
525        }
526        out
527    }
528
529    fn render_task_bar(&self, task: &Task, width: usize) -> String {
530        let w = width.saturating_sub(2);
531        if w < 3 {
532            return "[]".to_string();
533        }
534        let pct = task.progress();
535        let filled = (w as f64 * pct) as usize;
536        let empty = w - filled;
537        format!(
538            "[{}░{}]",
539            "█".repeat(filled),
540            " ".repeat(empty.saturating_sub(1))
541        )
542    }
543
544    /// Wrap an iterator with progress tracking, returning a [`TrackIterator`].
545    ///
546    /// Equivalent to Python Rich's `track()`.
547    pub fn track<I: IntoIterator>(
548        &mut self,
549        sequence: I,
550        description: &str,
551        total: Option<f64>,
552    ) -> TrackIterator<I::IntoIter> {
553        let iter = sequence.into_iter();
554        let (lower, upper) = iter.size_hint();
555        let total = total.unwrap_or(upper.unwrap_or(lower) as f64);
556        let task_id = self.add_task(description, Some(total));
557
558        TrackIterator {
559            inner: iter,
560            progress_id: task_id,
561            count: 0,
562            total,
563        }
564    }
565
566    /// Convenience: advance a task by a [`u64`] byte count (casts to `f64` internally).
567    pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
568        self.advance(task_id, bytes as f64);
569    }
570
571    /// Open a file at the given path and wrap it with progress tracking.
572    ///
573    /// Returns a [`ProgressFile`] whose reads are recorded via this [`Progress`].
574    pub fn open(
575        &mut self,
576        path: impl AsRef<std::path::Path>,
577        description: impl Into<String>,
578    ) -> std::io::Result<ProgressFile> {
579        let path = path.as_ref();
580        let metadata = std::fs::metadata(path)?;
581        let total = metadata.len();
582        let file = std::fs::File::open(path)?;
583        let desc = description.into();
584        Ok(self.wrap_file(file, &desc, Some(total)))
585    }
586
587    /// Wrap an already-open [`std::fs::File`] with progress tracking.
588    pub fn wrap_file(
589        &mut self,
590        file: std::fs::File,
591        description: &str,
592        total: Option<u64>,
593    ) -> ProgressFile {
594        let total_val = total.unwrap_or(0) as f64;
595        let task_id = self.add_task(description, Some(total_val));
596        ProgressFile::new(file, task_id, total.unwrap_or(0))
597    }
598}
599
600impl Default for Progress {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Standalone track function
608// ---------------------------------------------------------------------------
609
610/// Create a [`TrackIterator`] from a sequence (standalone, item counting only).
611///
612/// This standalone function does NOT update any [`Progress`] display — it only
613/// counts consumed items via [`TrackIterator::count`]. To get live progress
614/// updates, use [`Progress::track`] instead and call
615/// [`Progress::update`](Progress::update) with the tracker's `progress_id` and
616/// `count()` in your loop body.
617///
618/// # Example (count only)
619///
620/// ```rust
621/// use rusty_rich::progress::track;
622///
623/// let tracker = track(0..100, "Processing", None);
624/// for item in tracker {
625///     // item is yielded; tracker.count() tracks position
626/// }
627/// ```
628///
629/// # Example (with Progress display)
630///
631/// ```rust
632/// use rusty_rich::Progress;
633///
634/// let mut progress = Progress::new();
635/// let items: Vec<i32> = (0..100).collect();
636/// let tracker = progress.track(items, "Processing", None);
637/// let task_id = tracker.progress_id;
638/// let mut count = 0;
639/// for item in tracker {
640///     count += 1;
641///     progress.update(task_id, count as f64);
642///     // process item
643/// }
644/// ```
645pub fn track<T: IntoIterator>(
646    sequence: T,
647    _description: &str,
648    total: Option<f64>,
649) -> TrackIterator<T::IntoIter> {
650    let iter = sequence.into_iter();
651    let (lower, upper) = iter.size_hint();
652    let total_val = total.unwrap_or(upper.unwrap_or(lower) as f64);
653    TrackIterator {
654        inner: iter,
655        progress_id: 0,
656        count: 0,
657        total: total_val,
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Standalone wrap_file function
663// ---------------------------------------------------------------------------
664
665/// Wrap a file with progress tracking (standalone, no Progress).
666///
667/// To get live progress updates on reads, use [`Progress::wrap_file`] instead.
668pub fn wrap_file(file: std::fs::File, _description: &str, total: Option<u64>) -> ProgressFile {
669    ProgressFile::new(file, 0, total.unwrap_or(0))
670}
671
672// ---------------------------------------------------------------------------
673// TrackIterator — wraps an iterator with progress updates
674// ---------------------------------------------------------------------------
675
676/// An iterator wrapper that updates progress as items are consumed.
677/// Equivalent to Python Rich's `track()`.
678pub struct TrackIterator<I: Iterator> {
679    inner: I,
680    /// The progress task ID (caller must update progress externally).
681    pub progress_id: usize,
682    count: usize,
683    total: f64,
684}
685
686impl<I: Iterator> Iterator for TrackIterator<I> {
687    type Item = I::Item;
688
689    fn next(&mut self) -> Option<Self::Item> {
690        let item = self.inner.next();
691        if item.is_some() {
692            self.count += 1;
693        }
694        item
695    }
696
697    fn size_hint(&self) -> (usize, Option<usize>) {
698        self.inner.size_hint()
699    }
700}
701
702impl<I: Iterator> TrackIterator<I> {
703    /// Get the current count.
704    pub fn count(&self) -> usize {
705        self.count
706    }
707
708    /// Get the total.
709    pub fn total(&self) -> f64 {
710        self.total
711    }
712}
713
714// ---------------------------------------------------------------------------
715// ProgressFile — wraps a File with progress tracking
716// ---------------------------------------------------------------------------
717
718/// A file wrapper that tracks read progress for use with a Progress instance.
719#[derive(Debug)]
720pub struct ProgressFile {
721    inner: std::fs::File,
722    task_id: usize,
723    total: u64,
724    bytes_read: u64,
725}
726
727impl ProgressFile {
728    /// Create a new ProgressFile.
729    pub fn new(file: std::fs::File, task_id: usize, total: u64) -> Self {
730        Self {
731            inner: file,
732            task_id,
733            total,
734            bytes_read: 0,
735        }
736    }
737
738    /// Get the number of bytes read so far.
739    pub fn bytes_read(&self) -> u64 {
740        self.bytes_read
741    }
742
743    /// Get the total file size.
744    pub fn total(&self) -> u64 {
745        self.total
746    }
747
748    /// Get the task ID this ProgressFile is associated with.
749    pub fn task_id(&self) -> usize {
750        self.task_id
751    }
752
753    /// Sync the current read progress to a Progress instance.
754    pub fn sync(&self, progress: &mut Progress) {
755        if let Some(task) = progress.tasks.get_mut(&self.task_id) {
756            task.completed = self.bytes_read as f64;
757        }
758    }
759
760    /// Get a reference to the inner file.
761    pub fn inner(&self) -> &std::fs::File {
762        &self.inner
763    }
764
765    /// Get a mutable reference to the inner file.
766    pub fn inner_mut(&mut self) -> &mut std::fs::File {
767        &mut self.inner
768    }
769
770    /// Consume this ProgressFile and return the inner file.
771    pub fn into_inner(self) -> std::fs::File {
772        self.inner
773    }
774}
775
776impl std::io::Read for ProgressFile {
777    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
778        let n = self.inner.read(buf)?;
779        self.bytes_read += n as u64;
780        Ok(n)
781    }
782}
783
784// ---------------------------------------------------------------------------
785
786fn format_duration(d: &Duration) -> String {
787    let secs = d.as_secs();
788    if secs < 60 {
789        format!("0:{secs:02}")
790    } else if secs < 3600 {
791        format!("{}:{:02}", secs / 60, secs % 60)
792    } else {
793        format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    #[test]
802    fn test_progress_bar_render() {
803        let bar = ProgressBar::new().total(100.0).completed(50.0);
804        let r = bar.render(20);
805        assert!(r.contains('█'));
806    }
807
808    #[test]
809    fn test_progress_add_task() {
810        let mut p = Progress::new();
811        let id = p.add_task("Download", Some(100.0));
812        assert_eq!(id, 1);
813        p.advance(1, 50.0);
814        assert_eq!(p.tasks.get(&1).unwrap().completed, 50.0);
815    }
816
817    #[test]
818    fn test_advance_bytes() {
819        let mut p = Progress::new();
820        let id = p.add_task("Download", Some(1000.0));
821        p.advance_bytes(id, 256);
822        assert_eq!(p.tasks.get(&id).unwrap().completed, 256.0);
823    }
824
825    #[test]
826    fn test_progress_file_wrap_and_read() {
827        use std::io::Read;
828        let data = b"hello world";
829        let dir = std::env::temp_dir();
830        let path = dir.join("rusty_rich_test_progress.txt");
831
832        // Write test data
833        std::fs::write(&path, data).unwrap();
834
835        let mut p = Progress::new();
836        let mut pf = p.open(&path, "test file").unwrap();
837        assert_eq!(pf.total(), 11);
838        assert_eq!(pf.bytes_read(), 0);
839
840        // Read a few bytes
841        let mut buf = [0u8; 5];
842        let n = pf.read(&mut buf).unwrap();
843        assert_eq!(n, 5);
844        assert_eq!(pf.bytes_read(), 5);
845
846        // Sync progress
847        pf.sync(&mut p);
848        assert_eq!(p.tasks.get(&pf.task_id()).unwrap().completed, 5.0);
849
850        // Read remaining bytes
851        let mut buf = Vec::new();
852        pf.read_to_end(&mut buf).unwrap();
853        assert_eq!(pf.bytes_read(), 11);
854
855        // Sync again
856        pf.sync(&mut p);
857        assert_eq!(p.tasks.get(&pf.task_id()).unwrap().completed, 11.0);
858
859        drop(pf);
860        std::fs::remove_file(&path).unwrap();
861    }
862
863    #[test]
864    fn test_progress_file_wrap_existing() {
865        let data = b"test data for wrap";
866        let dir = std::env::temp_dir();
867        let path = dir.join("rusty_rich_test_wrap.txt");
868        std::fs::write(&path, data).unwrap();
869
870        let file = std::fs::File::open(&path).unwrap();
871        let mut p = Progress::new();
872        let pf = p.wrap_file(file, "wrapped", Some(data.len() as u64));
873        assert_eq!(pf.total(), data.len() as u64);
874        assert_eq!(pf.task_id(), 1);
875
876        drop(pf);
877        std::fs::remove_file(&path).unwrap();
878    }
879
880    // --- New feature tests ---
881
882    #[test]
883    fn test_start_task() {
884        let mut p = Progress::new();
885        let id = p.add_task("test", Some(100.0));
886        // start_task resets the start_time; just verify it doesn't panic
887        p.start_task(id);
888        assert!(!p.tasks.get(&id).unwrap().elapsed().is_zero());
889    }
890
891    #[test]
892    fn test_stop_task() {
893        let mut p = Progress::new();
894        let id = p.add_task("test", Some(100.0));
895        p.stop_task(id);
896        assert!(p.tasks.get(&id).unwrap().stop_time.is_some());
897    }
898
899    #[test]
900    fn test_reset_task() {
901        let mut p = Progress::new();
902        let id = p.add_task("test", Some(100.0));
903        p.advance(id, 50.0);
904        assert_eq!(p.tasks.get(&id).unwrap().completed, 50.0);
905        p.reset(id, Some(200.0));
906        assert_eq!(p.tasks.get(&id).unwrap().completed, 0.0);
907        assert_eq!(p.tasks.get(&id).unwrap().total, Some(200.0));
908    }
909
910    #[test]
911    fn test_finished() {
912        let mut p = Progress::new();
913        p.add_task("a", Some(100.0));
914        p.add_task("b", Some(100.0));
915        assert!(!p.finished());
916        p.update(1, 100.0);
917        p.update(2, 100.0);
918        assert!(p.finished());
919    }
920
921    #[test]
922    fn test_get_default_columns() {
923        let p = Progress::new();
924        let cols = p.get_default_columns();
925        assert_eq!(cols.len(), 5);
926    }
927
928    #[test]
929    fn test_refresh() {
930        let mut p = Progress::new();
931        p.add_task("test", Some(100.0));
932        // Should not panic
933        p.refresh();
934    }
935
936    #[test]
937    fn test_track_method() {
938        let mut p = Progress::new();
939        let items = vec![1, 2, 3];
940        let tracker = p.track(items, "counting", Some(3.0));
941        assert_eq!(tracker.progress_id, 1);
942        assert_eq!(p.tasks.len(), 1);
943    }
944
945    #[test]
946    fn test_standalone_track() {
947        let items = vec![1, 2, 3];
948        let tracker = track(items, "counting", Some(3.0));
949        assert_eq!(tracker.progress_id, 0);
950    }
951
952    #[test]
953    fn test_standalone_wrap_file() {
954        let data = b"hello";
955        let dir = std::env::temp_dir();
956        let path = dir.join("rusty_rich_test_standalone_wrap.txt");
957        std::fs::write(&path, data).unwrap();
958        let file = std::fs::File::open(&path).unwrap();
959        let pf = wrap_file(file, "standalone", Some(data.len() as u64));
960        assert_eq!(pf.total(), 5);
961        std::fs::remove_file(&path).unwrap();
962    }
963
964    #[test]
965    fn test_renderable_column() {
966        let col = RenderableColumn::new(|task: &Task| DynRenderable::new(task.description.clone()));
967        let task = Task::new(1, "hello", Some(100.0));
968        let result = col.render(&task, 20, Duration::from_secs(0));
969        assert!(result.contains("hello"));
970    }
971
972    #[test]
973    fn test_make_tasks_table() {
974        let mut p = Progress::new();
975        p.add_task("task1", Some(100.0));
976        p.add_task("task2", Some(50.0));
977        let cols = p.get_default_columns();
978        let table = p.make_tasks_table(&cols);
979        assert_eq!(table.row_count(), 2);
980    }
981
982    #[test]
983    fn test_get_renderable() {
984        let mut p = Progress::new();
985        let id = p.add_task("test", Some(100.0));
986        // No renderable set initially
987        assert!(p.get_renderable(id).is_none());
988    }
989
990    #[test]
991    fn test_get_renderables() {
992        let mut p = Progress::new();
993        p.add_task("a", Some(100.0));
994        p.add_task("b", Some(50.0));
995        let renderables = p.get_renderables();
996        assert!(renderables.is_empty());
997    }
998
999    #[test]
1000    fn test_auto_refresh_default() {
1001        let p = Progress::new();
1002        assert!(p.auto_refresh);
1003    }
1004
1005    #[test]
1006    fn test_refresh_per_second_default() {
1007        let p = Progress::new();
1008        assert!((p.refresh_per_second - 10.0).abs() < f64::EPSILON);
1009    }
1010}