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}