use std::fmt;
use std::io::{self, BufRead, Write};
use crate::console::Console;
use crate::style::Style;
#[derive(Debug)]
pub enum PromptError {
InvalidResponse(String),
IOError(io::Error),
Cancelled,
}
impl fmt::Display for PromptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidResponse(msg) => write!(f, "{}", msg),
Self::IOError(e) => write!(f, "I/O error: {}", e),
Self::Cancelled => write!(f, "cancelled"),
}
}
}
impl std::error::Error for PromptError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::IOError(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for PromptError {
fn from(e: io::Error) -> Self {
PromptError::IOError(e)
}
}
fn read_password() -> Result<String, PromptError> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
enable_raw_mode().map_err(PromptError::IOError)?;
let mut result = String::new();
let cleanup = || {
let _ = disable_raw_mode();
};
loop {
match event::read() {
Ok(Event::Key(key))
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
{
match key.code {
KeyCode::Enter => {
let _ = io::stdout().write(b"\n");
let _ = io::stdout().flush();
break;
}
KeyCode::Char(c) => {
result.push(c);
let _ = io::stdout().write(b"*");
let _ = io::stdout().flush();
}
KeyCode::Backspace => {
if result.pop().is_some() {
let _ = io::stdout().write(b"\x08 \x08");
let _ = io::stdout().flush();
}
}
KeyCode::Esc | KeyCode::Delete => {
cleanup();
return Err(PromptError::Cancelled);
}
_ => {}
}
}
Ok(Event::Key(key)) if key.code == KeyCode::Enter => {
let _ = io::stdout().write(b"\n");
let _ = io::stdout().flush();
break;
}
Ok(Event::Key(key)) if key.code == KeyCode::Esc => {
cleanup();
return Err(PromptError::Cancelled);
}
Ok(_) => {}
Err(e) => {
cleanup();
return Err(PromptError::IOError(e));
}
}
}
cleanup();
Ok(result)
}
pub struct PromptBase {
pub prompt: String,
pub console: Option<Console>,
pub password: bool,
pub choices: Option<Vec<String>>,
pub case_sensitive: bool,
pub show_default: bool,
pub show_choices: bool,
}
impl PromptBase {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
console: None,
password: false,
choices: None,
case_sensitive: false,
show_default: true,
show_choices: true,
}
}
pub fn console(mut self, console: Console) -> Self {
self.console = Some(console);
self
}
pub fn password(mut self, yes: bool) -> Self {
self.password = yes;
self
}
pub fn choices(mut self, choices: Vec<String>) -> Self {
self.choices = Some(choices);
self
}
pub fn case_sensitive(mut self, yes: bool) -> Self {
self.case_sensitive = yes;
self
}
pub fn show_default(mut self, yes: bool) -> Self {
self.show_default = yes;
self
}
pub fn show_choices(mut self, yes: bool) -> Self {
self.show_choices = yes;
self
}
pub fn render_default(&self, default: &str) -> String {
if !self.show_default || default.is_empty() {
return String::new();
}
let styled = apply_style(default, "prompt.default");
format!(" ({})", styled)
}
pub fn make_prompt(&self) -> String {
let mut parts = Vec::new();
if self.show_choices {
if let Some(choices) = &self.choices {
let display_choices: Vec<&str> = choices.iter().map(|s| s.as_str()).collect();
let styled = apply_style(&display_choices.join("/"), "prompt.choices");
parts.push(format!("[{}]", styled));
}
}
let suffix = if parts.is_empty() {
String::new()
} else {
format!(" {} ", parts.join(" "))
};
let styled_prompt = apply_style(&self.prompt, "prompt");
format!("{}{}: ", styled_prompt, suffix)
}
pub fn check_choice(&self, value: &str) -> bool {
match &self.choices {
None => true,
Some(choices) => {
if self.case_sensitive {
choices.iter().any(|c| c == value)
} else {
let lower = value.to_lowercase();
choices.iter().any(|c| c.to_lowercase() == lower)
}
}
}
}
fn read_line(&self) -> Result<String, PromptError> {
if self.password {
read_password()
} else {
let mut input = String::new();
io::stdin()
.lock()
.read_line(&mut input)
.map_err(PromptError::IOError)?;
if input.is_empty() {
return Err(PromptError::Cancelled);
}
Ok(input
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string())
}
}
fn write_output(&self, text: &str) -> Result<(), PromptError> {
let mut out = io::stdout();
out.write_all(text.as_bytes())?;
out.flush()?;
Ok(())
}
}
pub struct Prompt {
base: PromptBase,
}
impl Prompt {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
base: PromptBase::new(prompt),
}
}
pub fn console(mut self, console: Console) -> Self {
self.base.console = Some(console);
self
}
pub fn password(mut self, yes: bool) -> Self {
self.base.password = yes;
self
}
pub fn choices(mut self, choices: Vec<String>) -> Self {
self.base.choices = Some(choices);
self
}
pub fn case_sensitive(mut self, yes: bool) -> Self {
self.base.case_sensitive = yes;
self
}
pub fn show_choices(mut self, yes: bool) -> Self {
self.base.show_choices = yes;
self
}
pub fn show_default(mut self, yes: bool) -> Self {
self.base.show_default = yes;
self
}
pub fn render(&self) -> String {
self.base.make_prompt()
}
pub fn ask(&self) -> Result<String, PromptError> {
let prompt_str = self.base.make_prompt();
self.base.write_output(&prompt_str)?;
let value = self.base.read_line()?;
if !self.base.check_choice(&value) {
return Err(PromptError::InvalidResponse(format!(
"invalid choice: '{}'",
value
)));
}
Ok(value)
}
pub fn ask_with(prompt: impl Into<String>) -> Result<String, PromptError> {
Prompt::new(prompt).ask()
}
}
pub struct IntPrompt {
base: PromptBase,
}
impl IntPrompt {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
base: PromptBase::new(prompt),
}
}
pub fn console(mut self, console: Console) -> Self {
self.base.console = Some(console);
self
}
pub fn password(mut self, yes: bool) -> Self {
self.base.password = yes;
self
}
pub fn choices(mut self, choices: Vec<String>) -> Self {
self.base.choices = Some(choices);
self
}
pub fn case_sensitive(mut self, yes: bool) -> Self {
self.base.case_sensitive = yes;
self
}
pub fn ask(&self) -> Result<i64, PromptError> {
loop {
let prompt_str = self.base.make_prompt();
self.base.write_output(&prompt_str)?;
let value = self.base.read_line()?;
if value.is_empty() {
continue;
}
if !self.base.check_choice(&value) {
let _ = self.base.write_output(&format!(
"Invalid choice: '{}'. Please try again.\n",
value
));
continue;
}
match value.parse::<i64>() {
Ok(n) => return Ok(n),
Err(_) => {
let _ =
self.base.write_output("Please enter a valid integer.\n");
}
}
}
}
pub fn ask_with(prompt: impl Into<String>) -> Result<i64, PromptError> {
IntPrompt::new(prompt).ask()
}
}
pub struct FloatPrompt {
base: PromptBase,
}
impl FloatPrompt {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
base: PromptBase::new(prompt),
}
}
pub fn console(mut self, console: Console) -> Self {
self.base.console = Some(console);
self
}
pub fn password(mut self, yes: bool) -> Self {
self.base.password = yes;
self
}
pub fn choices(mut self, choices: Vec<String>) -> Self {
self.base.choices = Some(choices);
self
}
pub fn case_sensitive(mut self, yes: bool) -> Self {
self.base.case_sensitive = yes;
self
}
pub fn ask(&self) -> Result<f64, PromptError> {
loop {
let prompt_str = self.base.make_prompt();
self.base.write_output(&prompt_str)?;
let value = self.base.read_line()?;
if value.is_empty() {
continue;
}
if !self.base.check_choice(&value) {
let _ = self.base.write_output(&format!(
"Invalid choice: '{}'. Please try again.\n",
value
));
continue;
}
match value.parse::<f64>() {
Ok(n) => return Ok(n),
Err(_) => {
let _ =
self.base.write_output("Please enter a valid number.\n");
}
}
}
}
pub fn ask_with(prompt: impl Into<String>) -> Result<f64, PromptError> {
FloatPrompt::new(prompt).ask()
}
}
pub struct Confirm {
base: PromptBase,
pub default: bool,
}
impl Confirm {
pub fn new(prompt: impl Into<String>, default: bool) -> Self {
Self {
base: PromptBase::new(prompt),
default,
}
}
pub fn console(mut self, console: Console) -> Self {
self.base.console = Some(console);
self
}
fn make_confirm_prompt(&self) -> String {
let (yes, no) = if self.default {
("Y", "n")
} else {
("y", "N")
};
let styled_prompt = apply_style(&self.base.prompt, "prompt");
let styled_choices = apply_style(&format!("[{}/{}]", yes, no), "prompt.choices");
format!("{} {}: ", styled_prompt, styled_choices)
}
pub fn ask(&self) -> Result<bool, PromptError> {
loop {
let prompt_str = self.make_confirm_prompt();
self.base.write_output(&prompt_str)?;
let value = self.base.read_line()?;
match value.to_lowercase().as_str() {
"" => return Ok(self.default),
"y" | "yes" | "true" | "1" => return Ok(true),
"n" | "no" | "false" | "0" => return Ok(false),
_ => {
let _ =
self.base.write_output("Please answer y or n.\n");
}
}
}
}
pub fn ask_with(prompt: impl Into<String>, default: bool) -> Result<bool, PromptError> {
Confirm::new(prompt, default).ask()
}
}
pub struct Select<T> {
base: PromptBase,
choices: Vec<(String, T)>,
}
impl<T> Select<T> {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
base: PromptBase::new(prompt),
choices: Vec::new(),
}
}
pub fn console(mut self, console: Console) -> Self {
self.base.console = Some(console);
self
}
pub fn choice(mut self, label: impl Into<String>, value: T) -> Self {
self.choices.push((label.into(), value));
self
}
}
impl<T: fmt::Display> Select<T> {
pub fn render(&self) -> String {
let mut output = String::new();
let styled_prompt = apply_style(&self.base.prompt, "prompt");
output.push_str(&styled_prompt);
output.push('\n');
for (i, (label, _)) in self.choices.iter().enumerate() {
output.push_str(&format!(" {}) {}\n", i + 1, label));
}
let styled_choices = apply_style(
&format!("Enter number [1-{}]", self.choices.len()),
"prompt.choices",
);
output.push_str(&format!("{}: ", styled_choices));
output
}
}
impl<T: fmt::Display + Clone> Select<T> {
pub fn ask(&self) -> Result<T, PromptError> {
if self.choices.is_empty() {
return Err(PromptError::InvalidResponse(
"no choices available".into(),
));
}
let prompt_str = self.render();
self.base.write_output(&prompt_str)?;
loop {
let value = self.base.read_line()?;
if value.is_empty() {
continue;
}
match value.trim().parse::<usize>() {
Ok(n) if n >= 1 && n <= self.choices.len() => {
return Ok(self.choices[n - 1].1.clone());
}
_ => {
let _ = self.base.write_output(&format!(
"Please enter a number between 1 and {}.\n",
self.choices.len()
));
}
}
}
}
}
fn apply_style(text: &str, style_name: &str) -> String {
let theme = crate::theme::default_theme();
if let Some(style) = theme.get(style_name) {
let ansi = style.to_ansi();
if ansi.is_empty() {
text.to_string()
} else {
format!("\x1b[{}m{}\x1b[0m", ansi, text)
}
} else {
text.to_string()
}
}
#[allow(dead_code)]
fn apply_raw_style(text: &str, style: &Style) -> String {
let ansi = style.to_ansi();
if ansi.is_empty() {
text.to_string()
} else {
format!("\x1b[{}m{}\x1b[0m", ansi, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_make_prompt_no_choices() {
let pb = PromptBase::new("Enter name");
let result = pb.make_prompt();
assert!(result.contains("Enter name"));
assert!(result.ends_with(": "));
}
#[test]
fn test_make_prompt_with_choices() {
let pb = PromptBase::new("Choose").choices(vec!["a".into(), "b".into()]);
let result = pb.make_prompt();
assert!(result.contains("Choose"));
assert!(result.contains("["));
assert!(result.contains("a/b"));
assert!(result.contains("]"));
}
#[test]
fn test_render_default() {
let pb = PromptBase::new("test");
let rendered = pb.render_default("hello");
assert!(rendered.contains("hello"));
let pb_hidden = PromptBase::new("test").show_default(false);
let rendered_hidden = pb_hidden.render_default("hello");
assert_eq!(rendered_hidden, "");
}
#[test]
fn test_check_choice_no_choices() {
let pb = PromptBase::new("test");
assert!(pb.check_choice("anything"));
}
#[test]
fn test_check_choice_case_insensitive() {
let pb = PromptBase::new("test")
.choices(vec!["yes".into(), "no".into()])
.case_sensitive(false);
assert!(pb.check_choice("YES"));
assert!(pb.check_choice("yes"));
assert!(pb.check_choice("No"));
assert!(!pb.check_choice("maybe"));
}
#[test]
fn test_check_choice_case_sensitive() {
let pb = PromptBase::new("test")
.choices(vec!["Yes".into(), "No".into()])
.case_sensitive(true);
assert!(pb.check_choice("Yes"));
assert!(!pb.check_choice("yes"));
}
#[test]
fn test_prompt_error_display() {
let err = PromptError::InvalidResponse("bad input".into());
assert_eq!(format!("{}", err), "bad input");
let err = PromptError::Cancelled;
assert_eq!(format!("{}", err), "cancelled");
let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
let err = PromptError::IOError(io_err);
let msg = format!("{}", err);
assert!(msg.contains("I/O error"));
}
#[test]
fn test_prompt_error_source() {
use std::error::Error;
let err = PromptError::InvalidResponse("bad".into());
assert!(err.source().is_none());
let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
let err = PromptError::IOError(io_err);
assert!(err.source().is_some());
}
#[test]
fn test_from_io_error() {
let io_err = io::Error::new(io::ErrorKind::Other, "oh no");
let err: PromptError = io_err.into();
match err {
PromptError::IOError(_) => {}
_ => panic!("expected IOError"),
}
}
#[test]
fn test_confirm_prompt_text_default_true() {
let c = Confirm::new("Continue?", true);
let prompt = c.make_confirm_prompt();
assert!(prompt.contains("Continue?"));
assert!(prompt.contains("[Y/n]"));
}
#[test]
fn test_confirm_prompt_text_default_false() {
let c = Confirm::new("Continue?", false);
let prompt = c.make_confirm_prompt();
assert!(prompt.contains("Continue?"));
assert!(prompt.contains("[y/N]"));
}
#[test]
fn test_select_render() {
let s: Select<&str> = Select::new("Pick")
.choice("Option A", "a")
.choice("Option B", "b");
let rendered = s.render();
assert!(rendered.contains("Pick"));
assert!(rendered.contains("1) Option A"));
assert!(rendered.contains("2) Option B"));
assert!(rendered.contains("Enter number [1-2]"));
}
#[test]
fn test_select_no_choices_error() {
let s: Select<String> = Select::new("empty");
let result = s.ask();
match result {
Err(PromptError::InvalidResponse(msg)) => {
assert!(msg.contains("no choices"));
}
_ => panic!("expected InvalidResponse for no choices"),
}
}
#[test]
fn test_prompt_builder() {
let p = Prompt::new("Enter value").password(false).show_choices(true);
let rendered = p.render();
assert!(rendered.contains("Enter value"));
}
#[test]
fn test_prompt_render_default() {
let pb = PromptBase::new("Name").show_default(true);
assert!(pb.render_default("Alice").contains("Alice"));
}
#[test]
fn test_apply_style_plain() {
let result = apply_style("hello", "nonexistent.style");
assert_eq!(result, "hello");
}
#[test]
fn test_apply_style_with_theme() {
let result = apply_style("hello", "prompt");
assert!(result.contains("hello"));
}
#[test]
fn test_apply_raw_style_empty() {
let s = Style::new();
let result = apply_raw_style("test", &s);
assert_eq!(result, "test");
}
}