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
344impl CliRunner {
345    /// Create a new CLI runner with default settings.
346    ///
347    /// # Example
348    ///
349    /// ```rust
350    /// use click::testing::CliRunner;
351    ///
352    /// let runner = CliRunner::new();
353    /// ```
354    pub fn new() -> Self {
355        Self {
356            env: HashMap::new(),
357            env_unset: Vec::new(),
358            echo_stdin: false,
359            mix_stderr: true,
360            catch_panics: true,
361            charset: "utf-8".to_string(),
362        }
363    }
364
365    /// Set an environment variable for the invocation.
366    ///
367    /// # Example
368    ///
369    /// ```rust
370    /// use click::testing::CliRunner;
371    ///
372    /// let runner = CliRunner::new()
373    ///     .env("MY_VAR", "value");
374    /// ```
375    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
376        self.env.insert(key.into(), value.into());
377        self
378    }
379
380    /// Unset an environment variable for the invocation.
381    pub fn env_unset(mut self, key: impl Into<String>) -> Self {
382        self.env_unset.push(key.into());
383        self
384    }
385
386    /// Clear all custom environment settings.
387    pub fn env_clear(mut self) -> Self {
388        self.env.clear();
389        self.env_unset.clear();
390        self
391    }
392
393    /// Set whether to echo stdin to output.
394    pub fn echo_stdin(mut self, echo: bool) -> Self {
395        self.echo_stdin = echo;
396        self
397    }
398
399    /// Set whether to mix stderr into stdout.
400    ///
401    /// When true (default), `InvokeResult.output` contains `stdout + stderr`.
402    /// The `InvokeResult.stderr` field is always populated with captured stderr.
403    pub fn mix_stderr(mut self, mix: bool) -> Self {
404        self.mix_stderr = mix;
405        self
406    }
407
408    /// Set whether panics should be captured as a failure instead of re-panicking.
409    pub fn catch_panics(mut self, catch: bool) -> Self {
410        self.catch_panics = catch;
411        self
412    }
413
414    /// Set the charset used to decode captured output.
415    ///
416    /// Defaults to `"utf-8"`. Unknown labels fall back to UTF-8.
417    pub fn charset(mut self, charset: impl Into<String>) -> Self {
418        self.charset = charset.into();
419        self
420    }
421
422    /// Invoke a command and capture the result.
423    ///
424    /// # Arguments
425    ///
426    /// * `cmd` - The command to invoke
427    /// * `args` - Command-line arguments
428    ///
429    /// # Example
430    ///
431    /// ```rust
432    /// use click::testing::CliRunner;
433    /// use click::command::Command;
434    ///
435    /// let cmd = Command::new("hello")
436    ///     .callback(|_| {
437    ///         println!("Hello!");
438    ///         Ok(())
439    ///     })
440    ///     .build();
441    ///
442    /// let result = CliRunner::new().invoke(&cmd, &[]);
443    /// assert_eq!(result.exit_code, 0);
444    /// ```
445    pub fn invoke(&self, cmd: &dyn CommandLike, args: &[&str]) -> InvokeResult {
446        self.invoke_with_input(cmd, args, None)
447    }
448
449    /// Invoke a command with simulated stdin input.
450    ///
451    /// # Arguments
452    ///
453    /// * `cmd` - The command to invoke
454    /// * `args` - Command-line arguments
455    /// * `input` - Optional stdin input
456    ///
457    /// # Example
458    ///
459    /// ```rust
460    /// use click::testing::CliRunner;
461    /// use click::command::Command;
462    ///
463    /// let cmd = Command::new("echo")
464    ///     .callback(|_| Ok(()))
465    ///     .build();
466    ///
467    /// let result = CliRunner::new().invoke_with_input(&cmd, &[], Some("test input"));
468    /// ```
469    pub fn invoke_with_input(
470        &self,
471        cmd: &dyn CommandLike,
472        args: &[&str],
473        input: Option<&str>,
474    ) -> InvokeResult {
475        // Save current environment
476        let saved_env: Vec<(String, Option<String>)> = self
477            .env
478            .keys()
479            .chain(self.env_unset.iter())
480            .map(|k| (k.clone(), env::var(k).ok()))
481            .collect();
482
483        // Set test environment
484        for (key, value) in &self.env {
485            env::set_var(key, value);
486        }
487        for key in &self.env_unset {
488            env::remove_var(key);
489        }
490
491        // Convert args to owned strings
492        let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
493
494        // Always provide a stdin stream (empty by default) to avoid hanging on interactive reads.
495        let input_str = input.unwrap_or("");
496
497        let (outcome, stdout_bytes, stderr_bytes) = match run_with_capture(input_str, || {
498            // Run in a child thread so Rust's test harness output capture (thread-local) doesn't
499            // intercept stdout/stderr before our OS-level redirection does.
500            thread::scope(|s| {
501                let handle = s.spawn(|| cmd.main(args_owned));
502                match handle.join() {
503                    Ok(r) => r,
504                    Err(panic) => std::panic::resume_unwind(panic),
505                }
506            })
507        }) {
508            Ok(v) => v,
509            Err(e) => {
510                restore_env(&saved_env);
511                return InvokeResult::new(1, String::new(), String::new(), Some(e.to_string()));
512            }
513        };
514
515        // Determine exit code and exception message
516        let (exit_code, exception_message) = match outcome {
517            CaptureOutcome::Returned(r) => match &r {
518                Ok(()) => (0, None),
519                Err(e) => (e.exit_code(), Some(e.to_string())),
520            },
521            CaptureOutcome::Panicked(panic) => {
522                if !self.catch_panics {
523                    restore_env(&saved_env);
524                    std::panic::resume_unwind(panic);
525                }
526                (1, Some(format!("panic: {}", panic_message(&*panic))))
527            }
528        };
529
530        let label = self.charset.trim().to_lowercase();
531        let encoding = Encoding::for_label(label.as_bytes())
532            .or_else(|| {
533                if label == "latin-1" || label == "latin1" {
534                    Encoding::for_label(b"iso-8859-1")
535                } else {
536                    None
537                }
538            })
539            .unwrap_or(encoding_rs::UTF_8);
540        let mut stdout = encoding.decode(&stdout_bytes).0.into_owned();
541        let stderr = encoding.decode(&stderr_bytes).0.into_owned();
542
543        // Echo stdin into stdout like Python Click's CliRunner when enabled.
544        if self.echo_stdin && !input_str.is_empty() {
545            stdout = format!("{}{}", input_str, stdout);
546        }
547
548        let output = if self.mix_stderr {
549            format!("{}{}", stdout, stderr)
550        } else {
551            stdout
552        };
553
554        // Restore environment
555        restore_env(&saved_env);
556
557        InvokeResult::new(exit_code, output, stderr, exception_message)
558    }
559
560    /// Invoke a command within an isolated filesystem.
561    ///
562    /// This creates a temporary directory and runs the command there.
563    ///
564    /// # Arguments
565    ///
566    /// * `cmd` - The command to invoke
567    /// * `args` - Command-line arguments
568    ///
569    /// # Example
570    ///
571    /// ```rust
572    /// use click::testing::CliRunner;
573    /// use click::command::Command;
574    ///
575    /// let cmd = Command::new("test").build();
576    /// let result = CliRunner::new().invoke_isolated(&cmd, &[]);
577    /// ```
578    pub fn invoke_isolated(&self, cmd: &dyn CommandLike, args: &[&str]) -> InvokeResult {
579        let _isolated = IsolatedFilesystem::new().expect("Failed to create isolated filesystem");
580        self.invoke(cmd, args)
581    }
582}
583
584// =============================================================================
585// InvokeResult
586// =============================================================================
587
588/// The result of invoking a command through [`CliRunner`].
589///
590/// Contains the exit code, captured output, and any exception that occurred.
591#[derive(Debug, Clone)]
592pub struct InvokeResult {
593    /// The exit code from the command (0 for success).
594    pub exit_code: i32,
595
596    /// Captured stdout (and stderr if `mix_stderr` is true).
597    pub output: String,
598
599    /// Captured stderr (always captured, even if mixed into `output`).
600    pub stderr: String,
601
602    /// The error message if an exception occurred.
603    pub exception_message: Option<String>,
604}
605
606impl InvokeResult {
607    /// Create a new InvokeResult for testing purposes.
608    #[doc(hidden)]
609    pub fn new(
610        exit_code: i32,
611        output: String,
612        stderr: String,
613        exception_message: Option<String>,
614    ) -> Self {
615        Self {
616            exit_code,
617            output,
618            stderr,
619            exception_message,
620        }
621    }
622
623    /// Check if the command succeeded (exit code 0).
624    pub fn is_success(&self) -> bool {
625        self.exit_code == 0
626    }
627
628    /// Check if the command failed (exit code != 0).
629    pub fn is_failure(&self) -> bool {
630        self.exit_code != 0
631    }
632
633    /// Get the output lines.
634    pub fn output_lines(&self) -> Vec<&str> {
635        self.output.lines().collect()
636    }
637
638    /// Check if the output contains a substring.
639    pub fn output_contains(&self, substring: &str) -> bool {
640        self.output.contains(substring)
641    }
642
643    /// Check if the stderr contains a substring.
644    pub fn stderr_contains(&self, substring: &str) -> bool {
645        self.stderr.contains(substring)
646    }
647
648    /// Get the combined output (stdout + stderr).
649    ///
650    /// If `mix_stderr` was true, this is the same as `output`.
651    pub fn combined_output(&self) -> String {
652        if self.stderr.is_empty() {
653            return self.output.clone();
654        }
655
656        // If output already includes stderr (mix_stderr mode), avoid duplication.
657        if self.output.ends_with(&self.stderr) {
658            return self.output.clone();
659        }
660
661        format!("{}{}", self.output, self.stderr)
662    }
663}
664
665// =============================================================================
666// IsolatedFilesystem
667// =============================================================================
668
669/// A temporary filesystem context for isolated testing.
670///
671/// Creates a temporary directory that is automatically cleaned up when dropped.
672/// The current working directory is changed to the temporary directory for the
673/// duration of the test.
674///
675/// # Example
676///
677/// ```rust
678/// use click::testing::IsolatedFilesystem;
679/// use std::fs;
680///
681/// {
682///     let isolated = IsolatedFilesystem::new().unwrap();
683///     let path = isolated.path();
684///
685///     // Create files in the isolated directory
686///     fs::write(path.join("test.txt"), "hello").unwrap();
687///
688///     assert!(path.join("test.txt").exists());
689/// }
690/// // Directory is automatically cleaned up here
691/// ```
692#[derive(Debug)]
693pub struct IsolatedFilesystem {
694    /// The temporary directory path.
695    path: PathBuf,
696    /// The original working directory to restore on drop.
697    original_cwd: PathBuf,
698}
699
700impl IsolatedFilesystem {
701    /// Generate a unique suffix for directory names to avoid test interference.
702    fn unique_suffix() -> u64 {
703        use std::sync::atomic::{AtomicU64, Ordering};
704        use std::time::{SystemTime, UNIX_EPOCH};
705        static COUNTER: AtomicU64 = AtomicU64::new(0);
706        let count = COUNTER.fetch_add(1, Ordering::SeqCst);
707        let timestamp = SystemTime::now()
708            .duration_since(UNIX_EPOCH)
709            .unwrap_or_default()
710            .as_nanos() as u64;
711        timestamp ^ count
712    }
713
714    /// Get current directory, falling back to temp dir if current dir is invalid.
715    ///
716    /// This can happen when running tests in parallel and another test
717    /// deletes the directory that was the current working directory.
718    fn get_current_dir_safe() -> io::Result<PathBuf> {
719        match env::current_dir() {
720            Ok(cwd) => Ok(cwd),
721            Err(_) => {
722                // Fall back to temp dir if current dir is invalid
723                let temp = env::temp_dir();
724                let _ = env::set_current_dir(&temp);
725                Ok(temp)
726            }
727        }
728    }
729
730    /// Create a new isolated filesystem.
731    ///
732    /// This creates a temporary directory and changes to it.
733    pub fn new() -> io::Result<Self> {
734        let original_cwd = Self::get_current_dir_safe()?;
735        let path = env::temp_dir().join(format!(
736            "click_test_{}_{}",
737            std::process::id(),
738            Self::unique_suffix()
739        ));
740
741        // Create the directory
742        fs::create_dir_all(&path)?;
743
744        // Change to it
745        env::set_current_dir(&path)?;
746
747        Ok(Self { path, original_cwd })
748    }
749
750    /// Create an isolated filesystem with a specific name.
751    pub fn with_name(name: &str) -> io::Result<Self> {
752        let original_cwd = Self::get_current_dir_safe()?;
753        let path = env::temp_dir().join(format!(
754            "click_test_{}_{}_{}",
755            name,
756            std::process::id(),
757            Self::unique_suffix()
758        ));
759
760        // Create the directory fresh
761        fs::create_dir_all(&path)?;
762        env::set_current_dir(&path)?;
763
764        Ok(Self { path, original_cwd })
765    }
766
767    /// Get the path to the temporary directory.
768    pub fn path(&self) -> &Path {
769        &self.path
770    }
771
772    /// Create a file in the isolated filesystem.
773    pub fn create_file(&self, name: &str, content: &str) -> io::Result<PathBuf> {
774        let file_path = self.path.join(name);
775        if let Some(parent) = file_path.parent() {
776            fs::create_dir_all(parent)?;
777        }
778        fs::write(&file_path, content)?;
779        Ok(file_path)
780    }
781
782    /// Create a directory in the isolated filesystem.
783    pub fn create_dir(&self, name: &str) -> io::Result<PathBuf> {
784        let dir_path = self.path.join(name);
785        fs::create_dir_all(&dir_path)?;
786        Ok(dir_path)
787    }
788
789    /// Read a file from the isolated filesystem.
790    pub fn read_file(&self, name: &str) -> io::Result<String> {
791        fs::read_to_string(self.path.join(name))
792    }
793
794    /// Check if a file exists in the isolated filesystem.
795    pub fn file_exists(&self, name: &str) -> bool {
796        self.path.join(name).exists()
797    }
798
799    /// List files in the isolated filesystem.
800    pub fn list_files(&self) -> io::Result<Vec<String>> {
801        let mut files = Vec::new();
802        for entry in fs::read_dir(&self.path)? {
803            let entry = entry?;
804            if let Some(name) = entry.file_name().to_str() {
805                files.push(name.to_string());
806            }
807        }
808        files.sort();
809        Ok(files)
810    }
811}
812
813impl Drop for IsolatedFilesystem {
814    fn drop(&mut self) {
815        // Restore original working directory
816        let _ = env::set_current_dir(&self.original_cwd);
817
818        // Clean up the temporary directory
819        let _ = fs::remove_dir_all(&self.path);
820    }
821}
822
823// =============================================================================
824// EchoingStdin (for simulated input)
825// =============================================================================
826
827/// A wrapper that echoes input to output as it's read.
828#[derive(Debug)]
829pub struct EchoingStdin<R: Read, W: Write> {
830    input: R,
831    output: W,
832}
833
834impl<R: Read, W: Write> EchoingStdin<R, W> {
835    /// Create a new echoing stdin wrapper.
836    pub fn new(input: R, output: W) -> Self {
837        Self { input, output }
838    }
839}
840
841impl<R: Read, W: Write> Read for EchoingStdin<R, W> {
842    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
843        let n = self.input.read(buf)?;
844        if n > 0 {
845            self.output.write_all(&buf[..n])?;
846        }
847        Ok(n)
848    }
849}
850
851// =============================================================================
852// Test Utilities
853// =============================================================================
854
855/// Create a mock context for testing.
856///
857/// This is useful when you need to test callback functions in isolation.
858pub fn make_test_context(info_name: &str) -> crate::context::Context {
859    crate::context::ContextBuilder::new()
860        .info_name(info_name)
861        .build()
862}
863
864/// Assert that a result indicates success.
865#[macro_export]
866macro_rules! assert_success {
867    ($result:expr) => {
868        assert!(
869            $result.is_success(),
870            "Expected success but got exit code {} with output:\n{}",
871            $result.exit_code,
872            $result.combined_output()
873        );
874    };
875}
876
877/// Assert that a result indicates failure.
878#[macro_export]
879macro_rules! assert_failure {
880    ($result:expr) => {
881        assert!(
882            $result.is_failure(),
883            "Expected failure but got success with output:\n{}",
884            $result.output
885        );
886    };
887    ($result:expr, $code:expr) => {
888        assert_eq!(
889            $result.exit_code,
890            $code,
891            "Expected exit code {} but got {} with output:\n{}",
892            $code,
893            $result.exit_code,
894            $result.combined_output()
895        );
896    };
897}
898
899/// Assert that the output contains a substring.
900#[macro_export]
901macro_rules! assert_output_contains {
902    ($result:expr, $substring:expr) => {
903        assert!(
904            $result.output_contains($substring),
905            "Expected output to contain '{}' but got:\n{}",
906            $substring,
907            $result.output
908        );
909    };
910}
911
912// =============================================================================
913// Tests
914// =============================================================================
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use crate::command::Command;
920
921    #[test]
922    fn test_cli_runner_new() {
923        let runner = CliRunner::new();
924        assert!(runner.env.is_empty());
925        assert!(runner.env_unset.is_empty());
926    }
927
928    #[test]
929    fn test_cli_runner_env() {
930        let runner = CliRunner::new()
931            .env("TEST_VAR", "test_value")
932            .env("ANOTHER", "value");
933
934        assert_eq!(runner.env.get("TEST_VAR"), Some(&"test_value".to_string()));
935        assert_eq!(runner.env.get("ANOTHER"), Some(&"value".to_string()));
936    }
937
938    #[test]
939    fn test_cli_runner_env_unset() {
940        let runner = CliRunner::new().env("KEEP", "value").env_unset("REMOVE");
941
942        assert_eq!(runner.env.len(), 1);
943        assert_eq!(runner.env_unset.len(), 1);
944    }
945
946    #[test]
947    fn test_cli_runner_env_clear() {
948        let runner = CliRunner::new()
949            .env("VAR1", "val1")
950            .env("VAR2", "val2")
951            .env_unset("VAR3")
952            .env_clear();
953
954        assert!(runner.env.is_empty());
955        assert!(runner.env_unset.is_empty());
956    }
957
958    #[test]
959    fn test_invoke_simple_command() {
960        let cmd = Command::new("test").callback(|_ctx| Ok(())).build();
961
962        let runner = CliRunner::new();
963        let result = runner.invoke(&cmd, &[]);
964
965        assert_eq!(result.exit_code, 0);
966        assert!(result.exception_message.is_none());
967    }
968
969    #[test]
970    fn test_invoke_failing_command() {
971        let cmd = Command::new("fail")
972            .callback(|_ctx| Err(crate::ClickError::usage("test error")))
973            .build();
974
975        let runner = CliRunner::new();
976        let result = runner.invoke(&cmd, &[]);
977
978        assert_eq!(result.exit_code, 2); // Usage errors have exit code 2
979        assert!(result.exception_message.is_some());
980    }
981
982    #[test]
983    fn test_invoke_result_is_success() {
984        let result = InvokeResult::new(0, String::new(), String::new(), None);
985
986        assert!(result.is_success());
987        assert!(!result.is_failure());
988    }
989
990    #[test]
991    fn test_invoke_result_is_failure() {
992        let result = InvokeResult::new(1, String::new(), String::new(), None);
993
994        assert!(!result.is_success());
995        assert!(result.is_failure());
996    }
997
998    #[test]
999    fn test_invoke_result_output_contains() {
1000        let result = InvokeResult::new(0, "Hello, World!".to_string(), String::new(), None);
1001
1002        assert!(result.output_contains("Hello"));
1003        assert!(result.output_contains("World"));
1004        assert!(!result.output_contains("Goodbye"));
1005    }
1006
1007    #[test]
1008    fn test_invoke_result_output_lines() {
1009        let result = InvokeResult::new(0, "line1\nline2\nline3".to_string(), String::new(), None);
1010
1011        let lines = result.output_lines();
1012        assert_eq!(lines.len(), 3);
1013        assert_eq!(lines[0], "line1");
1014        assert_eq!(lines[1], "line2");
1015        assert_eq!(lines[2], "line3");
1016    }
1017
1018    #[test]
1019    fn test_invoke_result_combined_output() {
1020        let result = InvokeResult::new(0, "stdout".to_string(), "stderr".to_string(), None);
1021
1022        assert_eq!(result.combined_output(), "stdoutstderr");
1023    }
1024
1025    #[test]
1026    fn test_isolated_filesystem() {
1027        let isolated = IsolatedFilesystem::new().unwrap();
1028        let iso_path = isolated.path().to_path_buf();
1029
1030        // The isolated directory exists
1031        assert!(iso_path.exists());
1032        assert!(iso_path.starts_with(env::temp_dir()));
1033
1034        // Create a file
1035        isolated.create_file("test.txt", "hello").unwrap();
1036        assert!(isolated.file_exists("test.txt"));
1037        assert_eq!(isolated.read_file("test.txt").unwrap(), "hello");
1038
1039        // Drop the filesystem explicitly
1040        drop(isolated);
1041
1042        // After drop, the directory is cleaned up
1043        // Note: We don't test current_dir restoration because it's racy with parallel tests
1044        assert!(
1045            !iso_path.exists(),
1046            "temp directory should be cleaned up after drop"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_isolated_filesystem_with_name() {
1052        let isolated = IsolatedFilesystem::with_name("custom").unwrap();
1053        let path = isolated.path();
1054
1055        assert!(path.to_string_lossy().contains("custom"));
1056    }
1057
1058    #[test]
1059    fn test_isolated_filesystem_create_dir() {
1060        let isolated = IsolatedFilesystem::new().unwrap();
1061
1062        let dir_path = isolated.create_dir("subdir").unwrap();
1063        assert!(dir_path.exists());
1064        assert!(dir_path.is_dir());
1065    }
1066
1067    #[test]
1068    fn test_isolated_filesystem_list_files() {
1069        let isolated = IsolatedFilesystem::new().unwrap();
1070
1071        isolated.create_file("a.txt", "").unwrap();
1072        isolated.create_file("b.txt", "").unwrap();
1073        isolated.create_file("c.txt", "").unwrap();
1074
1075        let files = isolated.list_files().unwrap();
1076        assert_eq!(files, vec!["a.txt", "b.txt", "c.txt"]);
1077    }
1078
1079    #[test]
1080    fn test_isolated_filesystem_nested_file() {
1081        let isolated = IsolatedFilesystem::new().unwrap();
1082
1083        isolated
1084            .create_file("dir/nested/file.txt", "content")
1085            .unwrap();
1086        assert!(isolated.file_exists("dir/nested/file.txt"));
1087        assert_eq!(
1088            isolated.read_file("dir/nested/file.txt").unwrap(),
1089            "content"
1090        );
1091    }
1092
1093    #[test]
1094    fn test_make_test_context() {
1095        let ctx = make_test_context("test");
1096        assert_eq!(ctx.info_name(), Some("test"));
1097    }
1098
1099    #[test]
1100    fn test_echoing_stdin() {
1101        let input = b"hello";
1102        let mut output = Vec::new();
1103
1104        {
1105            let mut echoing = EchoingStdin::new(&input[..], &mut output);
1106            let mut buf = [0u8; 10];
1107            let n = echoing.read(&mut buf).unwrap();
1108            assert_eq!(n, 5);
1109            assert_eq!(&buf[..n], b"hello");
1110        }
1111
1112        assert_eq!(&output, b"hello");
1113    }
1114
1115    #[test]
1116    fn test_cli_runner_mix_stderr() {
1117        let runner = CliRunner::new().mix_stderr(false);
1118        assert!(!runner.mix_stderr);
1119
1120        let runner = CliRunner::new().mix_stderr(true);
1121        assert!(runner.mix_stderr);
1122    }
1123
1124    #[test]
1125    fn test_cli_runner_echo_stdin() {
1126        let runner = CliRunner::new().echo_stdin(true);
1127        assert!(runner.echo_stdin);
1128    }
1129}