Skip to main content

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}