1use std::ffi::OsString;
14use std::io;
15use std::path::PathBuf;
16use std::process::{Command as Child, Stdio};
17
18pub struct Handoff<Msg> {
54 program: Program,
55 on_finish: Box<dyn FnOnce(HandoffOutcome) -> Msg + Send>,
56}
57
58#[derive(Debug, Clone)]
61pub(crate) struct Program {
62 pub(crate) program: OsString,
63 pub(crate) args: Vec<OsString>,
64 pub(crate) dir: Option<PathBuf>,
65 pub(crate) env: Vec<(OsString, OsString)>,
66 pub(crate) notice: Option<String>,
67 pub(crate) pause: bool,
68}
69
70impl Program {
71 pub(crate) fn new(program: OsString) -> Self {
72 Self { program, args: Vec::new(), dir: None, env: Vec::new(), notice: None, pause: false }
73 }
74
75 pub(crate) fn command(&self) -> Child {
77 let mut child = Child::new(&self.program);
78 child.args(&self.args);
79 if let Some(dir) = &self.dir {
80 child.current_dir(dir);
81 }
82 for (key, value) in &self.env {
83 child.env(key, value);
84 }
85 child
86 }
87
88 pub(crate) fn request(&self) -> HandoffRequest {
90 HandoffRequest {
91 program: self.program.clone(),
92 args: self.args.clone(),
93 dir: self.dir.clone(),
94 notice: self.notice.clone(),
95 pause: self.pause,
96 }
97 }
98}
99
100impl<Msg: Send + 'static> Handoff<Msg> {
101 pub fn new(program: impl Into<OsString>, on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static) -> Self {
103 Self { program: Program::new(program.into()), on_finish: Box::new(on_finish) }
104 }
105
106 #[must_use]
108 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
109 self.program.args.push(arg.into());
110 self
111 }
112
113 #[must_use]
115 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
116 self.program.args.extend(args.into_iter().map(Into::into));
117 self
118 }
119
120 #[must_use]
122 pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
123 self.program.dir = Some(dir.into());
124 self
125 }
126
127 #[must_use]
129 pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
130 self.program.env.push((key.into(), value.into()));
131 self
132 }
133
134 #[must_use]
137 pub fn notice(mut self, text: impl Into<String>) -> Self {
138 self.program.notice = Some(text.into());
139 self
140 }
141
142 #[must_use]
145 pub fn pause(mut self, pause: bool) -> Self {
146 self.program.pause = pause;
147 self
148 }
149
150 pub(crate) fn request(&self) -> HandoffRequest {
152 self.program.request()
153 }
154
155 pub(crate) fn finish(self, outcome: HandoffOutcome) -> Msg {
157 (self.on_finish)(outcome)
158 }
159
160 pub(crate) fn map<B>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> Handoff<B> {
162 let on_finish = self.on_finish;
163 Handoff { program: self.program, on_finish: Box::new(move |outcome| map(on_finish(outcome))) }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum HandoffOutcome {
170 Finished {
172 code: Option<i32>,
174 },
175 Failed(String),
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct HandoffRequest {
182 pub program: OsString,
184 pub args: Vec<OsString>,
186 pub dir: Option<PathBuf>,
189 pub notice: Option<String>,
191 pub pause: bool,
193}
194
195pub(crate) struct HandoffScreen<'a> {
198 pub(crate) release: &'a mut dyn FnMut(Option<&str>) -> io::Result<()>,
201 pub(crate) take: &'a mut dyn FnMut() -> io::Result<()>,
204 pub(crate) wait_for_key: &'a mut dyn FnMut() -> io::Result<()>,
206}
207
208pub(crate) fn run<Msg: Send + 'static>(handoff: Handoff<Msg>, screen: &mut HandoffScreen<'_>) -> Msg {
215 let outcome = match (screen.release)(handoff.program.notice.as_deref()) {
216 Ok(()) => {
217 let outcome = spawn(&handoff.program);
218 if handoff.program.pause && matches!(outcome, HandoffOutcome::Finished { .. }) {
220 let _ = (screen.wait_for_key)();
221 }
222 match (screen.take)() {
223 Ok(()) => outcome,
224 Err(error) => HandoffOutcome::Failed(error.to_string()),
225 }
226 }
227 Err(error) => {
228 let _ = (screen.take)();
230 HandoffOutcome::Failed(error.to_string())
231 }
232 };
233 handoff.finish(outcome)
234}
235
236fn spawn(program: &Program) -> HandoffOutcome {
238 let mut child = program.command();
239 child.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
240 #[cfg(unix)]
241 let status = super::foreground::status(&mut child);
242 #[cfg(not(unix))]
243 let status = child.status();
244 match status {
245 Ok(status) => HandoffOutcome::Finished { code: status.code() },
246 Err(error) => HandoffOutcome::Failed(error.to_string()),
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn run_with(handoff: Handoff<HandoffOutcome>, release_fails: bool) -> (HandoffOutcome, Vec<String>) {
258 let steps = std::cell::RefCell::new(Vec::new());
259 let mut release = |notice: Option<&str>| -> io::Result<()> {
260 steps.borrow_mut().push(match notice {
261 Some(text) => format!("release {text}"),
262 None => "release".to_owned(),
263 });
264 if release_fails { Err(io::Error::other("no terminal")) } else { Ok(()) }
265 };
266 let mut take = || -> io::Result<()> {
267 steps.borrow_mut().push("take".to_owned());
268 Ok(())
269 };
270 let mut wait_for_key = || -> io::Result<()> {
271 steps.borrow_mut().push("key".to_owned());
272 Ok(())
273 };
274 let outcome = run(
275 handoff,
276 &mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
277 );
278 (outcome, steps.into_inner())
279 }
280
281 fn shell(script: &str) -> Handoff<HandoffOutcome> {
283 Handoff::new("sh", |outcome| outcome).arg("-c").arg(script)
284 }
285
286 #[test]
287 fn the_screen_is_released_around_the_program_and_taken_back() {
288 let (outcome, steps) = run_with(shell("exit 0").notice("Installing packages…"), false);
289 assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
290 assert_eq!(steps, ["release Installing packages…", "take"], "the program ran while the screen was released");
291 }
292
293 #[test]
294 fn the_exit_code_reaches_the_message() {
295 let (zero, _) = run_with(shell("exit 0"), false);
296 assert_eq!(zero, HandoffOutcome::Finished { code: Some(0) });
297 let (seven, _) = run_with(shell("exit 7"), false);
298 assert_eq!(seven, HandoffOutcome::Finished { code: Some(7) });
299 let (signal, _) = run_with(shell("kill -TERM $$"), false);
301 assert_eq!(signal, HandoffOutcome::Finished { code: None });
302 }
303
304 #[test]
305 fn arguments_the_directory_and_the_environment_reach_the_program() {
306 let (outcome, _) = run_with(
307 Handoff::new("sh", |outcome| outcome)
308 .args(["-c", r#"test "$(pwd)" = / && test "$QUVYTA_HANDOFF_TEST" = ok"#])
309 .dir("/")
310 .env("QUVYTA_HANDOFF_TEST", "ok"),
311 false,
312 );
313 assert_eq!(outcome, HandoffOutcome::Finished { code: Some(0) });
314 }
315
316 #[test]
317 fn an_unstartable_program_fails_and_the_screen_still_comes_back() {
318 let handoff = Handoff::new("quvyta-no-such-program", |outcome| outcome);
319 let (outcome, steps) = run_with(handoff, false);
320 let HandoffOutcome::Failed(reason) = outcome else {
321 panic!("a program that is not there cannot have finished: {outcome:?}");
322 };
323 assert!(!reason.is_empty(), "the reason names what went wrong");
324 assert_eq!(steps, ["release", "take"], "the terminal is taken back even so");
325 }
326
327 #[test]
328 fn a_terminal_that_cannot_be_released_fails_without_running_the_program() {
329 let handoff = shell("exit 0");
330 let (outcome, steps) = run_with(handoff, true);
331 assert_eq!(outcome, HandoffOutcome::Failed("no terminal".to_owned()));
332 assert_eq!(steps, ["release", "take"], "application mode is put back");
333 }
334
335 #[test]
336 fn pause_waits_for_a_key_only_when_it_is_asked_for() {
337 let (_, waited) = run_with(shell("exit 0").pause(true), false);
338 assert_eq!(waited, ["release", "key", "take"], "the key is awaited before the screen is taken back");
339 let (_, quiet) = run_with(shell("exit 0").pause(false), false);
340 assert_eq!(quiet, ["release", "take"]);
341 let (_, missing) = run_with(Handoff::new("quvyta-no-such-program", |o| o).pause(true), false);
342 assert_eq!(missing, ["release", "take"], "a program that never ran leaves nothing to read");
343 }
344
345 #[test]
346 fn the_request_a_harness_records_carries_the_program_and_its_options() {
347 let handoff = shell("less /etc/hostname").notice("Reading").pause(true);
348 let request = handoff.request();
349 assert_eq!(request.program, OsString::from("sh"));
350 assert_eq!(request.args, ["-c", "less /etc/hostname"].map(OsString::from));
351 assert_eq!(request.notice.as_deref(), Some("Reading"));
352 assert!(request.pause);
353 assert_eq!(request.dir, None, "without `dir` the program runs where the application does");
354 let placed = shell("exit 0").dir("/srv/notes").request();
355 assert_eq!(placed.dir, Some(PathBuf::from("/srv/notes")), "the folder is recorded");
356 }
357}