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
467                    .base
468                    .write_output(&format!("Invalid choice: '{}'. Please try again.\n", value));
469                continue;
470            }
471            match value.parse::<i64>() {
472                Ok(n) => return Ok(n),
473                Err(_) => {
474                    let _ = self.base.write_output("Please enter a valid integer.\n");
475                }
476            }
477        }
478    }
479
480    /// Convenience: create an integer prompt, ask, and return the result.
481    pub fn ask_with(prompt: impl Into<String>) -> Result<i64, PromptError> {
482        IntPrompt::new(prompt).ask()
483    }
484}
485
486// ---------------------------------------------------------------------------
487// FloatPrompt
488// ---------------------------------------------------------------------------
489
490/// Prompt the user for a floating-point number.
491///
492/// # Example
493///
494/// ```rust,no_run
495/// use rusty_rich::FloatPrompt;
496///
497/// let height = FloatPrompt::ask_with("Enter height").unwrap();
498/// ```
499pub struct FloatPrompt {
500    base: PromptBase,
501}
502
503impl FloatPrompt {
504    /// Create a new float prompt.
505    pub fn new(prompt: impl Into<String>) -> Self {
506        Self {
507            base: PromptBase::new(prompt),
508        }
509    }
510
511    /// Builder: set the console.
512    pub fn console(mut self, console: Console) -> Self {
513        self.base.console = Some(console);
514        self
515    }
516
517    /// Builder: enable password mode.
518    pub fn password(mut self, yes: bool) -> Self {
519        self.base.password = yes;
520        self
521    }
522
523    /// Builder: set valid choices.
524    pub fn choices(mut self, choices: Vec<String>) -> Self {
525        self.base.choices = Some(choices);
526        self
527    }
528
529    /// Builder: set case sensitivity.
530    pub fn case_sensitive(mut self, yes: bool) -> Self {
531        self.base.case_sensitive = yes;
532        self
533    }
534
535    /// Ask the user for a float.
536    ///
537    /// Reads input and attempts to parse it as `f64`. Loops until a valid
538    /// float is provided.
539    ///
540    /// # Errors
541    ///
542    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
543    /// Returns `PromptError::IOError` on I/O failures.
544    pub fn ask(&self) -> Result<f64, PromptError> {
545        loop {
546            let prompt_str = self.base.make_prompt();
547            self.base.write_output(&prompt_str)?;
548            let value = self.base.read_line()?;
549            if value.is_empty() {
550                continue;
551            }
552            if !self.base.check_choice(&value) {
553                let _ = self
554                    .base
555                    .write_output(&format!("Invalid choice: '{}'. Please try again.\n", value));
556                continue;
557            }
558            match value.parse::<f64>() {
559                Ok(n) => return Ok(n),
560                Err(_) => {
561                    let _ = self.base.write_output("Please enter a valid number.\n");
562                }
563            }
564        }
565    }
566
567    /// Convenience: create a float prompt, ask, and return the result.
568    pub fn ask_with(prompt: impl Into<String>) -> Result<f64, PromptError> {
569        FloatPrompt::new(prompt).ask()
570    }
571}
572
573// ---------------------------------------------------------------------------
574// Confirm
575// ---------------------------------------------------------------------------
576
577/// Prompt the user for a yes/no answer.
578///
579/// Returns `bool` where `true` means yes / affirmative.
580///
581/// # Example
582///
583/// ```rust,no_run
584/// use rusty_rich::Confirm;
585///
586/// let ok = Confirm::ask_with("Continue?", true).unwrap();
587/// ```
588pub struct Confirm {
589    base: PromptBase,
590    /// Default answer if the user presses Enter without typing.
591    pub default: bool,
592}
593
594impl Confirm {
595    /// Create a new confirmation prompt with a default answer.
596    pub fn new(prompt: impl Into<String>, default: bool) -> Self {
597        Self {
598            base: PromptBase::new(prompt),
599            default,
600        }
601    }
602
603    /// Builder: set the console.
604    pub fn console(mut self, console: Console) -> Self {
605        self.base.console = Some(console);
606        self
607    }
608
609    /// Build the confirmation prompt string.
610    ///
611    /// Displays `[y/N]` or `[Y/n]` depending on the default, followed by `: `.
612    fn make_confirm_prompt(&self) -> String {
613        let (yes, no) = if self.default { ("Y", "n") } else { ("y", "N") };
614        let styled_prompt = apply_style(&self.base.prompt, "prompt");
615        let styled_choices = apply_style(&format!("[{}/{}]", yes, no), "prompt.choices");
616        format!("{} {}: ", styled_prompt, styled_choices)
617    }
618
619    /// Ask the user for a yes/no answer.
620    ///
621    /// Recognises `y`, `yes`, `true`, `1` as affirmative;
622    /// `n`, `no`, `false`, `0` as negative.
623    /// An empty input returns the default.
624    ///
625    /// # Errors
626    ///
627    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
628    pub fn ask(&self) -> Result<bool, PromptError> {
629        loop {
630            let prompt_str = self.make_confirm_prompt();
631            self.base.write_output(&prompt_str)?;
632            let value = self.base.read_line()?;
633            match value.to_lowercase().as_str() {
634                "" => return Ok(self.default),
635                "y" | "yes" | "true" | "1" => return Ok(true),
636                "n" | "no" | "false" | "0" => return Ok(false),
637                _ => {
638                    let _ = self.base.write_output("Please answer y or n.\n");
639                }
640            }
641        }
642    }
643
644    /// Convenience: create a confirmation prompt with the given default,
645    /// ask, and return the result.
646    pub fn ask_with(prompt: impl Into<String>, default: bool) -> Result<bool, PromptError> {
647        Confirm::new(prompt, default).ask()
648    }
649}
650
651// ---------------------------------------------------------------------------
652// Select
653// ---------------------------------------------------------------------------
654
655/// Prompt the user to select from a list of named choices.
656///
657/// Each choice is a `(label, value)` pair. The user selects by number.
658///
659/// # Example
660///
661/// ```rust,no_run
662/// use rusty_rich::Select;
663///
664/// let choice = Select::new("Pick a color")
665///     .choice("Red", "red")
666///     .choice("Green", "green")
667///     .choice("Blue", "blue")
668///     .ask()
669///     .unwrap();
670/// ```
671pub struct Select<T> {
672    base: PromptBase,
673    choices: Vec<(String, T)>,
674}
675
676impl<T> Select<T> {
677    /// Create a new select prompt.
678    pub fn new(prompt: impl Into<String>) -> Self {
679        Self {
680            base: PromptBase::new(prompt),
681            choices: Vec::new(),
682        }
683    }
684
685    /// Builder: set the console.
686    pub fn console(mut self, console: Console) -> Self {
687        self.base.console = Some(console);
688        self
689    }
690
691    /// Builder: add a choice with the given label and value.
692    pub fn choice(mut self, label: impl Into<String>, value: T) -> Self {
693        self.choices.push((label.into(), value));
694        self
695    }
696}
697
698impl<T: fmt::Display> Select<T> {
699    /// Render the select prompt as a numbered list.
700    ///
701    /// Returns a multi-line string like:
702    /// ```text
703    /// Pick a color:
704    ///   1) Red
705    ///   2) Green
706    ///   3) Blue
707    /// Enter number [1-3]:
708    /// ```
709    pub fn render(&self) -> String {
710        let mut output = String::new();
711        let styled_prompt = apply_style(&self.base.prompt, "prompt");
712        output.push_str(&styled_prompt);
713        output.push('\n');
714
715        for (i, (label, _)) in self.choices.iter().enumerate() {
716            output.push_str(&format!("  {}) {}\n", i + 1, label));
717        }
718
719        let styled_choices = apply_style(
720            &format!("Enter number [1-{}]", self.choices.len()),
721            "prompt.choices",
722        );
723        output.push_str(&format!("{}: ", styled_choices));
724        output
725    }
726}
727
728impl<T: fmt::Display + Clone> Select<T> {
729    /// Ask the user to select from the choices.
730    ///
731    /// Displays a numbered list, then prompts for a number.
732    /// Loops until a valid number is entered.
733    ///
734    /// # Errors
735    ///
736    /// Returns `PromptError::Cancelled` on EOF or Ctrl+C.
737    /// Returns `PromptError::InvalidResponse` if there are no choices.
738    pub fn ask(&self) -> Result<T, PromptError> {
739        if self.choices.is_empty() {
740            return Err(PromptError::InvalidResponse("no choices available".into()));
741        }
742
743        let prompt_str = self.render();
744        self.base.write_output(&prompt_str)?;
745
746        loop {
747            let value = self.base.read_line()?;
748            if value.is_empty() {
749                continue;
750            }
751            match value.trim().parse::<usize>() {
752                Ok(n) if n >= 1 && n <= self.choices.len() => {
753                    return Ok(self.choices[n - 1].1.clone());
754                }
755                _ => {
756                    let _ = self.base.write_output(&format!(
757                        "Please enter a number between 1 and {}.\n",
758                        self.choices.len()
759                    ));
760                }
761            }
762        }
763    }
764}
765
766// ---------------------------------------------------------------------------
767// Helper: apply a theme style name to text via ANSI escapes
768// ---------------------------------------------------------------------------
769
770/// Apply the ANSI style for the given theme key to `text`.
771///
772/// Falls back to plain text if no style is configured.
773fn apply_style(text: &str, style_name: &str) -> String {
774    let theme = crate::theme::default_theme();
775    if let Some(style) = theme.get(style_name) {
776        let ansi = style.to_ansi();
777        if ansi.is_empty() {
778            text.to_string()
779        } else {
780            format!("\x1b[{}m{}\x1b[0m", ansi, text)
781        }
782    } else {
783        text.to_string()
784    }
785}
786
787/// Apply a raw `Style` to text via ANSI escapes.
788#[allow(dead_code)]
789fn apply_raw_style(text: &str, style: &Style) -> String {
790    let ansi = style.to_ansi();
791    if ansi.is_empty() {
792        text.to_string()
793    } else {
794        format!("\x1b[{}m{}\x1b[0m", ansi, text)
795    }
796}
797
798// ---------------------------------------------------------------------------
799// Tests
800// ---------------------------------------------------------------------------
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    // -- PromptBase tests ---------------------------------------------------
807
808    #[test]
809    fn test_make_prompt_no_choices() {
810        let pb = PromptBase::new("Enter name");
811        let result = pb.make_prompt();
812        assert!(result.contains("Enter name"));
813        assert!(result.ends_with(": "));
814    }
815
816    #[test]
817    fn test_make_prompt_with_choices() {
818        let pb = PromptBase::new("Choose").choices(vec!["a".into(), "b".into()]);
819        let result = pb.make_prompt();
820        assert!(result.contains("Choose"));
821        assert!(result.contains("["));
822        assert!(result.contains("a/b"));
823        assert!(result.contains("]"));
824    }
825
826    #[test]
827    fn test_render_default() {
828        let pb = PromptBase::new("test");
829        let rendered = pb.render_default("hello");
830        assert!(rendered.contains("hello"));
831
832        let pb_hidden = PromptBase::new("test").show_default(false);
833        let rendered_hidden = pb_hidden.render_default("hello");
834        assert_eq!(rendered_hidden, "");
835    }
836
837    #[test]
838    fn test_check_choice_no_choices() {
839        let pb = PromptBase::new("test");
840        assert!(pb.check_choice("anything"));
841    }
842
843    #[test]
844    fn test_check_choice_case_insensitive() {
845        let pb = PromptBase::new("test")
846            .choices(vec!["yes".into(), "no".into()])
847            .case_sensitive(false);
848        assert!(pb.check_choice("YES"));
849        assert!(pb.check_choice("yes"));
850        assert!(pb.check_choice("No"));
851        assert!(!pb.check_choice("maybe"));
852    }
853
854    #[test]
855    fn test_check_choice_case_sensitive() {
856        let pb = PromptBase::new("test")
857            .choices(vec!["Yes".into(), "No".into()])
858            .case_sensitive(true);
859        assert!(pb.check_choice("Yes"));
860        assert!(!pb.check_choice("yes"));
861    }
862
863    // -- PromptError tests --------------------------------------------------
864
865    #[test]
866    fn test_prompt_error_display() {
867        let err = PromptError::InvalidResponse("bad input".into());
868        assert_eq!(format!("{}", err), "bad input");
869
870        let err = PromptError::Cancelled;
871        assert_eq!(format!("{}", err), "cancelled");
872
873        let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
874        let err = PromptError::IOError(io_err);
875        let msg = format!("{}", err);
876        assert!(msg.contains("I/O error"));
877    }
878
879    #[test]
880    fn test_prompt_error_source() {
881        use std::error::Error;
882
883        let err = PromptError::InvalidResponse("bad".into());
884        assert!(err.source().is_none());
885
886        let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
887        let err = PromptError::IOError(io_err);
888        assert!(err.source().is_some());
889    }
890
891    #[test]
892    fn test_from_io_error() {
893        let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
894        let err: PromptError = io_err.into();
895        match err {
896            PromptError::IOError(_) => {}
897            _ => panic!("expected IOError"),
898        }
899    }
900
901    // -- Confirm tests ------------------------------------------------------
902
903    #[test]
904    fn test_confirm_prompt_text_default_true() {
905        let c = Confirm::new("Continue?", true);
906        let prompt = c.make_confirm_prompt();
907        assert!(prompt.contains("Continue?"));
908        assert!(prompt.contains("[Y/n]"));
909    }
910
911    #[test]
912    fn test_confirm_prompt_text_default_false() {
913        let c = Confirm::new("Continue?", false);
914        let prompt = c.make_confirm_prompt();
915        assert!(prompt.contains("Continue?"));
916        assert!(prompt.contains("[y/N]"));
917    }
918
919    // -- Select tests -------------------------------------------------------
920
921    #[test]
922    fn test_select_render() {
923        let s: Select<&str> = Select::new("Pick")
924            .choice("Option A", "a")
925            .choice("Option B", "b");
926        let rendered = s.render();
927        assert!(rendered.contains("Pick"));
928        assert!(rendered.contains("1) Option A"));
929        assert!(rendered.contains("2) Option B"));
930        assert!(rendered.contains("Enter number [1-2]"));
931    }
932
933    #[test]
934    fn test_select_no_choices_error() {
935        let s: Select<String> = Select::new("empty");
936        let result = s.ask();
937        match result {
938            Err(PromptError::InvalidResponse(msg)) => {
939                assert!(msg.contains("no choices"));
940            }
941            _ => panic!("expected InvalidResponse for no choices"),
942        }
943    }
944
945    // -- Prompt builder tests -----------------------------------------------
946
947    #[test]
948    fn test_prompt_builder() {
949        let p = Prompt::new("Enter value")
950            .password(false)
951            .show_choices(true);
952        let rendered = p.render();
953        assert!(rendered.contains("Enter value"));
954    }
955
956    #[test]
957    fn test_prompt_render_default() {
958        let pb = PromptBase::new("Name").show_default(true);
959        assert!(pb.render_default("Alice").contains("Alice"));
960    }
961
962    // -- Style helper tests -------------------------------------------------
963
964    #[test]
965    fn test_apply_style_plain() {
966        let result = apply_style("hello", "nonexistent.style");
967        assert_eq!(result, "hello");
968    }
969
970    #[test]
971    fn test_apply_style_with_theme() {
972        let result = apply_style("hello", "prompt");
973        assert!(result.contains("hello"));
974    }
975
976    #[test]
977    fn test_apply_raw_style_empty() {
978        let s = Style::new();
979        let result = apply_raw_style("test", &s);
980        assert_eq!(result, "test");
981    }
982}