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, Write};
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            choices
131                .iter()
132                .any(|choice| choice.eq_ignore_ascii_case(value))
133        }
134    }
135
136    /// The choice as originally spelled, for a case-insensitive match — upstream
137    /// deliberately returns the canonical spelling, not what was typed.
138    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    /// 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        // `end=""` upstream: the answer is typed on the same line as the question.
158        print!("{}", console.render_to_string(&prompt));
159        std::io::stdout().flush()?;
160        input.read_line()
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_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/// 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/// Generate the two numeric prompts, which differ only in their parse target and
348/// their rejection message.
349macro_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            /// The rendered question, as [`ask`](Self::ask) would print it.
364            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            /// Parse one answer, rejecting anything that is not a number.
370            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    /// Exhausted input must not spin forever waiting for an answer.
440    #[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    /// A rejected answer is re-asked rather than returned.
451    #[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    /// A case-insensitive match returns the choice as spelled in the list, not
463    /// as the user typed it — upstream is explicit about this.
464    #[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        // With case sensitivity on, the same answer is rejected.
471        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        // Empty takes the default; a junk answer is re-asked.
486        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    /// `show_choices(false)` hides the hint but still enforces the choices.
512    #[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}