use std::future::Future;
use std::io::IsTerminal;
use clawless_core::cancellation::Cancellation;
use clawless_core::context::{Context, Interactivity};
use clawless_core::event::event_channel;
use clawless_core::exit::Exit;
use clawless_core::output::Output;
use clawless_core::signal::wait_for_shutdown;
use crate::error::{CommandResult, ErrorContext};
use crate::output::OutputFlags;
use crate::presenter::{Presenter, TerminalPresenter};
fn detect_interactivity(input: &impl IsTerminal, display: &impl IsTerminal) -> Interactivity {
match (input.is_terminal(), display.is_terminal()) {
(true, true) => Interactivity::Interactive,
(true, false) => Interactivity::NonInteractive,
(false, true) => Interactivity::NonInteractive,
(false, false) => Interactivity::NonInteractive,
}
}
#[derive(Debug)]
pub struct CommandRunner;
impl CommandRunner {
pub fn run<E, F, T>(matches: clap::ArgMatches, exec: E) -> Exit
where
E: FnOnce(clap::ArgMatches, Context) -> F,
F: Future<Output = CommandResult<T>> + Send + 'static,
T: Into<Exit>,
{
let interactivity = detect_interactivity(&std::io::stdin(), &std::io::stderr());
Self::run_with(matches, exec, interactivity)
}
fn run_with<E, F, T>(matches: clap::ArgMatches, exec: E, interactivity: Interactivity) -> Exit
where
E: FnOnce(clap::ArgMatches, Context) -> F,
F: Future<Output = CommandResult<T>> + Send + 'static,
T: Into<Exit>,
{
Self::try_run(matches, exec, interactivity).unwrap_or_else(|error| Exit::from_error(&error))
}
fn try_run<E, F, T>(
matches: clap::ArgMatches,
exec: E,
interactivity: Interactivity,
) -> CommandResult<Exit>
where
E: FnOnce(clap::ArgMatches, Context) -> F,
F: Future<Output = CommandResult<T>> + Send + 'static,
T: Into<Exit>,
{
let cancellation = Cancellation::new();
let output_flags = OutputFlags::from_arg_matches(&matches);
let (sender, receiver) = event_channel();
let output = Output::new(sender);
let context = Context::builder()
.cancellation(cancellation.clone())
.interactivity(interactivity)
.output(output)
.build()
.context("build the context of the command")?;
let presenter = TerminalPresenter::builder()
.receiver(receiver)
.verbosity(output_flags.verbosity())
.mode(output_flags.mode())
.interactivity(interactivity)
.build();
let rt = tokio::runtime::Runtime::new().context("start the async runtime")?;
rt.block_on(async {
tokio::spawn(wait_for_shutdown(cancellation));
let command = exec(matches, context);
presenter
.present(Box::pin(async move { command.await.map(Into::into) }))
.await
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use std::fs::File;
use std::path::Path;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use super::*;
fn matches() -> clap::ArgMatches {
OutputFlags::augment_command(clap::Command::new("test")).get_matches_from(["test"])
}
#[test]
fn detect_interactivity_without_a_terminal_returns_non_interactive() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let input = File::open(&manifest).expect("should open the manifest");
let display = File::open(&manifest).expect("should open the manifest");
let interactivity = detect_interactivity(&input, &display);
assert_eq!(interactivity, Interactivity::NonInteractive);
}
#[test]
fn run_with_a_closure_that_owns_state_executes_the_leaf() {
let executed = Arc::new(AtomicBool::new(false));
let owned = Arc::clone(&executed);
drop(CommandRunner::run(
matches(),
move |_matches, _context| async move {
owned.store(true, Ordering::SeqCst);
Ok(())
},
));
assert!(executed.load(Ordering::SeqCst));
}
#[test]
fn run_with_a_command_that_chooses_its_exit_returns_that_exit() {
let chosen = Exit::builder()
.code(ExitCode::from(3))
.text("error: 0.7.0 is not a stable release\n")
.build();
let returned = chosen.clone();
let exit = CommandRunner::run_with(
matches(),
move |_matches, _context| async move { Ok(returned) },
Interactivity::NonInteractive,
);
assert_eq!(exit, chosen);
}
#[test]
fn run_with_a_command_that_fails_returns_the_error_with_code_1() {
let exit = CommandRunner::run_with(
matches(),
|_matches, _context| async { Err::<(), _>(anyhow::anyhow!("the file is missing")) },
Interactivity::NonInteractive,
);
assert_eq!(
exit,
Exit::builder()
.code(ExitCode::FAILURE)
.text("Error: the file is missing\n")
.build()
);
}
#[test]
fn run_with_a_command_that_succeeds_returns_success() {
let exit = CommandRunner::run_with(
matches(),
|_matches, _context| async { Ok(()) },
Interactivity::NonInteractive,
);
assert_eq!(exit, Exit::from(()));
}
#[test]
fn run_with_interactivity_passes_it_to_the_context() {
let seen = Arc::new(Mutex::new(None));
let owned = Arc::clone(&seen);
drop(CommandRunner::run_with(
matches(),
move |_matches, context| async move {
*owned.lock().expect("should lock") = Some(context.interactivity());
Ok(())
},
Interactivity::Interactive,
));
assert_eq!(
*seen.lock().expect("should lock"),
Some(Interactivity::Interactive)
);
}
#[test]
fn trait_send() {
fn assert_send<T: Send>() {}
assert_send::<CommandRunner>();
}
#[test]
fn trait_sync() {
fn assert_sync<T: Sync>() {}
assert_sync::<CommandRunner>();
}
#[test]
fn trait_unpin() {
fn assert_unpin<T: Unpin>() {}
assert_unpin::<CommandRunner>();
}
}