Skip to main content

torrust_tracker_deployer_lib/presentation/cli/views/progress/
mod.rs

1//! Progress Reporting for Long-Running Operations
2//!
3//! This module provides progress reporting functionality for multi-step operations
4//! that take significant time to complete. It builds on top of `UserOutput` to
5//! provide standardized progress updates with timing information.
6//!
7//! ## Sub-modules
8//!
9//! - `verbose_listener` - `CommandProgressListener` implementation that translates
10//!   application-layer progress events into user-facing output
11
12pub mod verbose_listener;
13
14pub use verbose_listener::VerboseProgressListener;
15
16use std::cell::RefCell;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use parking_lot::ReentrantMutex;
21
22use thiserror::Error;
23
24use crate::presentation::cli::views::UserOutput;
25
26/// Errors that can occur during progress reporting
27#[derive(Debug, Error)]
28pub enum ProgressReporterError {
29    /// `UserOutput` mutex was poisoned
30    ///
31    /// The shared `UserOutput` mutex was poisoned by a panic in another thread.
32    /// This indicates a critical internal error.
33    #[error(
34        "Internal error: UserOutput mutex was poisoned
35Tip: This is a critical bug - please report it with full logs using --log-output file-and-stderr"
36    )]
37    UserOutputMutexPoisoned,
38}
39
40/// Progress reporter for multi-step operations
41///
42/// Tracks progress through multiple steps of a long-running operation,
43/// providing clear feedback with step numbers, descriptions, and timing.
44///
45/// # Examples
46///
47/// ```rust
48/// use std::sync::Arc;
49/// use std::cell::RefCell;
50/// use parking_lot::ReentrantMutex;
51/// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
52/// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
53///
54/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
55/// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
56/// let mut progress = ProgressReporter::new(output, 2);
57///
58/// progress.start_step("Step 1")?;
59/// progress.complete_step(Some("Step 1 done"))?;
60///
61/// progress.start_step("Step 2")?;
62/// progress.complete_step(None)?;
63///
64/// progress.complete("All done!")?;
65/// # Ok(())
66/// # }
67/// ```
68pub struct ProgressReporter {
69    output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
70    total_steps: usize,
71    current_step: usize,
72    step_start: Option<Instant>,
73}
74
75impl ProgressReporter {
76    /// Create a new progress reporter
77    ///
78    /// # Arguments
79    ///
80    /// * `output` - Shared user output handler for displaying messages
81    /// * `total_steps` - Total number of steps in the operation
82    ///
83    /// # Examples
84    ///
85    /// ```rust
86    /// use std::sync::Arc;
87    /// use std::cell::RefCell;
88    /// use parking_lot::ReentrantMutex;
89    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
90    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
91    ///
92    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
93    /// let progress = ProgressReporter::new(output, 5);
94    /// ```
95    #[must_use]
96    pub fn new(output: Arc<ReentrantMutex<RefCell<UserOutput>>>, total_steps: usize) -> Self {
97        Self {
98            output,
99            total_steps,
100            current_step: 0,
101            step_start: None,
102        }
103    }
104
105    /// Execute a function with the locked `UserOutput`
106    ///
107    /// With `ReentrantMutex`, we can safely lock multiple times on the same thread.
108    /// The `RefCell` provides interior mutability.
109    fn with_output<F, R>(&self, f: F) -> Result<R, ProgressReporterError>
110    where
111        F: FnOnce(&mut UserOutput) -> R,
112    {
113        let guard = self.output.lock();
114        let mut user_output = guard
115            .try_borrow_mut()
116            .map_err(|_| ProgressReporterError::UserOutputMutexPoisoned)?;
117        Ok(f(&mut user_output))
118    }
119
120    /// Start a new step with a description
121    ///
122    /// Increments the current step counter and displays a progress message
123    /// in the format `[current/total] description...`.
124    ///
125    /// # Arguments
126    ///
127    /// * `description` - Human-readable description of what this step does
128    ///
129    /// # Errors
130    ///
131    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
132    ///
133    /// # Examples
134    ///
135    /// ```rust
136    /// use std::sync::Arc;
137    /// use std::cell::RefCell;
138    /// use parking_lot::ReentrantMutex;
139    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
140    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
141    ///
142    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
143    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
144    /// let mut progress = ProgressReporter::new(output, 3);
145    ///
146    /// progress.start_step("Loading configuration")?;
147    /// // Output: ⏳ [1/3] Loading configuration...
148    /// # Ok(())
149    /// # }
150    /// ```
151    pub fn start_step(&mut self, description: &str) -> Result<(), ProgressReporterError> {
152        self.current_step += 1;
153        self.step_start = Some(Instant::now());
154
155        self.with_output(|output| {
156            output.progress(&format!(
157                "[{}/{}] {}...",
158                self.current_step, self.total_steps, description
159            ));
160        })?;
161
162        Ok(())
163    }
164
165    /// Complete the current step with optional result message
166    ///
167    /// Displays a completion message with timing information.
168    /// The message shows either the provided result or a generic "Done" message.
169    ///
170    /// # Arguments
171    ///
172    /// * `result` - Optional description of what was accomplished
173    ///
174    /// # Errors
175    ///
176    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
177    ///
178    /// # Examples
179    ///
180    /// ```rust
181    /// use std::sync::Arc;
182    /// use std::cell::RefCell;
183    /// use parking_lot::ReentrantMutex;
184    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
185    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
186    ///
187    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
188    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
189    /// let mut progress = ProgressReporter::new(output, 2);
190    ///
191    /// progress.start_step("Loading data")?;
192    /// progress.complete_step(Some("Data loaded successfully"))?;
193    /// // Output: ✓ Data loaded successfully (took 150ms)
194    ///
195    /// progress.start_step("Processing")?;
196    /// progress.complete_step(None)?;
197    /// // Output: ✓ Done (took 2.3s)
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub fn complete_step(&mut self, result: Option<&str>) -> Result<(), ProgressReporterError> {
202        if let Some(start) = self.step_start {
203            let duration = start.elapsed();
204            self.with_output(|output| {
205                if let Some(msg) = result {
206                    output.progress(&format!("  ✓ {} (took {})", msg, format_duration(duration)));
207                } else {
208                    output.progress(&format!("  ✓ Done (took {})", format_duration(duration)));
209                }
210            })?;
211        }
212
213        self.step_start = None;
214        Ok(())
215    }
216
217    /// Report a sub-step within the current step
218    ///
219    /// Displays an indented message indicating progress within the current step.
220    /// Useful for showing detailed progress without starting a new numbered step.
221    ///
222    /// # Arguments
223    ///
224    /// * `description` - What is currently happening within this step
225    ///
226    /// # Errors
227    ///
228    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
229    ///
230    /// # Examples
231    ///
232    /// ```rust
233    /// use std::sync::Arc;
234    /// use std::cell::RefCell;
235    /// use parking_lot::ReentrantMutex;
236    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
237    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
238    ///
239    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
240    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
241    /// let mut progress = ProgressReporter::new(output.clone(), 1);
242    ///
243    /// progress.start_step("Provisioning infrastructure")?;
244    /// progress.sub_step("Creating virtual machine")?;
245    /// progress.sub_step("Configuring network")?;
246    /// progress.sub_step("Setting up storage")?;
247    /// progress.complete_step(Some("Infrastructure ready"))?;
248    /// # Ok(())
249    /// # }
250    /// ```
251    pub fn sub_step(&mut self, description: &str) -> Result<(), ProgressReporterError> {
252        self.with_output(|output| {
253            output.progress(&format!("    → {description}"));
254        })?;
255        Ok(())
256    }
257
258    /// Complete all steps and show summary
259    ///
260    /// Displays a final success message indicating the entire operation completed.
261    /// This should be called after all steps are done.
262    ///
263    /// # Arguments
264    ///
265    /// * `summary` - Final success message describing what was accomplished
266    ///
267    /// # Errors
268    ///
269    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// use std::sync::Arc;
275    /// use std::cell::RefCell;
276    /// use parking_lot::ReentrantMutex;
277    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
278    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
279    ///
280    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
281    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
282    /// let mut progress = ProgressReporter::new(output.clone(), 1);
283    ///
284    /// progress.start_step("Creating environment")?;
285    /// progress.complete_step(None)?;
286    /// progress.complete("Environment 'test-env' created successfully")?;
287    /// // Output: ✅ Environment 'test-env' created successfully
288    /// # Ok(())
289    /// # }
290    /// ```
291    pub fn complete(&mut self, summary: &str) -> Result<(), ProgressReporterError> {
292        self.with_output(|output| output.success(summary))?;
293        Ok(())
294    }
295
296    /// Get a reference to the shared `UserOutput`
297    ///
298    /// This allows using other output methods (like `error`, `warn`)
299    /// while progress is being tracked.
300    ///
301    /// # Examples
302    ///
303    /// ```rust
304    /// use std::sync::Arc;
305    /// use std::cell::RefCell;
306    /// use parking_lot::ReentrantMutex;
307    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
308    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
309    ///
310    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
311    /// let mut progress = ProgressReporter::new(output.clone(), 1);
312    ///
313    /// progress.start_step("Checking conditions");
314    /// progress.output().lock().borrow_mut().warn("Some non-critical warning");
315    /// progress.complete_step(None);
316    /// ```
317    #[must_use]
318    pub fn output(&self) -> &Arc<ReentrantMutex<RefCell<UserOutput>>> {
319        &self.output
320    }
321
322    /// Add a blank line to the output
323    ///
324    /// This is a wrapper around `UserOutput::blank_line()` that handles
325    /// mutex acquisition with timeout protection.
326    ///
327    /// # Errors
328    ///
329    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
330    /// Returns `ProgressReporterError::UserOutputMutexTimeout` if the mutex cannot be acquired within the timeout.
331    ///
332    /// # Examples
333    ///
334    /// ```rust
335    /// use std::sync::Arc;
336    /// use std::cell::RefCell;
337    /// use parking_lot::ReentrantMutex;
338    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
339    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
340    ///
341    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
342    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
343    /// let mut progress = ProgressReporter::new(output, 3);
344    ///
345    /// progress.blank_line()?;
346    /// # Ok(())
347    /// # }
348    /// ```
349    pub fn blank_line(&self) -> Result<(), ProgressReporterError> {
350        self.with_output(UserOutput::blank_line)?;
351        Ok(())
352    }
353
354    /// Display a list of steps with a title
355    ///
356    /// This is a wrapper around `UserOutput::steps()` that handles
357    /// mutex acquisition with timeout protection.
358    ///
359    /// # Arguments
360    ///
361    /// * `title` - The title for the steps list
362    /// * `steps` - Array of step descriptions
363    ///
364    /// # Errors
365    ///
366    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
367    /// Returns `ProgressReporterError::UserOutputMutexTimeout` if the mutex cannot be acquired within the timeout.
368    ///
369    /// # Examples
370    ///
371    /// ```rust
372    /// use std::sync::Arc;
373    /// use std::cell::RefCell;
374    /// use parking_lot::ReentrantMutex;
375    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
376    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
377    ///
378    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
379    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
380    /// let mut progress = ProgressReporter::new(output, 3);
381    ///
382    /// progress.steps("Next steps:", &[
383    ///     "Edit the configuration file",
384    ///     "Review the settings",
385    ///     "Run the deploy command"
386    /// ])?;
387    /// # Ok(())
388    /// # }
389    /// ```
390    pub fn steps(&self, title: &str, steps: &[&str]) -> Result<(), ProgressReporterError> {
391        self.with_output(|output| output.steps(title, steps))?;
392        Ok(())
393    }
394
395    /// Output result data to stdout
396    ///
397    /// Wraps `UserOutput::result()` to write result data to stdout.
398    /// Result data goes to stdout (not stderr) so it can be piped or redirected.
399    ///
400    /// # Arguments    ///
401    /// * `message` - The result data to output
402    ///
403    /// # Errors
404    ///
405    /// Returns error if the user output mutex is poisoned
406    ///
407    /// # Examples
408    ///
409    /// ```rust
410    /// use std::sync::Arc;
411    /// use std::cell::RefCell;
412    /// use parking_lot::ReentrantMutex;
413    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
414    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
415    ///
416    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
417    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
418    /// let progress = ProgressReporter::new(output, 1);
419    ///
420    /// progress.result(r#"{"schema": "..."}"#)?;
421    /// # Ok(())
422    /// # }
423    /// ```
424    pub fn result(&self, message: &str) -> Result<(), ProgressReporterError> {
425        self.with_output(|output| output.result(message))?;
426        Ok(())
427    }
428
429    /// Display a warning message to stderr
430    ///
431    /// Wraps `UserOutput::warn()` for use during progress-tracked workflows.
432    /// Warnings are non-blocking — they do not stop the current operation.
433    ///
434    /// # Arguments
435    ///
436    /// * `message` - Warning text (may contain newlines for multi-line warnings)
437    ///
438    /// # Errors
439    ///
440    /// Returns `ProgressReporterError::UserOutputMutexPoisoned` if the mutex is poisoned.
441    ///
442    /// # Examples
443    ///
444    /// ```rust
445    /// use std::sync::Arc;
446    /// use std::cell::RefCell;
447    /// use parking_lot::ReentrantMutex;
448    /// use torrust_tracker_deployer_lib::presentation::cli::views::progress::ProgressReporter;
449    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
450    ///
451    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
452    /// let output = Arc::new(ReentrantMutex::new(RefCell::new(UserOutput::new(VerbosityLevel::Normal))));
453    /// let progress = ProgressReporter::new(output, 1);
454    ///
455    /// progress.warn("SSH key appears to be passphrase-protected")?;
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub fn warn(&self, message: &str) -> Result<(), ProgressReporterError> {
460        self.with_output(|output| output.warn(message))?;
461        Ok(())
462    }
463}
464
465/// Format duration in a human-readable way
466///
467/// Converts durations to appropriate units:
468/// - Less than 1 second: milliseconds (e.g., "150ms")
469/// - 1 second or more: seconds with 1 decimal place (e.g., "2.3s")
470///
471/// # Arguments
472///
473/// * `duration` - The duration to format
474///
475/// # Returns
476///
477/// A human-readable string representation of the duration
478fn format_duration(duration: Duration) -> String {
479    let millis = duration.as_millis();
480    if millis < 1000 {
481        format!("{millis}ms")
482    } else {
483        format!("{:.1}s", duration.as_secs_f64())
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::presentation::cli::views::testing::TestUserOutput;
491    use crate::presentation::cli::views::VerbosityLevel;
492
493    #[test]
494    fn it_should_create_progress_reporter_with_total_steps() {
495        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
496        let (output, _stdout, _stderr) = test_output.into_reentrant_wrapped();
497        let progress = ProgressReporter::new(output, 5);
498
499        assert_eq!(progress.total_steps, 5);
500        assert_eq!(progress.current_step, 0);
501        assert!(progress.step_start.is_none());
502    }
503
504    #[test]
505    fn it_should_start_step_and_increment_counter() {
506        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
507        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
508        let mut progress = ProgressReporter::new(output, 3);
509
510        progress
511            .start_step("Loading configuration")
512            .expect("Failed to start step");
513
514        assert_eq!(progress.current_step, 1);
515        assert!(progress.step_start.is_some());
516
517        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
518        assert!(stderr_content.contains("[1/3] Loading configuration..."));
519    }
520
521    #[test]
522    fn it_should_track_multiple_steps() {
523        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
524        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
525        let mut progress = ProgressReporter::new(output, 3);
526
527        progress
528            .start_step("Step 1")
529            .expect("Failed to start step 1");
530        assert_eq!(progress.current_step, 1);
531
532        progress
533            .start_step("Step 2")
534            .expect("Failed to start step 2");
535        assert_eq!(progress.current_step, 2);
536
537        progress
538            .start_step("Step 3")
539            .expect("Failed to start step 3");
540        assert_eq!(progress.current_step, 3);
541
542        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
543        assert!(stderr_content.contains("[1/3] Step 1..."));
544        assert!(stderr_content.contains("[2/3] Step 2..."));
545        assert!(stderr_content.contains("[3/3] Step 3..."));
546    }
547
548    #[test]
549    fn it_should_complete_step_with_result_message() {
550        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
551        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
552        let mut progress = ProgressReporter::new(output, 1);
553
554        progress
555            .start_step("Loading data")
556            .expect("Failed to start step");
557        progress
558            .complete_step(Some("Data loaded successfully"))
559            .expect("Failed to complete step");
560
561        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
562        assert!(stderr_content.contains("✓ Data loaded successfully"));
563        assert!(stderr_content.contains("took"));
564        assert!(progress.step_start.is_none());
565    }
566
567    #[test]
568    fn it_should_complete_step_without_result_message() {
569        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
570        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
571        let mut progress = ProgressReporter::new(output, 1);
572
573        progress
574            .start_step("Processing")
575            .expect("Failed to start step");
576        progress
577            .complete_step(None)
578            .expect("Failed to complete step");
579
580        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
581        assert!(stderr_content.contains("✓ Done"));
582        assert!(stderr_content.contains("took"));
583        assert!(progress.step_start.is_none());
584    }
585
586    #[test]
587    fn it_should_report_sub_steps() {
588        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
589        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
590        let mut progress = ProgressReporter::new(output, 1);
591
592        progress
593            .start_step("Provisioning")
594            .expect("Failed to start step");
595        progress
596            .sub_step("Creating VM")
597            .expect("Failed to report sub-step");
598        progress
599            .sub_step("Configuring network")
600            .expect("Failed to report sub-step");
601        progress
602            .complete_step(None)
603            .expect("Failed to complete step");
604
605        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
606        assert!(stderr_content.contains("→ Creating VM"));
607        assert!(stderr_content.contains("→ Configuring network"));
608    }
609
610    #[test]
611    fn it_should_display_completion_summary() {
612        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
613        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
614        let mut progress = ProgressReporter::new(output, 1);
615
616        progress
617            .start_step("Creating environment")
618            .expect("Failed to start step");
619        progress
620            .complete_step(None)
621            .expect("Failed to complete step");
622        progress
623            .complete("Environment created successfully")
624            .expect("Failed to complete");
625
626        let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap();
627        assert!(stderr_content.contains("✅ Environment created successfully"));
628    }
629
630    #[test]
631    fn it_should_provide_access_to_output() {
632        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
633        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
634        let progress = ProgressReporter::new(output.clone(), 1);
635
636        progress
637            .with_output(|user_output| user_output.warn("Test warning"))
638            .expect("Failed to write to output");
639
640        let stderr_content = String::from_utf8(stderr.lock().clone()).expect("Invalid UTF-8");
641        assert!(stderr_content.contains("⚠️  Test warning"));
642    }
643
644    #[test]
645    fn it_should_respect_verbosity_levels() {
646        let test_output = TestUserOutput::new(VerbosityLevel::Quiet);
647        let (output, _stdout, stderr) = test_output.into_reentrant_wrapped();
648        let mut progress = ProgressReporter::new(output, 1);
649
650        progress.start_step("Step 1").expect("Failed to start step");
651        progress
652            .complete_step(Some("Done"))
653            .expect("Failed to complete step");
654
655        // At Quiet level, progress messages should not appear
656        let stderr_content = String::from_utf8(stderr.lock().clone()).expect("Invalid UTF-8");
657        assert_eq!(stderr_content, "");
658    }
659
660    #[test]
661    fn it_should_format_milliseconds_correctly() {
662        let duration = Duration::from_millis(150);
663        assert_eq!(format_duration(duration), "150ms");
664
665        let duration = Duration::from_millis(999);
666        assert_eq!(format_duration(duration), "999ms");
667    }
668
669    #[test]
670    fn it_should_format_seconds_correctly() {
671        let duration = Duration::from_secs(1);
672        assert_eq!(format_duration(duration), "1.0s");
673
674        let duration = Duration::from_millis(2345);
675        assert_eq!(format_duration(duration), "2.3s");
676
677        let duration = Duration::from_secs(10);
678        assert_eq!(format_duration(duration), "10.0s");
679    }
680
681    #[test]
682    fn it_should_handle_full_workflow() {
683        let test_output = TestUserOutput::new(VerbosityLevel::Normal);
684        let (output, stdout, stderr) = test_output.into_reentrant_wrapped();
685        let mut progress = ProgressReporter::new(output, 3);
686
687        // Step 1
688        progress
689            .start_step("Loading configuration")
690            .expect("Failed to start step 1");
691        progress
692            .complete_step(Some("Configuration loaded: test-env"))
693            .expect("Failed to complete step 1");
694
695        // Step 2 with sub-steps
696        progress
697            .start_step("Provisioning infrastructure")
698            .expect("Failed to start step 2");
699        progress
700            .sub_step("Creating virtual machine")
701            .expect("Failed to report sub-step");
702        progress
703            .sub_step("Configuring network")
704            .expect("Failed to report sub-step");
705        progress
706            .complete_step(Some("Instance created: test-instance"))
707            .expect("Failed to complete step 2");
708
709        // Step 3
710        progress
711            .start_step("Finalizing environment")
712            .expect("Failed to start step 3");
713        progress
714            .complete_step(None)
715            .expect("Failed to complete step 3");
716
717        // Complete
718        progress
719            .complete("Environment 'test-env' created successfully")
720            .expect("Failed to complete");
721
722        let stderr_content = String::from_utf8(stderr.lock().clone()).expect("Invalid UTF-8");
723        assert!(stderr_content.contains("[1/3] Loading configuration..."));
724        assert!(stderr_content.contains("[2/3] Provisioning infrastructure..."));
725        assert!(stderr_content.contains("[3/3] Finalizing environment..."));
726        assert!(stderr_content.contains("✅ Environment 'test-env' created successfully"));
727        assert!(stderr_content.contains("✓ Configuration loaded: test-env"));
728        assert!(stderr_content.contains("→ Creating virtual machine"));
729        assert!(stderr_content.contains("→ Configuring network"));
730        assert!(stderr_content.contains("✓ Instance created: test-instance"));
731        assert!(stderr_content.contains("✓ Done"));
732
733        let stdout_content = String::from_utf8(stdout.lock().clone()).expect("Invalid UTF-8");
734        // stdout should be empty - all progress goes to stderr
735        assert!(stdout_content.is_empty());
736    }
737}