cargo_plugin_utils/
logger.rs

1//! Logger for handling output with cargo-style progress and status messages.
2
3use std::io::Write;
4
5use anyhow::Context;
6use carlog::Status;
7use console;
8use indicatif::{
9    ProgressBar,
10    ProgressDrawTarget,
11    ProgressStyle,
12};
13use portable_pty::{
14    CommandBuilder,
15    PtySize,
16    native_pty_system,
17};
18
19use crate::scrolling::{
20    clear_scrolling_region,
21    get_terminal_size,
22    move_cursor_to_line,
23    reset_scrolling_region,
24    set_scrolling_region,
25};
26
27/// Logger for handling output with cargo-style progress and status messages.
28///
29/// All progress and status messages go to stderr (matching cargo's behavior).
30/// This allows command output (badges, changelog, etc.) to be piped cleanly
31/// through stdout while progress messages appear on the console.
32pub struct Logger {
33    progress_bar: Option<ProgressBar>,
34    line_count: usize,
35}
36
37impl Logger {
38    /// Create a new logger.
39    pub fn new() -> Self {
40        Self {
41            progress_bar: None,
42            line_count: 0,
43        }
44    }
45
46    /// Show a progress bar (ephemeral, disappears on finish).
47    ///
48    /// Use this for operations with known progress.
49    /// Always uses stderr (matching cargo's behavior).
50    #[allow(dead_code)] // Will be used for long-running operations
51    pub fn progress(&mut self, message: &str) {
52        let pb = ProgressBar::new_spinner();
53        pb.set_draw_target(ProgressDrawTarget::stderr());
54        pb.set_style(
55            ProgressStyle::default_spinner()
56                .template("{spinner:.green} {msg}")
57                .unwrap(),
58        );
59        pb.set_message(message.to_string());
60        pb.enable_steady_tick(std::time::Duration::from_millis(100));
61
62        self.progress_bar = Some(pb);
63    }
64
65    /// Update the progress bar message.
66    #[allow(dead_code)] // Will be used for long-running operations
67    pub fn set_progress_message(&self, message: &str) {
68        if let Some(pb) = &self.progress_bar {
69            pb.set_message(message.to_string());
70        }
71    }
72
73    /// Print a status message in cargo's style: "   Building crate-name".
74    ///
75    /// Uses cyan color for the action word (ephemeral operations).
76    /// This creates an ephemeral message that will be cleared on finish().
77    /// Always goes to stderr (matching cargo's behavior).
78    pub fn status(&mut self, action: &str, target: &str) {
79        // Clear previous status (replaces it with new one)
80        if let Some(pb) = self.progress_bar.take() {
81            pb.finish_and_clear();
82        }
83
84        // Format status message with cyan color (like cargo's "Building")
85        use console::style;
86        let formatted_message = format!("{:>12} {}", style(action).cyan().bold(), target);
87
88        // Create a progress bar that shows the message ephemerally
89        let pb = ProgressBar::new_spinner();
90        pb.set_draw_target(ProgressDrawTarget::stderr());
91        pb.set_style(ProgressStyle::default_spinner().template("{msg}").unwrap());
92        pb.set_message(formatted_message);
93
94        self.progress_bar = Some(pb);
95        self.line_count = 1;
96    }
97
98    /// Print a permanent status message in cargo's style: "   Compiling
99    /// crate-name".
100    ///
101    /// Uses green color for the action word (permanent operations).
102    /// This message will NOT be cleared - use for operations that spawn
103    /// subprocesses. Always goes to stderr (matching cargo's behavior).
104    #[allow(dead_code)] // Will be used for subprocess-heavy operations
105    pub fn status_permanent(&self, action: &str, target: &str) {
106        let status = Status::new()
107            .bold()
108            .justify()
109            .color(carlog::CargoColor::Green)
110            .status(action);
111
112        let formatted_target = format!(" {}", target);
113
114        // Print permanent message to stderr (suspend if progress bar active)
115        if let Some(pb) = &self.progress_bar {
116            pb.suspend(|| {
117                let _ = status.print_stderr(&formatted_target);
118            });
119        } else {
120            let _ = status.print_stderr(&formatted_target);
121        }
122    }
123
124    /// Print a permanent message (will be kept in output).
125    ///
126    /// Always goes to stderr (matching cargo's behavior).
127    #[allow(dead_code)] // May be used by other commands
128    pub fn print_message(&self, msg: &str) {
129        if let Some(pb) = &self.progress_bar {
130            pb.suspend(|| {
131                eprintln!("{}", msg);
132            });
133        } else {
134            eprintln!("{}", msg);
135        }
136    }
137
138    /// Print an info message (cyan colored).
139    ///
140    /// Info messages are permanent (not cleared).
141    /// Always goes to stderr (matching cargo's behavior).
142    #[allow(dead_code)] // May be used by other commands
143    pub fn info(&self, action: &str, target: &str) {
144        let status = Status::new()
145            .bold()
146            .justify()
147            .color(carlog::CargoColor::Cyan)
148            .status(action);
149
150        let formatted_target = format!(" {}", target);
151
152        // Suspend progress bar to print permanent message to stderr
153        if let Some(pb) = &self.progress_bar {
154            pb.suspend(|| {
155                let _ = status.print_stderr(&formatted_target);
156            });
157        } else {
158            let _ = status.print_stderr(&formatted_target);
159        }
160    }
161
162    /// Print a warning message (yellow colored).
163    ///
164    /// Warning messages are permanent (not cleared).
165    /// Always goes to stderr (matching cargo's behavior).
166    pub fn warning(&self, action: &str, target: &str) {
167        let status = Status::new()
168            .bold()
169            .justify()
170            .color(carlog::CargoColor::Yellow)
171            .status(action);
172
173        let formatted_target = format!(" {}", target);
174
175        // Suspend progress bar to print permanent message to stderr
176        if let Some(pb) = &self.progress_bar {
177            pb.suspend(|| {
178                let _ = status.print_stderr(&formatted_target);
179            });
180        } else {
181            let _ = status.print_stderr(&formatted_target);
182        }
183    }
184
185    /// Print an error message (red colored).
186    ///
187    /// Error messages are permanent (not cleared).
188    /// Always goes to stderr (matching cargo's behavior).
189    #[allow(dead_code)] // May be used by other commands
190    pub fn error(&self, action: &str, target: &str) {
191        let status = Status::new()
192            .bold()
193            .justify()
194            .color(carlog::CargoColor::Red)
195            .status(action);
196
197        let formatted_target = format!(" {}", target);
198
199        // Suspend progress bar to print permanent message to stderr
200        if let Some(pb) = &self.progress_bar {
201            pb.suspend(|| {
202                let _ = status.print_stderr(&formatted_target);
203            });
204        } else {
205            let _ = status.print_stderr(&formatted_target);
206        }
207    }
208
209    /// Clear the current status message immediately.
210    ///
211    /// Useful before subprocess operations that might write to stderr.
212    pub fn clear_status(&mut self) {
213        if let Some(pb) = self.progress_bar.take() {
214            pb.finish_and_clear();
215            self.line_count = 0;
216        }
217    }
218
219    /// Temporarily suspend the status message (for subprocess output).
220    ///
221    /// Call this before spawning subprocesses that write to stderr to avoid
222    /// mixing their output with our status line.
223    pub fn suspend<F, R>(&mut self, f: F) -> R
224    where
225        F: FnOnce() -> R,
226    {
227        if let Some(pb) = &self.progress_bar {
228            pb.suspend(f)
229        } else {
230            f()
231        }
232    }
233
234    /// Finish logging and clear ephemeral status messages.
235    pub fn finish(&mut self) {
236        if let Some(pb) = self.progress_bar.take() {
237            // finish_and_clear() will clear the progress bar's line
238            pb.finish_and_clear();
239            self.line_count = 0;
240        }
241    }
242}
243
244/// Result of running a subprocess with windowed stderr rendering.
245#[derive(Debug, Clone)]
246pub struct SubprocessOutput {
247    /// Captured stdout
248    pub stdout: Vec<u8>,
249    /// Captured stderr
250    pub stderr: Vec<u8>,
251    /// Exit code
252    pub exit_code: u32,
253}
254
255impl SubprocessOutput {
256    /// Get stdout as a string, with UTF-8 error handling.
257    pub fn stdout_str(&self) -> anyhow::Result<String> {
258        String::from_utf8(self.stdout.clone()).context("Failed to parse stdout as UTF-8")
259    }
260
261    /// Get stderr as a string, with UTF-8 error handling.
262    pub fn stderr_str(&self) -> anyhow::Result<String> {
263        String::from_utf8(self.stderr.clone()).context("Failed to parse stderr as UTF-8")
264    }
265
266    /// Check if the process exited successfully.
267    pub fn success(&self) -> bool {
268        self.exit_code == 0
269    }
270
271    /// Get the exit code.
272    pub fn exit_code(&self) -> u32 {
273        self.exit_code
274    }
275}
276
277/// Run a subprocess with piped stdout/stderr, capturing stdout fully while
278/// rendering stderr lines live in a ring buffer.
279///
280/// # Arguments
281///
282/// * `logger` - Logger instance to manage progress bar suspension/clearing
283/// * `cmd_builder` - Closure that builds a `portable_pty::CommandBuilder`
284/// * `stderr_lines` - Number of stderr lines to show in the scrolling region
285///   (default: 5)
286///
287/// # Behavior
288///
289/// - Uses PTY mode so subprocesses see a TTY (preserves ANSI colors)
290/// - Sets up a scrolling region at the bottom of the terminal
291/// - Suspends/clears any active progress bar before running
292/// - Captures stdout fully
293/// - Renders stderr lines live in the scrolling region
294/// - On success: clears the scrolling region cleanly
295/// - On failure: leaves/replays the final window
296///
297/// # Returns
298///
299/// Returns `SubprocessOutput` with captured stdout, stderr, and exit status.
300pub async fn run_subprocess<F>(
301    logger: &mut Logger,
302    cmd_builder: F,
303    stderr_lines: Option<usize>,
304) -> anyhow::Result<SubprocessOutput>
305where
306    F: FnOnce() -> CommandBuilder,
307{
308    let stderr_lines = stderr_lines.unwrap_or(5);
309    // Suspend/clear progress bar before subprocess
310    let had_progress = logger.progress_bar.is_some();
311    if had_progress {
312        logger.clear_status();
313    }
314
315    let term = console::Term::stderr();
316    let is_term = term.is_term();
317
318    // Get terminal size to set up scrolling region
319    let (term_rows, _term_cols) = if is_term {
320        get_terminal_size().unwrap_or((24u16, 80u16))
321    } else {
322        (24u16, 80u16) // Default if not a terminal
323    };
324
325    // Set up scrolling region at the bottom of the terminal
326    // The region will be the last `stderr_lines` lines
327    let stderr_lines_u16 = stderr_lines as u16;
328    let region_top = if stderr_lines_u16 < term_rows {
329        term_rows - stderr_lines_u16 + 1 // 1-indexed
330    } else {
331        1 // If stderr_lines >= term_rows, use entire terminal
332    };
333    let region_bottom = term_rows;
334
335    // Set scrolling region if we're in a terminal
336    if is_term {
337        set_scrolling_region(region_top, region_bottom)
338            .context("Failed to set scrolling region")?;
339        // Move cursor to the top of the scrolling region
340        move_cursor_to_line(region_top).context("Failed to move cursor to scrolling region")?;
341    }
342
343    // Build command using portable-pty
344    let cmd = cmd_builder();
345
346    // Create PTY
347    let pty_system = native_pty_system();
348    let pty_size = PtySize {
349        rows: stderr_lines_u16,
350        cols: 80,
351        pixel_width: 0,
352        pixel_height: 0,
353    };
354    let pty = pty_system
355        .openpty(pty_size)
356        .context("Failed to create PTY")?;
357
358    // Spawn command in PTY
359    let mut child = pty
360        .slave
361        .spawn_command(cmd)
362        .context("Failed to spawn command in PTY")?;
363
364    // Get handles for stdout and stderr from PTY
365    // We need to keep a reference to the master to close it later
366    let mut reader = pty
367        .master
368        .try_clone_reader()
369        .context("Failed to clone PTY reader")?;
370
371    // Keep the master alive until we're done reading
372    let master = pty.master;
373
374    // Channel to coordinate rendering (send raw bytes to preserve ANSI codes)
375    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
376    // Keep a clone of tx to close the channel if we timeout
377    let tx_clone = tx.clone();
378
379    // Collect output as it arrives (for timeout fallback)
380    let collected_output = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
381    let collected_output_clone = collected_output.clone();
382
383    // Task to read from PTY (combines stdout and stderr)
384    // PTY reader is blocking, so we use spawn_blocking
385    let pty_task = tokio::spawn(async move {
386        tokio::task::spawn_blocking(move || {
387            let mut full_output = Vec::new();
388            let mut buffer = vec![0u8; 4096];
389
390            loop {
391                match reader.read(&mut buffer) {
392                    Ok(0) => break, // EOF
393                    Ok(n) => {
394                        let chunk = &buffer[..n];
395                        full_output.extend_from_slice(chunk);
396                        // Also collect in shared buffer for timeout fallback
397                        if let Ok(mut collected) = collected_output_clone.lock() {
398                            collected.extend_from_slice(chunk);
399                        }
400                        let _ = tx.send(chunk.to_vec());
401                    }
402                    Err(e) => {
403                        // On error, still capture what we have
404                        let error_msg = format!("<pty read error: {}>", e);
405                        let error_bytes = error_msg.as_bytes();
406                        full_output.extend_from_slice(error_bytes);
407                        if let Ok(mut collected) = collected_output_clone.lock() {
408                            collected.extend_from_slice(error_bytes);
409                        }
410                        let _ = tx.send(error_bytes.to_vec());
411                        break;
412                    }
413                }
414            }
415
416            // Close the channel to signal completion
417            drop(tx);
418
419            Ok::<Vec<u8>, anyhow::Error>(full_output)
420        })
421        .await
422        .context("Failed to join blocking PTY read task")?
423    });
424
425    // Render output in scrolling region (preserving ANSI codes)
426    let mut output_buffer = Vec::new();
427    let mut output_ring: Vec<Vec<u8>> = Vec::with_capacity(stderr_lines);
428
429    // Process output bytes as they arrive
430    let render_task = tokio::spawn(async move {
431        while let Some(chunk) = rx.recv().await {
432            output_buffer.extend_from_slice(&chunk);
433
434            // Split buffer into complete lines (preserving ANSI codes)
435            let mut lines: Vec<Vec<u8>> = Vec::new();
436            let mut current_line = Vec::new();
437            let mut i = 0;
438            while i < output_buffer.len() {
439                let byte = output_buffer[i];
440                current_line.push(byte);
441                if byte == b'\n' {
442                    lines.push(current_line);
443                    current_line = Vec::new();
444                }
445                i += 1;
446            }
447            output_buffer = current_line;
448
449            // Update ring buffer with new complete lines
450            for line in lines {
451                output_ring.push(line);
452                if output_ring.len() > stderr_lines {
453                    output_ring.remove(0);
454                }
455            }
456
457            // Render ring buffer after processing all lines in this chunk
458            if is_term && !output_ring.is_empty() {
459                // Clear the scrolling region and redraw
460                move_cursor_to_line(region_top).ok();
461                clear_scrolling_region().ok();
462
463                // Write all lines in the ring buffer (preserving ANSI codes)
464                let mut stderr_handle = std::io::stderr();
465                for line_bytes in &output_ring {
466                    let _ = stderr_handle.write_all(line_bytes);
467                }
468                let _ = stderr_handle.flush();
469            }
470        }
471
472        // Handle any remaining partial line
473        if !output_buffer.is_empty() {
474            output_ring.push(output_buffer);
475            if output_ring.len() > stderr_lines {
476                output_ring.remove(0);
477            }
478            if is_term {
479                // Render final ring buffer state
480                move_cursor_to_line(region_top).ok();
481                clear_scrolling_region().ok();
482                let mut stderr_handle = std::io::stderr();
483                for line_bytes in &output_ring {
484                    let _ = stderr_handle.write_all(line_bytes);
485                }
486                let _ = stderr_handle.flush();
487            }
488        }
489
490        (output_ring, is_term)
491    });
492
493    // Wait for process to complete (blocking call, so wrap in spawn_blocking)
494    let status = tokio::task::spawn_blocking(move || child.wait())
495        .await
496        .context("Failed to join process wait task")?
497        .context("Failed to wait for subprocess")?;
498
499    // Close the PTY master to signal EOF to the reader
500    // This ensures the reader sees EOF even if the process has already exited
501    // On Windows, we need to drop the master earlier to help the blocking read
502    // return
503    drop(master);
504
505    // On Windows, give a small delay to allow the reader to see EOF
506    #[cfg(windows)]
507    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
508
509    // Wait for PTY reading to complete (with timeout to prevent hanging)
510    // If timeout occurs but process has exited, use collected output as fallback
511    // On Windows, use a very short timeout since blocking reads may never return
512    let timeout_duration = if cfg!(windows) {
513        std::time::Duration::from_millis(500)
514    } else {
515        std::time::Duration::from_secs(10)
516    };
517    let pty_output = match tokio::time::timeout(timeout_duration, pty_task).await {
518        Ok(result) => {
519            // Task completed, get the result
520            result.context("Failed to join PTY task")??
521        }
522        Err(_) => {
523            // Timeout occurred - this commonly happens on Windows where blocking
524            // reads in spawn_blocking cannot be cancelled. Since the process has
525            // already exited, we use the output we collected as it arrived through
526            // the channel. The blocking task will continue running in the background
527            // but won't affect the test outcome.
528            // Close the channel to allow render_task to complete
529            drop(tx_clone);
530            collected_output.lock().unwrap().clone()
531        }
532    };
533    // Wait for render task with timeout to prevent hanging
534    // Use very short timeout on Windows where operations may hang
535    let render_timeout = if cfg!(windows) {
536        std::time::Duration::from_millis(500)
537    } else {
538        std::time::Duration::from_secs(5)
539    };
540    let (_final_output_ring, was_term) =
541        match tokio::time::timeout(render_timeout, render_task).await {
542            Ok(result) => result.context("Failed to join render task")?,
543            Err(_) => {
544                // Render task timed out - this can happen on Windows where
545                // blocking operations may not complete. We'll continue without
546                // the final render state.
547                (Vec::new(), is_term)
548            }
549        };
550
551    // For now, treat all PTY output as stderr (we can separate later if needed)
552    // In PTY mode, stdout and stderr are combined
553    let stdout_bytes = Vec::new(); // PTY combines stdout/stderr, so we'll capture all as stderr
554    let stderr_bytes = pty_output;
555
556    // Handle final rendering based on success/failure
557    let exit_code = status.exit_code();
558    let success = exit_code == 0;
559
560    if was_term {
561        if success {
562            // Success: clear the scrolling region
563            clear_scrolling_region().ok();
564        } else {
565            // Failure: ensure final window is visible (it should already be)
566            // Just reset the scrolling region to restore normal scrolling
567            reset_scrolling_region().ok();
568        }
569    }
570
571    Ok(SubprocessOutput {
572        stdout: stdout_bytes,
573        stderr: stderr_bytes,
574        exit_code,
575    })
576}
577
578impl Default for Logger {
579    fn default() -> Self {
580        Self::new()
581    }
582}
583
584impl Drop for Logger {
585    fn drop(&mut self) {
586        // Clear the progress bar
587        if let Some(pb) = self.progress_bar.take() {
588            pb.finish_and_clear();
589        }
590
591        // Clear the reserved lines (including our status + subprocess output)
592        if self.line_count > 0 {
593            use console::Term;
594            let term = Term::stderr();
595            if term.is_term() {
596                let _ = term.clear_last_lines(self.line_count);
597            }
598            self.line_count = 0;
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    #[cfg(not(windows))]
606    use portable_pty::CommandBuilder;
607
608    use super::*;
609
610    #[tokio::test]
611    async fn test_logger_new() {
612        let logger = Logger::new();
613        assert!(logger.progress_bar.is_none());
614        assert_eq!(logger.line_count, 0);
615    }
616
617    #[tokio::test]
618    async fn test_logger_status() {
619        let mut logger = Logger::new();
620        logger.status("Building", "test-crate");
621        assert!(logger.progress_bar.is_some());
622        assert_eq!(logger.line_count, 1);
623    }
624
625    #[tokio::test]
626    async fn test_logger_clear_status() {
627        let mut logger = Logger::new();
628        logger.status("Building", "test-crate");
629        assert!(logger.progress_bar.is_some());
630        logger.clear_status();
631        assert!(logger.progress_bar.is_none());
632        assert_eq!(logger.line_count, 0);
633    }
634
635    #[tokio::test]
636    async fn test_logger_finish() {
637        let mut logger = Logger::new();
638        logger.status("Building", "test-crate");
639        logger.finish();
640        assert!(logger.progress_bar.is_none());
641        assert_eq!(logger.line_count, 0);
642    }
643
644    #[tokio::test]
645    async fn test_subprocess_output_success() {
646        let output = SubprocessOutput {
647            stdout: b"stdout content".to_vec(),
648            stderr: b"stderr content".to_vec(),
649            exit_code: 0,
650        };
651        assert!(output.success());
652        assert_eq!(output.exit_code(), 0);
653        assert_eq!(output.stdout_str().unwrap(), "stdout content");
654        assert_eq!(output.stderr_str().unwrap(), "stderr content");
655    }
656
657    #[tokio::test]
658    async fn test_subprocess_output_failure() {
659        let output = SubprocessOutput {
660            stdout: b"".to_vec(),
661            stderr: b"error message".to_vec(),
662            exit_code: 1,
663        };
664        assert!(!output.success());
665        assert_eq!(output.exit_code(), 1);
666        assert_eq!(output.stderr_str().unwrap(), "error message");
667    }
668
669    #[tokio::test]
670    #[cfg(not(windows))]
671    async fn test_run_subprocess_simple_success() {
672        let mut logger = Logger::new();
673        let output = run_subprocess(
674            &mut logger,
675            || {
676                let mut cmd = CommandBuilder::new("echo");
677                cmd.arg("hello world");
678                cmd
679            },
680            Some(3),
681        )
682        .await
683        .unwrap();
684
685        assert!(output.success());
686        assert_eq!(output.exit_code(), 0);
687        // PTY combines stdout/stderr, so output should be in stderr
688        let stderr = output.stderr_str().unwrap();
689        assert!(stderr.contains("hello world") || stderr.is_empty());
690    }
691
692    #[tokio::test]
693    #[cfg(not(windows))]
694    async fn test_run_subprocess_simple_failure() {
695        let mut logger = Logger::new();
696        let output = run_subprocess(&mut logger, || CommandBuilder::new("false"), Some(3))
697            .await
698            .unwrap();
699
700        assert!(!output.success());
701        assert_ne!(output.exit_code(), 0);
702    }
703
704    #[tokio::test]
705    #[cfg(not(windows))]
706    async fn test_run_subprocess_multiline_output() {
707        let mut logger = Logger::new();
708        let output = run_subprocess(
709            &mut logger,
710            || {
711                let mut cmd = CommandBuilder::new("sh");
712                cmd.arg("-c");
713                cmd.arg("echo 'line 1'; echo 'line 2'; echo 'line 3'; echo 'line 4'; echo 'line 5'; echo 'line 6'");
714                cmd
715            },
716            Some(3), // Only show 3 lines in ring buffer
717        )
718        .await
719        .unwrap();
720
721        assert!(output.success());
722        // Should capture all output even though only 3 lines shown
723        let stderr = output.stderr_str().unwrap();
724        assert!(stderr.contains("line 1"));
725        assert!(stderr.contains("line 6"));
726    }
727
728    #[tokio::test]
729    #[cfg(not(windows))]
730    async fn test_run_subprocess_with_progress_bar() {
731        let mut logger = Logger::new();
732        logger.status("Preparing", "test");
733        assert!(logger.progress_bar.is_some());
734
735        let output = run_subprocess(
736            &mut logger,
737            || {
738                let mut cmd = CommandBuilder::new("echo");
739                cmd.arg("test output");
740                cmd
741            },
742            None,
743        )
744        .await
745        .unwrap();
746
747        assert!(output.success());
748        // Progress bar should be cleared before subprocess
749        // (we can't easily test this without mocking, but the function should
750        // complete)
751    }
752
753    #[tokio::test]
754    #[cfg(not(windows))]
755    async fn test_run_subprocess_exit_code_preservation() {
756        let mut logger = Logger::new();
757        let output = run_subprocess(
758            &mut logger,
759            || {
760                let mut cmd = CommandBuilder::new("sh");
761                cmd.arg("-c");
762                cmd.arg("exit 42");
763                cmd
764            },
765            None,
766        )
767        .await
768        .unwrap();
769
770        assert!(!output.success());
771        assert_eq!(output.exit_code(), 42);
772    }
773
774    #[tokio::test]
775    #[cfg(not(windows))]
776    async fn test_run_subprocess_ansi_colors_preserved() {
777        let mut logger = Logger::new();
778        let output = run_subprocess(
779            &mut logger,
780            || {
781                let mut cmd = CommandBuilder::new("sh");
782                cmd.arg("-c");
783                cmd.arg("echo -e '\\033[31mred\\033[0m'");
784                cmd
785            },
786            None,
787        )
788        .await
789        .unwrap();
790
791        assert!(output.success());
792        let stderr = output.stderr_str().unwrap();
793        // ANSI codes should be preserved in PTY mode
794        assert!(stderr.contains("\x1b[31m") || stderr.contains("red"));
795    }
796
797    #[tokio::test]
798    #[cfg(not(windows))]
799    async fn test_run_subprocess_default_stderr_lines() {
800        let mut logger = Logger::new();
801        let output = run_subprocess(
802            &mut logger,
803            || {
804                let mut cmd = CommandBuilder::new("echo");
805                cmd.arg("test");
806                cmd
807            },
808            None, // Should default to 5 lines
809        )
810        .await
811        .unwrap();
812
813        assert!(output.success());
814    }
815
816    #[tokio::test]
817    #[cfg(not(windows))]
818    async fn test_run_subprocess_custom_stderr_lines() {
819        let mut logger = Logger::new();
820        let output = run_subprocess(
821            &mut logger,
822            || {
823                let mut cmd = CommandBuilder::new("echo");
824                cmd.arg("test");
825                cmd
826            },
827            Some(10), // Custom 10 lines
828        )
829        .await
830        .unwrap();
831
832        assert!(output.success());
833    }
834
835    #[tokio::test]
836    #[cfg(not(windows))]
837    async fn test_run_subprocess_nonexistent_command() {
838        let mut logger = Logger::new();
839        let result = run_subprocess(
840            &mut logger,
841            || CommandBuilder::new("nonexistent-command-xyz-123"),
842            None,
843        )
844        .await;
845
846        assert!(result.is_err());
847    }
848
849    #[tokio::test]
850    async fn test_subprocess_output_utf8_handling() {
851        let output = SubprocessOutput {
852            stdout: "hello 世界".as_bytes().to_vec(),
853            stderr: "error 错误".as_bytes().to_vec(),
854            exit_code: 0,
855        };
856
857        assert_eq!(output.stdout_str().unwrap(), "hello 世界");
858        assert_eq!(output.stderr_str().unwrap(), "error 错误");
859    }
860
861    #[tokio::test]
862    async fn test_subprocess_output_invalid_utf8() {
863        let output = SubprocessOutput {
864            stdout: vec![0xFF, 0xFE, 0xFD], // Invalid UTF-8
865            stderr: vec![],
866            exit_code: 0,
867        };
868
869        assert!(output.stdout_str().is_err());
870    }
871
872    #[tokio::test]
873    async fn test_logger_suspend() {
874        let mut logger = Logger::new();
875        logger.status("Building", "test");
876        let result = logger.suspend(|| 42);
877        assert_eq!(result, 42);
878    }
879
880    #[tokio::test]
881    async fn test_logger_suspend_without_progress() {
882        let mut logger = Logger::new();
883        let result = logger.suspend(|| 42);
884        assert_eq!(result, 42);
885    }
886
887    #[tokio::test]
888    async fn test_logger_status_permanent() {
889        let logger = Logger::new();
890        // Should not panic
891        logger.status_permanent("Compiling", "test-crate");
892    }
893
894    #[tokio::test]
895    async fn test_logger_warning() {
896        let logger = Logger::new();
897        // Should not panic
898        logger.warning("Warning", "test message");
899    }
900
901    #[tokio::test]
902    async fn test_logger_info() {
903        let logger = Logger::new();
904        // Should not panic
905        logger.info("Info", "test message");
906    }
907
908    #[tokio::test]
909    async fn test_logger_error() {
910        let logger = Logger::new();
911        // Should not panic
912        logger.error("Error", "test message");
913    }
914
915    #[tokio::test]
916    async fn test_logger_print_message() {
917        let logger = Logger::new();
918        // Should not panic
919        logger.print_message("test message");
920    }
921
922    #[tokio::test]
923    async fn test_logger_progress() {
924        let mut logger = Logger::new();
925        logger.progress("Processing...");
926        assert!(logger.progress_bar.is_some());
927    }
928
929    #[tokio::test]
930    async fn test_logger_set_progress_message() {
931        let mut logger = Logger::new();
932        logger.progress("Initial");
933        logger.set_progress_message("Updated");
934        assert!(logger.progress_bar.is_some());
935    }
936}