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}