use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use std::process::{Command as Child, Stdio};
pub struct Handoff<Msg> {
program: OsString,
args: Vec<OsString>,
dir: Option<PathBuf>,
env: Vec<(OsString, OsString)>,
notice: Option<String>,
pause: bool,
on_finish: Box<dyn FnOnce(HandoffOutcome) -> Msg + Send>,
}
impl<Msg: Send + 'static> Handoff<Msg> {
pub fn new(program: impl Into<OsString>, on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static) -> Self {
Self {
program: program.into(),
args: Vec::new(),
dir: None,
env: Vec::new(),
notice: None,
pause: false,
on_finish: Box::new(on_finish),
}
}
#[must_use]
pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dir = Some(dir.into());
self
}
#[must_use]
pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env.push((key.into(), value.into()));
self
}
#[must_use]
pub fn notice(mut self, text: impl Into<String>) -> Self {
self.notice = Some(text.into());
self
}
#[must_use]
pub fn pause(mut self, pause: bool) -> Self {
self.pause = pause;
self
}
pub(crate) fn request(&self) -> HandoffRequest {
HandoffRequest {
program: self.program.clone(),
args: self.args.clone(),
notice: self.notice.clone(),
pause: self.pause,
}
}
pub(crate) fn finish(self, outcome: HandoffOutcome) -> Msg {
(self.on_finish)(outcome)
}
pub(crate) fn map<B>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> Handoff<B> {
let on_finish = self.on_finish;
Handoff {
program: self.program,
args: self.args,
dir: self.dir,
env: self.env,
notice: self.notice,
pause: self.pause,
on_finish: Box::new(move |outcome| map(on_finish(outcome))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandoffOutcome {
Finished {
code: Option<i32>,
},
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandoffRequest {
pub program: OsString,
pub args: Vec<OsString>,
pub notice: Option<String>,
pub pause: bool,
}
pub(crate) struct HandoffScreen<'a> {
pub(crate) release: &'a mut dyn FnMut(Option<&str>) -> io::Result<()>,
pub(crate) take: &'a mut dyn FnMut() -> io::Result<()>,
pub(crate) wait_for_key: &'a mut dyn FnMut() -> io::Result<()>,
}
pub(crate) fn run<Msg: Send + 'static>(handoff: Handoff<Msg>, screen: &mut HandoffScreen<'_>) -> Msg {
let outcome = match (screen.release)(handoff.notice.as_deref()) {
Ok(()) => {
let outcome = spawn(&handoff);
if handoff.pause && matches!(outcome, HandoffOutcome::Finished { .. }) {
let _ = (screen.wait_for_key)();
}
match (screen.take)() {
Ok(()) => outcome,
Err(error) => HandoffOutcome::Failed(error.to_string()),
}
}
Err(error) => {
let _ = (screen.take)();
HandoffOutcome::Failed(error.to_string())
}
};
handoff.finish(outcome)
}
fn spawn<Msg>(handoff: &Handoff<Msg>) -> HandoffOutcome {
let mut child = Child::new(&handoff.program);
child.args(&handoff.args).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
if let Some(dir) = &handoff.dir {
child.current_dir(dir);
}
for (key, value) in &handoff.env {
child.env(key, value);
}
#[cfg(unix)]
let status = super::foreground::status(&mut child);
#[cfg(not(unix))]
let status = child.status();
match status {
Ok(status) => HandoffOutcome::Finished { code: status.code() },
Err(error) => HandoffOutcome::Failed(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_with(handoff: Handoff<HandoffOutcome>, release_fails: bool) -> (HandoffOutcome, Vec<String>) {
let steps = std::cell::RefCell::new(Vec::new());
let mut release = |notice: Option<&str>| -> io::Result<()> {
steps.borrow_mut().push(match notice {
Some(text) => format!("release {text}"),
None => "release".to_owned(),
});
if release_fails { Err(io::Error::other("no terminal")) } else { Ok(()) }
};
let mut take = || -> io::Result<()> {
steps.borrow_mut().push("take".to_owned());
Ok(())
};
let mut wait_for_key = || -> io::Result<()> {
steps.borrow_mut().push("key".to_owned());
Ok(())
};
let outcome = run(
handoff,
&mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
);
(outcome, steps.into_inner())
}
fn shell(script: &str) -> Handoff<HandoffOutcome> {
Handoff::new("sh", |outcome| outcome).arg("-c").arg(script)
}
#[test]
fn the_screen_is_released_around_the_program_and_taken_back() {
let (outcome, steps) = run_with(shell("exit 0").notice("Installing packages…"), false);
assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
assert_eq!(steps, ["release Installing packages…", "take"], "the program ran while the screen was released");
}
#[test]
fn the_exit_code_reaches_the_message() {
let (zero, _) = run_with(shell("exit 0"), false);
assert_eq!(zero, HandoffOutcome::Finished { code: Some(0) });
let (seven, _) = run_with(shell("exit 7"), false);
assert_eq!(seven, HandoffOutcome::Finished { code: Some(7) });
let (signal, _) = run_with(shell("kill -TERM $$"), false);
assert_eq!(signal, HandoffOutcome::Finished { code: None });
}
#[test]
fn arguments_the_directory_and_the_environment_reach_the_program() {
let (outcome, _) = run_with(
Handoff::new("sh", |outcome| outcome)
.args(["-c", r#"test "$(pwd)" = / && test "$QUVYTA_HANDOFF_TEST" = ok"#])
.dir("/")
.env("QUVYTA_HANDOFF_TEST", "ok"),
false,
);
assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
}
#[test]
fn an_unstartable_program_fails_and_the_screen_still_comes_back() {
let handoff = Handoff::new("quvyta-no-such-program", |outcome| outcome);
let (outcome, steps) = run_with(handoff, false);
let HandoffOutcome::Failed(reason) = outcome else {
panic!("a program that is not there cannot have finished: {outcome:?}");
};
assert!(!reason.is_empty(), "the reason names what went wrong");
assert_eq!(steps, ["release", "take"], "the terminal is taken back even so");
}
#[test]
fn a_terminal_that_cannot_be_released_fails_without_running_the_program() {
let handoff = shell("exit 0");
let (outcome, steps) = run_with(handoff, true);
assert_eq!(outcome, HandoffOutcome::Failed("no terminal".to_owned()));
assert_eq!(steps, ["release", "take"], "application mode is put back");
}
#[test]
fn pause_waits_for_a_key_only_when_it_is_asked_for() {
let (_, waited) = run_with(shell("exit 0").pause(true), false);
assert_eq!(waited, ["release", "key", "take"], "the key is awaited before the screen is taken back");
let (_, quiet) = run_with(shell("exit 0").pause(false), false);
assert_eq!(quiet, ["release", "take"]);
let (_, missing) = run_with(Handoff::new("quvyta-no-such-program", |o| o).pause(true), false);
assert_eq!(missing, ["release", "take"], "a program that never ran leaves nothing to read");
}
#[test]
fn the_request_a_harness_records_carries_the_program_and_its_options() {
let handoff = shell("less /etc/hostname").notice("Reading").pause(true);
let request = handoff.request();
assert_eq!(request.program, OsString::from("sh"));
assert_eq!(request.args, ["-c", "less /etc/hostname"].map(OsString::from));
assert_eq!(request.notice.as_deref(), Some("Reading"));
assert!(request.pause);
}
}