use std::io::{self, Write};
use clawless_core::event::prompt::Reply;
use clawless_core::prompt::AnswerPromptError;
use super::line_reader::LineReader;
mod confirm;
mod select;
mod text;
pub(super) trait LineQuestion {
type Answer;
fn introduction(&self) -> Option<String> {
None
}
fn prompt(&self) -> String;
fn parse(&self, line: &str) -> Result<Self::Answer, String>;
}
fn label(question: &str) -> String {
if question.ends_with([':', '?']) {
question.to_owned()
} else {
format!("{question}:")
}
}
pub(super) async fn ask<Q: LineQuestion>(
question: &Q,
mut reply: Reply<Q::Answer>,
input: &mut LineReader,
display: &mut impl Write,
) {
if reply.is_abandoned() {
return;
}
match converse(question, &mut reply, input, display).await {
Ok(Some(answer)) => reply.answer(answer),
Ok(None) => {}
Err(error) => reply.fail(error),
}
}
async fn converse<Q: LineQuestion>(
question: &Q,
reply: &mut Reply<Q::Answer>,
input: &mut LineReader,
display: &mut impl Write,
) -> Result<Option<Q::Answer>, AnswerPromptError> {
if let Some(introduction) = question.introduction() {
write!(display, "{introduction}").map_err(unusable)?;
}
loop {
write!(display, "{}", question.prompt()).map_err(unusable)?;
display.flush().map_err(unusable)?;
let line = tokio::select! {
biased;
() = reply.abandoned() => {
writeln!(display).map_err(unusable)?;
return Ok(None);
}
line = input.next_line() => line.map_err(unusable)?,
};
let Some(line) = line else {
writeln!(display).map_err(unusable)?;
return Err(AnswerPromptError::ClosedInput);
};
match question.parse(&line) {
Ok(answer) => return Ok(Some(answer)),
Err(hint) => writeln!(display, "{hint}").map_err(unusable)?,
}
}
}
fn unusable(source: io::Error) -> AnswerPromptError {
AnswerPromptError::UnusableTerminal { source }
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use std::collections::VecDeque;
use std::sync::mpsc;
use clawless_core::event::prompt::{PendingAnswer, PromptRequest};
use clawless_core::prompt::{Confirm, Confirmation, Select};
use super::*;
fn request() -> (Confirm, Reply<Confirmation>, PendingAnswer<Confirmation>) {
let question = Confirm::new("Release?");
let (request, pending) = PromptRequest::confirm(question.clone());
match request {
PromptRequest::Confirm { reply, .. } => (question, reply, pending),
PromptRequest::Select { .. } | PromptRequest::Text { .. } => {
unreachable!("the request is a confirmation")
}
}
}
fn typed(lines: &[&str]) -> LineReader {
let mut lines = lines
.iter()
.map(|line| (*line).to_owned())
.collect::<VecDeque<_>>();
LineReader::new(move |buffer| {
let line = lines.pop_front().unwrap_or_default();
buffer.push_str(&line);
Ok(line.len())
})
}
#[tokio::test]
async fn ask_with_a_command_that_stops_waiting_ends_the_line_of_the_question() {
let (question, reply, pending) = request();
let mut display = Vec::new();
let (_feed, source) = mpsc::channel::<String>();
let mut input = LineReader::new(move |buffer| {
let line = source.recv().unwrap_or_default();
buffer.push_str(&line);
Ok(line.len())
});
let command = tokio::spawn(async move {
tokio::task::yield_now().await;
drop(pending);
});
ask(&question, reply, &mut input, &mut display).await;
command.await.expect("should join");
assert_eq!(String::from_utf8_lossy(&display), "Release? [y/n] \n");
}
#[tokio::test]
async fn ask_with_a_line_that_is_no_answer_shows_the_hint_and_asks_again() {
let (question, reply, _pending) = request();
let mut display = Vec::new();
ask(
&question,
reply,
&mut typed(&["maybe\n", "n\n"]),
&mut display,
)
.await;
assert_eq!(
String::from_utf8_lossy(&display),
"Release? [y/n] Please answer \"y\" or \"n\".\nRelease? [y/n] "
);
}
#[tokio::test]
async fn ask_with_a_selection_shows_the_options_once() {
let (request, _pending) =
PromptRequest::select(Select::new("Kind of change", ["Added", "Fixed"]));
let mut display = Vec::new();
match request {
PromptRequest::Select { question, reply } => {
ask(&question, reply, &mut typed(&["9\n", "2\n"]), &mut display).await;
}
PromptRequest::Confirm { .. } | PromptRequest::Text { .. } => {}
}
assert_eq!(
String::from_utf8_lossy(&display),
"Kind of change:\n 1) Added\n 2) Fixed\n\
Enter a number [1-2]: Please enter a number from 1 to 2.\n\
Enter a number [1-2]: "
);
}
#[tokio::test]
async fn ask_with_an_abandoned_reply_shows_nothing() {
let (question, reply, pending) = request();
let mut display = Vec::new();
drop(pending);
ask(&question, reply, &mut typed(&["y\n"]), &mut display).await;
assert_eq!(String::from_utf8_lossy(&display), "");
}
#[tokio::test]
async fn ask_with_an_answer_sends_it_to_the_command() {
let (question, reply, pending) = request();
let mut display = Vec::new();
ask(&question, reply, &mut typed(&["y\n"]), &mut display).await;
let answer = pending.wait().await.expect("should answer");
assert_eq!(answer, Confirmation::Yes);
}
#[tokio::test]
async fn ask_with_the_end_of_the_input_ends_the_line_of_the_question() {
let (question, reply, _pending) = request();
let mut display = Vec::new();
ask(&question, reply, &mut typed(&[]), &mut display).await;
assert_eq!(String::from_utf8_lossy(&display), "Release? [y/n] \n");
}
#[tokio::test]
async fn ask_with_the_end_of_the_input_reports_the_closed_input() {
let (question, reply, pending) = request();
let mut display = Vec::new();
ask(&question, reply, &mut typed(&[]), &mut display).await;
let error = pending.wait().await.expect_err("should fail");
assert_eq!(
error.to_string(),
"the input ended before the user answered"
);
}
}