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    // Spawn command in PTY
341    let mut child = pty
342        .slave
343        .spawn_command(cmd)
344        .context("Failed to spawn command in PTY")?;
345
346    // Get handles for stdout and stderr from PTY
347    // We need to keep a reference to the master to close it later
348    let mut reader = pty
349        .master
350        .try_clone_reader()
351        .context("Failed to clone PTY reader")?;
352
353    // Keep the master alive until we're done reading
354    let master = pty.master;
355
356    // Channel to coordinate rendering (send raw bytes to preserve ANSI codes)
357    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
358    // Keep a clone of tx to close the channel if we timeout
359    let tx_clone = tx.clone();
360
361    // Collect output as it arrives (for timeout fallback)
362    let collected_output = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
363    let collected_output_clone = collected_output.clone();
364
365    // Task to read from PTY (combines stdout and stderr)
366    // PTY reader is blocking, so we use spawn_blocking
367    // Allow excessive nesting: inherent to async spawn + spawn_blocking + loop +
368    // match pattern
369    #[allow(clippy::excessive_nesting)]
370    let pty_task = tokio::spawn(async move {
371        tokio::task::spawn_blocking(move || {
372            let mut full_output = Vec::new();
373            let mut buffer = vec![0u8; 4096];
374
375            loop {
376                match reader.read(&mut buffer) {
377                    Ok(0) => break, // EOF
378                    Ok(bytes_read) => {
379                        let chunk = &buffer[..bytes_read];
380                        full_output.extend_from_slice(chunk);
381                        // Also collect in shared buffer for timeout fallback
382                        if let Ok(mut collected) = collected_output_clone.lock() {
383                            collected.extend_from_slice(chunk);
384                        }
385                        let _ = tx.send(chunk.to_vec());
386                    }
387                    Err(err) => {
388                        // On error, still capture what we have
389                        let error_msg = format!("<pty read error: {}>", err);
390                        let error_bytes = error_msg.as_bytes();
391                        full_output.extend_from_slice(error_bytes);
392                        if let Ok(mut collected) = collected_output_clone.lock() {
393                            collected.extend_from_slice(error_bytes);
394                        }
395                        let _ = tx.send(error_bytes.to_vec());
396                        break;
397                    }
398                }
399            }
400
401            // Close the channel to signal completion
402            drop(tx);
403
404            Ok::<Vec<u8>, anyhow::Error>(full_output)
405        })
406        .await
407        .context("Failed to join blocking PTY read task")?
408    });
409
410    // Render output inline (below current cursor position)
411    let mut output_buffer = Vec::new();
412    let mut output_ring: Vec<Vec<u8>> = Vec::with_capacity(stderr_lines);
413
414    // Process output bytes as they arrive
415    // Allow excessive nesting: inherent to async spawn with nested loops and
416    // conditionals
417    #[allow(clippy::excessive_nesting)]
418    let render_task = tokio::spawn(async move {
419        let mut current_lines_displayed: usize = 0;
420
421        while let Some(chunk) = rx.recv().await {
422            output_buffer.extend_from_slice(&chunk);
423
424            // Split buffer into complete lines (preserving ANSI codes)
425            let mut lines: Vec<Vec<u8>> = Vec::new();
426            let mut current_line = Vec::new();
427            for byte in output_buffer.iter().copied() {
428                current_line.push(byte);
429                if byte == b'\n' {
430                    lines.push(current_line);
431                    current_line = Vec::new();
432                }
433            }
434            output_buffer = current_line;
435
436            // Update ring buffer with new complete lines
437            for line in lines {
438                output_ring.push(line);
439                if output_ring.len() > stderr_lines {
440                    output_ring.remove(0);
441                }
442            }
443
444            // Render ring buffer inline (below current position)
445            if is_term && !output_ring.is_empty() {
446                let mut stderr_handle = std::io::stderr();
447
448                // Move cursor up to clear previous output (if any)
449                if current_lines_displayed > 0 {
450                    // Move up and clear each line
451                    write!(stderr_handle, "\x1b[{}A", current_lines_displayed).ok();
452                    for _ in 0..current_lines_displayed {
453                        write!(stderr_handle, "\x1b[2K\x1b[1B").ok(); // Clear line, move down
454                    }
455                    // Move back up to start position
456                    write!(stderr_handle, "\x1b[{}A", current_lines_displayed).ok();
457                }
458
459                // Write all lines in the ring buffer (preserving ANSI codes)
460                for line_bytes in &output_ring {
461                    let _ = stderr_handle.write_all(line_bytes);
462                }
463                let _ = stderr_handle.flush();
464
465                current_lines_displayed = output_ring.len();
466                lines_drawn_render
467                    .store(current_lines_displayed, std::sync::atomic::Ordering::SeqCst);
468            }
469        }
470
471        // Handle any remaining partial line
472        if !output_buffer.is_empty() {
473            output_ring.push(output_buffer);
474            if output_ring.len() > stderr_lines {
475                output_ring.remove(0);
476            }
477            if is_term {
478                let mut stderr_handle = std::io::stderr();
479
480                // Move cursor up to clear previous output (if any)
481                if current_lines_displayed > 0 {
482                    write!(stderr_handle, "\x1b[{}A", current_lines_displayed).ok();
483                    for _ in 0..current_lines_displayed {
484                        write!(stderr_handle, "\x1b[2K\x1b[1B").ok();
485                    }
486                    write!(stderr_handle, "\x1b[{}A", current_lines_displayed).ok();
487                }
488
489                // Render final ring buffer state
490                for line_bytes in &output_ring {
491                    let _ = stderr_handle.write_all(line_bytes);
492                }
493                let _ = stderr_handle.flush();
494
495                lines_drawn_render.store(output_ring.len(), std::sync::atomic::Ordering::SeqCst);
496            }
497        }
498
499        (output_ring, is_term)
500    });
501
502    // Wait for process to complete (blocking call, so wrap in spawn_blocking)
503    let status = tokio::task::spawn_blocking(move || child.wait())
504        .await
505        .context("Failed to join process wait task")?
506        .context("Failed to wait for subprocess")?;
507
508    // Close the PTY master to signal EOF to the reader
509    // This ensures the reader sees EOF even if the process has already exited
510    // On Windows, we need to drop the master earlier to help the blocking read
511    // return
512    drop(master);
513
514    // On Windows, give a small delay to allow the reader to see EOF
515    #[cfg(windows)]
516    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
517
518    // Wait for PTY reading to complete (with timeout to prevent hanging)
519    // If timeout occurs but process has exited, use collected output as fallback
520    // On Windows, use a very short timeout since blocking reads may never return
521    let timeout_duration = if cfg!(windows) {
522        std::time::Duration::from_millis(500)
523    } else {
524        std::time::Duration::from_secs(10)
525    };
526    let pty_output = match tokio::time::timeout(timeout_duration, pty_task).await {
527        Ok(result) => {
528            // Task completed, get the result
529            result.context("Failed to join PTY task")??
530        }
531        Err(_) => {
532            // Timeout occurred - this commonly happens on Windows where blocking
533            // reads in spawn_blocking cannot be cancelled. Since the process has
534            // already exited, we use the output we collected as it arrived through
535            // the channel. The blocking task will continue running in the background
536            // but won't affect the test outcome.
537            // Close the channel to allow render_task to complete
538            drop(tx_clone);
539            collected_output.lock().unwrap().clone()
540        }
541    };
542    // Wait for render task with timeout to prevent hanging
543    // Use very short timeout on Windows where operations may hang
544    let render_timeout = if cfg!(windows) {
545        std::time::Duration::from_millis(500)
546    } else {
547        std::time::Duration::from_secs(5)
548    };
549    let (_final_output_ring, was_term) =
550        match tokio::time::timeout(render_timeout, render_task).await {
551            Ok(result) => result.context("Failed to join render task")?,
552            Err(_) => {
553                // Render task timed out - this can happen on Windows where
554                // blocking operations may not complete. We'll continue without
555                // the final render state.
556                (Vec::new(), is_term)
557            }
558        };
559
560    // For now, treat all PTY output as stderr (we can separate later if needed)
561    // In PTY mode, stdout and stderr are combined
562    let stdout_bytes = Vec::new(); // PTY combines stdout/stderr, so we'll capture all as stderr
563    let stderr_bytes = pty_output;
564
565    // Handle final cleanup
566    let exit_code = status.exit_code();
567    let final_lines_drawn = lines_drawn.load(std::sync::atomic::Ordering::SeqCst);
568
569    if was_term && final_lines_drawn > 0 {
570        // Clear the lines we drew by moving up and clearing each line
571        let mut stderr_handle = std::io::stderr();
572        write!(stderr_handle, "\x1b[{}A", final_lines_drawn).ok();
573        for _ in 0..final_lines_drawn {
574            write!(stderr_handle, "\x1b[2K\x1b[1B").ok(); // Clear line, move down
575        }
576        // Move back up to where we started
577        write!(stderr_handle, "\x1b[{}A", final_lines_drawn).ok();
578        let _ = stderr_handle.flush();
579    }
580
581    Ok(SubprocessOutput {
582        stdout: stdout_bytes,
583        stderr: stderr_bytes,
584        exit_code,
585    })
586}
587
588impl Default for Logger {
589    fn default() -> Self {
590        Self::new()
591    }
592}
593
594impl Drop for Logger {
595    fn drop(&mut self) {
596        // Clear the progress bar
597        if let Some(pb) = self.progress_bar.take() {
598            pb.finish_and_clear();
599        }
600
601        // Clear the reserved lines (including our status + subprocess output)
602        if self.line_count > 0 {
603            use console::Term;
604            let term = Term::stderr();
605            if term.is_term() {
606                let _ = term.clear_last_lines(self.line_count);
607            }
608            self.line_count = 0;
609        }
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    #[cfg(not(windows))]
616    use portable_pty::CommandBuilder;
617
618    use super::*;
619
620    #[tokio::test]
621    async fn test_logger_new() {
622        let logger = Logger::new();
623        assert!(logger.progress_bar.is_none());
624        assert_eq!(logger.line_count, 0);
625    }
626
627    #[tokio::test]
628    async fn test_logger_status() {
629        let mut logger = Logger::new();
630        logger.status("Building", "test-crate");
631        assert!(logger.progress_bar.is_some());
632        assert_eq!(logger.line_count, 1);
633    }
634
635    #[tokio::test]
636    async fn test_logger_clear_status() {
637        let mut logger = Logger::new();
638        logger.status("Building", "test-crate");
639        assert!(logger.progress_bar.is_some());
640        logger.clear_status();
641        assert!(logger.progress_bar.is_none());
642        assert_eq!(logger.line_count, 0);
643    }
644
645    #[tokio::test]
646    async fn test_logger_finish() {
647        let mut logger = Logger::new();
648        logger.status("Building", "test-crate");
649        logger.finish();
650        assert!(logger.progress_bar.is_none());
651        assert_eq!(logger.line_count, 0);
652    }
653
654    #[tokio::test]
655    async fn test_subprocess_output_success() {
656        let output = SubprocessOutput {
657            stdout: b"stdout content".to_vec(),
658            stderr: b"stderr content".to_vec(),
659            exit_code: 0,
660        };
661        assert!(output.success());
662        assert_eq!(output.exit_code(), 0);
663        assert_eq!(output.stdout_str().unwrap(), "stdout content");
664        assert_eq!(output.stderr_str().unwrap(), "stderr content");
665    }
666
667    #[tokio::test]
668    async fn test_subprocess_output_failure() {
669        let output = SubprocessOutput {
670            stdout: b"".to_vec(),
671            stderr: b"error message".to_vec(),
672            exit_code: 1,
673        };
674        assert!(!output.success());
675        assert_eq!(output.exit_code(), 1);
676        assert_eq!(output.stderr_str().unwrap(), "error message");
677    }
678
679    #[tokio::test]
680    #[cfg(not(windows))]
681    async fn test_run_subprocess_simple_success() {
682        let mut logger = Logger::new();
683        let output = run_subprocess(
684            &mut logger,
685            || {
686                let mut cmd = CommandBuilder::new("echo");
687                cmd.arg("hello world");
688                cmd
689            },
690            Some(3),
691        )
692        .await
693        .unwrap();
694
695        assert!(output.success());
696        assert_eq!(output.exit_code(), 0);
697        // PTY combines stdout/stderr, so output should be in stderr
698        let stderr = output.stderr_str().unwrap();
699        assert!(stderr.contains("hello world") || stderr.is_empty());
700    }
701
702    #[tokio::test]
703    #[cfg(not(windows))]
704    async fn test_run_subprocess_simple_failure() {
705        let mut logger = Logger::new();
706        let output = run_subprocess(&mut logger, || CommandBuilder::new("false"), Some(3))
707            .await
708            .unwrap();
709
710        assert!(!output.success());
711        assert_ne!(output.exit_code(), 0);
712    }
713
714    #[tokio::test]
715    #[cfg(not(windows))]
716    async fn test_run_subprocess_multiline_output() {
717        let mut logger = Logger::new();
718        let output = run_subprocess(
719            &mut logger,
720            || {
721                let mut cmd = CommandBuilder::new("sh");
722                cmd.arg("-c");
723                cmd.arg("echo 'line 1'; echo 'line 2'; echo 'line 3'; echo 'line 4'; echo 'line 5'; echo 'line 6'");
724                cmd
725            },
726            Some(3), // Only show 3 lines in ring buffer
727        )
728        .await
729        .unwrap();
730
731        assert!(output.success());
732        // Should capture all output even though only 3 lines shown
733        let stderr = output.stderr_str().unwrap();
734        assert!(stderr.contains("line 1"));
735        assert!(stderr.contains("line 6"));
736    }
737
738    #[tokio::test]
739    #[cfg(not(windows))]
740    async fn test_run_subprocess_with_progress_bar() {
741        let mut logger = Logger::new();
742        logger.status("Preparing", "test");
743        assert!(logger.progress_bar.is_some());
744
745        let output = run_subprocess(
746            &mut logger,
747            || {
748                let mut cmd = CommandBuilder::new("echo");
749                cmd.arg("test output");
750                cmd
751            },
752            None,
753        )
754        .await
755        .unwrap();
756
757        assert!(output.success());
758        // Progress bar should be cleared before subprocess
759        // (we can't easily test this without mocking, but the function should
760        // complete)
761    }
762
763    #[tokio::test]
764    #[cfg(not(windows))]
765    async fn test_run_subprocess_exit_code_preservation() {
766        let mut logger = Logger::new();
767        let output = run_subprocess(
768            &mut logger,
769            || {
770                let mut cmd = CommandBuilder::new("sh");
771                cmd.arg("-c");
772                cmd.arg("exit 42");
773                cmd
774            },
775            None,
776        )
777        .await
778        .unwrap();
779
780        assert!(!output.success());
781        assert_eq!(output.exit_code(), 42);
782    }
783
784    #[tokio::test]
785    #[cfg(not(windows))]
786    async fn test_run_subprocess_ansi_colors_preserved() {
787        let mut logger = Logger::new();
788        let output = run_subprocess(
789            &mut logger,
790            || {
791                let mut cmd = CommandBuilder::new("sh");
792                cmd.arg("-c");
793                cmd.arg("echo -e '\\033[31mred\\033[0m'");
794                cmd
795            },
796            None,
797        )
798        .await
799        .unwrap();
800
801        assert!(output.success());
802        let stderr = output.stderr_str().unwrap();
803        // ANSI codes should be preserved in PTY mode
804        assert!(stderr.contains("\x1b[31m") || stderr.contains("red"));
805    }
806
807    #[tokio::test]
808    #[cfg(not(windows))]
809    async fn test_run_subprocess_default_stderr_lines() {
810        let mut logger = Logger::new();
811        let output = run_subprocess(
812            &mut logger,
813            || {
814                let mut cmd = CommandBuilder::new("echo");
815                cmd.arg("test");
816                cmd
817            },
818            None, // Should default to 5 lines
819        )
820        .await
821        .unwrap();
822
823        assert!(output.success());
824    }
825
826    #[tokio::test]
827    #[cfg(not(windows))]
828    async fn test_run_subprocess_custom_stderr_lines() {
829        let mut logger = Logger::new();
830        let output = run_subprocess(
831            &mut logger,
832            || {
833                let mut cmd = CommandBuilder::new("echo");
834                cmd.arg("test");
835                cmd
836            },
837            Some(10), // Custom 10 lines
838        )
839        .await
840        .unwrap();
841
842        assert!(output.success());
843    }
844
845    #[tokio::test]
846    #[cfg(not(windows))]
847    async fn test_run_subprocess_nonexistent_command() {
848        let mut logger = Logger::new();
849        let result = run_subprocess(
850            &mut logger,
851            || CommandBuilder::new("nonexistent-command-xyz-123"),
852            None,
853        )
854        .await;
855
856        assert!(result.is_err());
857    }
858
859    #[tokio::test]
860    async fn test_subprocess_output_utf8_handling() {
861        let output = SubprocessOutput {
862            stdout: "hello 世界".as_bytes().to_vec(),
863            stderr: "error 错误".as_bytes().to_vec(),
864            exit_code: 0,
865        };
866
867        assert_eq!(output.stdout_str().unwrap(), "hello 世界");
868        assert_eq!(output.stderr_str().unwrap(), "error 错误");
869    }
870
871    #[tokio::test]
872    async fn test_subprocess_output_invalid_utf8() {
873        let output = SubprocessOutput {
874            stdout: vec![0xFF, 0xFE, 0xFD], // Invalid UTF-8
875            stderr: vec![],
876            exit_code: 0,
877        };
878
879        assert!(output.stdout_str().is_err());
880    }
881
882    #[tokio::test]
883    async fn test_logger_suspend() {
884        let mut logger = Logger::new();
885        logger.status("Building", "test");
886        let result = logger.suspend(|| 42);
887        assert_eq!(result, 42);
888    }
889
890    #[tokio::test]
891    async fn test_logger_suspend_without_progress() {
892        let mut logger = Logger::new();
893        let result = logger.suspend(|| 42);
894        assert_eq!(result, 42);
895    }
896
897    #[tokio::test]
898    async fn test_logger_status_permanent() {
899        let logger = Logger::new();
900        // Should not panic
901        logger.status_permanent("Compiling", "test-crate");
902    }
903
904    #[tokio::test]
905    async fn test_logger_warning() {
906        let logger = Logger::new();
907        // Should not panic
908        logger.warning("Warning", "test message");
909    }
910
911    #[tokio::test]
912    async fn test_logger_info() {
913        let logger = Logger::new();
914        // Should not panic
915        logger.info("Info", "test message");
916    }
917
918    #[tokio::test]
919    async fn test_logger_error() {
920        let logger = Logger::new();
921        // Should not panic
922        logger.error("Error", "test message");
923    }
924
925    #[tokio::test]
926    async fn test_logger_print_message() {
927        let logger = Logger::new();
928        // Should not panic
929        logger.print_message("test message");
930    }
931
932    #[tokio::test]
933    async fn test_logger_progress() {
934        let mut logger = Logger::new();
935        logger.progress("Processing...");
936        assert!(logger.progress_bar.is_some());
937    }
938
939    #[tokio::test]
940    async fn test_logger_set_progress_message() {
941        let mut logger = Logger::new();
942        logger.progress("Initial");
943        logger.set_progress_message("Updated");
944        assert!(logger.progress_bar.is_some());
945    }
946}