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