use std::io;
use std::io::Write as _;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::console::Console;
use crate::console::PrintOptions;
use crate::live::{Live, LiveOptions};
use crate::markup;
use crate::style::Style;
use crate::text::Text;
pub const DEFAULT_MAX_INPUT_LENGTH: usize = 64 * 1024;
pub struct Status {
message: Arc<Mutex<String>>,
live: Option<Live>,
}
impl Status {
pub fn new(console: &Arc<Console>, message: impl Into<String>) -> io::Result<Self> {
let message = Arc::new(Mutex::new(message.into()));
if !console.is_interactive() {
{
let message = crate::sync::lock_recover(&message);
console.print_plain(&message);
}
return Ok(Self {
message,
live: None,
});
}
let start = Instant::now();
let frames: [&str; 4] = ["|", "/", "-", "\\"];
let frame_interval = Duration::from_millis(100);
let message_for_render = Arc::clone(&message);
let live_options = LiveOptions {
refresh_per_second: 10.0,
transient: true,
..LiveOptions::default()
};
let live =
Live::with_options(Arc::clone(console), live_options).get_renderable(move || {
let elapsed = start.elapsed();
let tick = elapsed.as_millis() / frame_interval.as_millis().max(1);
let idx = (tick as usize) % frames.len();
let frame = frames[idx];
let msg = crate::sync::lock_recover(&message_for_render).clone();
Box::new(Text::new(format!("{frame} {msg}")))
});
live.start(true)?;
Ok(Self {
message,
live: Some(live),
})
}
pub fn update(&self, message: impl Into<String>) {
*crate::sync::lock_recover(&self.message) = message.into();
}
}
impl Drop for Status {
fn drop(&mut self) {
if let Some(live) = &self.live {
let _ = live.stop();
}
}
}
#[derive(Debug)]
pub enum PromptError {
NotInteractive,
Eof,
Validation(String),
Io(io::Error),
InputTooLong {
limit: usize,
received: usize,
},
}
impl std::fmt::Display for PromptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInteractive => write!(f, "prompt requires an interactive console"),
Self::Eof => write!(f, "prompt input reached EOF"),
Self::Validation(message) => write!(f, "{message}"),
Self::Io(err) => write!(f, "{err}"),
Self::InputTooLong { limit, received } => {
write!(
f,
"input too long: received at least {received} bytes, limit is {limit} bytes"
)
}
}
}
}
impl PromptError {
#[must_use]
pub const fn is_input_too_long(&self) -> bool {
matches!(self, Self::InputTooLong { .. })
}
#[must_use]
pub const fn input_limit(&self) -> Option<usize> {
match self {
Self::InputTooLong { limit, .. } => Some(*limit),
_ => None,
}
}
}
impl std::error::Error for PromptError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<io::Error> for PromptError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
type PromptValidator = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct Prompt {
label: String,
default: Option<String>,
allow_empty: bool,
show_default: bool,
markup: bool,
validator: Option<PromptValidator>,
max_length: usize,
}
impl std::fmt::Debug for Prompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prompt")
.field("label", &self.label)
.field("default", &self.default)
.field("allow_empty", &self.allow_empty)
.field("show_default", &self.show_default)
.field("markup", &self.markup)
.field("max_length", &self.max_length)
.field("validator", &self.validator.as_ref().map(|_| "<validator>"))
.finish()
}
}
impl Prompt {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
default: None,
allow_empty: false,
show_default: true,
markup: true,
validator: None,
max_length: DEFAULT_MAX_INPUT_LENGTH,
}
}
#[must_use]
pub fn default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
#[must_use]
pub const fn allow_empty(mut self, allow_empty: bool) -> Self {
self.allow_empty = allow_empty;
self
}
#[must_use]
pub const fn show_default(mut self, show_default: bool) -> Self {
self.show_default = show_default;
self
}
#[must_use]
pub const fn markup(mut self, markup: bool) -> Self {
self.markup = markup;
self
}
#[must_use]
pub fn validate<F>(mut self, validator: F) -> Self
where
F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
{
self.validator = Some(Arc::new(validator));
self
}
#[must_use]
pub const fn max_length(mut self, max_bytes: usize) -> Self {
self.max_length = if max_bytes == 0 { 1 } else { max_bytes };
self
}
pub fn ask(&self, console: &Console) -> Result<String, PromptError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
self.ask_from(console, &mut reader)
}
pub fn ask_from<R: io::BufRead>(
&self,
console: &Console,
reader: &mut R,
) -> Result<String, PromptError> {
if !console.is_terminal() {
return self.default.clone().ok_or(PromptError::NotInteractive);
}
loop {
self.print_prompt(console);
let line = read_line_limited(reader, self.max_length)?;
let input = trim_newline(&line);
let mut value = if input.is_empty() {
self.default.clone().unwrap_or_default()
} else {
input.to_string()
};
if value.is_empty() && !self.allow_empty && self.default.is_none() {
self.print_error(console, "Input required.");
continue;
}
if let Some(validator) = &self.validator
&& let Err(message) = validator(&value)
{
self.print_error(console, &message);
continue;
}
value = value.trim_end().to_string();
return Ok(value);
}
}
fn print_prompt(&self, console: &Console) {
let mut prompt = self.label.clone();
if self.show_default
&& let Some(default) = &self.default
{
let default = if self.markup {
markup::escape(default)
} else {
default.clone()
};
prompt.push_str(" [");
prompt.push_str(&default);
prompt.push(']');
}
prompt.push_str(": ");
console.print_with_options(
&prompt,
&PrintOptions::new()
.with_markup(self.markup)
.with_no_newline(true)
.with_highlight(self.markup),
);
}
fn print_error(&self, console: &Console, message: &str) {
let style = Style::parse("bold red").unwrap_or_default();
console.print_with_options(
message,
&PrintOptions::new().with_markup(false).with_style(style),
);
}
}
#[derive(Debug, Clone)]
pub struct Pager {
command: Option<String>,
allow_color: bool,
}
impl Default for Pager {
fn default() -> Self {
Self::new()
}
}
impl Pager {
#[must_use]
pub fn new() -> Self {
Self {
command: None,
allow_color: true,
}
}
#[must_use]
pub fn command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
#[must_use]
pub const fn allow_color(mut self, allow_color: bool) -> Self {
self.allow_color = allow_color;
self
}
pub fn show(&self, console: &Console, content: &str) -> io::Result<()> {
if !console.is_terminal() {
print_exact(console, content);
return Ok(());
}
let (command, args) = self.resolve_command();
match spawn_pager(&command, &args, content) {
Ok(()) => Ok(()),
Err(_err) => {
print_exact(console, content);
Ok(())
}
}
}
fn resolve_command(&self) -> (String, Vec<String>) {
let command = self
.command
.clone()
.or_else(|| std::env::var("PAGER").ok())
.unwrap_or_else(|| {
#[cfg(windows)]
{
"more".to_string()
}
#[cfg(not(windows))]
{
"less".to_string()
}
});
let mut parts = command.split_whitespace();
let bin = parts.next().unwrap_or("less").to_string();
let mut args: Vec<String> = parts.map(str::to_string).collect();
if self.allow_color && bin == "less" && args.iter().all(|arg| arg != "-R") {
args.push("-R".to_string());
}
(bin, args)
}
}
fn spawn_pager(command: &str, args: &[String], content: &str) -> io::Result<()> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(content.as_bytes())?;
stdin.flush()?;
}
let _status = child.wait()?;
Ok(())
}
fn print_exact(console: &Console, content: &str) {
console.print_with_options(
content,
&PrintOptions::new().with_markup(false).with_no_newline(true),
);
}
fn read_line_limited<R: io::BufRead>(
reader: &mut R,
max_bytes: usize,
) -> Result<String, PromptError> {
let mut buf = Vec::with_capacity(max_bytes.min(1024));
let mut total = 0usize;
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
if buf.is_empty() {
return Err(PromptError::Eof);
}
break;
}
if let Some(newline_pos) = available.iter().position(|&b| b == b'\n') {
let line_len = newline_pos + 1; if total + line_len > max_bytes {
return Err(PromptError::InputTooLong {
limit: max_bytes,
received: total + line_len,
});
}
buf.extend_from_slice(&available[..line_len]);
reader.consume(line_len);
break;
}
if total + available.len() > max_bytes {
return Err(PromptError::InputTooLong {
limit: max_bytes,
received: total + available.len(),
});
}
buf.extend_from_slice(available);
total += available.len();
let len = available.len();
reader.consume(len);
}
String::from_utf8(buf).map_err(|e| PromptError::Validation(format!("invalid UTF-8: {e}")))
}
fn trim_newline(line: &str) -> &str {
line.trim_end_matches(&['\n', '\r'][..])
}
#[derive(Debug, Clone)]
pub struct Choice {
pub value: String,
pub label: Option<String>,
}
impl Choice {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
label: None,
}
}
#[must_use]
pub fn with_label(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: Some(label.into()),
}
}
#[must_use]
pub fn display(&self) -> &str {
self.label.as_deref().unwrap_or(&self.value)
}
}
impl<S: Into<String>> From<S> for Choice {
fn from(value: S) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone)]
pub struct Select {
label: String,
choices: Vec<Choice>,
default: Option<String>,
show_default: bool,
markup: bool,
max_length: usize,
}
impl Select {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
choices: Vec::new(),
default: None,
show_default: true,
markup: true,
max_length: DEFAULT_MAX_INPUT_LENGTH,
}
}
#[must_use]
pub fn choices<I, C>(mut self, choices: I) -> Self
where
I: IntoIterator<Item = C>,
C: Into<Choice>,
{
self.choices.extend(choices.into_iter().map(Into::into));
self
}
#[must_use]
pub fn choice(mut self, choice: impl Into<Choice>) -> Self {
self.choices.push(choice.into());
self
}
#[must_use]
pub fn default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
#[must_use]
pub const fn show_default(mut self, show_default: bool) -> Self {
self.show_default = show_default;
self
}
#[must_use]
pub const fn markup(mut self, markup: bool) -> Self {
self.markup = markup;
self
}
#[must_use]
pub const fn max_length(mut self, max_bytes: usize) -> Self {
self.max_length = if max_bytes == 0 { 1 } else { max_bytes };
self
}
pub fn ask(&self, console: &Console) -> Result<String, PromptError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
self.ask_from(console, &mut reader)
}
pub fn ask_from<R: io::BufRead>(
&self,
console: &Console,
reader: &mut R,
) -> Result<String, PromptError> {
if self.choices.is_empty() {
return Err(PromptError::Validation("No choices provided".to_string()));
}
if !console.is_terminal() {
return self.default.clone().ok_or(PromptError::NotInteractive);
}
loop {
self.print_choices(console);
self.print_prompt(console);
let line = read_line_limited(reader, self.max_length)?;
let input = trim_newline(&line).trim();
if input.is_empty() {
if let Some(default) = &self.default
&& self.find_choice(default).is_some()
{
return Ok(default.clone());
}
self.print_error(console, "Please select an option.");
continue;
}
if let Ok(num) = input.parse::<usize>()
&& num >= 1
&& num <= self.choices.len()
{
return Ok(self.choices[num - 1].value.clone());
}
if let Some(choice) = self.find_choice(input) {
return Ok(choice.value.clone());
}
self.print_error(console, &format!("Invalid choice: {input}"));
}
}
fn find_choice(&self, input: &str) -> Option<&Choice> {
let input_lower = input.to_lowercase();
self.choices.iter().find(|c| {
c.value.to_lowercase() == input_lower || c.display().to_lowercase() == input_lower
})
}
fn print_choices(&self, console: &Console) {
for (i, choice) in self.choices.iter().enumerate() {
let num = i + 1;
let display = choice.display();
let is_default = self.default.as_deref() == Some(&choice.value);
let line = if is_default && self.show_default {
format!(" [bold cyan]{num}.[/] {display} [dim](default)[/]")
} else {
format!(" [cyan]{num}.[/] {display}")
};
console.print_with_options(&line, &PrintOptions::new().with_markup(self.markup));
}
}
fn print_prompt(&self, console: &Console) {
let mut prompt = self.label.clone();
if self.show_default
&& let Some(default) = &self.default
{
let default_display = self
.find_choice(default)
.map_or(default.as_str(), Choice::display);
let escaped = if self.markup {
markup::escape(default_display)
} else {
default_display.to_string()
};
prompt.push_str(" [");
prompt.push_str(&escaped);
prompt.push(']');
}
prompt.push_str(": ");
console.print_with_options(
&prompt,
&PrintOptions::new()
.with_markup(self.markup)
.with_no_newline(true)
.with_highlight(self.markup),
);
}
fn print_error(&self, console: &Console, message: &str) {
let style = Style::parse("bold red").unwrap_or_default();
console.print_with_options(
message,
&PrintOptions::new().with_markup(false).with_style(style),
);
}
}
#[derive(Debug, Clone)]
pub struct Confirm {
label: String,
default: Option<bool>,
markup: bool,
max_length: usize,
}
impl Confirm {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
default: None,
markup: true,
max_length: DEFAULT_MAX_INPUT_LENGTH,
}
}
#[must_use]
pub const fn default(mut self, default: bool) -> Self {
self.default = Some(default);
self
}
#[must_use]
pub const fn markup(mut self, markup: bool) -> Self {
self.markup = markup;
self
}
#[must_use]
pub const fn max_length(mut self, max_bytes: usize) -> Self {
self.max_length = if max_bytes == 0 { 1 } else { max_bytes };
self
}
pub fn ask(&self, console: &Console) -> Result<bool, PromptError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
self.ask_from(console, &mut reader)
}
pub fn ask_from<R: io::BufRead>(
&self,
console: &Console,
reader: &mut R,
) -> Result<bool, PromptError> {
if !console.is_terminal() {
return self.default.ok_or(PromptError::NotInteractive);
}
loop {
self.print_prompt(console);
let line = read_line_limited(reader, self.max_length)?;
let input = trim_newline(&line).trim().to_lowercase();
if input.is_empty() {
if let Some(default) = self.default {
return Ok(default);
}
self.print_error(console, "Please enter y or n.");
continue;
}
match input.as_str() {
"y" | "yes" | "true" | "1" => return Ok(true),
"n" | "no" | "false" | "0" => return Ok(false),
_ => {
self.print_error(console, "Please enter y or n.");
}
}
}
}
fn print_prompt(&self, console: &Console) {
let mut prompt = self.label.clone();
let choices = match self.default {
Some(true) => "[Y/n]",
Some(false) => "[y/N]",
None => "[y/n]",
};
prompt.push(' ');
prompt.push_str(choices);
prompt.push_str(": ");
console.print_with_options(
&prompt,
&PrintOptions::new()
.with_markup(self.markup)
.with_no_newline(true)
.with_highlight(self.markup),
);
}
fn print_error(&self, console: &Console, message: &str) {
let style = Style::parse("bold red").unwrap_or_default();
console.print_with_options(
message,
&PrintOptions::new().with_markup(false).with_style(style),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as StdError;
use std::io::Write;
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
#[test]
fn test_status_non_interactive_prints_message_once() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let _status = Status::new(&console, "Working...").expect("status");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("Working...\n"));
}
#[test]
fn test_prompt_non_interactive_uses_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").default("Alice");
let answer = prompt.ask(&console).expect("prompt");
assert_eq!(answer, "Alice");
assert!(buffer.0.lock().unwrap().is_empty());
}
#[test]
fn test_prompt_from_reader_validates_and_reprompts() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Age").validate(|value| {
if value.chars().all(|c| c.is_ascii_digit()) {
Ok(())
} else {
Err("digits only".to_string())
}
});
let input = b"nope\n42\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "42");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
text.contains("digits only"),
"Expected error message 'digits only' in output, got: {text:?}"
);
}
#[test]
fn test_pager_non_interactive_falls_back_to_print() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build();
Pager::new()
.show(&console, "hello\nworld\n")
.expect("pager");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("hello\nworld\n"));
}
#[test]
fn test_select_by_number() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick a color").choices(["red", "green", "blue"]);
let input = b"2\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "green");
}
#[test]
fn test_select_by_value() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick a color").choices(["red", "green", "blue"]);
let input = b"blue\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "blue");
}
#[test]
fn test_select_case_insensitive() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["Red", "Green"]);
let input = b"red\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "Red");
}
#[test]
fn test_select_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b", "c"]).default("b");
let input = b"\n"; let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "b");
}
#[test]
fn test_select_non_interactive_uses_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b"]).default("b");
let answer = select.ask(&console).expect("select");
assert_eq!(answer, "b");
}
#[test]
fn test_select_with_labels() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick")
.choice(Choice::with_label("us-east-1", "US East (N. Virginia)"))
.choice(Choice::with_label("eu-west-1", "EU (Ireland)"));
let input = b"1\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "us-east-1");
}
#[test]
fn test_confirm_yes() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let input = b"y\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
}
#[test]
fn test_confirm_no() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let input = b"n\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(!answer);
}
#[test]
fn test_confirm_default_yes() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").default(true);
let input = b"\n"; let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
}
#[test]
fn test_confirm_non_interactive_uses_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").default(false);
let answer = confirm.ask(&console).expect("confirm");
assert!(!answer);
}
#[test]
fn test_choice_display() {
let simple = Choice::new("value");
assert_eq!(simple.display(), "value");
let labeled = Choice::with_label("value", "Display Label");
assert_eq!(labeled.display(), "Display Label");
}
#[test]
fn test_prompt_builder_chain() {
let prompt = Prompt::new("Enter name")
.default("Alice")
.allow_empty(true)
.show_default(false)
.markup(false)
.validate(|_| Ok(()));
assert_eq!(prompt.label, "Enter name");
assert_eq!(prompt.default, Some("Alice".to_string()));
assert!(prompt.allow_empty);
assert!(!prompt.show_default);
assert!(!prompt.markup);
assert!(prompt.validator.is_some());
}
#[test]
fn test_prompt_display_shows_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name")
.default("Bob")
.show_default(true)
.markup(false);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let _ = prompt.ask_from(&console, &mut reader);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("Name"), "Expected 'Name' in output: {text:?}");
assert!(
text.contains("[Bob]"),
"Expected '[Bob]' in output: {text:?}"
);
}
#[test]
fn test_prompt_display_hides_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").default("Bob").show_default(false);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let _ = prompt.ask_from(&console, &mut reader);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("Name"), "Expected 'Name' in output: {text:?}");
assert!(
!text.contains("[Bob]"),
"Should NOT show '[Bob]' when show_default=false: {text:?}"
);
}
#[test]
fn test_prompt_display_escapes_markup_in_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(true) .file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").default("[bold]text[/]").markup(true);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let _ = prompt.ask_from(&console, &mut reader);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("Name"), "Expected 'Name' in output: {text:?}");
}
#[test]
fn test_prompt_empty_input_uses_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").default("DefaultName");
let input = b"\n"; let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "DefaultName");
}
#[test]
fn test_prompt_no_default_no_allow_empty_reprompts() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").allow_empty(false);
let input = b"\nAlice\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "Alice");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
text.contains("Input required"),
"Expected 'Input required' error message: {text:?}"
);
}
#[test]
fn test_prompt_allow_empty_true() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").allow_empty(true);
let input = b"\n"; let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, ""); }
#[test]
fn test_prompt_validation_passes_on_valid_input() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Email").validate(|value| {
if value.contains('@') {
Ok(())
} else {
Err("must contain @".to_string())
}
});
let input = b"test@example.com\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "test@example.com");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
!text.contains("must contain @"),
"Should not show error for valid input: {text:?}"
);
}
#[test]
fn test_prompt_multiple_validation_failures() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Number").validate(|value| {
value
.parse::<i32>()
.map(|_| ())
.map_err(|_| "must be a number".to_string())
});
let input = b"abc\nxyz\n42\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "42");
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
let error_count = text.matches("must be a number").count();
assert!(
error_count >= 2,
"Expected at least 2 error messages, found {error_count}: {text:?}"
);
}
#[test]
fn test_prompt_input_whitespace_trimmed() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name");
let input = b" Alice \n"; let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, " Alice");
}
#[test]
fn test_prompt_eof_returns_error() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name");
let input = b""; let mut reader = io::Cursor::new(&input[..]);
let result = prompt.ask_from(&console, &mut reader);
assert!(matches!(result, Err(PromptError::Eof)));
}
#[test]
fn test_prompt_debug_impl() {
let prompt = Prompt::new("Name").default("Alice").validate(|_| Ok(()));
let debug_str = format!("{prompt:?}");
assert!(
debug_str.contains("Prompt"),
"Debug should contain 'Prompt': {debug_str}"
);
assert!(
debug_str.contains("Name"),
"Debug should contain label: {debug_str}"
);
assert!(
debug_str.contains("Alice"),
"Debug should contain default: {debug_str}"
);
assert!(
debug_str.contains("<validator>"),
"Debug should show validator placeholder: {debug_str}"
);
}
#[test]
fn test_prompt_error_display() {
let not_interactive = PromptError::NotInteractive;
assert_eq!(
format!("{not_interactive}"),
"prompt requires an interactive console"
);
let eof = PromptError::Eof;
assert_eq!(format!("{eof}"), "prompt input reached EOF");
let validation = PromptError::Validation("invalid input".to_string());
assert_eq!(format!("{validation}"), "invalid input");
let io_err = PromptError::Io(io::Error::new(io::ErrorKind::NotFound, "file not found"));
assert!(format!("{io_err}").contains("file not found"));
}
#[test]
fn test_prompt_error_source() {
let not_interactive = PromptError::NotInteractive;
assert!(StdError::source(¬_interactive).is_none());
let eof = PromptError::Eof;
assert!(StdError::source(&eof).is_none());
let validation = PromptError::Validation("test".to_string());
assert!(StdError::source(&validation).is_none());
let io_err = PromptError::Io(io::Error::new(io::ErrorKind::NotFound, "test"));
assert!(StdError::source(&io_err).is_some());
}
#[test]
fn test_prompt_error_from_io_error() {
let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
let prompt_err: PromptError = io_err.into();
assert!(matches!(prompt_err, PromptError::Io(_)));
assert!(format!("{prompt_err}").contains("access denied"));
}
#[test]
fn test_prompt_markup_in_label() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(true)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("[bold]Name[/]").markup(true);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let _ = prompt.ask_from(&console, &mut reader);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("Name"), "Expected 'Name' in output: {text:?}");
}
#[test]
fn test_prompt_markup_disabled_in_label() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(true)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("[bold]Name[/]").markup(false);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let _ = prompt.ask_from(&console, &mut reader);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
text.contains("[bold]Name[/]"),
"Expected literal '[bold]Name[/]' in output: {text:?}"
);
}
#[test]
fn test_prompt_clone() {
let prompt = Prompt::new("Name")
.default("Alice")
.allow_empty(true)
.show_default(false)
.markup(false);
let cloned = prompt.clone();
assert_eq!(cloned.label, prompt.label);
assert_eq!(cloned.default, prompt.default);
assert_eq!(cloned.allow_empty, prompt.allow_empty);
assert_eq!(cloned.show_default, prompt.show_default);
assert_eq!(cloned.markup, prompt.markup);
}
#[test]
fn test_prompt_not_interactive_error() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false) .markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name");
let result = prompt.ask(&console);
assert!(matches!(result, Err(PromptError::NotInteractive)));
}
#[test]
fn test_prompt_error_input_too_long_display() {
let err = PromptError::InputTooLong {
limit: 256,
received: 1024,
};
assert_eq!(
err.to_string(),
"input too long: received at least 1024 bytes, limit is 256 bytes"
);
}
#[test]
fn test_prompt_error_input_too_long_source_is_none() {
let err = PromptError::InputTooLong {
limit: 100,
received: 200,
};
assert!(StdError::source(&err).is_none());
}
#[test]
fn test_prompt_error_is_input_too_long() {
let too_long = PromptError::InputTooLong {
limit: 100,
received: 200,
};
assert!(too_long.is_input_too_long());
let eof = PromptError::Eof;
assert!(!eof.is_input_too_long());
let not_interactive = PromptError::NotInteractive;
assert!(!not_interactive.is_input_too_long());
let validation = PromptError::Validation("test".to_string());
assert!(!validation.is_input_too_long());
let io_err = PromptError::Io(io::Error::other("test"));
assert!(!io_err.is_input_too_long());
}
#[test]
fn test_prompt_error_input_limit() {
let too_long = PromptError::InputTooLong {
limit: 100,
received: 200,
};
assert_eq!(too_long.input_limit(), Some(100));
let eof = PromptError::Eof;
assert_eq!(eof.input_limit(), None);
let not_interactive = PromptError::NotInteractive;
assert_eq!(not_interactive.input_limit(), None);
}
#[test]
fn test_prompt_error_input_too_long_debug() {
let err = PromptError::InputTooLong {
limit: 64 * 1024,
received: 128 * 1024,
};
let debug_str = format!("{err:?}");
assert!(debug_str.contains("InputTooLong"));
assert!(debug_str.contains("65536"));
assert!(debug_str.contains("131072"));
}
#[test]
fn test_default_max_input_length_constant() {
assert_eq!(super::DEFAULT_MAX_INPUT_LENGTH, 64 * 1024);
assert_eq!(super::DEFAULT_MAX_INPUT_LENGTH, 65536);
}
#[test]
fn test_read_line_limited_normal_input() {
let mut reader = io::Cursor::new("hello world\n");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "hello world\n");
}
#[test]
fn test_read_line_limited_exactly_at_limit() {
let input = "ab\n"; let mut reader = io::Cursor::new(input);
let result = super::read_line_limited(&mut reader, 3).unwrap();
assert_eq!(result, "ab\n");
}
#[test]
fn test_read_line_limited_exceeds_limit() {
let mut reader = io::Cursor::new("this is a long input\n");
let result = super::read_line_limited(&mut reader, 5);
assert!(matches!(
result,
Err(PromptError::InputTooLong { limit: 5, .. })
));
}
#[test]
fn test_read_line_limited_empty_eof() {
let mut reader = io::Cursor::new("");
let result = super::read_line_limited(&mut reader, 100);
assert!(matches!(result, Err(PromptError::Eof)));
}
#[test]
fn test_read_line_limited_no_newline_eof() {
let mut reader = io::Cursor::new("no newline");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "no newline");
}
#[test]
fn test_read_line_limited_empty_line() {
let mut reader = io::Cursor::new("\n");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "\n");
}
#[test]
fn test_read_line_limited_unicode_input() {
let mut reader = io::Cursor::new("héllo 世界\n");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "héllo 世界\n");
}
#[test]
fn test_read_line_limited_invalid_utf8() {
let invalid: Vec<u8> = vec![0xff, 0xfe, b'\n'];
let mut reader = io::Cursor::new(invalid);
let result = super::read_line_limited(&mut reader, 100);
assert!(
matches!(result, Err(PromptError::Validation(ref msg)) if msg.contains("UTF-8")),
"Expected Validation error with UTF-8 message, got: {result:?}"
);
}
#[test]
fn test_read_line_limited_one_byte_limit() {
let mut reader = io::Cursor::new("\n");
let result = super::read_line_limited(&mut reader, 1).unwrap();
assert_eq!(result, "\n");
let mut reader2 = io::Cursor::new("a\n");
let result2 = super::read_line_limited(&mut reader2, 1);
assert!(matches!(
result2,
Err(PromptError::InputTooLong { limit: 1, .. })
));
}
#[test]
fn test_read_line_limited_multiple_lines_reads_first() {
let mut reader = io::Cursor::new("line1\nline2\n");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "line1\n");
let result2 = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result2, "line2\n");
}
#[test]
fn test_read_line_limited_crlf_input() {
let mut reader = io::Cursor::new("hello\r\n");
let result = super::read_line_limited(&mut reader, 100).unwrap();
assert_eq!(result, "hello\r\n");
}
#[test]
fn test_prompt_max_length_builder() {
let prompt = Prompt::new("Test").max_length(100);
assert_eq!(prompt.max_length, 100);
}
#[test]
fn test_prompt_max_length_zero_clamped_to_one() {
let prompt = Prompt::new("Test").max_length(0);
assert_eq!(prompt.max_length, 1);
}
#[test]
fn test_prompt_default_max_length() {
let prompt = Prompt::new("Test");
assert_eq!(prompt.max_length, super::DEFAULT_MAX_INPUT_LENGTH);
}
#[test]
fn test_prompt_max_length_in_debug() {
let prompt = Prompt::new("Test").max_length(256);
let debug_str = format!("{prompt:?}");
assert!(
debug_str.contains("256"),
"Debug should contain max_length value: {debug_str}"
);
assert!(
debug_str.contains("max_length"),
"Debug should contain 'max_length' field: {debug_str}"
);
}
#[test]
fn test_prompt_input_too_long_via_ask_from() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").max_length(5);
let input = b"this is too long\n";
let mut reader = io::Cursor::new(&input[..]);
let result = prompt.ask_from(&console, &mut reader);
assert!(
matches!(result, Err(PromptError::InputTooLong { limit: 5, .. })),
"Expected InputTooLong error, got: {result:?}"
);
}
#[test]
fn test_prompt_input_within_max_length() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").max_length(100);
let input = b"Alice\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "Alice");
}
#[test]
fn test_prompt_zero_max_length_still_accepts_newline_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let prompt = Prompt::new("Name").default("fallback").max_length(0);
let input = b"\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = prompt.ask_from(&console, &mut reader).expect("prompt");
assert_eq!(answer, "fallback");
}
#[test]
fn test_prompt_max_length_chaining() {
let prompt = Prompt::new("Test")
.default("default")
.max_length(256)
.allow_empty(true)
.validate(|_| Ok(()));
assert_eq!(prompt.max_length, 256);
assert_eq!(prompt.default, Some("default".to_string()));
assert!(prompt.allow_empty);
}
#[test]
fn test_select_max_length_builder() {
let select = Select::new("Pick").choices(["a", "b"]).max_length(128);
assert_eq!(select.max_length, 128);
}
#[test]
fn test_select_max_length_zero_clamped_to_one() {
let select = Select::new("Pick").choices(["a", "b"]).max_length(0);
assert_eq!(select.max_length, 1);
}
#[test]
fn test_select_default_max_length() {
let select = Select::new("Pick").choices(["a"]);
assert_eq!(select.max_length, super::DEFAULT_MAX_INPUT_LENGTH);
}
#[test]
fn test_select_input_too_long() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b"]).max_length(3);
let input = b"this exceeds limit\n";
let mut reader = io::Cursor::new(&input[..]);
let result = select.ask_from(&console, &mut reader);
assert!(
matches!(result, Err(PromptError::InputTooLong { limit: 3, .. })),
"Expected InputTooLong, got: {result:?}"
);
}
#[test]
fn test_select_zero_max_length_still_accepts_newline_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick")
.choices(["alpha", "beta"])
.default("alpha")
.max_length(0);
let input = b"\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(answer, "alpha");
}
#[test]
fn test_confirm_max_length_builder() {
let confirm = Confirm::new("Continue?").max_length(32);
assert_eq!(confirm.max_length, 32);
}
#[test]
fn test_confirm_max_length_zero_clamped_to_one() {
let confirm = Confirm::new("Continue?").max_length(0);
assert_eq!(confirm.max_length, 1);
}
#[test]
fn test_confirm_default_max_length() {
let confirm = Confirm::new("Continue?");
assert_eq!(confirm.max_length, super::DEFAULT_MAX_INPUT_LENGTH);
}
#[test]
fn test_confirm_input_too_long() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").max_length(3);
let input = b"this exceeds limit\n";
let mut reader = io::Cursor::new(&input[..]);
let result = confirm.ask_from(&console, &mut reader);
assert!(
matches!(result, Err(PromptError::InputTooLong { limit: 3, .. })),
"Expected InputTooLong, got: {result:?}"
);
}
#[test]
fn test_confirm_zero_max_length_still_accepts_newline_default() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").default(true).max_length(0);
let input = b"\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
}
#[test]
fn test_confirm_within_max_length() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").max_length(100);
let input = b"y\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
}
#[test]
fn test_pager_builder_defaults() {
let pager = Pager::new();
assert!(pager.command.is_none());
assert!(pager.allow_color);
}
#[test]
fn test_pager_custom_command() {
let pager = Pager::new().command("more -R");
assert_eq!(pager.command.as_deref(), Some("more -R"));
}
#[test]
fn test_pager_allow_color_false() {
let pager = Pager::new().allow_color(false);
assert!(!pager.allow_color);
}
#[test]
fn test_pager_default_impl() {
let pager = Pager::default();
assert!(pager.command.is_none());
assert!(pager.allow_color);
}
#[test]
fn test_pager_debug_impl() {
let pager = Pager::new().command("less");
let debug = format!("{pager:?}");
assert!(
debug.contains("Pager"),
"Debug should contain 'Pager': {debug}"
);
assert!(
debug.contains("less"),
"Debug should contain command: {debug}"
);
}
#[test]
fn test_pager_clone() {
let pager = Pager::new().command("cat").allow_color(false);
let cloned = pager.clone();
assert_eq!(cloned.command, pager.command);
assert_eq!(cloned.allow_color, pager.allow_color);
}
#[test]
fn test_choice_new() {
let choice = Choice::new("option1");
assert_eq!(choice.value, "option1");
assert!(choice.label.is_none());
assert_eq!(choice.display(), "option1");
}
#[test]
fn test_choice_with_label() {
let choice = Choice::with_label("us-east-1", "US East (Virginia)");
assert_eq!(choice.value, "us-east-1");
assert_eq!(choice.label.as_deref(), Some("US East (Virginia)"));
assert_eq!(choice.display(), "US East (Virginia)");
}
#[test]
fn test_choice_from_string() {
let choice: Choice = "hello".into();
assert_eq!(choice.value, "hello");
assert!(choice.label.is_none());
}
#[test]
fn test_choice_from_owned_string() {
let choice: Choice = String::from("world").into();
assert_eq!(choice.value, "world");
}
#[test]
fn test_choice_debug_and_clone() {
let choice = Choice::with_label("val", "lbl");
let debug = format!("{choice:?}");
assert!(debug.contains("val"));
assert!(debug.contains("lbl"));
let cloned = choice.clone();
assert_eq!(cloned.value, "val");
assert_eq!(cloned.label.as_deref(), Some("lbl"));
}
#[test]
fn test_select_empty_choices_error() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick");
let input = b"1\n";
let mut reader = io::Cursor::new(&input[..]);
let result = select.ask_from(&console, &mut reader);
assert!(
matches!(result, Err(PromptError::Validation(ref msg)) if msg.contains("No choices")),
"Expected Validation error about no choices, got: {result:?}"
);
}
#[test]
fn test_select_invalid_then_valid() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b", "c"]);
let input = b"999\n2\n";
let mut reader = io::Cursor::new(&input[..]);
let result = select.ask_from(&console, &mut reader).expect("select");
assert_eq!(result, "b");
}
#[test]
fn test_select_non_interactive_no_default_error() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b"]);
let result = select.ask(&console);
assert!(matches!(result, Err(PromptError::NotInteractive)));
}
#[test]
fn test_select_eof() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let select = Select::new("Pick").choices(["a", "b"]);
let input = b"";
let mut reader = io::Cursor::new(&input[..]);
let result = select.ask_from(&console, &mut reader);
assert!(matches!(result, Err(PromptError::Eof)));
}
#[test]
fn test_select_builder_chaining() {
let select = Select::new("Pick")
.choices(["a", "b"])
.choice("c")
.default("b")
.show_default(false)
.markup(false)
.max_length(512);
assert_eq!(select.label, "Pick");
assert_eq!(select.choices.len(), 3);
assert_eq!(select.default.as_deref(), Some("b"));
assert!(!select.show_default);
assert!(!select.markup);
assert_eq!(select.max_length, 512);
}
#[test]
fn test_confirm_all_yes_variants() {
let yes_inputs: &[&[u8]] = &[b"y\n", b"yes\n", b"true\n", b"1\n"];
for input_bytes in yes_inputs {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let mut reader = io::Cursor::new(*input_bytes);
let msg = format!(
"Failed on input: {:?}",
String::from_utf8_lossy(input_bytes)
);
let answer = confirm.ask_from(&console, &mut reader).expect(&msg);
assert!(
answer,
"Expected true for input {:?}",
String::from_utf8_lossy(input_bytes)
);
}
}
#[test]
fn test_confirm_all_no_variants() {
let no_inputs: &[&[u8]] = &[b"n\n", b"no\n", b"false\n", b"0\n"];
for input_bytes in no_inputs {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let mut reader = io::Cursor::new(*input_bytes);
let msg = format!(
"Failed on input: {:?}",
String::from_utf8_lossy(input_bytes)
);
let answer = confirm.ask_from(&console, &mut reader).expect(&msg);
assert!(
!answer,
"Expected false for input {:?}",
String::from_utf8_lossy(input_bytes)
);
}
}
#[test]
fn test_confirm_invalid_then_valid() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let input = b"maybe\ny\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
let out = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
text.contains("Please enter y or n"),
"Expected error prompt in output: {text:?}"
);
}
#[test]
fn test_confirm_default_false() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?").default(false);
let input = b"\n"; let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(!answer);
}
#[test]
fn test_confirm_no_default_empty_reprompts() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?"); let input = b"\ny\n";
let mut reader = io::Cursor::new(&input[..]);
let answer = confirm.ask_from(&console, &mut reader).expect("confirm");
assert!(answer);
}
#[test]
fn test_confirm_eof() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?");
let input = b"";
let mut reader = io::Cursor::new(&input[..]);
let result = confirm.ask_from(&console, &mut reader);
assert!(matches!(result, Err(PromptError::Eof)));
}
#[test]
fn test_confirm_non_interactive_no_default_error() {
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.force_terminal(false)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let confirm = Confirm::new("Continue?"); let result = confirm.ask(&console);
assert!(matches!(result, Err(PromptError::NotInteractive)));
}
#[test]
fn test_confirm_builder_chaining() {
let confirm = Confirm::new("Delete?")
.default(false)
.markup(false)
.max_length(64);
assert_eq!(confirm.label, "Delete?");
assert_eq!(confirm.default, Some(false));
assert!(!confirm.markup);
assert_eq!(confirm.max_length, 64);
}
#[test]
fn test_trim_newline_lf() {
assert_eq!(super::trim_newline("hello\n"), "hello");
}
#[test]
fn test_trim_newline_crlf() {
assert_eq!(super::trim_newline("hello\r\n"), "hello");
}
#[test]
fn test_trim_newline_none() {
assert_eq!(super::trim_newline("hello"), "hello");
}
#[test]
fn test_trim_newline_empty() {
assert_eq!(super::trim_newline(""), "");
}
#[test]
fn test_trim_newline_only_newline() {
assert_eq!(super::trim_newline("\n"), "");
}
}