Skip to main content

rich/
prompt.rs

1//! Interactive prompts.
2//!
3//! Port of upstream `rich/prompt.py`: [`Prompt`] for free text, [`Confirm`] for
4//! yes/no, and [`IntPrompt`]/[`FloatPrompt`] for numbers. Each renders a styled
5//! question, reads a line, and re-asks until the answer validates.
6//!
7//! Reading is behind the [`InputSource`] trait so the whole loop — including the
8//! re-ask path — is testable without a terminal. [`StdinInput`] is the default.
9
10use std::io::BufRead;
11
12use crate::console::Console;
13use crate::text::Text;
14
15/// Where a prompt reads its answers from. Upstream takes an optional `stream`
16/// argument for the same purpose.
17pub trait InputSource {
18    /// Read one line, without its trailing newline. `None` means end of input.
19    fn read_line(&mut self) -> std::io::Result<Option<String>>;
20}
21
22/// Reads from standard input — the default for [`Prompt::ask`].
23pub 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
36/// A canned list of answers. Handy for tests and for scripted runs.
37pub 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/// Why an answer was rejected. Carries the console markup upstream stores in
60/// `validate_error_message` / `illegal_choice_message`.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct InvalidResponse(pub String);
63
64/// The shared prompt behaviour: rendering the question and running the ask loop.
65/// Port of `PromptBase`.
66///
67/// The type parameter is supplied by the concrete prompts below rather than by
68/// generics, so each keeps a plain, obvious signature.
69#[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    /// Build the question line: the prompt, then `[a/b/c]`, then `(default)`,
92    /// then the suffix. Port of `PromptBase.make_prompt`.
93    fn make_prompt(&self, console: &Console, default: Option<&str>) -> Text {
94        // The prompt itself is markup, matching upstream's `Text.from_markup`
95        // default for a `str` prompt.
96        let mut text = console.build_text(&self.prompt);
97
98        // The style *names* go on the spans, as upstream passes them
99        // (`prompt.append(choices, "prompt.choices")`), so a console with a
100        // custom theme restyles the question without the prompt knowing.
101        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    /// True when `value` is one of the choices (or there are no choices).
121    /// Port of `PromptBase.check_choice`.
122    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    /// The choice as originally spelled, for a case-insensitive match — upstream
136    /// deliberately returns the canonical spelling, not what was typed.
137    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    /// Write the question (no trailing newline) and read one line back.
150    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        // `PromptBase.get_input` → `console.input(prompt, stream=…)`: the
158        // question goes through the console (`end=""`, so the answer is typed
159        // on the same line), where capture and export see it.
160        console.input_from(&prompt, input)
161    }
162
163    /// Report a rejected answer. Port of `PromptBase.on_validate_error`.
164    fn on_validate_error(&self, console: &Console, error: &InvalidResponse) {
165        console.print_str(&error.0);
166    }
167}
168
169/// Ask for a line of text. Port of `rich.prompt.Prompt`.
170///
171/// ```no_run
172/// # use rich::{Console, prompt::Prompt};
173/// let console = Console::new();
174/// let name = Prompt::new("What is your name").ask(&console, Some("World")).unwrap();
175/// ```
176#[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    /// Restrict answers to `choices`, shown as `[a/b/c]`.
189    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    /// Match choices regardless of case, returning the choice as spelled in the
195    /// list rather than as typed.
196    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
197        self.base.case_sensitive = case_sensitive;
198        self
199    }
200
201    /// Hide the `(default)` hint while still accepting an empty answer.
202    pub fn show_default(mut self, show: bool) -> Self {
203        self.base.show_default = show;
204        self
205    }
206
207    /// Hide the `[a/b/c]` hint while still enforcing the choices.
208    pub fn show_choices(mut self, show: bool) -> Self {
209        self.base.show_choices = show;
210        self
211    }
212
213    /// The rendered question, as [`ask`](Self::ask) would print it.
214    pub fn make_prompt(&self, console: &Console, default: Option<&str>) -> Text {
215        self.base.make_prompt(console, default)
216    }
217
218    /// Validate one answer. Port of `PromptBase.process_response`.
219    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    /// Ask on standard input, re-asking until the answer validates.
233    pub fn ask(&self, console: &Console, default: Option<&str>) -> std::io::Result<String> {
234        self.ask_from(console, &mut StdinInput, default)
235    }
236
237    /// As [`ask`](Self::ask), reading from `input`. Port of `PromptBase.__call__`.
238    ///
239    /// An empty answer takes the default when there is one. Exhausted input does
240    /// the same, rather than looping forever.
241    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/// Ask a yes/no question. Port of `rich.prompt.Confirm`.
265#[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    /// The rendered question. Unlike the other prompts the default renders as
288    /// `(y)`/`(n)` rather than the value itself. Port of `Confirm.render_default`.
289    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    /// Port of `Confirm.process_response` — case-insensitive, and anything that
295    /// is not a choice is rejected outright.
296    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/// Ask for a whole number. Port of `rich.prompt.IntPrompt`.
336#[derive(Debug, Clone)]
337pub struct IntPrompt {
338    base: PromptBase,
339}
340
341/// Ask for a number. Port of `rich.prompt.FloatPrompt`.
342#[derive(Debug, Clone)]
343pub struct FloatPrompt {
344    base: PromptBase,
345}
346
347/// Python's `str()` of a prompt's default value.
348trait 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    /// `str(1.0)` is `1.0` and `str(1e20)` is `1e+20`, where Rust writes
360    /// `1` and `100000000000000000000`.
361    fn py_str(self) -> String {
362        crate::pyformat::float_repr(self)
363    }
364}
365
366/// The text with the underscores Python's `int()` and `float()` allow (one
367/// between two digits) removed, or `None` when one is misplaced.
368fn 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
390/// Generate the two numeric prompts, which differ only in their parse target and
391/// their rejection message.
392macro_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            /// The rendered question, as [`ask`](Self::ask) would print it.
407            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            /// Parse one answer as Python's `int()` / `float()` would,
413            /// rejecting anything that is not a number.
414            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    /// Exhausted input must not spin forever waiting for an answer.
483    #[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    /// A rejected answer is re-asked rather than returned.
494    #[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    /// A case-insensitive match returns the choice as spelled in the list, not
506    /// as the user typed it — upstream is explicit about this.
507    #[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        // With case sensitivity on, the same answer is rejected.
514        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        // Empty takes the default; a junk answer is re-asked.
529        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    /// `show_choices(false)` hides the hint but still enforces the choices.
555    #[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        // Captured from rich 15.0.0's `process_response` and `make_prompt`.
566        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}