super_process/
lib.rs

1/*
2 * Description: An async process creation framework. More of a utility
3 * library.
4 *
5 * Copyright (C) 2022 Danny McClanahan <dmcC2@hypnicjerk.ai>
6 * SPDX-License-Identifier: Apache-2.0
7 */
8
9//! An async process creation framework. More of a utility library.
10//!
11//! - *TODO: [`fs`] doesn't do much yet.*
12//! - [`exe::Command`] covers all the configuration for a single process
13//!   invocation.
14//! - [`base::CommandBase`] abstracts a process invocation which requires setup
15//!   work.
16//! - [`sync`] and [`stream`] invoke processes "synchronously" or
17//!   "asynchronously".
18//! - [`sh`] wraps a shell script invocation.
19
20#![deny(rustdoc::missing_crate_level_docs)]
21#![warn(missing_docs)]
22/* Make all doctests fail if they produce any warnings. */
23#![doc(test(attr(deny(warnings))))]
24#![deny(clippy::all)]
25#![allow(clippy::collapsible_else_if)]
26#![allow(clippy::result_large_err)]
27
28/// Representations of filesystem locations on the local host.
29///
30/// *TODO: currently these don't do any validation!*
31pub mod fs {
32  use displaydoc::Display;
33
34  use std::path::PathBuf;
35
36  /// Trait for objects representing a handle to a filesystem path.
37  pub(crate) trait PathWrapper {
38    /// Consume this object and return a path.
39    fn into_path_buf(self) -> PathBuf;
40  }
41
42  /// @={0}
43  ///
44  /// A path to a file that is assumed to already exist.
45  #[derive(Debug, Display, Clone)]
46  #[ignore_extra_doc_attributes]
47  pub struct File(pub PathBuf);
48
49  impl PathWrapper for File {
50    fn into_path_buf(self) -> PathBuf {
51      let Self(path) = self;
52      path
53    }
54  }
55
56  /// @<{0}
57  ///
58  /// A path to a directory that is assumed to already exist.
59  #[derive(Debug, Display, Clone)]
60  #[ignore_extra_doc_attributes]
61  pub struct Directory(pub PathBuf);
62
63  impl PathWrapper for Directory {
64    fn into_path_buf(self) -> PathBuf {
65      let Self(path) = self;
66      path
67    }
68  }
69}
70
71/// Representations of executable files and methods to invoke them as async
72/// processes.
73pub mod exe {
74  use super::fs::{self, PathWrapper};
75
76  use displaydoc::Display;
77  use indexmap::IndexMap;
78  use once_cell::sync::Lazy;
79  use signal_hook::consts::{signal::*, TERM_SIGNALS};
80  use thiserror::Error;
81
82  use std::{
83    collections::VecDeque,
84    ffi::{OsStr, OsString},
85    io, iter,
86    os::unix::process::ExitStatusExt,
87    path::{Path, PathBuf},
88    process, str,
89  };
90
91  /// *{0}
92  ///
93  /// A path to an executable file which is assumed to exist.
94  #[derive(Debug, Display, Clone)]
95  #[ignore_extra_doc_attributes]
96  pub struct Exe(pub fs::File);
97
98  impl<R: AsRef<OsStr>> From<&R> for Exe {
99    fn from(value: &R) -> Self {
100      let p = Path::new(value);
101      let f = fs::File(p.to_path_buf());
102      Self(f)
103    }
104  }
105
106  impl Default for Exe {
107    fn default() -> Self { Self(fs::File(PathBuf::default())) }
108  }
109
110  impl Exe {
111    /// This is the default state of the executable.
112    ///
113    /// If an executable in this state is invoked, a panic will occur.
114    pub fn is_empty(&self) -> bool {
115      let Self(fs::File(exe)) = self;
116      exe.as_os_str().is_empty()
117    }
118  }
119
120  impl PathWrapper for Exe {
121    fn into_path_buf(self) -> PathBuf {
122      let Self(exe) = self;
123      exe.into_path_buf()
124    }
125  }
126
127  /// [{0:?}]
128  ///
129  /// The command line to provide to the executable. Note that the complete
130  /// "argv" used by [`Command`] contains the executable path prefixed to
131  /// these arguments.
132  #[derive(Debug, Display, Clone, Default)]
133  #[ignore_extra_doc_attributes]
134  pub struct Argv(pub VecDeque<OsString>);
135
136  impl<R: AsRef<OsStr>, I: iter::IntoIterator<Item=R>> From<I> for Argv {
137    fn from(value: I) -> Self {
138      let argv: VecDeque<OsString> = value
139        .into_iter()
140        .map(|s| {
141          let s: &OsStr = s.as_ref();
142          s.to_os_string()
143        })
144        .collect();
145      Self(argv)
146    }
147  }
148
149  impl Argv {
150    /// If we're non-empty, prefix ourself with `--`.
151    pub fn trailing_args(mut self) -> Self {
152      if self.0.is_empty() {
153        Self(VecDeque::new())
154      } else {
155        self.unshift("--".into());
156        self
157      }
158    }
159
160    /// Prefix ourself with the given `leftmost_arg`.
161    pub fn unshift(&mut self, leftmost_arg: OsString) {
162      let Self(ref mut argv) = self;
163      argv.push_front(leftmost_arg);
164    }
165  }
166
167  /// [{0:?}]
168  ///
169  /// Environment variables to set in the subprocess environment.
170  #[derive(Debug, Display, Clone, Default)]
171  #[ignore_extra_doc_attributes]
172  pub struct EnvModifications(pub IndexMap<OsString, OsString>);
173
174  impl<R: AsRef<OsStr>, I: iter::IntoIterator<Item=(R, R)>> From<I> for EnvModifications {
175    fn from(value: I) -> Self {
176      let env: IndexMap<OsString, OsString> = value
177        .into_iter()
178        .map(|(k, v)| {
179          let k: &OsStr = k.as_ref();
180          let v: &OsStr = v.as_ref();
181          (k.to_os_string(), v.to_os_string())
182        })
183        .collect();
184      Self(env)
185    }
186  }
187
188  /// <exe={exe}, wd={wd:?}, argv={argv}, env={env}>
189  ///
190  /// Request to execute a subprocess. See [`crate::sync`] and [`crate::stream`]
191  /// for examples of invocation.
192  #[derive(Debug, Display, Clone, Default)]
193  #[ignore_extra_doc_attributes]
194  pub struct Command {
195    /// Executable name, which may be absolute or relative to `$PATH` entries.
196    pub exe: Exe,
197    /// The working directory for the child process; otherwise, the working
198    /// directory is inherited from the parent process.
199    pub wd: Option<fs::Directory>,
200    /// Arguments to pass to the executable. These should *not* be quoted at
201    /// all.
202    pub argv: Argv,
203    /// Any new environment variables to set within the child process. The
204    /// environment is otherwise inherited from the parent.
205    pub env: EnvModifications,
206  }
207
208  impl Command {
209    pub(crate) fn command(self) -> async_process::Command {
210      dbg!(&self);
211      let Self {
212        exe,
213        wd,
214        argv,
215        env: EnvModifications(env),
216      } = self;
217      if exe.is_empty() {
218        unreachable!(
219          "command was executed before .exe was set; this can only occur using ::default()"
220        );
221      }
222      let mut command = async_process::Command::new(exe.into_path_buf());
223      if let Some(wd) = wd {
224        command.current_dir(wd.into_path_buf());
225      }
226      command.args(argv.0);
227      for (var, val) in env.into_iter() {
228        command.env(&var, &val);
229      }
230      command
231    }
232
233    /// Make this command execute the `new_exe` binary instead, shifting all
234    /// args one to the right.
235    pub fn unshift_new_exe(&mut self, new_exe: Exe) {
236      if new_exe.is_empty() {
237        unreachable!("new_exe is an empty string!! self was: {:?}", self);
238      }
239
240      let mut argv = self.argv.clone();
241      if !self.exe.is_empty() {
242        argv.unshift(self.exe.clone().into_path_buf().as_os_str().to_os_string());
243      }
244
245      self.argv = argv;
246      self.exe = new_exe;
247    }
248
249    pub(crate) fn unshift_shell_script(&mut self, script_path: Exe) {
250      self.unshift_new_exe(script_path);
251      self.unshift_new_exe(Exe(fs::File(PathBuf::from("sh"))));
252    }
253  }
254
255  /// Errors that can occur when executing command lines.
256  #[derive(Debug, Display, Error)]
257  pub enum CommandError {
258    /// a command line exited with non-zero status {0}
259    NonZeroExit(i32),
260    /// a command line exited with termination signal {0} ({1})
261    ProcessTerminated(i32, &'static str),
262    /// a command line exited with non-termination signal {0} ({1})
263    ProcessKilled(i32, &'static str),
264    /// i/o error invoking command line: {0}
265    Io(#[from] io::Error),
266    /// utf-8 decoding error for command line: {0}
267    Utf8(#[from] str::Utf8Error),
268  }
269
270  macro_rules! signal_pairs {
271    ($($name:ident),+) => {
272      [$(($name, stringify!($name))),+]
273    }
274  }
275
276  static SIGNAL_NAMES: Lazy<IndexMap<i32, &'static str>> = Lazy::new(|| {
277    signal_pairs![
278      SIGABRT, SIGALRM, SIGBUS, SIGCHLD, SIGCONT, SIGFPE, SIGHUP, SIGILL, SIGINT, SIGIO, SIGKILL,
279      SIGPIPE, SIGPROF, SIGQUIT, SIGSEGV, SIGSTOP, SIGSYS, SIGTERM, SIGTRAP, SIGTSTP, SIGTTIN,
280      SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM, SIGWINCH, SIGXCPU, SIGXFSZ
281    ]
282    .into()
283  });
284
285  impl CommandError {
286    /// Raise an error if the process exited with any type of failure.
287    pub fn analyze_exit_status(status: process::ExitStatus) -> Result<(), Self> {
288      if let Some(code) = status.code() {
289        if code == 0 {
290          Ok(())
291        } else {
292          Err(Self::NonZeroExit(code))
293        }
294      } else if let Some(signal) = status.signal() {
295        let name = SIGNAL_NAMES.get(&signal).unwrap();
296        Err(if TERM_SIGNALS.contains(&signal) {
297          Self::ProcessTerminated(signal, name)
298        } else {
299          Self::ProcessKilled(signal, name)
300        })
301      } else {
302        unreachable!("status {:?} had no exit code or signal", status)
303      }
304    }
305
306    pub(crate) fn command_with_context(
307      self,
308      command: Command,
309      context: String,
310    ) -> CommandErrorWrapper {
311      CommandErrorWrapper {
312        command,
313        context,
314        error: self,
315      }
316    }
317  }
318
319  /// command {command:?} failed ({context}): {error}
320  #[derive(Debug, Display, Error)]
321  pub struct CommandErrorWrapper {
322    /// The command that attempted to be executed.
323    pub command: Command,
324    /// Additional information about where the error occurred.
325    pub context: String,
326    /// The underlying error.
327    #[source]
328    pub error: CommandError,
329  }
330}
331
332/// Extend the concept of a "process" to include setup, to enable abstraction.
333pub mod base {
334  use super::*;
335
336  use async_trait::async_trait;
337  use displaydoc::Display;
338  use thiserror::Error;
339
340  use std::io;
341
342  /// Errors which may occur during the execution of
343  /// [`CommandBase::setup_command`].
344  #[derive(Debug, Display, Error)]
345  pub enum SetupError {
346    /// inner error: {0}
347    Inner(#[source] Box<SetupErrorWrapper>),
348    /// i/o error: {0}
349    Io(#[from] io::Error),
350  }
351
352  impl SetupError {
353    /// Wrap a raw error with `context` to produced a wrapped error.
354    pub fn with_context(self, context: String) -> SetupErrorWrapper {
355      SetupErrorWrapper {
356        context,
357        error: self,
358      }
359    }
360  }
361
362  /// setup error ({context}): {error}
363  #[derive(Debug, Display, Error)]
364  pub struct SetupErrorWrapper {
365    /// Additional information about where the error occurred.
366    pub context: String,
367    /// The underlying error.
368    #[source]
369    pub error: SetupError,
370  }
371
372  /// Declare higher-level operations which desugar to command lines by
373  /// implementing this trait.
374  #[async_trait]
375  pub trait CommandBase {
376    /// Generate a command line from the given object.
377    async fn setup_command(self) -> Result<exe::Command, SetupError>;
378  }
379}
380
381/// Methods to execute a process "synchronously", i.e. waiting until it has
382/// exited.
383///
384///```
385/// # tokio_test::block_on(async {
386/// use std::path::PathBuf;
387/// use super_process::{fs, exe, sync::SyncInvocable};
388///
389/// let command = exe::Command {
390///   exe: exe::Exe(fs::File(PathBuf::from("echo"))),
391///   argv: ["hey"].as_ref().into(),
392///   ..Default::default()
393/// };
394///
395/// // Spawn the child process and wait for it to end.
396/// let output = command.clone().invoke().await.expect("sync subprocess failed");
397/// // Parse stdout into utf8...
398/// let hey = output.decode(command).expect("utf8 decoding failed").stdout
399///   // ...and strip the trailing newline.
400///   .strip_suffix("\n")
401///   .expect("trailing newline not found");
402/// assert_eq!(hey, "hey");
403/// # }) // async
404/// ```
405pub mod sync {
406  use super::exe;
407
408  use async_trait::async_trait;
409
410  use std::{process, str};
411
412  /// The slurped streams for a synchronously-invoked process, as raw bytes.
413  #[derive(Debug, Clone)]
414  #[allow(missing_docs)]
415  pub struct RawOutput {
416    pub stdout: Vec<u8>,
417    pub stderr: Vec<u8>,
418  }
419
420  impl RawOutput {
421    /// Parse the process's exit status with
422    /// [`exe::CommandError::analyze_exit_status`].
423    pub fn extract(
424      command: exe::Command,
425      output: process::Output,
426    ) -> Result<Self, exe::CommandErrorWrapper> {
427      let process::Output {
428        status,
429        stdout,
430        stderr,
431      } = output;
432
433      let output = Self { stdout, stderr };
434      if let Err(e) = exe::CommandError::analyze_exit_status(status) {
435        let output_msg: String = match output.decode(command.clone()) {
436          Ok(decoded) => format!("(utf-8 decoded) {:?}", decoded),
437          Err(_) => format!("(could not decode) {:?}", &output),
438        };
439        return Err(e.command_with_context(
440          command,
441          format!("when analyzing exit status for output {}", output_msg),
442        ));
443      }
444
445      Ok(output)
446    }
447
448    /// Decode the output streams of this process, with the invoking `command`
449    /// provided for error context.
450    pub fn decode(
451      &self,
452      command: exe::Command,
453    ) -> Result<DecodedOutput<'_>, exe::CommandErrorWrapper> {
454      let Self { stdout, stderr } = self;
455
456      let stdout =
457        str::from_utf8(stdout)
458          .map_err(|e| e.into())
459          .map_err(|e: exe::CommandError| {
460            e.command_with_context(
461              command.clone(),
462              format!("when decoding stdout from {:?}", &self),
463            )
464          })?;
465      let stderr =
466        str::from_utf8(stderr)
467          .map_err(|e| e.into())
468          .map_err(|e: exe::CommandError| {
469            e.command_with_context(command, format!("when decoding stderr from {:?}", &self))
470          })?;
471
472      Ok(DecodedOutput { stdout, stderr })
473    }
474  }
475
476  /// The slurped streams for a synchronously-invoked process, after UTF-8
477  /// decoding.
478  #[derive(Debug, Clone)]
479  #[allow(missing_docs)]
480  pub struct DecodedOutput<'a> {
481    pub stdout: &'a str,
482    pub stderr: &'a str,
483  }
484
485  /// Trait that defines "synchronously" invokable processes.
486  #[async_trait]
487  pub trait SyncInvocable {
488    /// Invoke a child process and wait on it to complete while slurping its
489    /// output.
490    async fn invoke(self) -> Result<RawOutput, exe::CommandErrorWrapper>;
491  }
492
493  #[async_trait]
494  impl SyncInvocable for exe::Command {
495    async fn invoke(self) -> Result<RawOutput, exe::CommandErrorWrapper> {
496      let mut command = self.clone().command();
497      let output =
498        command
499          .output()
500          .await
501          .map_err(|e| e.into())
502          .map_err(|e: exe::CommandError| {
503            e.command_with_context(self.clone(), "waiting for output".to_string())
504          })?;
505      let output = RawOutput::extract(self, output)?;
506      Ok(output)
507    }
508  }
509}
510
511/// Methods to execute a process in an "asynchronous" or "streaming" fashion.
512///
513/// **TODO: define a generic stream type like the `Emission` trait in
514/// `learning-progress-bar`, then express the stdio lines stream in terms of the
515/// stdio byte chunks stream!** We avoid doing that here because we expect using
516/// a [`BufReader`](futures_lite::io::BufReader) to produce
517/// [`StdioLine`](stream::StdioLine)s will be more efficient and cleaner than
518/// manually implementing a `BufReader` with `async-channel` or something.
519pub mod stream {
520  use super::exe;
521
522  use async_process::{self, Child, Stdio};
523
524  /// A handle to the result an asynchronous invocation.
525  pub struct Streaming {
526    /// The handle to the live child process (live until [`Child::output`] is
527    /// called).
528    pub child: Child,
529    /// The command being executed.
530    pub command: exe::Command,
531  }
532
533  impl Streaming {
534    /// Wait for the process to exit, printing lines of stdout and stderr to the
535    /// terminal.
536    pub async fn wait(self) -> Result<(), exe::CommandErrorWrapper> {
537      let Self { mut child, command } = self;
538
539      let status = child.status().await.map_err(|e| {
540        let e: exe::CommandError = e.into();
541        e.command_with_context(command.clone(), "merging async streams".to_string())
542      })?;
543      exe::CommandError::analyze_exit_status(status)
544        .map_err(|e| e.command_with_context(command, "checking async exit status".to_string()))?;
545
546      Ok(())
547    }
548  }
549
550  /// Trait that defines "asynchronously" invokable processes.
551  pub trait Streamable {
552    /// Invoke a child process and return a handle to its output streams.
553    fn invoke_streaming(self) -> Result<Streaming, exe::CommandErrorWrapper>;
554  }
555
556  impl Streamable for exe::Command {
557    fn invoke_streaming(self) -> Result<Streaming, exe::CommandErrorWrapper> {
558      let mut command = self.clone().command();
559      let child = command
560        .stdout(Stdio::inherit())
561        .stderr(Stdio::inherit())
562        .spawn()
563        .map_err(|e| e.into())
564        .map_err(|e: exe::CommandError| {
565          e.command_with_context(self.clone(), "spawning async process".to_string())
566        })?;
567      Ok(Streaming {
568        child,
569        command: self,
570      })
571    }
572  }
573}
574
575/// Methods to execute a shell script as a process.
576pub mod sh {
577  use super::{
578    base::{self, CommandBase},
579    exe, fs,
580    sync::SyncInvocable,
581  };
582
583  use async_trait::async_trait;
584  use displaydoc::Display;
585  use indexmap::IndexMap;
586  use tempfile::{NamedTempFile, TempPath};
587  use thiserror::Error;
588
589  use std::{
590    ffi::OsString,
591    io::{self, BufRead, Write},
592    str,
593  };
594
595  /// Errors that may occur when executing a shell script.
596  #[derive(Debug, Display, Error)]
597  pub enum ShellError {
598    /// setup error {0}
599    Setup(#[from] base::SetupErrorWrapper),
600    /// command error {0}
601    Command(#[from] exe::CommandErrorWrapper),
602    /// i/o error {0}
603    Io(#[from] io::Error),
604    /// utf-8 decoding error {0}
605    Utf8(#[from] str::Utf8Error),
606  }
607
608  impl ShellError {
609    /// Wrap a raw error with `context` to produced a wrapped error.
610    pub fn with_context(self, context: String) -> ShellErrorWrapper {
611      ShellErrorWrapper {
612        context,
613        error: self,
614      }
615    }
616  }
617
618  /// shell error ({context}): {error}
619  #[derive(Debug, Display, Error)]
620  pub struct ShellErrorWrapper {
621    /// Additional information about where the error occurred.
622    pub context: String,
623    /// The underlying error.
624    #[source]
625    pub error: ShellError,
626  }
627
628  /// Generate a shell script to execute via [`ShellScript`].
629  ///
630  /// This script is generated by writing [`Self::contents`] to a temporary
631  /// file. ```
632  /// # tokio_test::block_on(async {
633  /// use super_process::{sh, exe, base::CommandBase, sync::SyncInvocable};
634  ///
635  /// let contents = "echo hey".as_bytes().to_vec();
636  /// let source = sh::ShellSource { contents };
637  /// let script = source.into_script().await.expect("generating shell script
638  /// failed"); let command = script.with_command(exe::Command::default())
639  ///   .setup_command().await.unwrap();
640  ///
641  /// let output = command.invoke().await.expect("shell script should succeed");
642  /// assert!(b"hey\n".as_ref() == &output.stdout);
643  /// # }) // async
644  /// ```
645  #[derive(Debug, Clone)]
646  pub struct ShellSource {
647    /// The bytes of a shell script to be written to file.
648    pub contents: Vec<u8>,
649  }
650
651  impl ShellSource {
652    fn write_to_temp_path(self) -> io::Result<TempPath> {
653      /* Create the script. */
654      let (mut script_file, script_path) = NamedTempFile::new()?.into_parts();
655      let Self { contents } = self;
656      script_file.write_all(&contents)?;
657      script_file.sync_all()?;
658      /* Close the file, but keep the path alive. */
659      Ok(script_path)
660    }
661
662    /// Create a handle to a shell script backed by a temp file.
663    ///
664    /// *FIXME: we don't ever delete the temp file! Use lifetimes to avoid
665    /// this!*
666    pub async fn into_script(self) -> Result<ShellScript, ShellError> {
667      let script_path = self.write_to_temp_path()?;
668
669      /* FIXME: We don't ever delete the script! */
670      let script_path = exe::Exe(fs::File(
671        script_path
672          .keep()
673          .expect("should never be any error keeping the shell script path"),
674      ));
675
676      Ok(ShellScript { script_path })
677    }
678  }
679
680  /// Request for dumping the components of the environment after evaluating a
681  /// shell script. ```
682  /// # tokio_test::block_on(async {
683  /// use std::ffi::OsStr;
684  /// use super_process::{sh, exe};
685  ///
686  /// let env = sh::EnvAfterScript {
687  ///   source: sh::ShellSource {
688  ///     contents: b"export A=3".to_vec(),
689  ///   },
690  /// };
691  /// let exe::EnvModifications(env) =
692  /// env.extract_env_bindings().await.unwrap(); let env_val =
693  /// env.get(OsStr::new("A")).unwrap().to_str().unwrap(); assert_eq!(3,
694  /// env_val.parse::<usize>().unwrap()); # }) // async
695  /// ```
696  #[derive(Debug, Clone)]
697  pub struct EnvAfterScript {
698    /// Script to run before extracting the environment.
699    pub source: ShellSource,
700  }
701
702  impl EnvAfterScript {
703    fn into_source(self) -> ShellSource {
704      let Self {
705        source: ShellSource { mut contents },
706      } = self;
707      contents.extend_from_slice(b"\n\nexec env");
708      ShellSource { contents }
709    }
710
711    async fn into_command(self) -> Result<exe::Command, ShellErrorWrapper> {
712      /* Write script file. */
713      let source = self.into_source();
714      let script = source
715        .into_script()
716        .await
717        .map_err(|e| e.with_context("when writing env script to file".to_string()))?;
718      /* Generate command. */
719      let sh = script.with_command(exe::Command::default());
720      let command = sh
721        .setup_command()
722        .await
723        .map_err(|e| {
724          e.with_context("when setting up the shell command".to_string())
725            .into()
726        })
727        .map_err(|e: ShellError| {
728          e.with_context("when setting up the shell command, again".to_string())
729        })?;
730      Ok(command)
731    }
732
733    async fn extract_stdout(self) -> Result<Vec<u8>, ShellErrorWrapper> {
734      /* Setup command. */
735      let command = self.into_command().await?;
736
737      /* Execute command. */
738      let output = command
739        .invoke()
740        .await
741        .map_err(|e| e.into())
742        .map_err(|e: ShellError| e.with_context("when extracting env bindings".to_string()))?;
743
744      Ok(output.stdout)
745    }
746
747    /// Execute the wrapped script and parse the output of the `env` command
748    /// executed afterwards!
749    pub async fn extract_env_bindings(self) -> Result<exe::EnvModifications, ShellErrorWrapper> {
750      let stdout = self.extract_stdout().await?;
751
752      /* Parse output into key-value pairs. */
753      let mut env_map: IndexMap<OsString, OsString> = IndexMap::new();
754      for line in stdout.lines() {
755        let line = line
756          .map_err(|e| e.into())
757          .map_err(|e: ShellError| e.with_context("when extracting stdout line".to_string()))?;
758        /* FIXME: we currently just ignore lines that don't have an '=' -- fix this! */
759        if let Some(equals_index) = line.find('=') {
760          let key = &line[..equals_index];
761          let value = &line[equals_index + 1..];
762          env_map.insert(key.into(), value.into());
763        }
764      }
765
766      Ok(exe::EnvModifications(env_map))
767    }
768  }
769
770  /// Execute a command line beginning with this shell script.
771  ///
772  /// The later arguments provided via [`Self::with_command`] (FIXME!)
773  ///```
774  /// # tokio_test::block_on(async {
775  /// use std::io::Write;
776  /// use tempfile::NamedTempFile;
777  /// use super_process::{sh, exe, sync::SyncInvocable, base::CommandBase, fs};
778  ///
779  /// let script_path = {
780  ///   let (mut script_file, script_path) = NamedTempFile::new().unwrap().into_parts();
781  ///   script_file.write_all(b"echo hey\n").unwrap();
782  ///   script_file.sync_all().unwrap();
783  ///   script_path.keep().unwrap()
784  /// };
785  /// let script_path = exe::Exe(fs::File(script_path));
786  /// let script = sh::ShellScript { script_path };
787  /// let command = script.with_command(exe::Command::default())
788  ///   .setup_command().await.unwrap();
789  ///
790  /// let output = command.invoke().await.expect("script should succeed");
791  /// assert!(b"hey\n".as_ref() == &output.stdout);
792  /// # }) // async
793  /// ```
794  #[derive(Debug, Clone)]
795  pub struct ShellScript {
796    /// The script to execute.
797    pub script_path: exe::Exe,
798  }
799
800  impl ShellScript {
801    /// Provide a command line for this shell script to execute.
802    pub fn with_command(self, base: exe::Command) -> ShellScriptInvocation {
803      ShellScriptInvocation { script: self, base }
804    }
805  }
806
807  /// The command wrapper for a shell script.
808  #[derive(Debug, Clone)]
809  pub struct ShellScriptInvocation {
810    /// The script to preface the command line with.
811    pub script: ShellScript,
812    /// The command line to provide to the script.
813    pub base: exe::Command,
814  }
815
816  #[async_trait]
817  impl CommandBase for ShellScriptInvocation {
818    async fn setup_command(self) -> Result<exe::Command, base::SetupError> {
819      let Self {
820        script: ShellScript { script_path },
821        mut base,
822      } = self;
823      base.unshift_shell_script(script_path);
824      Ok(base)
825    }
826  }
827}