1use std::io::{BufRead, Write};
11
12use crate::console::Console;
13use crate::text::Text;
14
15pub trait InputSource {
18 fn read_line(&mut self) -> std::io::Result<Option<String>>;
20}
21
22pub struct StdinInput;
24
25impl InputSource for StdinInput {
26 fn read_line(&mut self) -> std::io::Result<Option<String>> {
27 let mut buffer = String::new();
28 let read = std::io::stdin().lock().read_line(&mut buffer)?;
29 if read == 0 {
30 return Ok(None);
31 }
32 Ok(Some(buffer.trim_end_matches(['\r', '\n']).to_string()))
33 }
34}
35
36pub struct ScriptedInput {
38 lines: std::vec::IntoIter<String>,
39}
40
41impl ScriptedInput {
42 pub fn new(lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
43 ScriptedInput {
44 lines: lines
45 .into_iter()
46 .map(Into::into)
47 .collect::<Vec<_>>()
48 .into_iter(),
49 }
50 }
51}
52
53impl InputSource for ScriptedInput {
54 fn read_line(&mut self) -> std::io::Result<Option<String>> {
55 Ok(self.lines.next())
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct InvalidResponse(pub String);
63
64#[derive(Debug, Clone)]
70struct PromptBase {
71 prompt: String,
72 suffix: String,
73 choices: Option<Vec<String>>,
74 show_default: bool,
75 show_choices: bool,
76 case_sensitive: bool,
77}
78
79impl PromptBase {
80 fn new(prompt: impl Into<String>, choices: Option<Vec<String>>) -> Self {
81 PromptBase {
82 prompt: prompt.into(),
83 suffix: ": ".to_string(),
84 choices,
85 show_default: true,
86 show_choices: true,
87 case_sensitive: true,
88 }
89 }
90
91 fn make_prompt(&self, console: &Console, default: Option<&str>) -> Text {
94 let mut text = console.build_text(&self.prompt);
97
98 if self.show_choices {
102 if let Some(choices) = &self.choices {
103 text.append(" ", None);
104 text.append(
105 &format!("[{}]", choices.join("/")),
106 Some("prompt.choices".into()),
107 );
108 }
109 }
110 if self.show_default {
111 if let Some(default) = default {
112 text.append(" ", None);
113 text.append(&format!("({default})"), Some("prompt.default".into()));
114 }
115 }
116 text.append(&self.suffix, None);
117 text
118 }
119
120 fn check_choice(&self, value: &str) -> bool {
123 let Some(choices) = &self.choices else {
124 return true;
125 };
126 let value = value.trim();
127 if self.case_sensitive {
128 choices.iter().any(|choice| choice == value)
129 } else {
130 choices
131 .iter()
132 .any(|choice| choice.eq_ignore_ascii_case(value))
133 }
134 }
135
136 fn canonical_choice(&self, value: &str) -> Option<String> {
139 let choices = self.choices.as_ref()?;
140 if self.case_sensitive {
141 return None;
142 }
143 choices
144 .iter()
145 .find(|choice| choice.eq_ignore_ascii_case(value.trim()))
146 .cloned()
147 }
148
149 fn ask_once(
151 &self,
152 console: &Console,
153 input: &mut dyn InputSource,
154 default: Option<&str>,
155 ) -> std::io::Result<Option<String>> {
156 let prompt = self.make_prompt(console, default);
157 print!("{}", console.render_to_string(&prompt));
159 std::io::stdout().flush()?;
160 input.read_line()
161 }
162
163 fn on_validate_error(&self, console: &Console, error: &InvalidResponse) {
165 console.print_str(&error.0);
166 }
167}
168
169#[derive(Debug, Clone)]
177pub struct Prompt {
178 base: PromptBase,
179}
180
181impl Prompt {
182 pub fn new(prompt: impl Into<String>) -> Self {
183 Prompt {
184 base: PromptBase::new(prompt, None),
185 }
186 }
187
188 pub fn choices(mut self, choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
190 self.base.choices = Some(choices.into_iter().map(Into::into).collect());
191 self
192 }
193
194 pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
197 self.base.case_sensitive = case_sensitive;
198 self
199 }
200
201 pub fn show_default(mut self, show: bool) -> Self {
203 self.base.show_default = show;
204 self
205 }
206
207 pub fn show_choices(mut self, show: bool) -> Self {
209 self.base.show_choices = show;
210 self
211 }
212
213 pub fn make_prompt(&self, console: &Console, default: Option<&str>) -> Text {
215 self.base.make_prompt(console, default)
216 }
217
218 pub fn process_response(&self, value: &str) -> Result<String, InvalidResponse> {
220 let value = value.trim();
221 if !self.base.check_choice(value) {
222 return Err(InvalidResponse(
223 "[prompt.invalid.choice]Please select one of the available options".to_string(),
224 ));
225 }
226 Ok(self
227 .base
228 .canonical_choice(value)
229 .unwrap_or_else(|| value.to_string()))
230 }
231
232 pub fn ask(&self, console: &Console, default: Option<&str>) -> std::io::Result<String> {
234 self.ask_from(console, &mut StdinInput, default)
235 }
236
237 pub fn ask_from(
242 &self,
243 console: &Console,
244 input: &mut dyn InputSource,
245 default: Option<&str>,
246 ) -> std::io::Result<String> {
247 loop {
248 let Some(value) = self.base.ask_once(console, input, default)? else {
249 return Ok(default.unwrap_or_default().to_string());
250 };
251 if value.is_empty() {
252 if let Some(default) = default {
253 return Ok(default.to_string());
254 }
255 }
256 match self.process_response(&value) {
257 Ok(value) => return Ok(value),
258 Err(error) => self.base.on_validate_error(console, &error),
259 }
260 }
261 }
262}
263
264#[derive(Debug, Clone)]
266pub struct Confirm {
267 base: PromptBase,
268}
269
270impl Confirm {
271 pub fn new(prompt: impl Into<String>) -> Self {
272 Confirm {
273 base: PromptBase::new(prompt, Some(vec!["y".to_string(), "n".to_string()])),
274 }
275 }
276
277 pub fn show_default(mut self, show: bool) -> Self {
278 self.base.show_default = show;
279 self
280 }
281
282 pub fn show_choices(mut self, show: bool) -> Self {
283 self.base.show_choices = show;
284 self
285 }
286
287 pub fn make_prompt(&self, console: &Console, default: Option<bool>) -> Text {
290 let rendered = default.map(|yes| if yes { "y" } else { "n" });
291 self.base.make_prompt(console, rendered)
292 }
293
294 pub fn process_response(&self, value: &str) -> Result<bool, InvalidResponse> {
297 let value = value.trim().to_ascii_lowercase();
298 match value.as_str() {
299 "y" => Ok(true),
300 "n" => Ok(false),
301 _ => Err(InvalidResponse(
302 "[prompt.invalid]Please enter Y or N".to_string(),
303 )),
304 }
305 }
306
307 pub fn ask(&self, console: &Console, default: Option<bool>) -> std::io::Result<bool> {
308 self.ask_from(console, &mut StdinInput, default)
309 }
310
311 pub fn ask_from(
312 &self,
313 console: &Console,
314 input: &mut dyn InputSource,
315 default: Option<bool>,
316 ) -> std::io::Result<bool> {
317 let rendered = default.map(|yes| if yes { "y" } else { "n" });
318 loop {
319 let Some(value) = self.base.ask_once(console, input, rendered)? else {
320 return Ok(default.unwrap_or(false));
321 };
322 if value.trim().is_empty() {
323 if let Some(default) = default {
324 return Ok(default);
325 }
326 }
327 match self.process_response(&value) {
328 Ok(value) => return Ok(value),
329 Err(error) => self.base.on_validate_error(console, &error),
330 }
331 }
332 }
333}
334
335#[derive(Debug, Clone)]
337pub struct IntPrompt {
338 base: PromptBase,
339}
340
341#[derive(Debug, Clone)]
343pub struct FloatPrompt {
344 base: PromptBase,
345}
346
347macro_rules! numeric_prompt {
350 ($name:ident, $ty:ty, $message:expr) => {
351 impl $name {
352 pub fn new(prompt: impl Into<String>) -> Self {
353 $name {
354 base: PromptBase::new(prompt, None),
355 }
356 }
357
358 pub fn show_default(mut self, show: bool) -> Self {
359 self.base.show_default = show;
360 self
361 }
362
363 pub fn make_prompt(&self, console: &Console, default: Option<$ty>) -> Text {
365 self.base
366 .make_prompt(console, default.map(|d| d.to_string()).as_deref())
367 }
368
369 pub fn process_response(&self, value: &str) -> Result<$ty, InvalidResponse> {
371 value
372 .trim()
373 .parse::<$ty>()
374 .map_err(|_| InvalidResponse($message.to_string()))
375 }
376
377 pub fn ask(&self, console: &Console, default: Option<$ty>) -> std::io::Result<$ty> {
378 self.ask_from(console, &mut StdinInput, default)
379 }
380
381 pub fn ask_from(
382 &self,
383 console: &Console,
384 input: &mut dyn InputSource,
385 default: Option<$ty>,
386 ) -> std::io::Result<$ty> {
387 let rendered = default.map(|d| d.to_string());
388 loop {
389 let Some(value) = self.base.ask_once(console, input, rendered.as_deref())?
390 else {
391 return Ok(default.unwrap_or_default());
392 };
393 if value.trim().is_empty() {
394 if let Some(default) = default {
395 return Ok(default);
396 }
397 }
398 match self.process_response(&value) {
399 Ok(value) => return Ok(value),
400 Err(error) => self.base.on_validate_error(console, &error),
401 }
402 }
403 }
404 }
405 };
406}
407
408numeric_prompt!(
409 IntPrompt,
410 i64,
411 "[prompt.invalid]Please enter a valid integer number"
412);
413numeric_prompt!(FloatPrompt, f64, "[prompt.invalid]Please enter a number");
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::color::ColorSystem;
419
420 fn console() -> Console {
421 Console::builder()
422 .force_terminal(true)
423 .color_system(Some(ColorSystem::Truecolor))
424 .width(80)
425 .no_color(false)
426 .build()
427 }
428
429 #[test]
430 fn empty_answer_takes_the_default() {
431 let console = console();
432 let mut input = ScriptedInput::new([""]);
433 let answer = Prompt::new("Name")
434 .ask_from(&console, &mut input, Some("World"))
435 .unwrap();
436 assert_eq!(answer, "World");
437 }
438
439 #[test]
441 fn exhausted_input_falls_back_to_the_default() {
442 let console = console();
443 let mut input = ScriptedInput::new(Vec::<String>::new());
444 let answer = Prompt::new("Name")
445 .ask_from(&console, &mut input, Some("World"))
446 .unwrap();
447 assert_eq!(answer, "World");
448 }
449
450 #[test]
452 fn invalid_choice_is_re_asked() {
453 let console = console();
454 let mut input = ScriptedInput::new(["maybe", "yes"]);
455 let answer = Prompt::new("Pick")
456 .choices(["yes", "no"])
457 .ask_from(&console, &mut input, None)
458 .unwrap();
459 assert_eq!(answer, "yes");
460 }
461
462 #[test]
465 fn case_insensitive_returns_the_canonical_spelling() {
466 let prompt = Prompt::new("Pick")
467 .choices(["Yes", "No"])
468 .case_sensitive(false);
469 assert_eq!(prompt.process_response("yES").unwrap(), "Yes");
470 let strict = Prompt::new("Pick").choices(["Yes", "No"]);
472 assert!(strict.process_response("yES").is_err());
473 }
474
475 #[test]
476 fn confirm_reads_y_and_n() {
477 let console = console();
478 let confirm = Confirm::new("Sure");
479 assert!(confirm
480 .ask_from(&console, &mut ScriptedInput::new(["Y"]), None)
481 .unwrap());
482 assert!(!confirm
483 .ask_from(&console, &mut ScriptedInput::new(["n"]), None)
484 .unwrap());
485 assert!(confirm
487 .ask_from(&console, &mut ScriptedInput::new([""]), Some(true))
488 .unwrap());
489 assert!(!confirm
490 .ask_from(&console, &mut ScriptedInput::new(["what", "n"]), None)
491 .unwrap());
492 }
493
494 #[test]
495 fn numeric_prompts_reject_non_numbers() {
496 let int = IntPrompt::new("Age");
497 assert_eq!(int.process_response(" 42 ").unwrap(), 42);
498 assert_eq!(
499 int.process_response("4.5").unwrap_err(),
500 InvalidResponse("[prompt.invalid]Please enter a valid integer number".to_string())
501 );
502
503 let float = FloatPrompt::new("Ratio");
504 assert!((float.process_response("1.5").unwrap() - 1.5).abs() < f64::EPSILON);
505 assert_eq!(
506 float.process_response("abc").unwrap_err(),
507 InvalidResponse("[prompt.invalid]Please enter a number".to_string())
508 );
509 }
510
511 #[test]
513 fn hidden_choices_are_still_enforced() {
514 let console = console();
515 let prompt = Prompt::new("Pick").choices(["a", "b"]).show_choices(false);
516 assert!(!prompt.make_prompt(&console, None).plain().contains("[a/b]"));
517 assert!(prompt.process_response("c").is_err());
518 }
519}