Skip to main content

rusty_rich/
prompt.rs

1//! Interactive prompts — equivalent to Rich's `rich/prompt.py`.
2//!
3//! This module provides types for prompting the user for input:
4//!
5//! - `Prompt` — string input
6//! - `IntPrompt` — integer input
7//! - `FloatPrompt` — float input
8//! - `Confirm` — yes / no input
9//! - `Select<T>` — pick from a list of named choices
10//!
11//! All prompts support:
12//! - Optional `Console` for styled output (falls back to raw stdout)
13//! - Password mode (hidden input, masked with `*`)
14//! - Choice validation with optional case sensitivity
15//! - Display of default values and choices
16
17use std::fmt;
18use std::io::{self, BufRead, Write};
19
20use crate::console::Console;
21use crate::style::Style;
22
23// ---------------------------------------------------------------------------
24// PromptError
25// ---------------------------------------------------------------------------
26
27/// Errors that can occur during prompting.
28#[derive(Debug)]
29pub enum PromptError {
30    /// The user provided an invalid response.
31    InvalidResponse(String),
32    /// An underlying I/O error occurred.
33    IOError(io::Error),
34    /// The user cancelled the prompt (e.g. Ctrl+C / Ctrl+D).
35    Cancelled,
36}
37
38impl fmt::Display for PromptError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::InvalidResponse(msg) => write!(f, "{}", msg),
42            Self::IOError(e) => write!(f, "I/O error: {}", e),
43            Self::Cancelled => write!(f, "cancelled"),
44        }
45    }
46}
47
48impl std::error::Error for PromptError {
49    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50        match self {
51            Self::IOError(e) => Some(e),
52            _ => None,
53        }
54    }
55}
56
57impl From<io::Error> for PromptError {
58    fn from(e: io::Error) -> Self {
59        PromptError::IOError(e)
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Password reader (crossterm raw mode, no rpassword dependency)
65// ---------------------------------------------------------------------------
66
67/// Read a line of input with echoing disabled; show `*` for each character.
68/// Handles backspace for erasing the last character.
69fn read_password() -> Result<String, PromptError> {
70    use crossterm::event::{self, Event, KeyCode, KeyEventKind};
71    use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
72
73    enable_raw_mode().map_err(PromptError::IOError)?;
74
75    let mut result = String::new();
76
77    let cleanup = || {
78        let _ = disable_raw_mode();
79    };
80
81    loop {
82        match event::read() {
83            Ok(Event::Key(key))
84                if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
85            {
86                match key.code {
87                    KeyCode::Enter => {
88                        let _ = io::stdout().write(b"\n");
89                        let _ = io::stdout().flush();
90                        break;
91                    }
92                    KeyCode::Char(c) => {
93                        result.push(c);
94                        let _ = io::stdout().write(b"*");
95                        let _ = io::stdout().flush();
96                    }
97                    KeyCode::Backspace => {
98                        if result.pop().is_some() {
99                            let _ = io::stdout().write(b"\x08 \x08");
100                            let _ = io::stdout().flush();
101                        }
102                    }
103                    KeyCode::Esc | KeyCode::Delete => {
104                        cleanup();
105                        return Err(PromptError::Cancelled);
106                    }
107                    _ => {}
108                }
109            }
110            Ok(Event::Key(key)) if key.code == KeyCode::Enter => {
111                let _ = io::stdout().write(b"\n");
112                let _ = io::stdout().flush();
113                break;
114            }
115            Ok(Event::Key(key)) if key.code == KeyCode::Esc => {
116                cleanup();
117                return Err(PromptError::Cancelled);
118            }
119            Ok(_) => {}
120            Err(e) => {
121                cleanup();
122                return Err(PromptError::IOError(e));
123            }
124        }
125    }
126
127    cleanup();
128    Ok(result)
129}
130
131// ---------------------------------------------------------------------------
132// PromptBase
133// ---------------------------------------------------------------------------
134
135/// Base configuration for all prompt types.
136///
137/// Holds common fields like the prompt text, optional console, password mode,
138/// choices list, case sensitivity, and display flags.
139pub struct PromptBase {
140    /// The prompt text to display.
141    pub prompt: String,
142    /// An optional `Console` for styled output. If `None`, writes directly to
143    /// `std::io::stdout()`.
144    pub console: Option<Console>,
145    /// When `true`, input characters are masked with `*`.
146    pub password: bool,
147    /// Optional list of valid choices. When set, the user's response is
148    /// validated against this list.
149    pub choices: Option<Vec<String>>,
150    /// Whether choice matching is case-sensitive (default `false`).
151    pub case_sensitive: bool,
152    /// Whether to show the default value in the prompt string.
153    pub show_default: bool,
154    /// Whether to show the list of choices in the prompt string.
155    pub show_choices: bool,
156}
157
158impl PromptBase {
159    /// Create a new `PromptBase` with the given prompt text.
160    pub fn new(prompt: impl Into<String>) -> Self {
161        Self {
162            prompt: prompt.into(),
163            console: None,
164            password: false,
165            choices: None,
166            case_sensitive: false,
167            show_default: true,
168            show_choices: true,
169        }
170    }
171
172    /// Builder: set the console.
173    pub fn console(mut self, console: Console) -> Self {
174        self.console = Some(console);
175        self
176    }
177
178    /// Builder: enable or disable password mode.
179    pub fn password(mut self, yes: bool) -> Self {
180        self.password = yes;
181        self
182    }
183
184    /// Builder: set the valid choices.
185    pub fn choices(mut self, choices: Vec<String>) -> Self {
186        self.choices = Some(choices);
187        self
188    }
189
190    /// Builder: set case sensitivity for choice validation.
191    pub fn case_sensitive(mut self, yes: bool) -> Self {
192        self.case_sensitive = yes;
193        self
194    }
195
196    /// Builder: show or hide the default value.
197    pub fn show_default(mut self, yes: bool) -> Self {
198        self.show_default = yes;
199        self
200    }
201
202    /// Builder: show or hide the choices list.
203    pub fn show_choices(mut self, yes: bool) -> Self {
204        self.show_choices = yes;
205        self
206    }
207
208    // ------------------------------------------------------------------
209    // Helpers
210    // ------------------------------------------------------------------
211
212    /// Format the default value for display.
213    ///
214    /// Returns `" (default: value)"` wrapped in the `prompt.default` style, or
215    /// an empty string if `show_default` is `false`.
216    pub fn render_default(&self, default: &str) -> String {
217        if !self.show_default || default.is_empty() {
218            return String::new();
219        }
220        let styled = apply_style(default, "prompt.default");
221        format!(" ({})", styled)
222    }
223
224    /// Build the full prompt string including choices and default.
225    ///
226    /// Returns a string like:
227    /// `"Enter choice [a/b/c] (default: x): "`
228    pub fn make_prompt(&self) -> String {
229        let mut parts = Vec::new();
230
231        // Choices display
232        if self.show_choices {
233            if let Some(choices) = &self.choices {
234                let display_choices: Vec<&str> = choices.iter().map(|s| s.as_str()).collect();
235                let styled = apply_style(&display_choices.join("/"), "prompt.choices");
236                parts.push(format!("[{}]", styled));
237            }
238        }
239
240        let suffix = if parts.is_empty() {
241            String::new()
242        } else {
243            format!(" {} ", parts.join(" "))
244        };
245
246        let styled_prompt = apply_style(&self.prompt, "prompt");
247        format!("{}{}: ", styled_prompt, suffix)
248    }
249
250    /// Check whether `value` is a valid choice.
251    ///
252    /// If `choices` is `None`, returns `true`.
253    /// Otherwise returns `true` only if `value` (optionally case-insensitive)
254    /// matches one of the allowed choices.
255    pub fn check_choice(&self, value: &str) -> bool {
256        match &self.choices {
257            None => true,
258            Some(choices) => {
259                if self.case_sensitive {
260                    choices.iter().any(|c| c == value)
261                } else {
262                    let lower = value.to_lowercase();
263                    choices.iter().any(|c| c.to_lowercase() == lower)
264                }
265            }
266        }
267    }
268
269    /// Read a line from stdin.
270    fn read_line(&self) -> Result<String, PromptError> {
271        if self.password {
272            read_password()
273        } else {
274            let mut input = String::new();
275            io::stdin()
276                .lock()
277                .read_line(&mut input)
278                .map_err(PromptError::IOError)?;
279            if input.is_empty() {
280                return Err(PromptError::Cancelled);
281            }
282            Ok(input
283                .trim_end_matches('\n')
284                .trim_end_matches('\r')
285                .to_string())
286        }
287    }
288
289    /// Write a string to stdout.
290    fn write_output(&self, text: &str) -> Result<(), PromptError> {
291        let mut out = io::stdout();
292        out.write_all(text.as_bytes())?;
293        out.flush()?;
294        Ok(())
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Prompt (string)
300// ---------------------------------------------------------------------------
301
302/// Prompt the user for a string.
303///
304/// # Example
305///
306/// ```rust,no_run
307/// use rusty_rich::Prompt;
308///
309/// let name = Prompt::ask_with("Enter name").unwrap();
310/// ```
311pub struct Prompt {
312    base: PromptBase,
313}
314
315impl Prompt {
316    /// Create a new string prompt.
317    pub fn new(prompt: impl Into<String>) -> Self {
318        Self {
319            base: PromptBase::new(prompt),
320        }
321    }
322
323    /// Builder: set the console.
324    pub fn console(mut self, console: Console) -> Self {
325        self.base.console = Some(console);
326        self
327    }
328
329    /// Builder: enable password mode.
330    pub fn password(mut self, yes: bool) -> Self {
331        self.base.password = yes;
332        self
333    }
334
335    /// Builder: set valid choices.
336    pub fn choices(mut self, choices: Vec<String>) -> Self {
337        self.base.choices = Some(choices);
338        self
339    }
340
341    /// Builder: set case sensitivity.
342    pub fn case_sensitive(mut self, yes: bool) -> Self {
343        self.base.case_sensitive = yes;
344        self
345    }
346
347    /// Builder: show or hide choices.
348    pub fn show_choices(mut self, yes: bool) -> Self {
349        self.base.show_choices = yes;
350        self
351    }
352
353    /// Builder: show or hide default.
354    pub fn show_default(mut self, yes: bool) -> Self {
355        self.base.show_default = yes;
356        self
357    }
358
359    /// Render the prompt string with styling applied.
360    ///
361    /// Returns a styled string like `"Enter name: "` where the prompt text and
362    /// choices are colored using the theme's `prompt` and `prompt.choices`
363    /// styles.
364    pub fn render(&self) -> String {
365        self.base.make_prompt()
366    }
367
368    /// Ask the user for string input.
369    ///
370    /// Displays the prompt, reads a line from stdin, validates it against
371    /// any configured choices, and returns the trimmed string.
372    ///
373    /// # Errors
374    ///
375    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C,
376    /// `PromptError::InvalidResponse` when the input does not match choices,
377    /// and `PromptError::IOError` on I/O failures.
378    pub fn ask(&self) -> Result<String, PromptError> {
379        let prompt_str = self.base.make_prompt();
380        self.base.write_output(&prompt_str)?;
381        let value = self.base.read_line()?;
382        if !self.base.check_choice(&value) {
383            return Err(PromptError::InvalidResponse(format!(
384                "invalid choice: '{}'",
385                value
386            )));
387        }
388        Ok(value)
389    }
390
391    /// Convenience: create a prompt, ask, and return the result.
392    ///
393    /// Equivalent to `Prompt::new(prompt).ask()`.
394    pub fn ask_with(prompt: impl Into<String>) -> Result<String, PromptError> {
395        Prompt::new(prompt).ask()
396    }
397}
398
399// ---------------------------------------------------------------------------
400// IntPrompt
401// ---------------------------------------------------------------------------
402
403/// Prompt the user for an integer.
404///
405/// # Example
406///
407/// ```rust,no_run
408/// use rusty_rich::IntPrompt;
409///
410/// let age = IntPrompt::ask_with("Enter age").unwrap();
411/// ```
412pub struct IntPrompt {
413    base: PromptBase,
414}
415
416impl IntPrompt {
417    /// Create a new integer prompt.
418    pub fn new(prompt: impl Into<String>) -> Self {
419        Self {
420            base: PromptBase::new(prompt),
421        }
422    }
423
424    /// Builder: set the console.
425    pub fn console(mut self, console: Console) -> Self {
426        self.base.console = Some(console);
427        self
428    }
429
430    /// Builder: enable password mode.
431    pub fn password(mut self, yes: bool) -> Self {
432        self.base.password = yes;
433        self
434    }
435
436    /// Builder: set valid choices.
437    pub fn choices(mut self, choices: Vec<String>) -> Self {
438        self.base.choices = Some(choices);
439        self
440    }
441
442    /// Builder: set case sensitivity.
443    pub fn case_sensitive(mut self, yes: bool) -> Self {
444        self.base.case_sensitive = yes;
445        self
446    }
447
448    /// Ask the user for an integer.
449    ///
450    /// Reads input and attempts to parse it as `i64`. Loops until a valid
451    /// integer is provided.
452    ///
453    /// # Errors
454    ///
455    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
456    /// Returns `PromptError::IOError` on I/O failures.
457    pub fn ask(&self) -> Result<i64, PromptError> {
458        loop {
459            let prompt_str = self.base.make_prompt();
460            self.base.write_output(&prompt_str)?;
461            let value = self.base.read_line()?;
462            if value.is_empty() {
463                continue;
464            }
465            if !self.base.check_choice(&value) {
466                let _ = self.base.write_output(&format!(
467                    "Invalid choice: '{}'. Please try again.\n",
468                    value
469                ));
470                continue;
471            }
472            match value.parse::<i64>() {
473                Ok(n) => return Ok(n),
474                Err(_) => {
475                    let _ =
476                        self.base.write_output("Please enter a valid integer.\n");
477                }
478            }
479        }
480    }
481
482    /// Convenience: create an integer prompt, ask, and return the result.
483    pub fn ask_with(prompt: impl Into<String>) -> Result<i64, PromptError> {
484        IntPrompt::new(prompt).ask()
485    }
486}
487
488// ---------------------------------------------------------------------------
489// FloatPrompt
490// ---------------------------------------------------------------------------
491
492/// Prompt the user for a floating-point number.
493///
494/// # Example
495///
496/// ```rust,no_run
497/// use rusty_rich::FloatPrompt;
498///
499/// let height = FloatPrompt::ask_with("Enter height").unwrap();
500/// ```
501pub struct FloatPrompt {
502    base: PromptBase,
503}
504
505impl FloatPrompt {
506    /// Create a new float prompt.
507    pub fn new(prompt: impl Into<String>) -> Self {
508        Self {
509            base: PromptBase::new(prompt),
510        }
511    }
512
513    /// Builder: set the console.
514    pub fn console(mut self, console: Console) -> Self {
515        self.base.console = Some(console);
516        self
517    }
518
519    /// Builder: enable password mode.
520    pub fn password(mut self, yes: bool) -> Self {
521        self.base.password = yes;
522        self
523    }
524
525    /// Builder: set valid choices.
526    pub fn choices(mut self, choices: Vec<String>) -> Self {
527        self.base.choices = Some(choices);
528        self
529    }
530
531    /// Builder: set case sensitivity.
532    pub fn case_sensitive(mut self, yes: bool) -> Self {
533        self.base.case_sensitive = yes;
534        self
535    }
536
537    /// Ask the user for a float.
538    ///
539    /// Reads input and attempts to parse it as `f64`. Loops until a valid
540    /// float is provided.
541    ///
542    /// # Errors
543    ///
544    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
545    /// Returns `PromptError::IOError` on I/O failures.
546    pub fn ask(&self) -> Result<f64, PromptError> {
547        loop {
548            let prompt_str = self.base.make_prompt();
549            self.base.write_output(&prompt_str)?;
550            let value = self.base.read_line()?;
551            if value.is_empty() {
552                continue;
553            }
554            if !self.base.check_choice(&value) {
555                let _ = self.base.write_output(&format!(
556                    "Invalid choice: '{}'. Please try again.\n",
557                    value
558                ));
559                continue;
560            }
561            match value.parse::<f64>() {
562                Ok(n) => return Ok(n),
563                Err(_) => {
564                    let _ =
565                        self.base.write_output("Please enter a valid number.\n");
566                }
567            }
568        }
569    }
570
571    /// Convenience: create a float prompt, ask, and return the result.
572    pub fn ask_with(prompt: impl Into<String>) -> Result<f64, PromptError> {
573        FloatPrompt::new(prompt).ask()
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Confirm
579// ---------------------------------------------------------------------------
580
581/// Prompt the user for a yes/no answer.
582///
583/// Returns `bool` where `true` means yes / affirmative.
584///
585/// # Example
586///
587/// ```rust,no_run
588/// use rusty_rich::Confirm;
589///
590/// let ok = Confirm::ask_with("Continue?", true).unwrap();
591/// ```
592pub struct Confirm {
593    base: PromptBase,
594    /// Default answer if the user presses Enter without typing.
595    pub default: bool,
596}
597
598impl Confirm {
599    /// Create a new confirmation prompt with a default answer.
600    pub fn new(prompt: impl Into<String>, default: bool) -> Self {
601        Self {
602            base: PromptBase::new(prompt),
603            default,
604        }
605    }
606
607    /// Builder: set the console.
608    pub fn console(mut self, console: Console) -> Self {
609        self.base.console = Some(console);
610        self
611    }
612
613    /// Build the confirmation prompt string.
614    ///
615    /// Displays `[y/N]` or `[Y/n]` depending on the default, followed by `: `.
616    fn make_confirm_prompt(&self) -> String {
617        let (yes, no) = if self.default {
618            ("Y", "n")
619        } else {
620            ("y", "N")
621        };
622        let styled_prompt = apply_style(&self.base.prompt, "prompt");
623        let styled_choices = apply_style(&format!("[{}/{}]", yes, no), "prompt.choices");
624        format!("{} {}: ", styled_prompt, styled_choices)
625    }
626
627    /// Ask the user for a yes/no answer.
628    ///
629    /// Recognises `y`, `yes`, `true`, `1` as affirmative;
630    /// `n`, `no`, `false`, `0` as negative.
631    /// An empty input returns the default.
632    ///
633    /// # Errors
634    ///
635    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
636    pub fn ask(&self) -> Result<bool, PromptError> {
637        loop {
638            let prompt_str = self.make_confirm_prompt();
639            self.base.write_output(&prompt_str)?;
640            let value = self.base.read_line()?;
641            match value.to_lowercase().as_str() {
642                "" => return Ok(self.default),
643                "y" | "yes" | "true" | "1" => return Ok(true),
644                "n" | "no" | "false" | "0" => return Ok(false),
645                _ => {
646                    let _ =
647                        self.base.write_output("Please answer y or n.\n");
648                }
649            }
650        }
651    }
652
653    /// Convenience: create a confirmation prompt with the given default,
654    /// ask, and return the result.
655    pub fn ask_with(prompt: impl Into<String>, default: bool) -> Result<bool, PromptError> {
656        Confirm::new(prompt, default).ask()
657    }
658}
659
660// ---------------------------------------------------------------------------
661// Select
662// ---------------------------------------------------------------------------
663
664/// Prompt the user to select from a list of named choices.
665///
666/// Each choice is a `(label, value)` pair. The user selects by number.
667///
668/// # Example
669///
670/// ```rust,no_run
671/// use rusty_rich::Select;
672///
673/// let choice = Select::new("Pick a color")
674///     .choice("Red", "red")
675///     .choice("Green", "green")
676///     .choice("Blue", "blue")
677///     .ask()
678///     .unwrap();
679/// ```
680pub struct Select<T> {
681    base: PromptBase,
682    choices: Vec<(String, T)>,
683}
684
685impl<T> Select<T> {
686    /// Create a new select prompt.
687    pub fn new(prompt: impl Into<String>) -> Self {
688        Self {
689            base: PromptBase::new(prompt),
690            choices: Vec::new(),
691        }
692    }
693
694    /// Builder: set the console.
695    pub fn console(mut self, console: Console) -> Self {
696        self.base.console = Some(console);
697        self
698    }
699
700    /// Builder: add a choice with the given label and value.
701    pub fn choice(mut self, label: impl Into<String>, value: T) -> Self {
702        self.choices.push((label.into(), value));
703        self
704    }
705}
706
707impl<T: fmt::Display> Select<T> {
708    /// Render the select prompt as a numbered list.
709    ///
710    /// Returns a multi-line string like:
711    /// ```text
712    /// Pick a color:
713    ///   1) Red
714    ///   2) Green
715    ///   3) Blue
716    /// Enter number [1-3]:
717    /// ```
718    pub fn render(&self) -> String {
719        let mut output = String::new();
720        let styled_prompt = apply_style(&self.base.prompt, "prompt");
721        output.push_str(&styled_prompt);
722        output.push('\n');
723
724        for (i, (label, _)) in self.choices.iter().enumerate() {
725            output.push_str(&format!("  {}) {}\n", i + 1, label));
726        }
727
728        let styled_choices = apply_style(
729            &format!("Enter number [1-{}]", self.choices.len()),
730            "prompt.choices",
731        );
732        output.push_str(&format!("{}: ", styled_choices));
733        output
734    }
735}
736
737impl<T: fmt::Display + Clone> Select<T> {
738    /// Ask the user to select from the choices.
739    ///
740    /// Displays a numbered list, then prompts for a number.
741    /// Loops until a valid number is entered.
742    ///
743    /// # Errors
744    ///
745    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
746    /// Returns `PromptError::InvalidResponse` if there are no choices.
747    pub fn ask(&self) -> Result<T, PromptError> {
748        if self.choices.is_empty() {
749            return Err(PromptError::InvalidResponse(
750                "no choices available".into(),
751            ));
752        }
753
754        let prompt_str = self.render();
755        self.base.write_output(&prompt_str)?;
756
757        loop {
758            let value = self.base.read_line()?;
759            if value.is_empty() {
760                continue;
761            }
762            match value.trim().parse::<usize>() {
763                Ok(n) if n >= 1 && n <= self.choices.len() => {
764                    return Ok(self.choices[n - 1].1.clone());
765                }
766                _ => {
767                    let _ = self.base.write_output(&format!(
768                        "Please enter a number between 1 and {}.\n",
769                        self.choices.len()
770                    ));
771                }
772            }
773        }
774    }
775}
776
777// ---------------------------------------------------------------------------
778// Helper: apply a theme style name to text via ANSI escapes
779// ---------------------------------------------------------------------------
780
781/// Apply the ANSI style for the given theme key to `text`.
782///
783/// Falls back to plain text if no style is configured.
784fn apply_style(text: &str, style_name: &str) -> String {
785    let theme = crate::theme::default_theme();
786    if let Some(style) = theme.get(style_name) {
787        let ansi = style.to_ansi();
788        if ansi.is_empty() {
789            text.to_string()
790        } else {
791            format!("\x1b[{}m{}\x1b[0m", ansi, text)
792        }
793    } else {
794        text.to_string()
795    }
796}
797
798/// Apply a raw `Style` to text via ANSI escapes.
799#[allow(dead_code)]
800fn apply_raw_style(text: &str, style: &Style) -> String {
801    let ansi = style.to_ansi();
802    if ansi.is_empty() {
803        text.to_string()
804    } else {
805        format!("\x1b[{}m{}\x1b[0m", ansi, text)
806    }
807}
808
809// ---------------------------------------------------------------------------
810// Tests
811// ---------------------------------------------------------------------------
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    // -- PromptBase tests ---------------------------------------------------
818
819    #[test]
820    fn test_make_prompt_no_choices() {
821        let pb = PromptBase::new("Enter name");
822        let result = pb.make_prompt();
823        assert!(result.contains("Enter name"));
824        assert!(result.ends_with(": "));
825    }
826
827    #[test]
828    fn test_make_prompt_with_choices() {
829        let pb = PromptBase::new("Choose").choices(vec!["a".into(), "b".into()]);
830        let result = pb.make_prompt();
831        assert!(result.contains("Choose"));
832        assert!(result.contains("["));
833        assert!(result.contains("a/b"));
834        assert!(result.contains("]"));
835    }
836
837    #[test]
838    fn test_render_default() {
839        let pb = PromptBase::new("test");
840        let rendered = pb.render_default("hello");
841        assert!(rendered.contains("hello"));
842
843        let pb_hidden = PromptBase::new("test").show_default(false);
844        let rendered_hidden = pb_hidden.render_default("hello");
845        assert_eq!(rendered_hidden, "");
846    }
847
848    #[test]
849    fn test_check_choice_no_choices() {
850        let pb = PromptBase::new("test");
851        assert!(pb.check_choice("anything"));
852    }
853
854    #[test]
855    fn test_check_choice_case_insensitive() {
856        let pb = PromptBase::new("test")
857            .choices(vec!["yes".into(), "no".into()])
858            .case_sensitive(false);
859        assert!(pb.check_choice("YES"));
860        assert!(pb.check_choice("yes"));
861        assert!(pb.check_choice("No"));
862        assert!(!pb.check_choice("maybe"));
863    }
864
865    #[test]
866    fn test_check_choice_case_sensitive() {
867        let pb = PromptBase::new("test")
868            .choices(vec!["Yes".into(), "No".into()])
869            .case_sensitive(true);
870        assert!(pb.check_choice("Yes"));
871        assert!(!pb.check_choice("yes"));
872    }
873
874    // -- PromptError tests --------------------------------------------------
875
876    #[test]
877    fn test_prompt_error_display() {
878        let err = PromptError::InvalidResponse("bad input".into());
879        assert_eq!(format!("{}", err), "bad input");
880
881        let err = PromptError::Cancelled;
882        assert_eq!(format!("{}", err), "cancelled");
883
884        let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
885        let err = PromptError::IOError(io_err);
886        let msg = format!("{}", err);
887        assert!(msg.contains("I/O error"));
888    }
889
890    #[test]
891    fn test_prompt_error_source() {
892        use std::error::Error;
893
894        let err = PromptError::InvalidResponse("bad".into());
895        assert!(err.source().is_none());
896
897        let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
898        let err = PromptError::IOError(io_err);
899        assert!(err.source().is_some());
900    }
901
902    #[test]
903    fn test_from_io_error() {
904        let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
905        let err: PromptError = io_err.into();
906        match err {
907            PromptError::IOError(_) => {}
908            _ => panic!("expected IOError"),
909        }
910    }
911
912    // -- Confirm tests ------------------------------------------------------
913
914    #[test]
915    fn test_confirm_prompt_text_default_true() {
916        let c = Confirm::new("Continue?", true);
917        let prompt = c.make_confirm_prompt();
918        assert!(prompt.contains("Continue?"));
919        assert!(prompt.contains("[Y/n]"));
920    }
921
922    #[test]
923    fn test_confirm_prompt_text_default_false() {
924        let c = Confirm::new("Continue?", false);
925        let prompt = c.make_confirm_prompt();
926        assert!(prompt.contains("Continue?"));
927        assert!(prompt.contains("[y/N]"));
928    }
929
930    // -- Select tests -------------------------------------------------------
931
932    #[test]
933    fn test_select_render() {
934        let s: Select<&str> = Select::new("Pick")
935            .choice("Option A", "a")
936            .choice("Option B", "b");
937        let rendered = s.render();
938        assert!(rendered.contains("Pick"));
939        assert!(rendered.contains("1) Option A"));
940        assert!(rendered.contains("2) Option B"));
941        assert!(rendered.contains("Enter number [1-2]"));
942    }
943
944    #[test]
945    fn test_select_no_choices_error() {
946        let s: Select<String> = Select::new("empty");
947        let result = s.ask();
948        match result {
949            Err(PromptError::InvalidResponse(msg)) => {
950                assert!(msg.contains("no choices"));
951            }
952            _ => panic!("expected InvalidResponse for no choices"),
953        }
954    }
955
956    // -- Prompt builder tests -----------------------------------------------
957
958    #[test]
959    fn test_prompt_builder() {
960        let p = Prompt::new("Enter value").password(false).show_choices(true);
961        let rendered = p.render();
962        assert!(rendered.contains("Enter value"));
963    }
964
965    #[test]
966    fn test_prompt_render_default() {
967        let pb = PromptBase::new("Name").show_default(true);
968        assert!(pb.render_default("Alice").contains("Alice"));
969    }
970
971    // -- Style helper tests -------------------------------------------------
972
973    #[test]
974    fn test_apply_style_plain() {
975        let result = apply_style("hello", "nonexistent.style");
976        assert_eq!(result, "hello");
977    }
978
979    #[test]
980    fn test_apply_style_with_theme() {
981        let result = apply_style("hello", "prompt");
982        assert!(result.contains("hello"));
983    }
984
985    #[test]
986    fn test_apply_raw_style_empty() {
987        let s = Style::new();
988        let result = apply_raw_style("test", &s);
989        assert_eq!(result, "test");
990    }
991}