standout_input/responder.rs
1//! Test injection for interactive prompts.
2//!
3//! Wizard / setup-helper / REPL flows that build on the `.prompt()` shortcut
4//! on every interactive source ([`InquireText`](crate::InquireText),
5//! [`InquireSelect`](crate::InquireSelect), [`TextPromptSource`](crate::TextPromptSource),
6//! and friends) are otherwise untestable in process — the inquire backends
7//! reach for raw stdin and the simple-prompts and editor sources need a TTY.
8//!
9//! [`PromptResponder`] is the test seam: every `.prompt()` call consults a
10//! process-global responder first, and falls through to the real backend
11//! only when none is installed. Tests install a [`ScriptedResponder`] with
12//! a queue of typed [`PromptResponse`] values; the production wizard code
13//! is unchanged.
14//!
15//! # Why responses are typed by *kind*, not by message text
16//!
17//! For finite-choice prompts ([`Select`](PromptKind::Select),
18//! [`MultiSelect`](PromptKind::MultiSelect), [`Confirm`](PromptKind::Confirm))
19//! the response is the *position* (or boolean) — never the option's display
20//! label. Renaming "Production" to "Live" doesn't break a test that picked
21//! `Choice(2)`. Same for confirm: a test asserts on `true`/`false`, not on
22//! the prompt copy.
23//!
24//! Open prompts ([`Text`](PromptKind::Text), [`Password`](PromptKind::Password),
25//! [`Editor`](PromptKind::Editor)) take a `String`, since the value *is* the
26//! free-form answer.
27//!
28//! See the "Testing Wizards" section in the
29//! [Interactive Flows topic](../../docs/topics/interactive-flows.md) for a
30//! full example.
31
32use std::sync::Arc;
33
34use once_cell::sync::Lazy;
35use std::sync::Mutex;
36
37/// The kind of prompt being responded to.
38///
39/// The interactive source passes its kind to the responder; the responder
40/// returns a [`PromptResponse`]. A scripted responder uses the kind to
41/// validate that the next queued response matches what the source actually
42/// asked for, panicking with a descriptive message on mismatch (a wizard-
43/// reorder bug surfaces at the test, not as a silent wrong-data assert
44/// downstream).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PromptKind {
47 /// Free-form text input ([`InquireText`](crate::InquireText),
48 /// [`TextPromptSource`](crate::TextPromptSource)).
49 Text,
50 /// Masked password input ([`InquirePassword`](crate::InquirePassword)).
51 Password,
52 /// Editor-based multi-line input ([`EditorSource`](crate::EditorSource),
53 /// [`InquireEditor`](crate::InquireEditor)).
54 Editor,
55 /// Yes/no ([`InquireConfirm`](crate::InquireConfirm),
56 /// [`ConfirmPromptSource`](crate::ConfirmPromptSource)).
57 Confirm,
58 /// Single selection from a list ([`InquireSelect`](crate::InquireSelect)).
59 Select,
60 /// Multi-selection from a list ([`InquireMultiSelect`](crate::InquireMultiSelect)).
61 MultiSelect,
62}
63
64impl std::fmt::Display for PromptKind {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::Text => write!(f, "text"),
68 Self::Password => write!(f, "password"),
69 Self::Editor => write!(f, "editor"),
70 Self::Confirm => write!(f, "confirm"),
71 Self::Select => write!(f, "select"),
72 Self::MultiSelect => write!(f, "multi-select"),
73 }
74 }
75}
76
77/// Context the source passes to a [`PromptResponder`].
78///
79/// Includes everything a smart responder might want: the prompt kind, the
80/// human-facing message (for diagnostic / advanced matching), and — for
81/// finite-choice prompts — the size of the option list so a `Choice(i)`
82/// response can be range-checked.
83#[derive(Debug, Clone, Copy)]
84pub struct PromptContext<'a> {
85 /// What kind of prompt is asking.
86 pub kind: PromptKind,
87 /// The human-facing prompt message (e.g. `"Pack name:"`).
88 ///
89 /// Mostly useful for diagnostics in panic messages and for advanced
90 /// responders that want to match on text. Position-based scripted
91 /// responders don't need to consult it.
92 pub message: &'a str,
93 /// Size of the option list, for `Select` / `MultiSelect`. `None` for
94 /// open prompts and confirm.
95 pub options: Option<usize>,
96}
97
98/// A response a [`PromptResponder`] can return.
99#[derive(Debug, Clone)]
100pub enum PromptResponse {
101 /// Free-form text answer for [`Text`](PromptKind::Text),
102 /// [`Password`](PromptKind::Password), and
103 /// [`Editor`](PromptKind::Editor) prompts.
104 Text(String),
105 /// Boolean answer for [`Confirm`](PromptKind::Confirm) prompts.
106 Bool(bool),
107 /// Index of the chosen option for [`Select`](PromptKind::Select) prompts.
108 /// Must be `< options` or the source will panic.
109 Choice(usize),
110 /// Indices of the chosen options for [`MultiSelect`](PromptKind::MultiSelect).
111 /// Each must be `< options`.
112 Choices(Vec<usize>),
113 /// Surface this prompt as user cancellation
114 /// ([`InputError::PromptCancelled`](crate::InputError::PromptCancelled)).
115 Cancel,
116 /// Surface this prompt as "no input"
117 /// ([`InputError::NoInput`](crate::InputError::NoInput)) — the same path
118 /// the source takes when stdin is not a TTY.
119 Skip,
120}
121
122impl PromptResponse {
123 /// Convenience constructor for a text response.
124 pub fn text(s: impl Into<String>) -> Self {
125 Self::Text(s.into())
126 }
127
128 /// Convenience constructor for a multi-select response.
129 pub fn choices(indices: impl IntoIterator<Item = usize>) -> Self {
130 Self::Choices(indices.into_iter().collect())
131 }
132
133 /// Returns the kind this response is *valid* for, if any. `Cancel` and
134 /// `Skip` are always valid, so they return `None`.
135 pub(crate) fn expected_kind(&self) -> Option<&'static [PromptKind]> {
136 match self {
137 Self::Text(_) => Some(&[PromptKind::Text, PromptKind::Password, PromptKind::Editor]),
138 Self::Bool(_) => Some(&[PromptKind::Confirm]),
139 Self::Choice(_) => Some(&[PromptKind::Select]),
140 Self::Choices(_) => Some(&[PromptKind::MultiSelect]),
141 Self::Cancel | Self::Skip => None,
142 }
143 }
144}
145
146/// Test seam for the `.prompt()` shortcut on interactive sources.
147///
148/// When a responder is installed via [`set_default_prompt_responder`],
149/// every `prompt()` call routes through it instead of opening a real prompt.
150/// Implement this trait for custom dispatch logic, or use the bundled
151/// [`ScriptedResponder`].
152pub trait PromptResponder: Send + Sync {
153 /// Produce a response for the given prompt.
154 fn respond(&self, ctx: PromptContext<'_>) -> PromptResponse;
155}
156
157/// A position-based scripted responder.
158///
159/// Built from a queue of [`PromptResponse`] values. Each call to
160/// [`respond`](PromptResponder::respond) pops the next response and
161/// validates that its kind is compatible with the prompt the source
162/// actually asked for; if not, it panics with a message that names the
163/// position, the prompt kind, and the response kind.
164///
165/// This makes wizard-reorder bugs surface as test failures at the offending
166/// step rather than as silent wrong-data assertions later.
167///
168/// ```
169/// use standout_input::{ScriptedResponder, PromptResponse};
170///
171/// let responder = ScriptedResponder::new([
172/// PromptResponse::text("buy milk"),
173/// PromptResponse::Bool(true),
174/// PromptResponse::Choice(2),
175/// ]);
176/// ```
177pub struct ScriptedResponder {
178 queue: Mutex<std::collections::VecDeque<PromptResponse>>,
179}
180
181impl ScriptedResponder {
182 /// Create a scripted responder from a sequence of responses.
183 pub fn new(responses: impl IntoIterator<Item = PromptResponse>) -> Self {
184 Self {
185 queue: Mutex::new(responses.into_iter().collect()),
186 }
187 }
188
189 /// Number of responses still queued.
190 pub fn remaining(&self) -> usize {
191 self.queue.lock().unwrap().len()
192 }
193}
194
195impl PromptResponder for ScriptedResponder {
196 fn respond(&self, ctx: PromptContext<'_>) -> PromptResponse {
197 let response = self.queue.lock().unwrap().pop_front().unwrap_or_else(|| {
198 panic!(
199 "ScriptedResponder ran out of responses; \
200 next prompt was a `{}` prompt with message {:?}",
201 ctx.kind, ctx.message
202 )
203 });
204
205 if let Some(allowed) = response.expected_kind() {
206 if !allowed.contains(&ctx.kind) {
207 panic!(
208 "ScriptedResponder kind mismatch: expected response for `{}` prompt \
209 ({:?}), but got {:?}",
210 ctx.kind, ctx.message, response
211 );
212 }
213 }
214
215 // Range-check by reference so we don't move the response we're
216 // about to return.
217 if let PromptResponse::Choice(i) = &response {
218 let n = ctx.options.unwrap_or(0);
219 assert!(
220 *i < n,
221 "ScriptedResponder: Choice({i}) is out of range for select prompt \
222 with {n} option(s) ({:?})",
223 ctx.message
224 );
225 }
226 if let PromptResponse::Choices(indices) = &response {
227 let n = ctx.options.unwrap_or(0);
228 for &i in indices {
229 assert!(
230 i < n,
231 "ScriptedResponder: Choices contains {i}, out of range for \
232 multi-select prompt with {n} option(s) ({:?})",
233 ctx.message
234 );
235 }
236 }
237
238 response
239 }
240}
241
242impl std::fmt::Debug for ScriptedResponder {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("ScriptedResponder")
245 .field("remaining", &self.remaining())
246 .finish()
247 }
248}
249
250// ============================================================================
251// Process-global responder override
252// ============================================================================
253
254type SharedResponder = Arc<dyn PromptResponder>;
255
256static RESPONDER_OVERRIDE: Lazy<Mutex<Option<SharedResponder>>> = Lazy::new(|| Mutex::new(None));
257
258/// Installs a process-global [`PromptResponder`] that every `.prompt()` call
259/// on an interactive source will route through until
260/// [`reset_default_prompt_responder`] is called.
261///
262/// Intended for test harnesses; the `standout-test` crate's
263/// `TestHarness::prompts(...)` wires this automatically. Tests using it must
264/// run serially (e.g. via `#[serial]`) because the override is process-global.
265pub fn set_default_prompt_responder(responder: SharedResponder) {
266 *RESPONDER_OVERRIDE.lock().unwrap() = Some(responder);
267}
268
269/// Clears the override installed by [`set_default_prompt_responder`].
270pub fn reset_default_prompt_responder() {
271 *RESPONDER_OVERRIDE.lock().unwrap() = None;
272}
273
274/// Returns a clone of the currently installed responder, if any.
275///
276/// Used by source `.prompt()` implementations to decide whether to short-
277/// circuit through the responder or fall through to the real backend.
278#[cfg(any(feature = "editor", feature = "simple-prompts", feature = "inquire"))]
279pub(crate) fn current_prompt_responder() -> Option<SharedResponder> {
280 RESPONDER_OVERRIDE.lock().unwrap().clone()
281}
282
283/// Helper used by source `.prompt()` shortcuts that return a free-form
284/// `String` (text / password / editor prompts).
285///
286/// If a responder is installed, dispatches and maps `Text(s) -> Ok(s)`,
287/// `Cancel -> PromptCancelled`, `Skip -> NoInput`. Returns `Ok(None)` (i.e.
288/// "fall through to the real backend") when no responder is installed, so
289/// the caller can use the original `is_available` + `collect` path.
290///
291/// `Bool` / `Choice` / `Choices` responses against an open prompt panic
292/// via `ScriptedResponder`'s validation in production tests.
293#[cfg(any(feature = "editor", feature = "simple-prompts", feature = "inquire"))]
294pub(crate) fn intercept_text(
295 kind: PromptKind,
296 message: &str,
297) -> Result<Option<String>, crate::InputError> {
298 let Some(responder) = current_prompt_responder() else {
299 return Ok(None);
300 };
301 let response = responder.respond(PromptContext {
302 kind,
303 message,
304 options: None,
305 });
306 match response {
307 PromptResponse::Text(s) => Ok(Some(s)),
308 PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
309 PromptResponse::Skip => Err(crate::InputError::NoInput),
310 other => panic!(
311 "PromptResponder returned {other:?} for a `{kind}` prompt; \
312 expected Text / Cancel / Skip"
313 ),
314 }
315}
316
317/// Helper for `.prompt()` shortcuts that return a `bool`
318/// ([`InquireConfirm`](crate::InquireConfirm),
319/// [`ConfirmPromptSource`](crate::ConfirmPromptSource)).
320#[cfg(any(feature = "simple-prompts", feature = "inquire"))]
321pub(crate) fn intercept_bool(
322 kind: PromptKind,
323 message: &str,
324) -> Result<Option<bool>, crate::InputError> {
325 let Some(responder) = current_prompt_responder() else {
326 return Ok(None);
327 };
328 let response = responder.respond(PromptContext {
329 kind,
330 message,
331 options: None,
332 });
333 match response {
334 PromptResponse::Bool(b) => Ok(Some(b)),
335 PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
336 PromptResponse::Skip => Err(crate::InputError::NoInput),
337 other => panic!(
338 "PromptResponder returned {other:?} for a `{kind}` prompt; \
339 expected Bool / Cancel / Skip"
340 ),
341 }
342}
343
344/// Helper for [`InquireSelect`](crate::InquireSelect)::prompt(). Returns
345/// the selected *index* into the source's options vector; the caller
346/// performs the `options[i].clone()` so the typed `T` flows out.
347#[cfg(feature = "inquire")]
348pub(crate) fn intercept_choice(
349 message: &str,
350 n: usize,
351) -> Result<Option<usize>, crate::InputError> {
352 let Some(responder) = current_prompt_responder() else {
353 return Ok(None);
354 };
355 let response = responder.respond(PromptContext {
356 kind: PromptKind::Select,
357 message,
358 options: Some(n),
359 });
360 match response {
361 PromptResponse::Choice(i) => {
362 assert!(
363 i < n,
364 "PromptResponder returned Choice({i}) for select prompt with {n} option(s)"
365 );
366 Ok(Some(i))
367 }
368 PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
369 PromptResponse::Skip => Err(crate::InputError::NoInput),
370 other => panic!(
371 "PromptResponder returned {other:?} for a `select` prompt; \
372 expected Choice / Cancel / Skip"
373 ),
374 }
375}
376
377/// Helper for [`InquireMultiSelect`](crate::InquireMultiSelect)::prompt().
378/// Returns the selected indices.
379#[cfg(feature = "inquire")]
380pub(crate) fn intercept_choices(
381 message: &str,
382 n: usize,
383) -> Result<Option<Vec<usize>>, crate::InputError> {
384 let Some(responder) = current_prompt_responder() else {
385 return Ok(None);
386 };
387 let response = responder.respond(PromptContext {
388 kind: PromptKind::MultiSelect,
389 message,
390 options: Some(n),
391 });
392 match response {
393 PromptResponse::Choices(indices) => {
394 for &i in &indices {
395 assert!(
396 i < n,
397 "PromptResponder returned Choices containing {i} for multi-select \
398 prompt with {n} option(s)"
399 );
400 }
401 Ok(Some(indices))
402 }
403 PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
404 PromptResponse::Skip => Err(crate::InputError::NoInput),
405 other => panic!(
406 "PromptResponder returned {other:?} for a `multi-select` prompt; \
407 expected Choices / Cancel / Skip"
408 ),
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use serial_test::serial;
416
417 fn ctx(kind: PromptKind, options: Option<usize>) -> PromptContext<'static> {
418 PromptContext {
419 kind,
420 message: "test prompt",
421 options,
422 }
423 }
424
425 #[test]
426 fn scripted_responder_returns_in_order() {
427 let r = ScriptedResponder::new([
428 PromptResponse::text("first"),
429 PromptResponse::Bool(true),
430 PromptResponse::Choice(1),
431 ]);
432 assert!(
433 matches!(r.respond(ctx(PromptKind::Text, None)), PromptResponse::Text(s) if s == "first")
434 );
435 assert!(matches!(
436 r.respond(ctx(PromptKind::Confirm, None)),
437 PromptResponse::Bool(true)
438 ));
439 assert!(matches!(
440 r.respond(ctx(PromptKind::Select, Some(3))),
441 PromptResponse::Choice(1)
442 ));
443 assert_eq!(r.remaining(), 0);
444 }
445
446 #[test]
447 fn cancel_and_skip_are_kind_agnostic() {
448 let r = ScriptedResponder::new([PromptResponse::Cancel, PromptResponse::Skip]);
449 // Cancel is fine for any kind
450 assert!(matches!(
451 r.respond(ctx(PromptKind::Select, Some(2))),
452 PromptResponse::Cancel
453 ));
454 // Skip too
455 assert!(matches!(
456 r.respond(ctx(PromptKind::Confirm, None)),
457 PromptResponse::Skip
458 ));
459 }
460
461 #[test]
462 fn text_response_works_for_all_open_kinds() {
463 let r = ScriptedResponder::new([
464 PromptResponse::text("a"),
465 PromptResponse::text("b"),
466 PromptResponse::text("c"),
467 ]);
468 assert!(matches!(
469 r.respond(ctx(PromptKind::Text, None)),
470 PromptResponse::Text(_)
471 ));
472 assert!(matches!(
473 r.respond(ctx(PromptKind::Password, None)),
474 PromptResponse::Text(_)
475 ));
476 assert!(matches!(
477 r.respond(ctx(PromptKind::Editor, None)),
478 PromptResponse::Text(_)
479 ));
480 }
481
482 #[test]
483 #[should_panic(expected = "kind mismatch")]
484 fn scripted_responder_panics_on_kind_mismatch() {
485 let r = ScriptedResponder::new([PromptResponse::text("oops")]);
486 // Confirm prompt with a Text response — wizard order changed and
487 // the test should fail loudly here, not 3 lines later.
488 let _ = r.respond(ctx(PromptKind::Confirm, None));
489 }
490
491 #[test]
492 #[should_panic(expected = "out of range")]
493 fn scripted_responder_panics_on_out_of_range_choice() {
494 let r = ScriptedResponder::new([PromptResponse::Choice(5)]);
495 let _ = r.respond(ctx(PromptKind::Select, Some(3)));
496 }
497
498 #[test]
499 #[should_panic(expected = "out of range")]
500 fn scripted_responder_panics_on_out_of_range_multiselect() {
501 let r = ScriptedResponder::new([PromptResponse::choices([0, 7])]);
502 let _ = r.respond(ctx(PromptKind::MultiSelect, Some(3)));
503 }
504
505 #[test]
506 #[should_panic(expected = "ran out of responses")]
507 fn scripted_responder_panics_when_exhausted() {
508 let r = ScriptedResponder::new([PromptResponse::text("only")]);
509 let _ = r.respond(ctx(PromptKind::Text, None));
510 let _ = r.respond(ctx(PromptKind::Text, None));
511 }
512
513 // current_prompt_responder() is only compiled when at least one
514 // prompt-producing feature is enabled, so the test that exercises it
515 // shares the same cfg gate. Under --no-default-features the install /
516 // reset path is unobservable from the public API, so there's no test
517 // to write.
518 #[cfg(any(feature = "editor", feature = "simple-prompts", feature = "inquire"))]
519 #[test]
520 #[serial(prompt_responder)]
521 fn install_and_reset_default_responder() {
522 assert!(current_prompt_responder().is_none());
523 set_default_prompt_responder(Arc::new(ScriptedResponder::new([])));
524 assert!(current_prompt_responder().is_some());
525 reset_default_prompt_responder();
526 assert!(current_prompt_responder().is_none());
527 }
528}