Skip to main content

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