use std::fmt::Display;
use bon::Builder;
use getset::CopyGetters;
pub use self::answer_prompt_error::AnswerPromptError;
pub use self::confirm::Confirm;
pub use self::confirmation::Confirmation;
pub use self::prompt_user_error::PromptUserError;
pub use self::scripted_answer::ScriptedAnswer;
pub use self::scripted_user::ScriptedUser;
pub use self::select::Select;
pub use self::text::Text;
use crate::cancellation::Cancellation;
use crate::context::Interactivity;
use crate::event::prompt::{PendingAnswer, PromptRequest};
use crate::output::Output;
mod answer_prompt_error;
mod confirm;
mod confirmation;
mod prompt_user_error;
mod scripted_answer;
mod scripted_user;
mod select;
mod text;
#[derive(Clone, Debug, Builder, CopyGetters)]
pub struct Prompt {
output: Output,
#[builder(default)]
cancellation: Cancellation,
#[builder(default)]
#[getset(get_copy = "pub")]
interactivity: Interactivity,
}
impl Prompt {
pub async fn confirm(
&self,
question: impl Into<Confirm>,
) -> Result<Confirmation, PromptUserError> {
let question = question.into();
let text = question.question().clone();
let (request, pending) = PromptRequest::confirm(question);
self.ask(text, request, pending).await
}
pub async fn select<T: Display>(
&self,
question: impl Into<String>,
options: impl IntoIterator<Item = T>,
) -> Result<T, PromptUserError> {
let question = question.into();
let mut options = options.into_iter().collect::<Vec<_>>();
let count = options.len();
if options.is_empty() {
return Err(PromptUserError::MissingOptions { question });
}
let select = Select::new(question.clone(), options.iter().map(ToString::to_string));
let (request, pending) = PromptRequest::select(select);
let index = self.ask(question.clone(), request, pending).await?;
if index < count {
Ok(options.swap_remove(index))
} else {
Err(PromptUserError::UnknownOption {
question,
index,
count,
})
}
}
pub async fn text(&self, question: impl Into<Text>) -> Result<String, PromptUserError> {
let question = question.into();
let text = question.question().clone();
let (request, pending) = PromptRequest::text(question);
self.ask(text, request, pending).await
}
async fn ask<T>(
&self,
question: String,
request: PromptRequest,
pending: PendingAnswer<T>,
) -> Result<T, PromptUserError> {
match self.interactivity {
Interactivity::Interactive => {}
Interactivity::NonInteractive => return Err(PromptUserError::AbsentUser { question }),
}
let sent = tokio::select! {
biased;
() = self.cancellation.cancelled() => {
return Err(PromptUserError::CancelledPrompt { question });
}
sent = self.output.prompt(request) => sent,
};
if let Err(source) = sent {
return Err(PromptUserError::UndeliverablePrompt { question, source });
}
let answer = tokio::select! {
biased;
() = self.cancellation.cancelled() => {
return Err(PromptUserError::CancelledPrompt { question });
}
answer = pending.wait() => answer,
};
answer.map_err(|source| PromptUserError::UnansweredPrompt { question, source })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use super::*;
use crate::event::{Event, EventReceiver, event_channel};
fn interactive() -> (Prompt, EventReceiver) {
let (sender, receiver) = event_channel();
let prompt = Prompt::builder()
.output(Output::new(sender))
.interactivity(Interactivity::Interactive)
.build();
(prompt, receiver)
}
async fn next_request(receiver: &mut EventReceiver) -> Option<PromptRequest> {
while let Some(event) = receiver.recv().await {
match event {
Event::Prompt(request) => return Some(*request),
Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {}
}
}
None
}
fn is_absent_user(error: &PromptUserError) -> bool {
match error {
PromptUserError::AbsentUser { .. } => true,
PromptUserError::CancelledPrompt { .. } => false,
PromptUserError::UnansweredPrompt { .. } => false,
PromptUserError::UndeliverablePrompt { .. } => false,
PromptUserError::MissingOptions { .. } => false,
PromptUserError::UnknownOption { .. } => false,
}
}
fn is_cancelled(error: &PromptUserError) -> bool {
match error {
PromptUserError::CancelledPrompt { .. } => true,
PromptUserError::AbsentUser { .. } => false,
PromptUserError::UnansweredPrompt { .. } => false,
PromptUserError::UndeliverablePrompt { .. } => false,
PromptUserError::MissingOptions { .. } => false,
PromptUserError::UnknownOption { .. } => false,
}
}
fn is_dropped(error: &PromptUserError) -> bool {
match error {
PromptUserError::UnansweredPrompt { source, .. } => match source {
AnswerPromptError::DroppedRequest => true,
AnswerPromptError::ClosedInput => false,
AnswerPromptError::UnscriptedPrompt => false,
AnswerPromptError::UnusableTerminal { .. } => false,
},
PromptUserError::AbsentUser { .. } => false,
PromptUserError::CancelledPrompt { .. } => false,
PromptUserError::UndeliverablePrompt { .. } => false,
PromptUserError::MissingOptions { .. } => false,
PromptUserError::UnknownOption { .. } => false,
}
}
fn is_undeliverable(error: &PromptUserError) -> bool {
match error {
PromptUserError::UndeliverablePrompt { .. } => true,
PromptUserError::AbsentUser { .. } => false,
PromptUserError::CancelledPrompt { .. } => false,
PromptUserError::UnansweredPrompt { .. } => false,
PromptUserError::MissingOptions { .. } => false,
PromptUserError::UnknownOption { .. } => false,
}
}
#[tokio::test]
async fn confirm_sends_the_question_with_its_default() {
let (prompt, mut receiver) = interactive();
let question = Confirm::new("Release?").with_default(Confirmation::No);
let asked = question.clone();
let command = tokio::spawn(async move { prompt.confirm(asked).await });
let request = next_request(&mut receiver).await;
command.abort();
assert_eq!(
request.and_then(|request| match request {
PromptRequest::Confirm { question, .. } => Some(question),
PromptRequest::Select { .. } | PromptRequest::Text { .. } => None,
}),
Some(question)
);
}
#[tokio::test]
async fn confirm_with_a_cancelled_token_returns_an_error() {
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let prompt = Prompt::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.interactivity(Interactivity::Interactive)
.build();
cancellation.cancel();
let error = prompt.confirm("Release?").await.expect_err("should fail");
assert!(is_cancelled(&error));
}
#[tokio::test]
async fn confirm_with_a_cancelled_token_sends_no_request() {
let cancellation = Cancellation::new();
let (sender, mut receiver) = event_channel();
let prompt = Prompt::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.interactivity(Interactivity::Interactive)
.build();
cancellation.cancel();
drop(prompt.confirm("Release?").await);
drop(prompt);
assert!(next_request(&mut receiver).await.is_none());
}
#[tokio::test]
async fn confirm_with_a_closed_channel_returns_an_error() {
let (prompt, receiver) = interactive();
drop(receiver);
let error = prompt.confirm("Release?").await.expect_err("should fail");
assert!(is_undeliverable(&error));
}
#[tokio::test]
async fn confirm_with_a_dropped_request_returns_an_error() {
let (prompt, mut receiver) = interactive();
let presenter = tokio::spawn(async move { drop(next_request(&mut receiver).await) });
let error = prompt.confirm("Release?").await.expect_err("should fail");
presenter.await.expect("should join");
assert!(is_dropped(&error));
}
#[tokio::test]
async fn confirm_with_an_answer_returns_it() {
let (prompt, mut receiver) = interactive();
let presenter = tokio::spawn(async move {
match next_request(&mut receiver).await {
Some(PromptRequest::Confirm { reply, .. }) => reply.answer(Confirmation::Yes),
Some(PromptRequest::Select { .. } | PromptRequest::Text { .. }) | None => {}
}
});
let answer = prompt.confirm("Release?").await.expect("should answer");
presenter.await.expect("should join");
assert_eq!(answer, Confirmation::Yes);
}
#[tokio::test]
async fn confirm_with_cancellation_while_it_waits_abandons_the_reply() {
let cancellation = Cancellation::new();
let (sender, mut receiver) = event_channel();
let prompt = Prompt::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.interactivity(Interactivity::Interactive)
.build();
let command = tokio::spawn(async move { prompt.confirm("Release?").await });
let request = next_request(&mut receiver).await;
cancellation.cancel();
drop(command.await.expect("should join"));
assert_eq!(
request.map(|request| match request {
PromptRequest::Confirm { reply, .. } => reply.is_abandoned(),
PromptRequest::Select { reply, .. } => reply.is_abandoned(),
PromptRequest::Text { reply, .. } => reply.is_abandoned(),
}),
Some(true)
);
}
#[tokio::test]
async fn confirm_with_cancellation_while_it_waits_returns_an_error() {
let cancellation = Cancellation::new();
let (sender, mut receiver) = event_channel();
let prompt = Prompt::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.interactivity(Interactivity::Interactive)
.build();
let command = tokio::spawn(async move { prompt.confirm("Release?").await });
let _request = next_request(&mut receiver).await;
cancellation.cancel();
let error = command
.await
.expect("should join")
.expect_err("should fail");
assert!(is_cancelled(&error));
}
#[tokio::test]
async fn confirm_without_a_user_returns_an_error() {
let (sender, _receiver) = event_channel();
let prompt = Prompt::builder().output(Output::new(sender)).build();
let error = prompt.confirm("Release?").await.expect_err("should fail");
assert!(is_absent_user(&error));
}
#[tokio::test]
async fn confirm_without_a_user_sends_no_request() {
let (sender, mut receiver) = event_channel();
let prompt = Prompt::builder().output(Output::new(sender)).build();
drop(prompt.confirm("Release?").await);
drop(prompt);
assert!(next_request(&mut receiver).await.is_none());
}
#[test]
fn interactivity_with_defaults_is_non_interactive() {
let (sender, _receiver) = event_channel();
let prompt = Prompt::builder().output(Output::new(sender)).build();
assert_eq!(prompt.interactivity(), Interactivity::NonInteractive);
}
#[tokio::test]
async fn select_sends_the_options_as_their_display_text() {
let (prompt, mut receiver) = interactive();
let command = tokio::spawn(async move { prompt.select("Port", [80, 443]).await });
let request = next_request(&mut receiver).await;
command.abort();
assert_eq!(
request.and_then(|request| match request {
PromptRequest::Select { question, .. } => Some(question),
PromptRequest::Confirm { .. } | PromptRequest::Text { .. } => None,
}),
Some(Select::new("Port", ["80", "443"]))
);
}
#[tokio::test]
async fn select_with_an_answer_returns_the_option_at_that_index() {
let (prompt, mut receiver) = interactive();
let presenter = tokio::spawn(async move {
match next_request(&mut receiver).await {
Some(PromptRequest::Select { reply, .. }) => reply.answer(1),
Some(PromptRequest::Confirm { .. } | PromptRequest::Text { .. }) | None => {}
}
});
let answer = prompt
.select("Port", [80, 443])
.await
.expect("should answer");
presenter.await.expect("should join");
assert_eq!(answer, 443);
}
#[tokio::test]
async fn select_with_an_index_beyond_the_options_returns_an_error() {
let (prompt, mut receiver) = interactive();
let presenter = tokio::spawn(async move {
match next_request(&mut receiver).await {
Some(PromptRequest::Select { reply, .. }) => reply.answer(2),
Some(PromptRequest::Confirm { .. } | PromptRequest::Text { .. }) | None => {}
}
});
let error = prompt
.select("Port", [80, 443])
.await
.expect_err("should fail");
presenter.await.expect("should join");
assert_eq!(
error.to_string(),
"the answer to the prompt `Port` names option 2, and the prompt has 2 options"
);
}
#[tokio::test]
async fn select_without_a_user_returns_an_error() {
let (sender, _receiver) = event_channel();
let prompt = Prompt::builder().output(Output::new(sender)).build();
let error = prompt
.select("Port", [80, 443])
.await
.expect_err("should fail");
assert!(is_absent_user(&error));
}
#[tokio::test]
async fn select_without_options_returns_an_error() {
let (prompt, _receiver) = interactive();
let error = prompt
.select("Port", Vec::<u16>::new())
.await
.expect_err("should fail");
assert_eq!(
error.to_string(),
"the prompt `Port` has no options to select from"
);
}
#[tokio::test]
async fn text_with_an_answer_returns_it() {
let (prompt, mut receiver) = interactive();
let presenter = tokio::spawn(async move {
match next_request(&mut receiver).await {
Some(PromptRequest::Text { reply, .. }) => reply.answer("Fix the race".to_owned()),
Some(PromptRequest::Confirm { .. } | PromptRequest::Select { .. }) | None => {}
}
});
let answer = prompt.text("Title").await.expect("should answer");
presenter.await.expect("should join");
assert_eq!(answer, "Fix the race");
}
#[tokio::test]
async fn text_without_a_user_returns_an_error() {
let (sender, _receiver) = event_channel();
let prompt = Prompt::builder().output(Output::new(sender)).build();
let error = prompt.text("Title").await.expect_err("should fail");
assert!(is_absent_user(&error));
}
#[test]
fn trait_send() {
fn assert_send<T: Send>() {}
assert_send::<Prompt>();
}
#[test]
fn trait_sync() {
fn assert_sync<T: Sync>() {}
assert_sync::<Prompt>();
}
#[test]
fn trait_unpin() {
fn assert_unpin<T: Unpin>() {}
assert_unpin::<Prompt>();
}
}