Skip to main content

standout_test/
lib.rs

1//! In-process test harness for apps built on the `standout` CLI framework.
2//!
3//! `TestHarness` bundles the scattered injection seams — environment
4//! detectors, env vars, working directory, stdin, clipboard, output mode,
5//! and tempdir fixtures — into a single fluent builder, and restores every
6//! override when the harness is dropped.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use standout_test::TestHarness;
12//! # fn example(app: &standout::cli::App, cmd: clap::Command) {
13//! let result = TestHarness::new()
14//!     .env("HOME", "/tmp/fake")
15//!     .clipboard("pasted content")
16//!     .terminal_width(80)
17//!     .piped_stdin("extra input\n")
18//!     .no_color()
19//!     .fixture("notes/todo.txt", "- buy milk\n")
20//!     .run(app, cmd, ["myapp", "notes", "list"]);
21//!
22//! result.assert_success();
23//! result.assert_stdout_contains("buy milk");
24//! # }
25//! ```
26//!
27//! # Concurrency and restoration
28//!
29//! The harness mutates process-global state (env vars, cwd, environment
30//! detectors, default input readers). Tests that instantiate a
31//! `TestHarness` must be annotated `#[serial]` (from the re-exported
32//! `serial_test` crate).
33//!
34//! A `Drop` impl restores every override on both normal exit and panic
35//! unwind, with two nuances:
36//!
37//! - Env vars and cwd are restored to the values captured at `run()` time.
38//! - Terminal detectors and default input readers are reset to the
39//!   library defaults, not to whatever was installed before `run()`. This
40//!   matches the behavior of [`standout_render::DetectorGuard`]. Don't
41//!   mix a `TestHarness` with a manually installed detector override on
42//!   the same thread.
43
44use std::collections::HashMap;
45use std::ffi::OsString;
46use std::path::{Path, PathBuf};
47use std::sync::Arc;
48
49use clap::Command;
50use standout::cli::{App, RunResult};
51use standout_input::env::{MockClipboard, MockStdin};
52use standout_input::{
53    reset_default_clipboard_reader, reset_default_prompt_responder, reset_default_stdin_reader,
54    set_default_clipboard_reader, set_default_prompt_responder, set_default_stdin_reader,
55    PromptResponder,
56};
57use standout_render::{
58    reset_environment_detectors, set_color_capability_detector, set_terminal_width_detector,
59    set_tty_detector, OutputMode,
60};
61use tempfile::TempDir;
62
63pub use serial_test::serial;
64
65/// How stdin should appear to handlers during the run.
66#[derive(Debug, Clone)]
67enum StdinMode {
68    /// Leave the real-stdin default in place.
69    Inherit,
70    /// Simulate piped stdin with the given content.
71    Piped(String),
72    /// Simulate an interactive terminal (no piped input).
73    Interactive,
74}
75
76/// Fluent builder for in-process CLI tests.
77///
78/// See the [crate-level docs](crate) for the usage pattern. The harness
79/// installs every override in [`TestHarness::run`] and tears them down on
80/// [`Drop`], so a failed assertion never leaks state into the next test.
81#[must_use = "TestHarness is inert until you call run(...)"]
82pub struct TestHarness {
83    env_set: HashMap<String, String>,
84    env_remove: Vec<String>,
85    cwd: Option<PathBuf>,
86    tempdir: Option<TempDir>,
87    fixtures: Vec<(PathBuf, Vec<u8>)>,
88    terminal_width: Option<Option<usize>>,
89    is_tty: Option<bool>,
90    color_capable: Option<bool>,
91    output_mode: Option<OutputMode>,
92    output_flag_name: String,
93    stdin: StdinMode,
94    clipboard: Option<String>,
95    prompts: Option<Arc<dyn PromptResponder>>,
96}
97
98impl TestHarness {
99    /// Creates an empty harness with no overrides applied.
100    pub fn new() -> Self {
101        Self {
102            env_set: HashMap::new(),
103            env_remove: Vec::new(),
104            cwd: None,
105            tempdir: None,
106            fixtures: Vec::new(),
107            terminal_width: None,
108            is_tty: None,
109            color_capable: None,
110            output_mode: None,
111            output_flag_name: "output".to_string(),
112            stdin: StdinMode::Inherit,
113            clipboard: None,
114            prompts: None,
115        }
116    }
117
118    // --- environment variables ------------------------------------------------
119
120    /// Sets `key=value` as a real environment variable for the duration of
121    /// the run. Handlers that use `EnvSource::new` / `std::env::var` will
122    /// see it.
123    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
124        self.env_set.insert(key.into(), value.into());
125        self
126    }
127
128    /// Removes `key` from the real environment for the duration of the run.
129    pub fn env_remove(mut self, key: impl Into<String>) -> Self {
130        self.env_remove.push(key.into());
131        self
132    }
133
134    // --- terminal detectors ---------------------------------------------------
135
136    /// Forces the reported terminal width to `cols`.
137    pub fn terminal_width(mut self, cols: usize) -> Self {
138        self.terminal_width = Some(Some(cols));
139        self
140    }
141
142    /// Forces terminal-width detection to report "unknown" (as if stdout
143    /// is not a TTY).
144    pub fn no_terminal_width(mut self) -> Self {
145        self.terminal_width = Some(None);
146        self
147    }
148
149    /// Claims stdout is attached to a TTY.
150    pub fn is_tty(mut self) -> Self {
151        self.is_tty = Some(true);
152        self
153    }
154
155    /// Claims stdout is not a TTY (piped, redirected, …).
156    pub fn no_tty(mut self) -> Self {
157        self.is_tty = Some(false);
158        self
159    }
160
161    /// Declares that the output target supports ANSI color.
162    pub fn with_color(mut self) -> Self {
163        self.color_capable = Some(true);
164        self
165    }
166
167    /// Declares that the output target does not support ANSI color. When
168    /// `--output=auto` is used, this forces the `Text` render path.
169    pub fn no_color(mut self) -> Self {
170        self.color_capable = Some(false);
171        self
172    }
173
174    // --- explicit output-mode override ---------------------------------------
175
176    /// Forces a specific [`OutputMode`] regardless of the `--output` flag.
177    ///
178    /// Internally this injects `--<flag>=<mode>` as the last argument when
179    /// [`TestHarness::run`] is called. `<flag>` defaults to `output`;
180    /// override it with [`output_flag_name`](Self::output_flag_name) for
181    /// apps that renamed the flag via `AppBuilder::output_flag(...)`.
182    pub fn output_mode(mut self, mode: OutputMode) -> Self {
183        self.output_mode = Some(mode);
184        self
185    }
186
187    /// Configures the CLI flag name used to force [`output_mode`](Self::output_mode).
188    ///
189    /// Defaults to `"output"` (matching `AppBuilder`'s default). Change it
190    /// if the app under test was built with a renamed flag (e.g.
191    /// `AppBuilder::output_flag(Some("format"))`).
192    ///
193    /// No-op when [`output_mode`](Self::output_mode) isn't set.
194    pub fn output_flag_name(mut self, name: impl Into<String>) -> Self {
195        self.output_flag_name = name.into();
196        self
197    }
198
199    /// Shortcut for [`output_mode(OutputMode::Text)`](Self::output_mode).
200    pub fn text_output(self) -> Self {
201        self.output_mode(OutputMode::Text)
202    }
203
204    // --- stdin ----------------------------------------------------------------
205
206    /// Simulates piped stdin with `content`. Handlers using
207    /// `StdinSource::new()` will see `is_terminal() == false` and read
208    /// `content`.
209    pub fn piped_stdin(mut self, content: impl Into<String>) -> Self {
210        self.stdin = StdinMode::Piped(content.into());
211        self
212    }
213
214    /// Simulates an interactive terminal for stdin (no piped content).
215    pub fn interactive_stdin(mut self) -> Self {
216        self.stdin = StdinMode::Interactive;
217        self
218    }
219
220    // --- clipboard ------------------------------------------------------------
221
222    /// Installs `content` as the mock clipboard. Handlers using
223    /// `ClipboardSource::new()` will read it.
224    pub fn clipboard(mut self, content: impl Into<String>) -> Self {
225        self.clipboard = Some(content.into());
226        self
227    }
228
229    // --- interactive prompts --------------------------------------------------
230
231    /// Installs a [`PromptResponder`](standout_input::PromptResponder) that
232    /// every `.prompt()` call on a [`standout_input`] interactive source
233    /// will route through during the run.
234    ///
235    /// Use this to test wizard / setup / REPL flows that call
236    /// `InquireText::new(...).prompt()`, `InquireSelect::new(...).prompt()`,
237    /// etc., without launching real prompts. The
238    /// [`ScriptedResponder`](standout_input::ScriptedResponder) bundled with
239    /// `standout-input` covers the common case:
240    ///
241    /// ```ignore
242    /// use standout_input::{PromptResponse, ScriptedResponder};
243    /// use std::sync::Arc;
244    ///
245    /// let result = TestHarness::new()
246    ///     .prompts(Arc::new(ScriptedResponder::new([
247    ///         PromptResponse::text("buy milk"),       // first text prompt
248    ///         PromptResponse::Bool(true),             // first confirm
249    ///         PromptResponse::Choice(2),              // first select -> options[2]
250    ///     ])))
251    ///     .run(&app, cmd, ["mycli", "setup"]);
252    /// ```
253    ///
254    /// The responder is installed via
255    /// [`set_default_prompt_responder`](standout_input::set_default_prompt_responder)
256    /// for the duration of the run and reset on drop, matching the
257    /// stdin / clipboard pattern.
258    pub fn prompts(mut self, responder: Arc<dyn PromptResponder>) -> Self {
259        self.prompts = Some(responder);
260        self
261    }
262
263    // --- filesystem -----------------------------------------------------------
264
265    /// Sets the working directory for the run to `path`.
266    ///
267    /// If not set and any [`fixture`](Self::fixture) is declared, the
268    /// harness uses the fixture tempdir as the cwd.
269    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
270        self.cwd = Some(path.into());
271        self
272    }
273
274    /// Declares a file that should exist at `path` (relative to the
275    /// fixture tempdir) with the given text `content`.
276    ///
277    /// The first call to `fixture` creates a fresh `tempfile::TempDir`
278    /// which becomes the default cwd. Access it via [`tempdir`](Self::tempdir).
279    ///
280    /// # Panics
281    ///
282    /// Panics if `path` is absolute or contains a `..` component — both
283    /// would let the fixture escape the harness-owned tempdir and
284    /// potentially clobber files in the user's real filesystem.
285    pub fn fixture(mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Self {
286        let path = validate_fixture_path(path.as_ref());
287        self.fixtures.push((path, content.into().into_bytes()));
288        self.ensure_tempdir();
289        self
290    }
291
292    /// Declares a binary fixture file. Same as [`fixture`](Self::fixture)
293    /// but takes raw bytes. Applies the same path validation.
294    pub fn fixture_bytes(mut self, path: impl AsRef<Path>, content: impl Into<Vec<u8>>) -> Self {
295        let path = validate_fixture_path(path.as_ref());
296        self.fixtures.push((path, content.into()));
297        self.ensure_tempdir();
298        self
299    }
300
301    /// Returns the fixture tempdir path if one has been allocated.
302    ///
303    /// Useful for constructing absolute paths to pass as handler arguments.
304    pub fn tempdir(&self) -> Option<&Path> {
305        self.tempdir.as_ref().map(|t| t.path())
306    }
307
308    fn ensure_tempdir(&mut self) {
309        if self.tempdir.is_none() {
310            self.tempdir =
311                Some(TempDir::new().expect("TestHarness: failed to create tempdir for fixtures"));
312        }
313    }
314
315    // --- execution ------------------------------------------------------------
316
317    /// Installs every override, runs `app` with the given `cmd` definition
318    /// and argv, and returns a [`TestResult`].
319    ///
320    /// Overrides are torn down when the returned guard held inside the
321    /// `TestResult` is dropped. The `TestResult` and the harness share the
322    /// same lifetime, so a typical test binds the result and lets it fall
323    /// out of scope at the end.
324    pub fn run<I, T>(mut self, app: &App, cmd: Command, args: I) -> TestResult
325    where
326        I: IntoIterator<Item = T>,
327        T: Into<OsString> + Clone,
328    {
329        // 1. Materialize fixtures + cwd.
330        let mut restore = RestoreState::default();
331
332        if let Some(dir) = self.tempdir.as_ref() {
333            for (rel, content) in &self.fixtures {
334                let abs = dir.path().join(rel);
335                if let Some(parent) = abs.parent() {
336                    std::fs::create_dir_all(parent)
337                        .expect("TestHarness: failed to create fixture parent dir");
338                }
339                std::fs::write(&abs, content).expect("TestHarness: failed to write fixture file");
340            }
341        }
342
343        let cwd_target = self
344            .cwd
345            .clone()
346            .or_else(|| self.tempdir.as_ref().map(|d| d.path().to_path_buf()));
347        if let Some(target) = cwd_target {
348            restore.original_cwd = std::env::current_dir().ok();
349            std::env::set_current_dir(&target)
350                .expect("TestHarness: failed to change working directory");
351        }
352
353        // 2. Env vars. Save originals so we can restore even on panic.
354        // Record each original only once per key (before any mutation), so
355        // if the same key appears in both env_set and env_remove, or is
356        // listed multiple times, restore brings back the true original.
357        // Precedence: set is applied first, then remove — so removal wins
358        // when both are requested for the same key.
359        for (k, v) in &self.env_set {
360            restore
361                .env_originals
362                .entry(k.clone())
363                .or_insert_with(|| std::env::var(k).ok());
364            std::env::set_var(k, v);
365        }
366        for k in &self.env_remove {
367            restore
368                .env_originals
369                .entry(k.clone())
370                .or_insert_with(|| std::env::var(k).ok());
371            std::env::remove_var(k);
372        }
373
374        // 3. Environment detectors.
375        if let Some(w) = self.terminal_width {
376            static WIDTH_SLOT: std::sync::OnceLock<std::sync::Mutex<Option<usize>>> =
377                std::sync::OnceLock::new();
378            let slot = WIDTH_SLOT.get_or_init(|| std::sync::Mutex::new(None));
379            *slot.lock().unwrap() = w;
380            set_terminal_width_detector(|| {
381                *WIDTH_SLOT
382                    .get()
383                    .expect("width slot initialized above")
384                    .lock()
385                    .unwrap()
386            });
387            restore.reset_env_detectors = true;
388        }
389        if let Some(flag) = self.is_tty {
390            static TTY_SLOT: std::sync::OnceLock<std::sync::Mutex<bool>> =
391                std::sync::OnceLock::new();
392            let slot = TTY_SLOT.get_or_init(|| std::sync::Mutex::new(false));
393            *slot.lock().unwrap() = flag;
394            set_tty_detector(|| {
395                *TTY_SLOT
396                    .get()
397                    .expect("tty slot initialized above")
398                    .lock()
399                    .unwrap()
400            });
401            restore.reset_env_detectors = true;
402        }
403        if let Some(flag) = self.color_capable {
404            static COLOR_SLOT: std::sync::OnceLock<std::sync::Mutex<bool>> =
405                std::sync::OnceLock::new();
406            let slot = COLOR_SLOT.get_or_init(|| std::sync::Mutex::new(false));
407            *slot.lock().unwrap() = flag;
408            set_color_capability_detector(|| {
409                *COLOR_SLOT
410                    .get()
411                    .expect("color slot initialized above")
412                    .lock()
413                    .unwrap()
414            });
415            restore.reset_env_detectors = true;
416        }
417
418        // 4. Stdin / clipboard overrides.
419        match std::mem::replace(&mut self.stdin, StdinMode::Inherit) {
420            StdinMode::Inherit => {}
421            StdinMode::Piped(content) => {
422                set_default_stdin_reader(Arc::new(MockStdin::piped(content)));
423                restore.reset_stdin = true;
424            }
425            StdinMode::Interactive => {
426                set_default_stdin_reader(Arc::new(MockStdin::terminal()));
427                restore.reset_stdin = true;
428            }
429        }
430        if let Some(content) = self.clipboard.take() {
431            set_default_clipboard_reader(Arc::new(MockClipboard::with_content(content)));
432            restore.reset_clipboard = true;
433        }
434        if let Some(responder) = self.prompts.take() {
435            set_default_prompt_responder(responder);
436            restore.reset_prompts = true;
437        }
438
439        // 5. Argv: append --<flag>=<mode> if an output mode was forced.
440        let mut argv: Vec<OsString> = args.into_iter().map(|a| a.into()).collect();
441        if let Some(mode) = self.output_mode {
442            argv.push(format!("--{}={}", self.output_flag_name, output_mode_flag(mode)).into());
443        }
444
445        let outcome = app.run_to_string(cmd, argv);
446
447        // `self` (and its tempdir) move into TestResult so the fixture dir
448        // survives until the test is finished with the result.
449        TestResult {
450            outcome,
451            _tempdir: self.tempdir.take(),
452            _restore: restore,
453        }
454    }
455}
456
457impl Default for TestHarness {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463fn validate_fixture_path(path: &Path) -> PathBuf {
464    use std::path::Component;
465    if path.is_absolute() {
466        panic!(
467            "TestHarness::fixture: path {:?} is absolute; only relative paths are allowed so \
468             the fixture is confined to the harness tempdir",
469            path
470        );
471    }
472    for component in path.components() {
473        match component {
474            Component::ParentDir => panic!(
475                "TestHarness::fixture: path {:?} contains a `..` component; only relative \
476                 paths that stay inside the tempdir are allowed",
477                path
478            ),
479            Component::Prefix(_) | Component::RootDir => panic!(
480                "TestHarness::fixture: path {:?} has a root or prefix component; only \
481                 relative paths inside the tempdir are allowed",
482                path
483            ),
484            _ => {}
485        }
486    }
487    path.to_path_buf()
488}
489
490fn output_mode_flag(mode: OutputMode) -> &'static str {
491    match mode {
492        OutputMode::Auto => "auto",
493        OutputMode::Term => "term",
494        OutputMode::Text => "text",
495        OutputMode::TermDebug => "term-debug",
496        OutputMode::Json => "json",
497        OutputMode::Yaml => "yaml",
498        OutputMode::Xml => "xml",
499        OutputMode::Csv => "csv",
500    }
501}
502
503/// Restores process-global state when dropped.
504///
505/// The harness hands ownership of this to the [`TestResult`] so restoration
506/// runs after the test has finished consuming the result (and on panic).
507#[derive(Default)]
508struct RestoreState {
509    env_originals: HashMap<String, Option<String>>,
510    original_cwd: Option<PathBuf>,
511    reset_env_detectors: bool,
512    reset_stdin: bool,
513    reset_clipboard: bool,
514    reset_prompts: bool,
515}
516
517impl Drop for RestoreState {
518    fn drop(&mut self) {
519        for (k, original) in self.env_originals.drain() {
520            match original {
521                Some(v) => std::env::set_var(&k, v),
522                None => std::env::remove_var(&k),
523            }
524        }
525        if let Some(cwd) = self.original_cwd.take() {
526            let _ = std::env::set_current_dir(cwd);
527        }
528        if self.reset_env_detectors {
529            reset_environment_detectors();
530        }
531        if self.reset_stdin {
532            reset_default_stdin_reader();
533        }
534        if self.reset_clipboard {
535            reset_default_clipboard_reader();
536        }
537        if self.reset_prompts {
538            reset_default_prompt_responder();
539        }
540    }
541}
542
543/// Outcome of a [`TestHarness::run`] invocation.
544///
545/// Holds the raw [`RunResult`] produced by the app, plus convenience
546/// accessors and assertion helpers oriented at text output.
547pub struct TestResult {
548    outcome: RunResult,
549    // Kept alive so fixture files remain readable while the test inspects
550    // the result; dropped after restore state is torn down.
551    _tempdir: Option<TempDir>,
552    _restore: RestoreState,
553}
554
555impl TestResult {
556    /// Returns the raw [`RunResult`] for cases where the structured
557    /// accessors aren't enough.
558    pub fn outcome(&self) -> &RunResult {
559        &self.outcome
560    }
561
562    /// Returns the rendered text output, or `""` for `Silent` / `Binary` /
563    /// `NoMatch`.
564    pub fn stdout(&self) -> &str {
565        match &self.outcome {
566            RunResult::Handled(s) => s.as_str(),
567            _ => "",
568        }
569    }
570
571    /// Returns `true` if the run produced text output.
572    pub fn is_handled(&self) -> bool {
573        matches!(self.outcome, RunResult::Handled(_))
574    }
575
576    /// Returns `true` if no handler matched the argv.
577    pub fn is_no_match(&self) -> bool {
578        matches!(self.outcome, RunResult::NoMatch(_))
579    }
580
581    /// If the run produced binary output, returns the bytes and suggested
582    /// filename.
583    pub fn binary(&self) -> Option<(&[u8], &str)> {
584        match &self.outcome {
585            RunResult::Binary(bytes, filename) => Some((bytes.as_slice(), filename.as_str())),
586            _ => None,
587        }
588    }
589
590    // --- assertions ----------------------------------------------------------
591
592    /// Panics unless the run ended in a successful dispatch
593    /// (`RunResult::Handled`, `RunResult::Silent`, or `RunResult::Binary`).
594    /// `RunResult::NoMatch` triggers a panic.
595    #[track_caller]
596    pub fn assert_success(&self) {
597        match &self.outcome {
598            RunResult::Handled(_) | RunResult::Silent | RunResult::Binary(_, _) => {}
599            RunResult::NoMatch(_) => {
600                panic!("expected successful dispatch but no handler matched; stdout was empty")
601            }
602        }
603    }
604
605    /// Panics unless the run ended in `RunResult::NoMatch`.
606    #[track_caller]
607    pub fn assert_no_match(&self) {
608        if !self.is_no_match() {
609            panic!(
610                "expected no handler match, got: {:?}",
611                describe_outcome(&self.outcome)
612            );
613        }
614    }
615
616    /// Panics unless [`stdout`](Self::stdout) contains `needle`.
617    #[track_caller]
618    pub fn assert_stdout_contains(&self, needle: &str) {
619        let out = self.stdout();
620        if !out.contains(needle) {
621            panic!(
622                "stdout did not contain {:?}\n--- stdout ---\n{}\n--------------",
623                needle, out
624            );
625        }
626    }
627
628    /// Panics unless [`stdout`](Self::stdout) equals `expected` exactly.
629    #[track_caller]
630    pub fn assert_stdout_eq(&self, expected: &str) {
631        let out = self.stdout();
632        if out != expected {
633            panic!(
634                "stdout mismatch\n--- expected ---\n{}\n--- actual -----\n{}\n----------------",
635                expected, out
636            );
637        }
638    }
639}
640
641fn describe_outcome(o: &RunResult) -> String {
642    match o {
643        RunResult::Handled(s) => format!("Handled({:?})", s),
644        RunResult::Silent => "Silent".into(),
645        RunResult::Binary(b, f) => format!("Binary(len={}, {:?})", b.len(), f),
646        RunResult::NoMatch(_) => "NoMatch".into(),
647    }
648}