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}