1use std::io::BufRead;
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 let value = value.to_lowercase();
131 choices.iter().any(|choice| choice.to_lowercase() == value)
132 }
133 }
134
135 fn canonical_choice(&self, value: &str) -> Option<String> {
138 let choices = self.choices.as_ref()?;
139 if self.case_sensitive {
140 return None;
141 }
142 let value = value.trim().to_lowercase();
143 choices
144 .iter()
145 .find(|choice| choice.to_lowercase() == value)
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 console.input_from(&prompt, input)
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_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.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
347trait PyStr {
349 fn py_str(self) -> String;
350}
351
352impl PyStr for i64 {
353 fn py_str(self) -> String {
354 self.to_string()
355 }
356}
357
358impl PyStr for f64 {
359 fn py_str(self) -> String {
362 crate::pyformat::float_repr(self)
363 }
364}
365
366fn without_digit_underscores(value: &str) -> Option<String> {
369 if !value.contains('_') {
370 return Some(value.to_string());
371 }
372 let chars: Vec<char> = value.chars().collect();
373 let mut out = String::with_capacity(value.len());
374 for (index, &ch) in chars.iter().enumerate() {
375 if ch == '_' {
376 let before = index.checked_sub(1).map(|i| chars[i]);
377 let after = chars.get(index + 1).copied();
378 if !(before.is_some_and(|c| c.is_ascii_digit())
379 && after.is_some_and(|c| c.is_ascii_digit()))
380 {
381 return None;
382 }
383 } else {
384 out.push(ch);
385 }
386 }
387 Some(out)
388}
389
390macro_rules! numeric_prompt {
393 ($name:ident, $ty:ty, $message:expr) => {
394 impl $name {
395 pub fn new(prompt: impl Into<String>) -> Self {
396 $name {
397 base: PromptBase::new(prompt, None),
398 }
399 }
400
401 pub fn show_default(mut self, show: bool) -> Self {
402 self.base.show_default = show;
403 self
404 }
405
406 pub fn make_prompt(&self, console: &Console, default: Option<$ty>) -> Text {
408 self.base
409 .make_prompt(console, default.map(PyStr::py_str).as_deref())
410 }
411
412 pub fn process_response(&self, value: &str) -> Result<$ty, InvalidResponse> {
415 without_digit_underscores(value.trim())
416 .and_then(|value| value.parse::<$ty>().ok())
417 .ok_or_else(|| InvalidResponse($message.to_string()))
418 }
419
420 pub fn ask(&self, console: &Console, default: Option<$ty>) -> std::io::Result<$ty> {
421 self.ask_from(console, &mut StdinInput, default)
422 }
423
424 pub fn ask_from(
425 &self,
426 console: &Console,
427 input: &mut dyn InputSource,
428 default: Option<$ty>,
429 ) -> std::io::Result<$ty> {
430 let rendered = default.map(PyStr::py_str);
431 loop {
432 let Some(value) = self.base.ask_once(console, input, rendered.as_deref())?
433 else {
434 return Ok(default.unwrap_or_default());
435 };
436 if value.is_empty() {
437 if let Some(default) = default {
438 return Ok(default);
439 }
440 }
441 match self.process_response(&value) {
442 Ok(value) => return Ok(value),
443 Err(error) => self.base.on_validate_error(console, &error),
444 }
445 }
446 }
447 }
448 };
449}
450
451numeric_prompt!(
452 IntPrompt,
453 i64,
454 "[prompt.invalid]Please enter a valid integer number"
455);
456numeric_prompt!(FloatPrompt, f64, "[prompt.invalid]Please enter a number");
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use crate::color::ColorSystem;
462
463 fn console() -> Console {
464 Console::builder()
465 .force_terminal(true)
466 .color_system(Some(ColorSystem::Truecolor))
467 .width(80)
468 .no_color(false)
469 .build()
470 }
471
472 #[test]
473 fn empty_answer_takes_the_default() {
474 let console = console();
475 let mut input = ScriptedInput::new([""]);
476 let answer = Prompt::new("Name")
477 .ask_from(&console, &mut input, Some("World"))
478 .unwrap();
479 assert_eq!(answer, "World");
480 }
481
482 #[test]
484 fn exhausted_input_falls_back_to_the_default() {
485 let console = console();
486 let mut input = ScriptedInput::new(Vec::<String>::new());
487 let answer = Prompt::new("Name")
488 .ask_from(&console, &mut input, Some("World"))
489 .unwrap();
490 assert_eq!(answer, "World");
491 }
492
493 #[test]
495 fn invalid_choice_is_re_asked() {
496 let console = console();
497 let mut input = ScriptedInput::new(["maybe", "yes"]);
498 let answer = Prompt::new("Pick")
499 .choices(["yes", "no"])
500 .ask_from(&console, &mut input, None)
501 .unwrap();
502 assert_eq!(answer, "yes");
503 }
504
505 #[test]
508 fn case_insensitive_returns_the_canonical_spelling() {
509 let prompt = Prompt::new("Pick")
510 .choices(["Yes", "No"])
511 .case_sensitive(false);
512 assert_eq!(prompt.process_response("yES").unwrap(), "Yes");
513 let strict = Prompt::new("Pick").choices(["Yes", "No"]);
515 assert!(strict.process_response("yES").is_err());
516 }
517
518 #[test]
519 fn confirm_reads_y_and_n() {
520 let console = console();
521 let confirm = Confirm::new("Sure");
522 assert!(confirm
523 .ask_from(&console, &mut ScriptedInput::new(["Y"]), None)
524 .unwrap());
525 assert!(!confirm
526 .ask_from(&console, &mut ScriptedInput::new(["n"]), None)
527 .unwrap());
528 assert!(confirm
530 .ask_from(&console, &mut ScriptedInput::new([""]), Some(true))
531 .unwrap());
532 assert!(!confirm
533 .ask_from(&console, &mut ScriptedInput::new(["what", "n"]), None)
534 .unwrap());
535 }
536
537 #[test]
538 fn numeric_prompts_reject_non_numbers() {
539 let int = IntPrompt::new("Age");
540 assert_eq!(int.process_response(" 42 ").unwrap(), 42);
541 assert_eq!(
542 int.process_response("4.5").unwrap_err(),
543 InvalidResponse("[prompt.invalid]Please enter a valid integer number".to_string())
544 );
545
546 let float = FloatPrompt::new("Ratio");
547 assert!((float.process_response("1.5").unwrap() - 1.5).abs() < f64::EPSILON);
548 assert_eq!(
549 float.process_response("abc").unwrap_err(),
550 InvalidResponse("[prompt.invalid]Please enter a number".to_string())
551 );
552 }
553
554 #[test]
556 fn hidden_choices_are_still_enforced() {
557 let console = console();
558 let prompt = Prompt::new("Pick").choices(["a", "b"]).show_choices(false);
559 assert!(!prompt.make_prompt(&console, None).plain().contains("[a/b]"));
560 assert!(prompt.process_response("c").is_err());
561 }
562
563 #[test]
564 fn numbers_parse_as_python_does() {
565 let int = IntPrompt::new("q");
567 let ints: Vec<Option<i64>> = ["1_000", " +5 ", "1__0", "_1", "1_", "0x10", "-0"]
568 .iter()
569 .map(|v| int.process_response(v).ok())
570 .collect();
571 assert_eq!(ints, [Some(1000), Some(5), None, None, None, None, Some(0)]);
572 let float = FloatPrompt::new("q");
573 let floats: Vec<Option<String>> = [
574 "1_0.5",
575 "inf",
576 "-Infinity",
577 ".5",
578 "5.",
579 "1e3",
580 "1e1_0",
581 "abc",
582 ]
583 .iter()
584 .map(|v| {
585 float
586 .process_response(v)
587 .ok()
588 .map(crate::pyformat::float_repr)
589 })
590 .collect();
591 let expected = [
592 "10.5",
593 "inf",
594 "-inf",
595 "0.5",
596 "5.0",
597 "1000.0",
598 "10000000000.0",
599 ];
600 let expected: Vec<Option<String>> = expected
601 .iter()
602 .map(|v| Some(v.to_string()))
603 .chain([None])
604 .collect();
605 assert_eq!(floats, expected);
606 assert!(float.process_response("nan").unwrap().is_nan());
607 let console = console();
608 assert_eq!(float.make_prompt(&console, Some(1.0)).plain(), "q (1.0): ");
609 assert_eq!(
610 float.make_prompt(&console, Some(1e20)).plain(),
611 "q (1e+20): "
612 );
613 let prompt = Prompt::new("q")
614 .choices(["Ärger", "b"])
615 .case_sensitive(false);
616 assert_eq!(prompt.process_response("ärger").unwrap(), "Ärger");
617 }
618}