use std::io::IsTerminal;
use rskit_errors::AppResult;
use super::choice::{Choice, ChoiceId};
use super::kinds;
use super::mode::PromptMode;
use super::render::Style;
use super::terminal::{LineTerminal, Terminal};
use super::validate::Validator;
use crate::theme::{ColorChoice, Glyphs, Palette};
pub struct Prompter<T> {
terminal: T,
mode: PromptMode,
style: Style,
}
impl Prompter<Box<dyn Terminal>> {
#[must_use]
pub fn from_env(color: ColorChoice) -> Self {
let stderr = std::io::stderr();
let mode = PromptMode::from_stdio(std::io::stdin().is_terminal(), stderr.is_terminal());
let palette = Palette::for_stream(color, &stderr);
let glyphs = Glyphs::from_env();
let terminal = resolve_terminal(mode.is_interactive());
Self {
terminal,
mode,
style: Style::new(palette, glyphs),
}
}
}
impl<T: Terminal> Prompter<T> {
#[must_use]
pub const fn new(terminal: T, mode: PromptMode, palette: Palette) -> Self {
Self {
terminal,
mode,
style: Style::new(palette, Glyphs::new(false)),
}
}
#[must_use]
pub const fn with_glyphs(mut self, glyphs: Glyphs) -> Self {
self.style = Style::new(self.style.palette(), glyphs);
self
}
#[must_use]
pub const fn mode(&self) -> PromptMode {
self.mode
}
#[must_use]
pub const fn terminal(&self) -> &T {
&self.terminal
}
pub fn select(&mut self, prompt: &str, choices: &[Choice]) -> AppResult<ChoiceId> {
kinds::select::run(&mut self.terminal, self.style, self.mode, prompt, choices)
}
pub fn multi_select(&mut self, prompt: &str, choices: &[Choice]) -> AppResult<Vec<ChoiceId>> {
kinds::multi_select::run(&mut self.terminal, self.style, self.mode, prompt, choices)
}
pub fn confirm(&mut self, prompt: &str, default: bool) -> AppResult<bool> {
kinds::confirm::run(&mut self.terminal, self.style, self.mode, prompt, default)
}
pub fn text(&mut self, prompt: &str, default: Option<&str>) -> AppResult<String> {
kinds::text::run(
&mut self.terminal,
self.style,
self.mode,
prompt,
default,
None,
)
}
pub fn text_with(
&mut self,
prompt: &str,
default: Option<&str>,
validator: &dyn Validator,
) -> AppResult<String> {
kinds::text::run(
&mut self.terminal,
self.style,
self.mode,
prompt,
default,
Some(validator),
)
}
}
fn resolve_terminal(is_tty: bool) -> Box<dyn Terminal> {
#[cfg(feature = "interactive")]
{
if is_tty && let Ok(terminal) = super::terminal::RichTerminal::stderr() {
return Box::new(terminal);
}
}
#[cfg(not(feature = "interactive"))]
let _ = is_tty;
Box::new(LineTerminal::stdio())
}
#[cfg(test)]
mod tests {
use super::{Choice, ChoiceId, Palette, PromptMode, Prompter};
use crate::prompt::key::Key;
use crate::prompt::terminal::ScriptedTerminal;
use crate::prompt::validate::non_empty;
use crate::theme::{ColorChoice, Glyphs};
fn plain_choices() -> Vec<Choice> {
vec![
Choice::new("go", "Go"),
Choice::new("rust", "Rust").recommended(),
Choice::new("node", "Node.js"),
]
}
fn no_default_choices() -> Vec<Choice> {
vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")]
}
fn line_prompter(
terminal: ScriptedTerminal,
mode: PromptMode,
color: bool,
) -> Prompter<ScriptedTerminal> {
Prompter::new(terminal, mode, Palette::new(color))
}
#[test]
fn non_interactive_select_resolves_to_recommended() {
let choice = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("recommended default resolves");
assert_eq!(choice, ChoiceId::new("rust"));
}
#[test]
fn non_interactive_select_without_default_errors() {
let choices = vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")];
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.select("Ecosystem?", &choices)
.expect_err("no recommended default must error");
assert!(err.message().contains("non-interactive"));
}
#[test]
fn non_interactive_multi_select_returns_recommended_set() {
let selected = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect("recommended set resolves");
assert_eq!(selected, vec![ChoiceId::new("rust")]);
}
#[test]
fn non_interactive_multi_select_allows_empty_default() {
let choices = vec![Choice::new("a", "A"), Choice::new("b", "B")];
let selected = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.multi_select("Tasks?", &choices)
.expect("empty selection is valid");
assert!(selected.is_empty());
}
#[test]
fn non_interactive_confirm_returns_default() {
let value = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.confirm("Proceed?", true)
.expect("confirm resolves to default");
assert!(value);
}
#[test]
fn non_interactive_text_uses_default_and_errors_without_one() {
let value = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.text("Name?", Some("toven"))
.expect("text resolves to default");
assert_eq!(value, "toven");
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.text("Name?", None)
.expect_err("no default must error");
assert!(err.message().contains("non-interactive"));
}
#[test]
fn non_interactive_text_with_rejects_invalid_default() {
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.text_with("Name?", Some(" "), &non_empty("required"))
.expect_err("invalid default must error");
assert!(err.message().contains("invalid"));
}
#[test]
fn line_select_reads_a_numbered_answer() {
let choice = line_prompter(
ScriptedTerminal::line_driven().with_line("1"),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("first choice selected");
assert_eq!(choice, ChoiceId::new("go"));
}
#[test]
fn line_select_empty_line_accepts_recommended() {
let choice = line_prompter(
ScriptedTerminal::line_driven().with_line(""),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("blank accepts recommended");
assert_eq!(choice, ChoiceId::new("rust"));
}
#[test]
fn line_select_reprompts_on_invalid_then_succeeds() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines(["9", "x", "3"]),
PromptMode::Interactive,
false,
);
let choice = prompter
.select("Ecosystem?", &plain_choices())
.expect("valid choice after retries");
assert_eq!(choice, ChoiceId::new("node"));
assert!(prompter.terminal().output().contains("between 1 and 3"));
}
#[test]
fn line_select_errors_when_input_closes() {
let choices = vec![Choice::new("go", "Go"), Choice::new("rust", "Rust")];
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &choices)
.expect_err("closed input must error, not hang");
assert!(err.message().contains("input closed"));
}
#[test]
fn line_multi_select_parses_and_dedupes() {
let selected = line_prompter(
ScriptedTerminal::line_driven().with_line("3, 1, 1"),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect("comma list parses");
assert_eq!(selected, vec![ChoiceId::new("node"), ChoiceId::new("go")]);
}
#[test]
fn line_multi_select_empty_line_uses_defaults() {
let selected = line_prompter(
ScriptedTerminal::line_driven().with_line(""),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect("blank uses recommended defaults");
assert_eq!(selected, vec![ChoiceId::new("rust")]);
}
#[test]
fn line_confirm_parses_yes_no_and_default() {
assert!(
line_prompter(
ScriptedTerminal::line_driven().with_line("y"),
PromptMode::Interactive,
false
)
.confirm("Proceed?", false)
.expect("yes")
);
assert!(
!line_prompter(
ScriptedTerminal::line_driven().with_line("no"),
PromptMode::Interactive,
false
)
.confirm("Proceed?", true)
.expect("no")
);
assert!(
line_prompter(
ScriptedTerminal::line_driven().with_line(""),
PromptMode::Interactive,
false
)
.confirm("Proceed?", true)
.expect("blank uses default")
);
}
#[test]
fn line_text_reads_value_and_falls_back_to_default() {
let value = line_prompter(
ScriptedTerminal::line_driven().with_line("custom"),
PromptMode::Interactive,
false,
)
.text("Name?", Some("toven"))
.expect("typed value");
assert_eq!(value, "custom");
let value = line_prompter(
ScriptedTerminal::line_driven().with_line(""),
PromptMode::Interactive,
false,
)
.text("Name?", Some("toven"))
.expect("blank uses default");
assert_eq!(value, "toven");
}
#[test]
fn line_text_with_reasks_until_valid() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines([" ", "ok"]),
PromptMode::Interactive,
false,
);
let value = prompter
.text_with("Name?", None, &non_empty("required"))
.expect("valid after retry");
assert_eq!(value, "ok");
assert!(prompter.terminal().output().contains("required"));
}
#[test]
fn key_select_navigates_and_confirms() {
let choice = line_prompter(
ScriptedTerminal::key_driven().with_keys([Key::Down, Key::Enter]),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("arrow navigation selects");
assert_eq!(choice, ChoiceId::new("node"));
}
#[test]
fn key_select_starts_on_recommended_default() {
let choice = line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Enter),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("enter accepts default");
assert_eq!(choice, ChoiceId::new("rust"));
}
#[test]
fn key_select_escape_cancels() {
let err = line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Escape),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect_err("escape cancels");
assert!(err.message().contains("cancelled"));
}
#[test]
fn key_multi_select_toggles_selection() {
let selected = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::Space, Key::Down,
Key::Space, Key::Down,
Key::Space, Key::Enter,
]),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect("space toggles");
assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("node")]);
}
#[test]
fn key_confirm_reads_letter() {
assert!(
line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Char('y')),
PromptMode::Interactive,
false
)
.confirm("Proceed?", false)
.expect("y confirms")
);
}
#[test]
fn key_text_edits_with_backspace() {
let value = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::Char('h'),
Key::Char('i'),
Key::Char('x'),
Key::Backspace,
Key::Enter,
]),
PromptMode::Interactive,
false,
)
.text("Name?", None)
.expect("typed value");
assert_eq!(value, "hi");
}
#[test]
fn key_text_inserts_literal_space() {
let value = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::Char('h'),
Key::Char('i'),
Key::Space,
Key::Char('t'),
Key::Char('h'),
Key::Char('e'),
Key::Char('r'),
Key::Char('e'),
Key::Enter,
]),
PromptMode::Interactive,
false,
)
.text("Name?", None)
.expect("typed value");
assert_eq!(value, "hi there");
}
#[test]
fn key_select_runs_in_raw_mode_and_restores() {
let mut prompter = line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Enter),
PromptMode::Interactive,
false,
);
let _ = prompter
.select("Ecosystem?", &plain_choices())
.expect("select");
assert!(!prompter.terminal().is_interactive(), "raw mode restored");
}
#[test]
fn choice_metadata_round_trips_into_rendered_output() {
let choices = vec![
Choice::new("rust", "Rust")
.with_annotation("detected in dev-deps")
.recommended(),
Choice::new("go", "Go"),
];
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_line("1"),
PromptMode::Interactive,
false,
);
let _ = prompter.select("Ecosystem?", &choices).expect("select");
let rendered = prompter.terminal().output();
assert!(rendered.contains("Rust"));
assert!(rendered.contains("detected in dev-deps"));
assert!(rendered.contains("(recommended)"));
}
#[test]
fn disabled_palette_renders_without_ansi_escapes() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_line("1"),
PromptMode::Interactive,
false,
);
let _ = prompter
.select("Ecosystem?", &plain_choices())
.expect("select");
assert!(
!prompter.terminal().output().contains('\u{1b}'),
"no color must be byte-clean"
);
}
#[test]
fn enabled_palette_emits_ansi_escapes() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_line("1"),
PromptMode::Interactive,
true,
);
let _ = prompter
.select("Ecosystem?", &plain_choices())
.expect("select");
assert!(
prompter.terminal().output().contains('\u{1b}'),
"color must emit SGR escapes"
);
}
#[test]
fn empty_choice_set_is_rejected() {
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &[])
.expect_err("empty choices rejected");
assert!(err.message().contains("at least one choice"));
}
#[test]
fn multi_select_empty_choice_set_is_rejected() {
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &[])
.expect_err("empty choices rejected");
assert!(err.message().contains("at least one choice"));
}
#[test]
fn key_confirm_enter_default_letter_no_and_escape() {
assert!(
line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Enter),
PromptMode::Interactive,
false
)
.confirm("Proceed?", true)
.expect("enter accepts default")
);
assert!(
!line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Char('n')),
PromptMode::Interactive,
false
)
.confirm("Proceed?", true)
.expect("n declines")
);
assert!(
line_prompter(
ScriptedTerminal::key_driven().with_keys([Key::Left, Key::Char('Y')]),
PromptMode::Interactive,
false
)
.confirm("Proceed?", false)
.expect("Y confirms after an ignored key")
);
let err = line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Escape),
PromptMode::Interactive,
false,
)
.confirm("Proceed?", true)
.expect_err("escape cancels");
assert!(err.message().contains("cancelled"));
}
#[test]
fn line_confirm_reprompts_on_invalid_and_errors_on_close() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines(["maybe", "yes"]),
PromptMode::Interactive,
false,
);
assert!(
prompter
.confirm("Proceed?", false)
.expect("yes after retry")
);
assert!(prompter.terminal().output().contains("'y' or 'n'"));
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.confirm("Proceed?", true)
.expect_err("closed input must error");
assert!(err.message().contains("input closed"));
}
#[test]
fn key_select_home_end_and_up_navigate_and_ignore_unrelated_keys() {
let choice = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::End,
Key::Home,
Key::Down,
Key::Up,
Key::Tab,
Key::Left,
Key::Enter,
]),
PromptMode::Interactive,
false,
)
.select("Ecosystem?", &plain_choices())
.expect("navigation resolves");
assert_eq!(choice, ChoiceId::new("rust"));
}
#[test]
fn line_select_empty_without_default_requires_a_choice() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines(["", "2"]),
PromptMode::Interactive,
false,
);
let choice = prompter
.select("Ecosystem?", &no_default_choices())
.expect("valid choice after the required notice");
assert_eq!(choice, ChoiceId::new("rust"));
assert!(
prompter
.terminal()
.output()
.contains("a choice is required")
);
}
#[test]
fn key_multi_select_escape_cancels_and_navigation_wraps() {
let err = line_prompter(
ScriptedTerminal::key_driven().with_key(Key::Escape),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect_err("escape cancels");
assert!(err.message().contains("cancelled"));
let selected = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::End,
Key::Home,
Key::Up,
Key::Tab,
Key::Left,
Key::Space,
Key::Enter,
]),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &plain_choices())
.expect("navigation and toggle resolve");
assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("rust")]);
}
#[test]
fn line_multi_select_notice_none_hint_and_close() {
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines(["x", "1,2"]),
PromptMode::Interactive,
false,
);
let selected = prompter
.multi_select("Tasks?", &no_default_choices())
.expect("valid list after the notice");
assert_eq!(selected, vec![ChoiceId::new("go"), ChoiceId::new("rust")]);
let out = prompter.terminal().output();
assert!(out.contains("[none]"));
assert!(out.contains("comma-separated"));
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.multi_select("Tasks?", &no_default_choices())
.expect_err("closed input must error");
assert!(err.message().contains("input closed"));
}
#[test]
fn key_text_required_then_validator_reason_then_value() {
let reject_short = |value: &str| {
if value.len() >= 2 {
Ok(())
} else {
Err("too short".to_string())
}
};
let value = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::Enter,
Key::Char('h'),
Key::Char('i'),
Key::Enter,
]),
PromptMode::Interactive,
false,
)
.text("Name?", None)
.expect("value after the required notice");
assert_eq!(value, "hi");
let mut prompter = line_prompter(
ScriptedTerminal::key_driven().with_keys([
Key::Char('a'),
Key::Enter,
Key::Char('b'),
Key::Enter,
]),
PromptMode::Interactive,
false,
);
let value = prompter
.text_with("Name?", None, &reject_short)
.expect("valid after the reason");
assert_eq!(value, "ab");
assert!(prompter.terminal().output().contains("too short"));
}
#[test]
fn key_text_ignores_unrelated_keys_and_escape_cancels() {
let err = line_prompter(
ScriptedTerminal::key_driven().with_keys([Key::Left, Key::Escape]),
PromptMode::Interactive,
false,
)
.text("Name?", None)
.expect_err("escape cancels");
assert!(err.message().contains("cancelled"));
}
#[test]
fn line_text_errors_when_input_closes() {
let err = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::Interactive,
false,
)
.text("Name?", None)
.expect_err("closed input must error");
assert!(err.message().contains("input closed"));
}
#[test]
fn line_text_with_shows_validator_reason_before_accepting() {
let reject_short = |value: &str| {
if value.len() >= 2 {
Ok(())
} else {
Err("too short".to_string())
}
};
let mut prompter = line_prompter(
ScriptedTerminal::line_driven().with_lines(["a", "ok"]),
PromptMode::Interactive,
false,
);
let value = prompter
.text_with("Name?", None, &reject_short)
.expect("valid after the reason");
assert_eq!(value, "ok");
assert!(prompter.terminal().output().contains("too short"));
}
#[test]
fn from_env_builds_a_prompter_and_reports_its_mode() {
let prompter = Prompter::from_env(ColorChoice::Never);
assert_eq!(prompter.mode(), PromptMode::NonInteractive);
}
#[test]
fn with_glyphs_overrides_symbols_and_preserves_mode() {
let prompter = line_prompter(
ScriptedTerminal::line_driven(),
PromptMode::NonInteractive,
false,
)
.with_glyphs(Glyphs::new(true));
assert_eq!(prompter.mode(), PromptMode::NonInteractive);
}
}