Skip to main content

click/
testing.rs

1//! Testing utilities for click-rs applications.
2//!
3//! This module provides utilities for testing CLI applications built with click-rs.
4//! The main component is [`CliRunner`], which allows invoking commands with captured
5//! output for assertions.
6//!
7//! # Reference
8//!
9//! Based on Python Click's `testing.py`.
10//!
11//! # Example
12//!
13//! ```rust
14//! use click::testing::CliRunner;
15//! use click::command::Command;
16//!
17//! let cmd = Command::new("hello")
18//!     .callback(|_ctx| {
19//!         // Your command logic here
20//!         Ok(())
21//!     })
22//!     .build();
23//!
24//! let runner = CliRunner::new();
25//! let result = runner.invoke(&cmd, &[]);
26//!
27//! assert_eq!(result.exit_code, 0);
28//! assert!(result.is_success());
29//! ```
30
31use std::collections::HashMap;
32use std::env;
33use std::fs;
34use std::io::{self, Read, Write};
35use std::panic::{catch_unwind, AssertUnwindSafe};
36use std::path::{Path, PathBuf};
37use std::sync::{Mutex, OnceLock};
38use std::thread;
39
40use crate::group::CommandLike;
41use encoding_rs::Encoding;
42
43#[derive(Debug)]
44enum CaptureOutcome {
45    Returned(Result<(), crate::ClickError>),
46    Panicked(Box<dyn std::any::Any + Send + 'static>),
47}
48
49fn panic_message(panic: &(dyn std::any::Any + Send + 'static)) -> String {
50    if let Some(s) = panic.downcast_ref::<&str>() {
51        (*s).to_string()
52    } else if let Some(s) = panic.downcast_ref::<String>() {
53        s.clone()
54    } else {
55        "panic".to_string()
56    }
57}
58
59fn restore_env(saved_env: &[(String, Option<String>)]) {
60    for (key, value) in saved_env {
61        match value {
62            Some(v) => env::set_var(key, v),
63            None => env::remove_var(key),
64        }
65    }
66}
67
68/// A global lock used to serialize stdio redirection during output capture.
69///
70/// Output capture redirects process-global file descriptors, which is not safe
71/// to do concurrently from multiple threads. The lock prevents tests (and other
72/// concurrent invocations) from corrupting each other's stdio streams.
73#[cfg(any(unix, windows))]
74static IO_CAPTURE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
75
76#[cfg(any(unix, windows))]
77fn capture_lock() -> std::sync::MutexGuard<'static, ()> {
78    IO_CAPTURE_LOCK
79        .get_or_init(|| Mutex::new(()))
80        .lock()
81        .expect("IO capture lock poisoned")
82}
83
84#[cfg(unix)]
85fn run_with_capture<F>(input: &str, f: F) -> io::Result<(CaptureOutcome, Vec<u8>, Vec<u8>)>
86where
87    F: FnOnce() -> Result<(), crate::ClickError>,
88{
89    use nix::unistd::{dup, dup2_stderr, dup2_stdin, dup2_stdout};
90
91    let _lock = capture_lock();
92
93    // Pipes for stdout/stderr/stdin capture (os_pipe: safe, cross-platform).
94    let (out_reader_pipe, out_writer_pipe) = os_pipe::pipe()?;
95    let (err_reader_pipe, err_writer_pipe) = os_pipe::pipe()?;
96    let (in_reader_pipe, in_writer_pipe) = os_pipe::pipe()?;
97
98    // Save original fds (nix::dup returns OwnedFd with RAII cleanup).
99    let saved_stdin = dup(io::stdin()).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
100    let saved_stdout = dup(io::stdout()).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
101    let saved_stderr = dup(io::stderr()).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
102
103    // Write input then close stdin write end (PipeWriter Drop closes fd).
104    {
105        let mut writer = in_writer_pipe;
106        let _ = writer.write_all(input.as_bytes());
107    }
108
109    // Redirect stdio using nix safe wrappers.
110    let redir_ok = dup2_stdin(&in_reader_pipe)
111        .and_then(|_| dup2_stdout(&out_writer_pipe))
112        .and_then(|_| dup2_stderr(&err_writer_pipe))
113        .is_ok();
114
115    // Close pipe ends that have been duped into fd 0/1/2 (RAII Drop).
116    drop(in_reader_pipe);
117    drop(out_writer_pipe);
118    drop(err_writer_pipe);
119
120    if !redir_ok {
121        let _ = dup2_stdin(&saved_stdin);
122        let _ = dup2_stdout(&saved_stdout);
123        let _ = dup2_stderr(&saved_stderr);
124        return Err(io::Error::new(
125            io::ErrorKind::Other,
126            "failed to redirect stdio",
127        ));
128    }
129
130    // Reader threads use PipeReader directly (implements Read, no unsafe needed).
131    let out_thread = thread::spawn(move || {
132        let mut buf = Vec::new();
133        let mut reader = out_reader_pipe;
134        let _ = reader.read_to_end(&mut buf);
135        buf
136    });
137    let err_thread = thread::spawn(move || {
138        let mut buf = Vec::new();
139        let mut reader = err_reader_pipe;
140        let _ = reader.read_to_end(&mut buf);
141        buf
142    });
143
144    let old_panic_hook = std::panic::take_hook();
145    std::panic::set_hook(Box::new(|_| {}));
146    let unwind = catch_unwind(AssertUnwindSafe(f));
147    std::panic::set_hook(old_panic_hook);
148
149    let _ = io::stdout().flush();
150    let _ = io::stderr().flush();
151
152    // Restore stdio (OwnedFd auto-closes via Drop after dup2).
153    let _ = dup2_stdin(&saved_stdin);
154    let _ = dup2_stdout(&saved_stdout);
155    let _ = dup2_stderr(&saved_stderr);
156
157    let stdout_bytes = out_thread.join().unwrap_or_default();
158    let stderr_bytes = err_thread.join().unwrap_or_default();
159
160    let outcome = match unwind {
161        Ok(r) => CaptureOutcome::Returned(r),
162        Err(panic) => CaptureOutcome::Panicked(panic),
163    };
164
165    Ok((outcome, stdout_bytes, stderr_bytes))
166}
167
168#[cfg(windows)]
169fn run_with_capture<F>(input: &str, f: F) -> io::Result<(CaptureOutcome, Vec<u8>, Vec<u8>)>
170where
171    F: FnOnce() -> Result<(), crate::ClickError>,
172{
173    use std::os::windows::io::AsRawHandle;
174    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
175    use windows_sys::Win32::System::Console::{
176        GetStdHandle, SetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
177    };
178
179    let _lock = capture_lock();
180
181    // Pipes for stdout/stderr/stdin capture (os_pipe: safe, cross-platform).
182    let (out_reader_pipe, out_writer_pipe) = os_pipe::pipe()?;
183    let (err_reader_pipe, err_writer_pipe) = os_pipe::pipe()?;
184    let (in_reader_pipe, in_writer_pipe) = os_pipe::pipe()?;
185
186    // SAFETY: GetStdHandle is a read-only query of the process-global stdio table.
187    // The returned handles are borrowed (not owned) and we only use them to
188    // restore the original state later. IO_CAPTURE_LOCK serializes access.
189    let (saved_in, saved_out, saved_err) = unsafe {
190        (
191            GetStdHandle(STD_INPUT_HANDLE),
192            GetStdHandle(STD_OUTPUT_HANDLE),
193            GetStdHandle(STD_ERROR_HANDLE),
194        )
195    };
196
197    if saved_in == 0
198        || saved_out == 0
199        || saved_err == 0
200        || saved_in == INVALID_HANDLE_VALUE
201        || saved_out == INVALID_HANDLE_VALUE
202        || saved_err == INVALID_HANDLE_VALUE
203    {
204        return Err(io::Error::new(io::ErrorKind::Other, "GetStdHandle failed"));
205    }
206
207    // Write input then close stdin write end (PipeWriter Drop closes handle).
208    {
209        let mut writer = in_writer_pipe;
210        let _ = writer.write_all(input.as_bytes());
211    }
212
213    // SAFETY: SetStdHandle replaces the process-global stdio handles. This is
214    // serialized by IO_CAPTURE_LOCK, and we restore the original handles in all
215    // exit paths (including panics) before releasing the lock.
216    let redir_ok = unsafe {
217        SetStdHandle(STD_INPUT_HANDLE, in_reader_pipe.as_raw_handle() as _) != 0
218            && SetStdHandle(STD_OUTPUT_HANDLE, out_writer_pipe.as_raw_handle() as _) != 0
219            && SetStdHandle(STD_ERROR_HANDLE, err_writer_pipe.as_raw_handle() as _) != 0
220    };
221
222    if !redir_ok {
223        // SAFETY: Restoring original handles after failed redirect.
224        unsafe {
225            let _ = SetStdHandle(STD_INPUT_HANDLE, saved_in);
226            let _ = SetStdHandle(STD_OUTPUT_HANDLE, saved_out);
227            let _ = SetStdHandle(STD_ERROR_HANDLE, saved_err);
228        }
229        return Err(io::Error::last_os_error());
230    }
231
232    // Reader threads use PipeReader directly (implements Read, no unsafe needed).
233    let out_thread = thread::spawn(move || {
234        let mut buf = Vec::new();
235        let mut reader = out_reader_pipe;
236        let _ = reader.read_to_end(&mut buf);
237        buf
238    });
239    let err_thread = thread::spawn(move || {
240        let mut buf = Vec::new();
241        let mut reader = err_reader_pipe;
242        let _ = reader.read_to_end(&mut buf);
243        buf
244    });
245
246    let old_panic_hook = std::panic::take_hook();
247    std::panic::set_hook(Box::new(|_| {}));
248    let unwind = catch_unwind(AssertUnwindSafe(f));
249    std::panic::set_hook(old_panic_hook);
250
251    let _ = io::stdout().flush();
252    let _ = io::stderr().flush();
253
254    // SAFETY: Restoring original handles and dropping redirected pipe ends
255    // to signal EOF to reader threads.
256    unsafe {
257        let _ = SetStdHandle(STD_INPUT_HANDLE, saved_in);
258        let _ = SetStdHandle(STD_OUTPUT_HANDLE, saved_out);
259        let _ = SetStdHandle(STD_ERROR_HANDLE, saved_err);
260    }
261
262    // Close redirected pipe ends to signal EOF to readers (safe RAII Drop).
263    drop(out_writer_pipe);
264    drop(err_writer_pipe);
265    drop(in_reader_pipe);
266
267    let stdout_bytes = out_thread.join().unwrap_or_default();
268    let stderr_bytes = err_thread.join().unwrap_or_default();
269
270    let outcome = match unwind {
271        Ok(r) => CaptureOutcome::Returned(r),
272        Err(panic) => CaptureOutcome::Panicked(panic),
273    };
274
275    Ok((outcome, stdout_bytes, stderr_bytes))
276}
277
278#[cfg(not(any(unix, windows)))]
279fn run_with_capture<F>(_input: &str, f: F) -> io::Result<(CaptureOutcome, Vec<u8>, Vec<u8>)>
280where
281    F: FnOnce() -> Result<(), crate::ClickError>,
282{
283    let unwind = catch_unwind(AssertUnwindSafe(f));
284    let outcome = match unwind {
285        Ok(r) => CaptureOutcome::Returned(r),
286        Err(panic) => CaptureOutcome::Panicked(panic),
287    };
288    Ok((outcome, Vec::new(), Vec::new()))
289}
290
291// =============================================================================
292// CliRunner
293// =============================================================================
294
295/// A test runner for CLI applications.
296///
297/// `CliRunner` provides a controlled environment for testing CLI commands.
298/// It captures stdout, stderr, and tracks exit codes.
299///
300/// # Example
301///
302/// ```rust
303/// use click::testing::CliRunner;
304/// use click::command::Command;
305///
306/// let cmd = Command::new("greet")
307///     .callback(|ctx| {
308///         let default_name = "World".to_string();
309///         let name = ctx.get_param::<String>("name").unwrap_or(&default_name);
310///         // Use name for greeting
311///         Ok(())
312///     })
313///     .build();
314///
315/// let runner = CliRunner::new();
316/// let result = runner.invoke(&cmd, &[]);
317///
318/// assert_eq!(result.exit_code, 0);
319/// ```
320#[derive(Debug, Clone, Default)]
321pub struct CliRunner {
322    /// Environment variables to set during invocation.
323    env: HashMap<String, String>,
324
325    /// Environment variables to unset during invocation.
326    env_unset: Vec<String>,
327
328    /// Whether to echo input to output.
329    echo_stdin: bool,
330
331    /// Whether to mix stderr into stdout.
332    mix_stderr: bool,
333
334    /// Whether to capture panics as a failure instead of re-panicking.
335    ///
336    /// When enabled (default), panics inside the invoked command are caught and returned as
337    /// `InvokeResult { exit_code: 1, exception_message: Some(...) }`.
338    catch_panics: bool,
339
340    /// Output charset for decoding captured bytes.
341    charset: String,
342
343}
344
345impl CliRunner {
346    /// Create a new CLI runner with default settings.
347    ///
348    /// # Example
349    ///
350    /// ```rust
351    /// use click::testing::CliRunner;
352    ///
353    /// let runner = CliRunner::new();
354    /// ```
355    pub fn new() -> Self {
356        Self {
357            env: HashMap::new(),
358            env_unset: Vec::new(),
359            echo_stdin: false,
360            mix_stderr: true,
361            catch_panics: true,
362            charset: "utf-8".to_string(),
363        }
364    }
365
366    /// Set an environment variable for the invocation.
367    ///
368    /// # Example
369    ///
370    /// ```rust
371    /// use click::testing::CliRunner;
372    ///
373    /// let runner = CliRunner::new()
374    ///     .env("MY_VAR", "value");
375    /// ```
376    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
377        self.env.insert(key.into(), value.into());
378        self
379    }
380
381    /// Unset an environment variable for the invocation.
382    pub fn env_unset(mut self, key: impl Into<String>) -> Self {
383        self.env_unset.push(key.into());
384        self
385    }
386
387    /// Clear all custom environment settings.
388    pub fn env_clear(mut self) -> Self {
389        self.env.clear();
390        self.env_unset.clear();
391        self
392    }
393
394    /// Set whether to echo stdin to output.
395    pub fn echo_stdin(mut self, echo: bool) -> Self {
396        self.echo_stdin = echo;
397        self
398    }
399
400    /// Set whether to mix stderr into stdout.
401    ///
402    /// When true (default), `InvokeResult.output` contains `stdout + stderr`.
403    /// The `InvokeResult.stderr` field is always populated with captured stderr.
404    pub fn mix_stderr(mut self, mix: bool) -> Self {
405        self.mix_stderr = mix;
406        self
407    }
408
409    /// Set whether panics should be captured as a failure instead of re-panicking.
410    pub fn catch_panics(mut self, catch: bool) -> Self {
411        self.catch_panics = catch;
412        self
413    }
414
415    /// Set the charset used to decode captured output.
416    ///
417    /// Defaults to `"utf-8"`. Unknown labels fall back to UTF-8.
418    pub fn charset(mut self, charset: impl Into<String>) -> Self {
419        self.charset = charset.into();
420        self
421    }
422
423    /// Invoke a command and capture the result.
424    ///
425    /// # Arguments
426    ///
427    /// * `cmd` - The command to invoke
428    /// * `args` - Command-line arguments
429    ///
430    /// # Example
431    ///
432    /// ```rust
433    /// use click::testing::CliRunner;
434    /// use click::command::Command;
435    ///
436    /// let cmd = Command::new("hello")
437    ///     .callback(|_| {
438    ///         println!("Hello!");
439    ///         Ok(())
440    ///     })
441    ///     .build();
442    ///
443    /// let result = CliRunner::new().invoke(&cmd, &[]);
444    /// assert_eq!(result.exit_code, 0);
445    /// ```
446    pub fn invoke(&self, cmd: &dyn CommandLike, args: &[&str]) -> InvokeResult {
447        self.invoke_with_input(cmd, args, None)
448    }
449
450    /// Invoke a command with simulated stdin input.
451    ///
452    /// # Arguments
453    ///
454    /// * `cmd` - The command to invoke
455    /// * `args` - Command-line arguments
456    /// * `input` - Optional stdin input
457    ///
458    /// # Example
459    ///
460    /// ```rust
461    /// use click::testing::CliRunner;
462    /// use click::command::Command;
463    ///
464    /// let cmd = Command::new("echo")
465    ///     .callback(|_| Ok(()))
466    ///     .build();
467    ///
468    /// let result = CliRunner::new().invoke_with_input(&cmd, &[], Some("test input"));
469    /// ```
470    pub fn invoke_with_input(
471        &self,
472        cmd: &dyn CommandLike,
473        args: &[&str],
474        input: Option<&str>,
475    ) -> InvokeResult {
476        // Save current environment
477        let saved_env: Vec<(String, Option<String>)> = self
478            .env
479            .keys()
480            .chain(self.env_unset.iter())
481            .map(|k| (k.clone(), env::var(k).ok()))
482            .collect();
483
484        // Set test environment
485        for (key, value) in &self.env {
486            env::set_var(key, value);
487        }
488        for key in &self.env_unset {
489            env::remove_var(key);
490        }
491
492        // Convert args to owned strings
493        let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
494
495        // Always provide a stdin stream (empty by default) to avoid hanging on interactive reads.
496        let input_str = input.unwrap_or("");
497
498        let (outcome, stdout_bytes, stderr_bytes) = match run_with_capture(input_str, || {
499            // Run in a child thread so Rust's test harness output capture (thread-local) doesn't
500            // intercept stdout/stderr before our OS-level redirection does.
501            thread::scope(|s| {
502                let handle = s.spawn(|| cmd.main(args_owned));
503                match handle.join() {
504                    Ok(r) => r,
505                    Err(panic) => std::panic::resume_unwind(panic),
506                }
507            })
508        }) {
509            Ok(v) => v,
510            Err(e) => {
511                restore_env(&saved_env);
512                return InvokeResult::new(1, String::new(), String::new(), Some(e.to_string()));
513            }
514        };
515
516        // Determine exit code and exception message
517        let (exit_code, exception_message) = match outcome {
518            CaptureOutcome::Returned(r) => match &r {
519                Ok(()) => (0, None),
520                Err(e) => (e.exit_code(), Some(e.to_string())),
521            },
522            CaptureOutcome::Panicked(panic) => {
523                if !self.catch_panics {
524                    restore_env(&saved_env);
525                    std::panic::resume_unwind(panic);
526                }
527                (1, Some(format!("panic: {}", panic_message(&*panic))))
528            }
529        };
530
531        let label = self.charset.trim().to_lowercase();
532        let encoding = Encoding::for_label(label.as_bytes())
533            .or_else(|| {
534                if label == "latin-1" || label == "latin1" {
535                    Encoding::for_label(b"iso-8859-1")
536                } else {
537                    None
538                }
539            })
540            .unwrap_or(encoding_rs::UTF_8);
541        let mut stdout = encoding.decode(&stdout_bytes).0.into_owned();
542        let stderr = encoding.decode(&stderr_bytes).0.into_owned();
543
544        // Echo stdin into stdout like Python Click's CliRunner when enabled.
545        if self.echo_stdin && !input_str.is_empty() {
546            stdout = format!("{}{}", input_str, stdout);
547        }
548
549        let output = if self.mix_stderr {
550            format!("{}{}", stdout, stderr)
551        } else {
552            stdout
553        };
554
555        // Restore environment
556        restore_env(&saved_env);
557
558        InvokeResult::new(exit_code, output, stderr, exception_message)
559    }
560
561    /// Invoke a command within an isolated filesystem.
562    ///
563    /// This creates a temporary directory and runs the command there.
564    ///
565    /// # Arguments
566    ///
567    /// * `cmd` - The command to invoke
568    /// * `args` - Command-line arguments
569    ///
570    /// # Example
571    ///
572    /// ```rust
573    /// use click::testing::CliRunner;
574    /// use click::command::Command;
575    ///
576    /// let cmd = Command::new("test").build();
577    /// let result = CliRunner::new().invoke_isolated(&cmd, &[]);
578    /// ```
579    pub fn invoke_isolated(&self, cmd: &dyn CommandLike, args: &[&str]) -> InvokeResult {
580        let _isolated = IsolatedFilesystem::new().expect("Failed to create isolated filesystem");
581        self.invoke(cmd, args)
582    }
583}
584
585// =============================================================================
586// InvokeResult
587// =============================================================================
588
589/// The result of invoking a command through [`CliRunner`].
590///
591/// Contains the exit code, captured output, and any exception that occurred.
592#[derive(Debug, Clone)]
593pub struct InvokeResult {
594    /// The exit code from the command (0 for success).
595    pub exit_code: i32,
596
597    /// Captured stdout (and stderr if `mix_stderr` is true).
598    pub output: String,
599
600    /// Captured stderr (always captured, even if mixed into `output`).
601    pub stderr: String,
602
603    /// The error message if an exception occurred.
604    pub exception_message: Option<String>,
605}
606
607impl InvokeResult {
608    /// Create a new InvokeResult for testing purposes.
609    #[doc(hidden)]
610    pub fn new(
611        exit_code: i32,
612        output: String,
613        stderr: String,
614        exception_message: Option<String>,
615    ) -> Self {
616        Self {
617            exit_code,
618            output,
619            stderr,
620            exception_message,
621        }
622    }
623
624    /// Check if the command succeeded (exit code 0).
625    pub fn is_success(&self) -> bool {
626        self.exit_code == 0
627    }
628
629    /// Check if the command failed (exit code != 0).
630    pub fn is_failure(&self) -> bool {
631        self.exit_code != 0
632    }
633
634    /// Get the output lines.
635    pub fn output_lines(&self) -> Vec<&str> {
636        self.output.lines().collect()
637    }
638
639    /// Check if the output contains a substring.
640    pub fn output_contains(&self, substring: &str) -> bool {
641        self.output.contains(substring)
642    }
643
644    /// Check if the stderr contains a substring.
645    pub fn stderr_contains(&self, substring: &str) -> bool {
646        self.stderr.contains(substring)
647    }
648
649    /// Get the combined output (stdout + stderr).
650    ///
651    /// If `mix_stderr` was true, this is the same as `output`.
652    pub fn combined_output(&self) -> String {
653        if self.stderr.is_empty() {
654            return self.output.clone();
655        }
656
657        // If output already includes stderr (mix_stderr mode), avoid duplication.
658        if self.output.ends_with(&self.stderr) {
659            return self.output.clone();
660        }
661
662        format!("{}{}", self.output, self.stderr)
663    }
664}
665
666// =============================================================================
667// IsolatedFilesystem
668// =============================================================================
669
670/// A temporary filesystem context for isolated testing.
671///
672/// Creates a temporary directory that is automatically cleaned up when dropped.
673/// The current working directory is changed to the temporary directory for the
674/// duration of the test.
675///
676/// # Example
677///
678/// ```rust
679/// use click::testing::IsolatedFilesystem;
680/// use std::fs;
681///
682/// {
683///     let isolated = IsolatedFilesystem::new().unwrap();
684///     let path = isolated.path();
685///
686///     // Create files in the isolated directory
687///     fs::write(path.join("test.txt"), "hello").unwrap();
688///
689///     assert!(path.join("test.txt").exists());
690/// }
691/// // Directory is automatically cleaned up here
692/// ```
693#[derive(Debug)]
694pub struct IsolatedFilesystem {
695    /// The temporary directory path.
696    path: PathBuf,
697    /// The original working directory to restore on drop.
698    original_cwd: PathBuf,
699}
700
701impl IsolatedFilesystem {
702    /// Generate a unique suffix for directory names to avoid test interference.
703    fn unique_suffix() -> u64 {
704        use std::sync::atomic::{AtomicU64, Ordering};
705        use std::time::{SystemTime, UNIX_EPOCH};
706        static COUNTER: AtomicU64 = AtomicU64::new(0);
707        let count = COUNTER.fetch_add(1, Ordering::SeqCst);
708        let timestamp = SystemTime::now()
709            .duration_since(UNIX_EPOCH)
710            .unwrap_or_default()
711            .as_nanos() as u64;
712        timestamp ^ count
713    }
714
715    /// Get current directory, falling back to temp dir if current dir is invalid.
716    ///
717    /// This can happen when running tests in parallel and another test
718    /// deletes the directory that was the current working directory.
719    fn get_current_dir_safe() -> io::Result<PathBuf> {
720        match env::current_dir() {
721            Ok(cwd) => Ok(cwd),
722            Err(_) => {
723                // Fall back to temp dir if current dir is invalid
724                let temp = env::temp_dir();
725                let _ = env::set_current_dir(&temp);
726                Ok(temp)
727            }
728        }
729    }
730
731    /// Create a new isolated filesystem.
732    ///
733    /// This creates a temporary directory and changes to it.
734    pub fn new() -> io::Result<Self> {
735        let original_cwd = Self::get_current_dir_safe()?;
736        let path = env::temp_dir().join(format!(
737            "click_test_{}_{}",
738            std::process::id(),
739            Self::unique_suffix()
740        ));
741
742        // Create the directory
743        fs::create_dir_all(&path)?;
744
745        // Change to it
746        env::set_current_dir(&path)?;
747
748        Ok(Self { path, original_cwd })
749    }
750
751    /// Create an isolated filesystem with a specific name.
752    pub fn with_name(name: &str) -> io::Result<Self> {
753        let original_cwd = Self::get_current_dir_safe()?;
754        let path = env::temp_dir().join(format!(
755            "click_test_{}_{}_{}",
756            name,
757            std::process::id(),
758            Self::unique_suffix()
759        ));
760
761        // Create the directory fresh
762        fs::create_dir_all(&path)?;
763        env::set_current_dir(&path)?;
764
765        Ok(Self { path, original_cwd })
766    }
767
768    /// Get the path to the temporary directory.
769    pub fn path(&self) -> &Path {
770        &self.path
771    }
772
773    /// Create a file in the isolated filesystem.
774    pub fn create_file(&self, name: &str, content: &str) -> io::Result<PathBuf> {
775        let file_path = self.path.join(name);
776        if let Some(parent) = file_path.parent() {
777            fs::create_dir_all(parent)?;
778        }
779        fs::write(&file_path, content)?;
780        Ok(file_path)
781    }
782
783    /// Create a directory in the isolated filesystem.
784    pub fn create_dir(&self, name: &str) -> io::Result<PathBuf> {
785        let dir_path = self.path.join(name);
786        fs::create_dir_all(&dir_path)?;
787        Ok(dir_path)
788    }
789
790    /// Read a file from the isolated filesystem.
791    pub fn read_file(&self, name: &str) -> io::Result<String> {
792        fs::read_to_string(self.path.join(name))
793    }
794
795    /// Check if a file exists in the isolated filesystem.
796    pub fn file_exists(&self, name: &str) -> bool {
797        self.path.join(name).exists()
798    }
799
800    /// List files in the isolated filesystem.
801    pub fn list_files(&self) -> io::Result<Vec<String>> {
802        let mut files = Vec::new();
803        for entry in fs::read_dir(&self.path)? {
804            let entry = entry?;
805            if let Some(name) = entry.file_name().to_str() {
806                files.push(name.to_string());
807            }
808        }
809        files.sort();
810        Ok(files)
811    }
812}
813
814impl Drop for IsolatedFilesystem {
815    fn drop(&mut self) {
816        // Restore original working directory
817        let _ = env::set_current_dir(&self.original_cwd);
818
819        // Clean up the temporary directory
820        let _ = fs::remove_dir_all(&self.path);
821    }
822}
823
824// =============================================================================
825// EchoingStdin (for simulated input)
826// =============================================================================
827
828/// A wrapper that echoes input to output as it's read.
829#[derive(Debug)]
830pub struct EchoingStdin<R: Read, W: Write> {
831    input: R,
832    output: W,
833}
834
835impl<R: Read, W: Write> EchoingStdin<R, W> {
836    /// Create a new echoing stdin wrapper.
837    pub fn new(input: R, output: W) -> Self {
838        Self { input, output }
839    }
840}
841
842impl<R: Read, W: Write> Read for EchoingStdin<R, W> {
843    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
844        let n = self.input.read(buf)?;
845        if n > 0 {
846            self.output.write_all(&buf[..n])?;
847        }
848        Ok(n)
849    }
850}
851
852// =============================================================================
853// Test Utilities
854// =============================================================================
855
856/// Create a mock context for testing.
857///
858/// This is useful when you need to test callback functions in isolation.
859pub fn make_test_context(info_name: &str) -> crate::context::Context {
860    crate::context::ContextBuilder::new()
861        .info_name(info_name)
862        .build()
863}
864
865/// Assert that a result indicates success.
866#[macro_export]
867macro_rules! assert_success {
868    ($result:expr) => {
869        assert!(
870            $result.is_success(),
871            "Expected success but got exit code {} with output:\n{}",
872            $result.exit_code,
873            $result.combined_output()
874        );
875    };
876}
877
878/// Assert that a result indicates failure.
879#[macro_export]
880macro_rules! assert_failure {
881    ($result:expr) => {
882        assert!(
883            $result.is_failure(),
884            "Expected failure but got success with output:\n{}",
885            $result.output
886        );
887    };
888    ($result:expr, $code:expr) => {
889        assert_eq!(
890            $result.exit_code,
891            $code,
892            "Expected exit code {} but got {} with output:\n{}",
893            $code,
894            $result.exit_code,
895            $result.combined_output()
896        );
897    };
898}
899
900/// Assert that the output contains a substring.
901#[macro_export]
902macro_rules! assert_output_contains {
903    ($result:expr, $substring:expr) => {
904        assert!(
905            $result.output_contains($substring),
906            "Expected output to contain '{}' but got:\n{}",
907            $substring,
908            $result.output
909        );
910    };
911}
912
913// =============================================================================
914// Tests
915// =============================================================================
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use crate::command::Command;
921
922    #[test]
923    fn test_cli_runner_new() {
924        let runner = CliRunner::new();
925        assert!(runner.env.is_empty());
926        assert!(runner.env_unset.is_empty());
927    }
928
929    #[test]
930    fn test_cli_runner_env() {
931        let runner = CliRunner::new()
932            .env("TEST_VAR", "test_value")
933            .env("ANOTHER", "value");
934
935        assert_eq!(runner.env.get("TEST_VAR"), Some(&"test_value".to_string()));
936        assert_eq!(runner.env.get("ANOTHER"), Some(&"value".to_string()));
937    }
938
939    #[test]
940    fn test_cli_runner_env_unset() {
941        let runner = CliRunner::new().env("KEEP", "value").env_unset("REMOVE");
942
943        assert_eq!(runner.env.len(), 1);
944        assert_eq!(runner.env_unset.len(), 1);
945    }
946
947    #[test]
948    fn test_cli_runner_env_clear() {
949        let runner = CliRunner::new()
950            .env("VAR1", "val1")
951            .env("VAR2", "val2")
952            .env_unset("VAR3")
953            .env_clear();
954
955        assert!(runner.env.is_empty());
956        assert!(runner.env_unset.is_empty());
957    }
958
959    #[test]
960    fn test_invoke_simple_command() {
961        let cmd = Command::new("test").callback(|_ctx| Ok(())).build();
962
963        let runner = CliRunner::new();
964        let result = runner.invoke(&cmd, &[]);
965
966        assert_eq!(result.exit_code, 0);
967        assert!(result.exception_message.is_none());
968    }
969
970    #[test]
971    fn test_invoke_failing_command() {
972        let cmd = Command::new("fail")
973            .callback(|_ctx| Err(crate::ClickError::usage("test error")))
974            .build();
975
976        let runner = CliRunner::new();
977        let result = runner.invoke(&cmd, &[]);
978
979        assert_eq!(result.exit_code, 2); // Usage errors have exit code 2
980        assert!(result.exception_message.is_some());
981    }
982
983    #[test]
984    fn test_invoke_result_is_success() {
985        let result = InvokeResult::new(0, String::new(), String::new(), None);
986
987        assert!(result.is_success());
988        assert!(!result.is_failure());
989    }
990
991    #[test]
992    fn test_invoke_result_is_failure() {
993        let result = InvokeResult::new(1, String::new(), String::new(), None);
994
995        assert!(!result.is_success());
996        assert!(result.is_failure());
997    }
998
999    #[test]
1000    fn test_invoke_result_output_contains() {
1001        let result = InvokeResult::new(0, "Hello, World!".to_string(), String::new(), None);
1002
1003        assert!(result.output_contains("Hello"));
1004        assert!(result.output_contains("World"));
1005        assert!(!result.output_contains("Goodbye"));
1006    }
1007
1008    #[test]
1009    fn test_invoke_result_output_lines() {
1010        let result = InvokeResult::new(0, "line1\nline2\nline3".to_string(), String::new(), None);
1011
1012        let lines = result.output_lines();
1013        assert_eq!(lines.len(), 3);
1014        assert_eq!(lines[0], "line1");
1015        assert_eq!(lines[1], "line2");
1016        assert_eq!(lines[2], "line3");
1017    }
1018
1019    #[test]
1020    fn test_invoke_result_combined_output() {
1021        let result = InvokeResult::new(0, "stdout".to_string(), "stderr".to_string(), None);
1022
1023        assert_eq!(result.combined_output(), "stdoutstderr");
1024    }
1025
1026    #[test]
1027    fn test_isolated_filesystem() {
1028        let isolated = IsolatedFilesystem::new().unwrap();
1029        let iso_path = isolated.path().to_path_buf();
1030
1031        // The isolated directory exists
1032        assert!(iso_path.exists());
1033        assert!(iso_path.starts_with(env::temp_dir()));
1034
1035        // Create a file
1036        isolated.create_file("test.txt", "hello").unwrap();
1037        assert!(isolated.file_exists("test.txt"));
1038        assert_eq!(isolated.read_file("test.txt").unwrap(), "hello");
1039
1040        // Drop the filesystem explicitly
1041        drop(isolated);
1042
1043        // After drop, the directory is cleaned up
1044        // Note: We don't test current_dir restoration because it's racy with parallel tests
1045        assert!(
1046            !iso_path.exists(),
1047            "temp directory should be cleaned up after drop"
1048        );
1049    }
1050
1051    #[test]
1052    fn test_isolated_filesystem_with_name() {
1053        let isolated = IsolatedFilesystem::with_name("custom").unwrap();
1054        let path = isolated.path();
1055
1056        assert!(path.to_string_lossy().contains("custom"));
1057    }
1058
1059    #[test]
1060    fn test_isolated_filesystem_create_dir() {
1061        let isolated = IsolatedFilesystem::new().unwrap();
1062
1063        let dir_path = isolated.create_dir("subdir").unwrap();
1064        assert!(dir_path.exists());
1065        assert!(dir_path.is_dir());
1066    }
1067
1068    #[test]
1069    fn test_isolated_filesystem_list_files() {
1070        let isolated = IsolatedFilesystem::new().unwrap();
1071
1072        isolated.create_file("a.txt", "").unwrap();
1073        isolated.create_file("b.txt", "").unwrap();
1074        isolated.create_file("c.txt", "").unwrap();
1075
1076        let files = isolated.list_files().unwrap();
1077        assert_eq!(files, vec!["a.txt", "b.txt", "c.txt"]);
1078    }
1079
1080    #[test]
1081    fn test_isolated_filesystem_nested_file() {
1082        let isolated = IsolatedFilesystem::new().unwrap();
1083
1084        isolated
1085            .create_file("dir/nested/file.txt", "content")
1086            .unwrap();
1087        assert!(isolated.file_exists("dir/nested/file.txt"));
1088        assert_eq!(
1089            isolated.read_file("dir/nested/file.txt").unwrap(),
1090            "content"
1091        );
1092    }
1093
1094    #[test]
1095    fn test_make_test_context() {
1096        let ctx = make_test_context("test");
1097        assert_eq!(ctx.info_name(), Some("test"));
1098    }
1099
1100    #[test]
1101    fn test_echoing_stdin() {
1102        let input = b"hello";
1103        let mut output = Vec::new();
1104
1105        {
1106            let mut echoing = EchoingStdin::new(&input[..], &mut output);
1107            let mut buf = [0u8; 10];
1108            let n = echoing.read(&mut buf).unwrap();
1109            assert_eq!(n, 5);
1110            assert_eq!(&buf[..n], b"hello");
1111        }
1112
1113        assert_eq!(&output, b"hello");
1114    }
1115
1116    #[test]
1117    fn test_cli_runner_mix_stderr() {
1118        let runner = CliRunner::new().mix_stderr(false);
1119        assert!(!runner.mix_stderr);
1120
1121        let runner = CliRunner::new().mix_stderr(true);
1122        assert!(runner.mix_stderr);
1123    }
1124
1125    #[test]
1126    fn test_cli_runner_echo_stdin() {
1127        let runner = CliRunner::new().echo_stdin(true);
1128        assert!(runner.echo_stdin);
1129    }
1130}