Skip to main content

standout_input/
responder.rs

1use std::sync::Mutex;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum PromptKind {
5    Text,
6    Password,
7    Editor,
8    Confirm,
9    Select,
10    MultiSelect,
11}
12
13impl std::fmt::Display for PromptKind {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            Self::Text => write!(f, "text"),
17            Self::Password => write!(f, "password"),
18            Self::Editor => write!(f, "editor"),
19            Self::Confirm => write!(f, "confirm"),
20            Self::Select => write!(f, "select"),
21            Self::MultiSelect => write!(f, "multi-select"),
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct PromptContext<'a> {
28    pub kind: PromptKind,
29    pub message: &'a str,
30    pub options: Option<usize>,
31}
32
33#[derive(Debug, Clone)]
34pub enum PromptResponse {
35    Text(String),
36    Bool(bool),
37    Choice(usize),
38    Choices(Vec<usize>),
39    Cancel,
40    Skip,
41}
42
43impl PromptResponse {
44    pub fn text(s: impl Into<String>) -> Self {
45        Self::Text(s.into())
46    }
47
48    pub fn choices(indices: impl IntoIterator<Item = usize>) -> Self {
49        Self::Choices(indices.into_iter().collect())
50    }
51
52    pub(crate) fn expected_kind(&self) -> Option<&'static [PromptKind]> {
53        match self {
54            Self::Text(_) => Some(&[PromptKind::Text, PromptKind::Password, PromptKind::Editor]),
55            Self::Bool(_) => Some(&[PromptKind::Confirm]),
56            Self::Choice(_) => Some(&[PromptKind::Select]),
57            Self::Choices(_) => Some(&[PromptKind::MultiSelect]),
58            Self::Cancel | Self::Skip => None,
59        }
60    }
61}
62
63pub trait PromptResponder: Send + Sync {
64    fn respond(&self, ctx: PromptContext<'_>) -> PromptResponse;
65}
66
67pub struct ScriptedResponder {
68    queue: Mutex<std::collections::VecDeque<PromptResponse>>,
69}
70
71impl ScriptedResponder {
72    pub fn new(responses: impl IntoIterator<Item = PromptResponse>) -> Self {
73        Self {
74            queue: Mutex::new(responses.into_iter().collect()),
75        }
76    }
77
78    pub fn remaining(&self) -> usize {
79        self.queue.lock().unwrap().len()
80    }
81}
82
83impl PromptResponder for ScriptedResponder {
84    fn respond(&self, ctx: PromptContext<'_>) -> PromptResponse {
85        let response = self.queue.lock().unwrap().pop_front().unwrap_or_else(|| {
86            panic!(
87                "ScriptedResponder ran out of responses; \
88                 next prompt was a `{}` prompt with message {:?}",
89                ctx.kind, ctx.message
90            )
91        });
92
93        if let Some(allowed) = response.expected_kind() {
94            if !allowed.contains(&ctx.kind) {
95                panic!(
96                    "ScriptedResponder kind mismatch: expected response for `{}` prompt \
97                     ({:?}), but got {:?}",
98                    ctx.kind, ctx.message, response
99                );
100            }
101        }
102
103        if let PromptResponse::Choice(i) = &response {
104            let n = ctx.options.unwrap_or(0);
105            assert!(
106                *i < n,
107                "ScriptedResponder: Choice({i}) is out of range for select prompt \
108                 with {n} option(s) ({:?})",
109                ctx.message
110            );
111        }
112        if let PromptResponse::Choices(indices) = &response {
113            let n = ctx.options.unwrap_or(0);
114            for &i in indices {
115                assert!(
116                    i < n,
117                    "ScriptedResponder: Choices contains {i}, out of range for \
118                     multi-select prompt with {n} option(s) ({:?})",
119                    ctx.message
120                );
121            }
122        }
123
124        response
125    }
126}
127
128impl std::fmt::Debug for ScriptedResponder {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("ScriptedResponder")
131            .field("remaining", &self.remaining())
132            .finish()
133    }
134}
135
136#[cfg(any(feature = "editor", feature = "simple-prompts", feature = "inquire"))]
137pub(crate) fn intercept_text(
138    kind: PromptKind,
139    message: &str,
140    responder: Option<&dyn PromptResponder>,
141) -> Result<Option<String>, crate::InputError> {
142    let Some(responder) = responder else {
143        return Ok(None);
144    };
145    let response = responder.respond(PromptContext {
146        kind,
147        message,
148        options: None,
149    });
150    match response {
151        PromptResponse::Text(s) => Ok(Some(s)),
152        PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
153        PromptResponse::Skip => Err(crate::InputError::NoInput),
154        other => panic!(
155            "PromptResponder returned {other:?} for a `{kind}` prompt; \
156             expected Text / Cancel / Skip"
157        ),
158    }
159}
160
161#[cfg(any(feature = "simple-prompts", feature = "inquire"))]
162pub(crate) fn intercept_bool(
163    kind: PromptKind,
164    message: &str,
165    responder: Option<&dyn PromptResponder>,
166) -> Result<Option<bool>, crate::InputError> {
167    let Some(responder) = responder else {
168        return Ok(None);
169    };
170    let response = responder.respond(PromptContext {
171        kind,
172        message,
173        options: None,
174    });
175    match response {
176        PromptResponse::Bool(b) => Ok(Some(b)),
177        PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
178        PromptResponse::Skip => Err(crate::InputError::NoInput),
179        other => panic!(
180            "PromptResponder returned {other:?} for a `{kind}` prompt; \
181             expected Bool / Cancel / Skip"
182        ),
183    }
184}
185
186#[cfg(feature = "inquire")]
187pub(crate) fn intercept_choice(
188    message: &str,
189    n: usize,
190    responder: Option<&dyn PromptResponder>,
191) -> Result<Option<usize>, crate::InputError> {
192    let Some(responder) = responder else {
193        return Ok(None);
194    };
195    let response = responder.respond(PromptContext {
196        kind: PromptKind::Select,
197        message,
198        options: Some(n),
199    });
200    match response {
201        PromptResponse::Choice(i) => {
202            assert!(
203                i < n,
204                "PromptResponder returned Choice({i}) for select prompt with {n} option(s)"
205            );
206            Ok(Some(i))
207        }
208        PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
209        PromptResponse::Skip => Err(crate::InputError::NoInput),
210        other => panic!(
211            "PromptResponder returned {other:?} for a `select` prompt; \
212             expected Choice / Cancel / Skip"
213        ),
214    }
215}
216
217#[cfg(feature = "inquire")]
218pub(crate) fn intercept_choices(
219    message: &str,
220    n: usize,
221    responder: Option<&dyn PromptResponder>,
222) -> Result<Option<Vec<usize>>, crate::InputError> {
223    let Some(responder) = responder else {
224        return Ok(None);
225    };
226    let response = responder.respond(PromptContext {
227        kind: PromptKind::MultiSelect,
228        message,
229        options: Some(n),
230    });
231    match response {
232        PromptResponse::Choices(indices) => {
233            for &i in &indices {
234                assert!(
235                    i < n,
236                    "PromptResponder returned Choices containing {i} for multi-select \
237                     prompt with {n} option(s)"
238                );
239            }
240            Ok(Some(indices))
241        }
242        PromptResponse::Cancel => Err(crate::InputError::PromptCancelled),
243        PromptResponse::Skip => Err(crate::InputError::NoInput),
244        other => panic!(
245            "PromptResponder returned {other:?} for a `multi-select` prompt; \
246             expected Choices / Cancel / Skip"
247        ),
248    }
249}
250
251#[cfg(any(feature = "editor", feature = "simple-prompts", feature = "inquire"))]
252pub(crate) fn collect_intercept<T>(
253    intercepted: Result<Option<T>, crate::InputError>,
254) -> Result<std::ops::ControlFlow<Option<T>>, crate::InputError> {
255    match intercepted {
256        Ok(Some(value)) => Ok(std::ops::ControlFlow::Break(Some(value))),
257        Ok(None) => Ok(std::ops::ControlFlow::Continue(())),
258        Err(crate::InputError::NoInput) => Ok(std::ops::ControlFlow::Break(None)),
259        Err(error) => Err(error),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn ctx(kind: PromptKind, options: Option<usize>) -> PromptContext<'static> {
268        PromptContext {
269            kind,
270            message: "test prompt",
271            options,
272        }
273    }
274
275    #[test]
276    fn scripted_responder_returns_in_order() {
277        let r = ScriptedResponder::new([
278            PromptResponse::text("first"),
279            PromptResponse::Bool(true),
280            PromptResponse::Choice(1),
281        ]);
282        assert!(
283            matches!(r.respond(ctx(PromptKind::Text, None)), PromptResponse::Text(s) if s == "first")
284        );
285        assert!(matches!(
286            r.respond(ctx(PromptKind::Confirm, None)),
287            PromptResponse::Bool(true)
288        ));
289        assert!(matches!(
290            r.respond(ctx(PromptKind::Select, Some(3))),
291            PromptResponse::Choice(1)
292        ));
293        assert_eq!(r.remaining(), 0);
294    }
295
296    #[test]
297    fn cancel_and_skip_are_kind_agnostic() {
298        let r = ScriptedResponder::new([PromptResponse::Cancel, PromptResponse::Skip]);
299        assert!(matches!(
300            r.respond(ctx(PromptKind::Select, Some(2))),
301            PromptResponse::Cancel
302        ));
303        assert!(matches!(
304            r.respond(ctx(PromptKind::Confirm, None)),
305            PromptResponse::Skip
306        ));
307    }
308
309    #[test]
310    fn text_response_works_for_all_open_kinds() {
311        let r = ScriptedResponder::new([
312            PromptResponse::text("a"),
313            PromptResponse::text("b"),
314            PromptResponse::text("c"),
315        ]);
316        assert!(matches!(
317            r.respond(ctx(PromptKind::Text, None)),
318            PromptResponse::Text(_)
319        ));
320        assert!(matches!(
321            r.respond(ctx(PromptKind::Password, None)),
322            PromptResponse::Text(_)
323        ));
324        assert!(matches!(
325            r.respond(ctx(PromptKind::Editor, None)),
326            PromptResponse::Text(_)
327        ));
328    }
329
330    #[test]
331    #[should_panic(expected = "kind mismatch")]
332    fn scripted_responder_panics_on_kind_mismatch() {
333        let r = ScriptedResponder::new([PromptResponse::text("oops")]);
334        let _ = r.respond(ctx(PromptKind::Confirm, None));
335    }
336
337    #[test]
338    #[should_panic(expected = "out of range")]
339    fn scripted_responder_panics_on_out_of_range_choice() {
340        let r = ScriptedResponder::new([PromptResponse::Choice(5)]);
341        let _ = r.respond(ctx(PromptKind::Select, Some(3)));
342    }
343
344    #[test]
345    #[should_panic(expected = "out of range")]
346    fn scripted_responder_panics_on_out_of_range_multiselect() {
347        let r = ScriptedResponder::new([PromptResponse::choices([0, 7])]);
348        let _ = r.respond(ctx(PromptKind::MultiSelect, Some(3)));
349    }
350
351    #[test]
352    #[should_panic(expected = "ran out of responses")]
353    fn scripted_responder_panics_when_exhausted() {
354        let r = ScriptedResponder::new([PromptResponse::text("only")]);
355        let _ = r.respond(ctx(PromptKind::Text, None));
356        let _ = r.respond(ctx(PromptKind::Text, None));
357    }
358}