testutils 0.0.12

Offers a range of utility functions, macros, and tools, such as `simple_benchmark()` and `dbg_ref!()`, `os_cmd::Runner`, designed for testing purposes.
Documentation
use alloc::borrow::Cow;
use std::{
  ffi::OsStr,
  io::{self, Write},
  path::PathBuf,
  process::{Child, Command, Stdio},
};

use getset::{Getters, Setters, WithSetters};
use tap::Pipe;

use crate::{
  bool_ext::BoolExt,
  os_cmd::{DecodedText, MiniStr, Runner},
};

pub type CowOsStrVec<'a, const N: usize> = tinyvec::TinyVec<[Cow<'a, OsStr>; N]>;

fn err_invalid_input(msg: &str) -> io::Error {
  let kind = io::ErrorKind::InvalidInput;
  io::Error::new(kind, msg)
}

fn err_empty_command() -> io::Error {
  err_invalid_input("empty command argv")
}

pub(crate) fn err_failed_to_run(program: Option<&OsStr>) -> io::Error {
  format!("Failed to run command: {program:?}") //
    .pipe(io::Error::other)
}

/// Runs an OS command without capturing stdout/stderr (inherits the parent's
/// stdio).
pub fn run_os_cmd<I>(into_iter: I) -> io::Result<()>
where
  I: IntoIterator,
  I::Item: AsRef<OsStr>,
{
  let mut iter = into_iter.into_iter();

  let program = iter
    .next()
    .ok_or_else(err_empty_command)?
    .as_ref()
    .to_os_string();

  let failed_to_run = || err_failed_to_run(Some(&program));

  Command::new(&program) // Main command creation
    .args(iter) // Remainder as arguments
    .status()? // Execute and get status
    .success() // Convert status to bool
    .then_ok_or_else(failed_to_run) // Convert bool to Result
}

/// How to wire a stdio stream for the child process.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StdioMode {
  /// Inherit from current process.
  #[default]
  Inherit,
  /// Create a pipe between parent and child.
  Piped,
  /// Redirect to null device.
  Null,
}

impl From<StdioMode> for Stdio {
  fn from(val: StdioMode) -> Self {
    use StdioMode::*;
    match val {
      Inherit => Stdio::inherit(),
      Piped => Stdio::piped(),
      Null => Stdio::null(),
    }
  }
}

/// `CommandSpawner` is a small builder that treats an iterator as an
/// `argv`-like sequence:
///
/// - The **first** item is the program to execute.
/// - The remaining items are passed as arguments, without going through a
///   shell.
///
/// In addition, you may provide `stdin_data`, which (if present) will be
/// written into the child's stdin after spawning. When `stdin_data` is set,
/// stdin is forced to `Piped` so the parent can write to it.
///
/// # Notes
///
/// - If you pipe **both** stdin (and write a lot of data) **and** pipe
///   stdout/stderr, be aware of potential deadlocks if the child writes enough
///   output to fill its pipe buffer while the parent is blocked writing stdin.
///   For large payloads, consider writing stdin from another thread while
///   concurrently reading output.
#[derive(Debug, Clone, PartialEq, Eq, WithSetters, Setters, Getters)]
#[getset(set = "pub", set_with = "pub", get = "pub with_prefix")]
pub struct CommandSpawner<'a> {
  /// child's stdin mode.
  stdin: StdioMode,
  /// child's stdout mode.
  stdout: StdioMode,
  /// child's stderr mode.
  stderr: StdioMode,

  /// An argv-like iterator where the first item is the program.
  argv: CowOsStrVec<'a, 9>,

  /// Optional bytes to write into the child's stdin after spawning.
  ///
  /// When set, stdin will be forced to `Piped` so `write_all` can succeed.
  stdin_data: Option<&'a [u8]>,

  /// environment variables
  envs: Option<Box<[(MiniStr, MiniStr)]>>,

  /// working directory for the child process.
  working_dir: Option<PathBuf>,
}

impl<'a> Default for CommandSpawner<'a> {
  /// Creates a spawner with "inherit everything" stdio, and no command/data.
  ///
  /// ```ignore
  /// Self {
  ///   stdin:  Inherit,
  ///   stdout: Inherit,
  ///   stderr: Inherit,
  ///   argv: Default::default(),
  ///   stdin_data: None,
  ///   envs: None,
  ///   working_dir: None,
  /// }
  /// ```
  fn default() -> Self {
    use StdioMode::*;
    Self {
      stdin: Inherit,
      stdout: Inherit,
      stderr: Inherit,
      argv: Default::default(),
      stdin_data: None,
      envs: None,
      working_dir: None,
    }
  }
}

impl<'a> CommandSpawner<'a> {
  /// Computes the effective stdin mode.
  ///
  /// If `stdin_data` is present, stdin must be `Piped` so we can write to it.
  /// Otherwise, preserve the configured stdin mode as-is.
  ///
  /// This function is kept small and pure to stay friendly to a pipeline/FP
  /// style.
  #[inline]
  fn effective_stdin_mode(has_data: bool, stdin: StdioMode) -> StdioMode {
    use StdioMode::*;
    match (has_data, stdin) {
      (true, _) => Piped,
      // (false, Piped) => Inherit,
      _ => stdin,
    }
  }

  /// Spawns a child process
  ///
  /// ## Errors
  ///
  /// - Returns an error if `command` is `None`.
  /// - Returns an error if the iterator is empty (no program).
  /// - Propagates any I/O error from `Command::spawn`.
  /// - If `stdin_data` is set, returns an error if `stdin` is not available
  ///   (e.g., misconfigured to not be piped).
  pub fn spawn(self) -> io::Result<Child> {
    let Self {
      argv: command,
      stdin_data,
      stdin,
      stdout: stdout_mode,
      stderr: stderr_mode,
      envs: environment_vars,
      working_dir,
      ..
    } = self;

    let stdin_mode = Self::effective_stdin_mode(stdin_data.is_some(), stdin);

    command
      .into_iter()
      .pipe(|mut iter| {
        // Split into (program, remaining args).
        iter
          .next()
          .ok_or_else(err_empty_command)
          .map(|prog| (prog, iter))
      })?
      .pipe(|(prog, iter)| {
        // Build and spawn the process without going through a shell.
        prog
          .pipe(Command::new)
          .args(iter)
          .stdin(stdin_mode)
          .stdout(stdout_mode)
          .stderr(stderr_mode)
          .pipe(|x| match environment_vars {
            Some(map) => x.envs(map),
            _ => x,
          })
          .pipe(|x| match working_dir {
            Some(p) => x.current_dir(p),
            _ => x,
          })
          .spawn()
      })?
      // Optionally write stdin data, then return the (possibly modified) child.
      .pipe(|child| Self::write_child_stdin(child, stdin_data))
  }

  /// Writes `stdin_data` to the child's stdin (if present) and return the
  /// child.
  ///
  /// # Errors
  ///
  /// Returns an error if `stdin_data` is `Some(_)` but the child's stdin handle
  /// is not available (typically because stdin was not piped).
  pub fn write_child_stdin(
    mut child: Child,
    stdin_data: Option<&[u8]>,
  ) -> io::Result<Child> {
    if let Some(data) = stdin_data {
      child
        .stdin
        .as_mut()
        .ok_or_else(|| err_invalid_input("Failed to access child's stdin."))?
        .write_all(data)?
    }
    Ok(child)
  }

  /// Spawns the process and capture output according to the requested streams.
  ///
  /// - When `cap_out` is true, stdout is forced to `Piped`.
  /// - When `cap_err` is true, stderr is forced to `Piped`.
  ///
  /// This returns the raw `std::process::Output` (bytes for stdout/stderr).
  /// Higher-level helpers (`capture_stdout`, `capture_stderr`,
  /// `capture_stdout_and_stderr`) decode those bytes into `DecodedText`.
  #[inline]
  pub fn capture_raw_output(
    self,
    cap_out: bool,
    cap_err: bool,
  ) -> io::Result<std::process::Output> {
    match (cap_out, cap_err) {
      (true, true) => self
        .with_stdout(StdioMode::Piped)
        .with_stderr(StdioMode::Piped),
      (true, false) => self.with_stdout(StdioMode::Piped),
      (false, true) => self.with_stderr(StdioMode::Piped),
      _ => self,
    }
    .spawn()?
    .wait_with_output()
  }

  /// Captures stdout as decoded text.
  ///
  /// This forces stdout to `Piped`, spawns the child, waits for completion,
  /// and decodes `output.stdout` into `DecodedText`.
  ///
  /// ## Example
  ///
  /// ```
  /// # #[cfg(unix)] {
  /// use testutils::{os_cmd::CommandSpawner, tap::Pipe};
  ///
  /// let v = ["printf", "%s", "hello"]
  ///   .pipe(CommandSpawner::from)
  ///   .capture_stdout()?;
  /// assert_eq!(v.data(), "hello");
  ///
  /// # }
  /// # Ok::<(), std::io::Error>(())
  /// ```
  pub fn capture_stdout(self) -> io::Result<DecodedText> {
    self
      .capture_raw_output(true, false)?
      .stdout
      .pipe(DecodedText::from_vec)
      .pipe(Ok)
  }

  /// Captures stderr as decoded text.
  ///
  /// This forces stderr to `Piped`, spawns the child, waits for completion,
  /// and decodes `output.stderr` into `DecodedText`.
  pub fn capture_stderr(self) -> io::Result<DecodedText> {
    self
      .capture_raw_output(false, true)?
      .stderr
      .pipe(DecodedText::from_vec)
      .pipe(Ok)
  }

  /// Captures both stdout and stderr as decoded text.
  ///
  /// This forces both streams to `Piped`, waits for completion,
  /// and returns `[stdout, stderr]` in that order.
  ///
  /// ## Example
  ///
  /// ```
  /// # #[cfg(unix)] {
  /// use testutils::{os_cmd::CommandSpawner, tap::Pipe};
  ///
  /// let [stdout, stderr] = "wc -m"
  ///   .pipe(CommandSpawner::from)
  ///   .with_stdin_data(Some("world".as_bytes()))
  ///   .capture_stdout_and_stderr()
  ///   .expect("Failed to capture output");
  ///
  /// assert_eq!(&*stdout, "5\n");
  /// assert_eq!(&*stderr, "");
  /// # }
  /// ```
  pub fn capture_stdout_and_stderr(self) -> io::Result<[DecodedText; 2]> {
    self
      .capture_raw_output(true, true)?
      .pipe(|o| [o.stdout, o.stderr])
      .map(DecodedText::from_vec)
      .pipe(Ok)
  }
}

impl<'a, T> From<T> for CommandSpawner<'a>
where
  T: Into<Runner<'a>>,
{
  fn from(value: T) -> Self {
    let Runner {
      command,
      remove_comments,
      stdin_data,
      ..
    } = value.into();

    command
      .into_tinyvec(remove_comments)
      .into_iter()
      .map(super::cow_str_into_cow_osstr)
      .collect::<CowOsStrVec<_>>()
      .pipe(|x| CommandSpawner::default().with_argv(x))
      .with_stdin_data(stdin_data)
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[cfg(target_os = "linux")]
  #[test]
  fn capture_stdout_hello() -> io::Result<()> {
    let v = ["printf", "%s", "hello"]
      .pipe(CommandSpawner::from)
      .capture_stdout()?;
    assert_eq!(v.data(), "hello");

    Ok(())
  }

  #[test]
  #[cfg(target_os = "linux")]
  fn pass_stdin_data() -> io::Result<()> {
    let [stdout, stderr] = "wc -m"
      .pipe(CommandSpawner::from)
      .with_stdin_data(Some("world".as_bytes()))
      .capture_stdout_and_stderr()?;

    assert_eq!(&*stdout, "5\n");
    assert_eq!(&*stderr, "");

    Ok(())
  }
}