use std::future::Future;
use std::io::Write;
use std::pin::Pin;
use async_trait::async_trait;
use bon::Builder;
use clawless_core::context::Interactivity;
use clawless_core::event::process::ProcessEvent;
use clawless_core::event::prompt::PromptRequest;
use clawless_core::event::{Event, EventReceiver};
use clawless_core::exit::Exit;
use clawless_core::process::Stream;
use super::Presenter;
use super::line_question;
use super::line_reader::LineReader;
use crate::error::CommandResult;
use crate::output::OutputMode;
use crate::output::Verbosity;
#[derive(Debug, Builder)]
pub struct TerminalPresenter {
#[builder(default)]
verbosity: Verbosity,
#[builder(default)]
mode: OutputMode,
#[builder(default)]
interactivity: Interactivity,
receiver: EventReceiver,
}
fn is_diagnostic(event: &ProcessEvent) -> bool {
match event {
ProcessEvent::Started { .. } => false,
ProcessEvent::Finished { .. } => false,
ProcessEvent::Line { line, .. } => match line.stream() {
Stream::StandardError => true,
Stream::StandardOutput => false,
},
}
}
#[allow(clippy::expect_used)]
fn render_event(
event: Event,
verbosity: Verbosity,
mode: OutputMode,
stdout: &mut impl Write,
stderr: &mut impl Write,
) {
match event {
Event::Message(msg) => match verbosity {
Verbosity::Quiet => {}
Verbosity::Default | Verbosity::Verbose => match mode {
OutputMode::Text => writeln!(stdout, "{msg}").expect("should write message"),
OutputMode::Json => writeln!(stderr, "{msg}").expect("should write message"),
},
},
Event::Detail(msg) => match verbosity {
Verbosity::Quiet | Verbosity::Default => {}
Verbosity::Verbose => match mode {
OutputMode::Text => writeln!(stdout, "{msg}").expect("should write detail"),
OutputMode::Json => writeln!(stderr, "{msg}").expect("should write detail"),
},
},
Event::Artifact(artifact) => {
let line = match mode {
OutputMode::Text => artifact.to_string(),
OutputMode::Json => {
serde_json::to_string(&artifact).expect("should serialize artifact to JSON")
}
};
writeln!(stdout, "{line}").expect("should write artifact");
}
Event::Process(event) => match verbosity {
Verbosity::Quiet | Verbosity::Default => {}
Verbosity::Verbose => {
let to_stderr = match mode {
OutputMode::Json => true,
OutputMode::Text => is_diagnostic(&event),
};
if to_stderr {
writeln!(stderr, "{event}").expect("should write process event");
} else {
writeln!(stdout, "{event}").expect("should write process event");
}
}
},
Event::Prompt(request) => drop(request),
}
}
async fn ask_user(request: PromptRequest, input: &mut LineReader, display: &mut impl Write) {
match request {
PromptRequest::Confirm { question, reply } => {
line_question::ask(&question, reply, input, display).await;
}
PromptRequest::Select { question, reply } => {
if question.options().is_empty() {
drop(reply);
} else {
line_question::ask(&question, reply, input, display).await;
}
}
PromptRequest::Text { question, reply } => {
line_question::ask(&question, reply, input, display).await;
}
}
}
impl TerminalPresenter {
async fn present_on(
self,
command: Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>>,
stdout: &mut impl Write,
stderr: &mut impl Write,
mut input: Option<LineReader>,
) -> CommandResult<Exit> {
let Self {
verbosity,
mode,
interactivity: _,
mut receiver,
} = self;
let command_handle = tokio::spawn(command);
while let Some(event) = receiver.recv().await {
match event {
Event::Prompt(request) => match &mut input {
Some(input) => ask_user(*request, input, stderr).await,
None => drop(request),
},
Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Process(_) => {
render_event(event, verbosity, mode, stdout, stderr);
}
}
}
#[allow(clippy::expect_used)]
command_handle.await.expect("command task panicked")
}
}
#[async_trait(?Send)]
impl Presenter for TerminalPresenter {
async fn present(
self,
command: Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>>,
) -> CommandResult<Exit> {
let input = match self.interactivity {
Interactivity::Interactive => Some(LineReader::stdin()),
Interactivity::NonInteractive => None,
};
self.present_on(
command,
&mut std::io::stdout(),
&mut std::io::stderr(),
input,
)
.await
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use std::collections::VecDeque;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use clawless_core::context::Interactivity;
use clawless_core::event::process::{Outcome, RunId};
use clawless_core::event::prompt::PromptRequest;
use clawless_core::event::{EventSender, event_channel};
use clawless_core::output::Output;
use clawless_core::process::Invocation;
use clawless_core::process::Line;
use clawless_core::prompt::{Confirm, Confirmation, Prompt, Select};
use super::*;
#[derive(Clone, Debug, Default)]
struct Transcript {
bytes: Arc<Mutex<Vec<u8>>>,
}
impl Transcript {
fn text(&self) -> String {
String::from_utf8_lossy(&self.bytes.lock().expect("should lock")).into_owned()
}
}
impl Write for Transcript {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.bytes
.lock()
.expect("should lock")
.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn confirming(
sender: EventSender,
) -> Pin<Box<dyn Future<Output = CommandResult<Exit>> + Send>> {
Box::pin(async move {
let output = Output::new(sender);
let prompt = Prompt::builder()
.output(output.clone())
.interactivity(Interactivity::Interactive)
.build();
output.message("About to release 1.4.0.").await?;
let answer = prompt
.confirm(Confirm::new("Release?").with_default(Confirmation::No))
.await?;
output.message(format!("The user said {answer:?}.")).await?;
Ok(Exit::from(()))
})
}
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())
})
}
#[test]
fn builder_with_defaults_uses_default_verbosity_and_mode() {
let (_sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
assert_eq!(presenter.verbosity, Verbosity::Default);
assert_eq!(presenter.mode, OutputMode::Text);
}
#[test]
fn builder_with_mode_uses_provided_mode() {
let (_sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder()
.receiver(receiver)
.mode(OutputMode::Json)
.build();
assert_eq!(presenter.mode, OutputMode::Json);
}
#[test]
fn builder_with_verbosity_uses_provided_verbosity() {
let (_sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder()
.receiver(receiver)
.verbosity(Verbosity::Verbose)
.build();
assert_eq!(presenter.verbosity, Verbosity::Verbose);
}
#[test]
fn is_diagnostic_with_a_finished_run_returns_false() {
let event = ProcessEvent::Finished {
id: RunId::next(),
invocation: Invocation::new("git"),
outcome: Outcome::Incomplete,
duration: Duration::ZERO,
};
let diagnostic = is_diagnostic(&event);
assert!(!diagnostic);
}
#[test]
fn is_diagnostic_with_a_standard_error_line_returns_true() {
let event = ProcessEvent::Line {
id: RunId::next(),
line: Line::new(Stream::StandardError, "no such file"),
};
let diagnostic = is_diagnostic(&event);
assert!(diagnostic);
}
#[test]
fn is_diagnostic_with_a_standard_output_line_returns_false() {
let event = ProcessEvent::Line {
id: RunId::next(),
line: Line::new(Stream::StandardOutput, "hello"),
};
let diagnostic = is_diagnostic(&event);
assert!(!diagnostic);
}
#[test]
fn is_diagnostic_with_a_started_run_returns_false() {
let event = ProcessEvent::Started {
id: RunId::next(),
invocation: Invocation::new("git"),
process_id: None,
};
let diagnostic = is_diagnostic(&event);
assert!(!diagnostic);
}
#[tokio::test]
async fn present_consumes_events_from_channel() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
drop(
presenter
.present(Box::pin(async move {
sender
.send(Event::Message("consumed".to_string()))
.await
.expect("should send");
Ok(Exit::from(()))
}))
.await
.expect("should succeed"),
);
}
#[tokio::test]
async fn present_on_with_a_prompt_asks_after_the_earlier_output() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let transcript = Transcript::default();
drop(
presenter
.present_on(
confirming(sender),
&mut transcript.clone(),
&mut transcript.clone(),
Some(typed(&["y\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(
transcript.text(),
"About to release 1.4.0.\nRelease? [y/N] The user said Yes.\n"
);
}
#[tokio::test]
async fn present_on_with_a_prompt_at_quiet_verbosity_still_asks() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder()
.receiver(receiver)
.verbosity(Verbosity::Quiet)
.build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
drop(
presenter
.present_on(
confirming(sender),
&mut stdout,
&mut stderr,
Some(typed(&["y\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
}
#[tokio::test]
async fn present_on_with_a_prompt_in_json_mode_keeps_the_question_off_stdout() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder()
.receiver(receiver)
.mode(OutputMode::Json)
.build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
drop(
presenter
.present_on(
confirming(sender),
&mut stdout,
&mut stderr,
Some(typed(&["y\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(String::from_utf8_lossy(&stdout), "");
}
#[tokio::test]
async fn present_on_with_a_prompt_in_text_mode_asks_on_stderr() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
drop(
presenter
.present_on(
confirming(sender),
&mut stdout,
&mut stderr,
Some(typed(&["y\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(String::from_utf8_lossy(&stderr), "Release? [y/N] ");
}
#[tokio::test]
async fn present_on_with_a_selection_sends_the_position_to_the_command() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let transcript = Transcript::default();
drop(
presenter
.present_on(
Box::pin(async move {
let output = Output::new(sender);
let prompt = Prompt::builder()
.output(output.clone())
.interactivity(Interactivity::Interactive)
.build();
let kind = prompt.select("Kind", ["Added", "Fixed"]).await?;
output.message(format!("The kind is {kind}.")).await?;
Ok(Exit::from(()))
}),
&mut transcript.clone(),
&mut transcript.clone(),
Some(typed(&["2\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(
transcript.text(),
"Kind:\n 1) Added\n 2) Fixed\nEnter a number [1-2]: The kind is Fixed.\n"
);
}
#[tokio::test]
async fn present_on_with_a_selection_without_options_drops_the_prompt() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let error = presenter
.present_on(
Box::pin(async move {
let options: [&str; 0] = [];
let (request, pending) = PromptRequest::select(Select::new("Kind", options));
Output::new(sender).prompt(request).await?;
pending.wait().await?;
Ok(Exit::from(()))
}),
&mut stdout,
&mut stderr,
Some(typed(&["1\n"])),
)
.await
.expect_err("should fail");
assert_eq!(
error.to_string(),
"the prompt was dropped without an answer"
);
}
#[tokio::test]
async fn present_on_with_a_text_prompt_sends_the_line_to_the_command() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let transcript = Transcript::default();
drop(
presenter
.present_on(
Box::pin(async move {
let output = Output::new(sender);
let prompt = Prompt::builder()
.output(output.clone())
.interactivity(Interactivity::Interactive)
.build();
let title = prompt.text("Title").await?;
output.message(format!("The title is {title}.")).await?;
Ok(Exit::from(()))
}),
&mut transcript.clone(),
&mut transcript.clone(),
Some(typed(&["Fix the race\n"])),
)
.await
.expect("should succeed"),
);
assert_eq!(transcript.text(), "Title: The title is Fix the race.\n");
}
#[tokio::test]
async fn present_on_without_an_input_drops_the_prompt() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let error = presenter
.present_on(confirming(sender), &mut stdout, &mut stderr, None)
.await
.expect_err("should fail");
assert_eq!(
error.root_cause().to_string(),
"the prompt was dropped without an answer"
);
}
#[tokio::test]
async fn present_on_writes_the_events_of_the_command_to_the_streams() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
drop(
presenter
.present_on(
Box::pin(async move {
sender
.send(Event::Message("hello".to_owned()))
.await
.expect("should send");
Ok(Exit::from(()))
}),
&mut stdout,
&mut stderr,
None,
)
.await
.expect("should succeed"),
);
assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
}
#[tokio::test]
async fn present_with_a_prompt_drops_the_request() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let error = presenter
.present(Box::pin(async move {
let (request, pending) = PromptRequest::confirm(Confirm::new("Release?"));
sender
.send(Event::Prompt(Box::new(request)))
.await
.expect("should send");
pending.wait().await?;
Ok(Exit::from(()))
}))
.await
.expect_err("should fail");
assert_eq!(
error.to_string(),
"the prompt was dropped without an answer"
);
}
#[tokio::test]
async fn present_with_an_exit_returns_the_exit() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let exit = presenter
.present(Box::pin(async move {
drop(sender);
Ok(Exit::builder().code(ExitCode::from(3)).build())
}))
.await
.expect("should succeed");
assert_eq!(exit, Exit::builder().code(ExitCode::from(3)).build());
}
#[tokio::test]
async fn present_with_error_propagates_error() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
let error = presenter
.present(Box::pin(async move {
drop(sender);
Err(anyhow::anyhow!("command failed"))
}))
.await
.expect_err("should fail");
assert_eq!(error.to_string(), "command failed");
}
#[tokio::test]
async fn present_with_receiver_keeps_channel_open_during_execution() {
let (sender, receiver) = event_channel();
let presenter = TerminalPresenter::builder().receiver(receiver).build();
drop(
presenter
.present(Box::pin(async move {
sender
.send(clawless_core::event::Event::Message("hello".to_string()))
.await
.expect("should send while presenter holds receiver");
Ok(Exit::from(()))
}))
.await
.expect("should succeed"),
);
}
#[test]
fn render_event_with_a_detail_at_default_verbosity_writes_nothing() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Detail("reading".to_owned()),
Verbosity::Default,
OutputMode::Text,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
}
#[test]
fn render_event_with_a_detail_at_verbose_verbosity_writes_to_stdout() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Detail("reading".to_owned()),
Verbosity::Verbose,
OutputMode::Text,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (b"reading\n".to_vec(), Vec::new()));
}
#[test]
fn render_event_with_a_message_at_quiet_verbosity_writes_nothing() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Message("hello".to_owned()),
Verbosity::Quiet,
OutputMode::Text,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (Vec::new(), Vec::new()));
}
#[test]
fn render_event_with_a_message_in_json_mode_writes_to_stderr() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Message("hello".to_owned()),
Verbosity::Default,
OutputMode::Json,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (Vec::new(), b"hello\n".to_vec()));
}
#[test]
fn render_event_with_a_message_in_text_mode_writes_to_stdout() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Message("hello".to_owned()),
Verbosity::Default,
OutputMode::Text,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (b"hello\n".to_vec(), Vec::new()));
}
#[test]
fn render_event_with_a_standard_error_line_in_text_mode_writes_to_stderr() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Process(Box::new(ProcessEvent::Line {
id: RunId::next(),
line: Line::new(Stream::StandardError, "no such file"),
})),
Verbosity::Verbose,
OutputMode::Text,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (Vec::new(), b"no such file\n".to_vec()));
}
#[test]
fn render_event_with_an_artifact_in_json_mode_writes_json_to_stdout() {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
render_event(
Event::Artifact(Box::new(42)),
Verbosity::Quiet,
OutputMode::Json,
&mut stdout,
&mut stderr,
);
assert_eq!((stdout, stderr), (b"42\n".to_vec(), Vec::new()));
}
#[test]
fn trait_send() {
fn assert_send<T: Send>() {}
assert_send::<TerminalPresenter>();
}
#[test]
fn trait_unpin() {
fn assert_unpin<T: Unpin>() {}
assert_unpin::<TerminalPresenter>();
}
}