calloop-subproc 1.0.0

Subprocess support for the Calloop event loop
Documentation
//! Subprocess handling event source for the [Calloop event loop][calloop].
//!
//! [calloop]: https://crates.io/crates/calloop
//!
//! Calloop is built around the concept of *event sources* eg. timers, channels,
//! file descriptors themselves, which generate events and call a callback for
//! them. This crate provides two kinds of event sources that let you manage
//! subprocess:
//! - a [chain], which runs a series of commands and generates a success/failure
//!   event
//! - a [listener], which runs a single command and generates an event based on
//!   lines of output
//!
//! [chain]: crate::SubprocChain
//! [listener]: crate::SubprocListen
//!
//! # Error handling
//!
//! Errors in this crate are classified into two high-level kinds, as is the
//! [custom for Calloop][calloop-errors].
//!
//! [calloop-errors]: https://smithay.github.io/calloop/ch02-06-errors.html
//!
//! [`LaunchError`] might be generated by an event source's [`process_events()`]
//! method when a critical error is encountered. This generally means that the
//! event source itself can't continue sensibly and should be removed from the
//! event loop.
//!
//! The [`ErrorEvent`] type is concerned with errors of the subprocess it's
//! managing, including the command not being found or requiring permissions
//! that the process doesn't have. These will be contained in the type of event
//! that is given to the callback.
//!
//! The main way that these are different is: if there's a problem using
//! whatever underlying mechanism we use to spawn a subprocess or get results
//! back from it, then a [`LaunchError`] is produced then and there. But if the
//! underlying mechanics for setting up a subprocess succeed, and it's just that
//! the command contains a typo and can't be found, then it's perfectly possible
//! to generate an event for that. It will just happen to contain the [IO error]
//! you'd expect when trying to run the command directly.
//!
//! [IO error]: std::io::Error
//!
//! # Releasing file descriptors
//!
//! To avoid "holding on" to file descriptors (and just the memory associated
//! with the event source itself), you **must** honour the
//! [`calloop::PostAction`] returned from [`process_events()`] and remove the
//! event source if requested.
//!
//! This is automatically done for top level event sources ie. those added by
//! [`insert_source()`]. If you use it as part of a composed event source, you
//! must either manage reregistration yourself, or wrap the source with
//! [`TransientSource`](calloop::transient::TransientSource).
//!
//! [`insert_source()`]: calloop::LoopHandle::insert_source()
//!
//! # Example
//!
//! This runs `ls -a` and prints the events received.
//!
//! ```
//! use calloop::{EventLoop, LoopSignal};
//! use calloop_subproc::{Command, SubprocListen};
//!
//! let ls_cmd = Command::new("ls").with_args(["-a"]);
//! let listener = SubprocListen::new(ls_cmd).unwrap();
//!
//! let mut event_loop: EventLoop<LoopSignal> = EventLoop::try_new().unwrap();
//!
//! event_loop
//!     .handle()
//!     .insert_source(listener, |event, _, stopper| {
//!         // What kind of event did we get this time?
//!         let msg = match event {
//!             // The subprocess was just started.
//!             calloop_subproc::ListenEvent::Start => "Subprocess started".to_owned(),
//!             // We got a line of output from the subprocess.
//!             calloop_subproc::ListenEvent::Line(line) => format!("Output: {}", line),
//!             // The subprocess ended.
//!             calloop_subproc::ListenEvent::End(res) => {
//!                 // Since the subprocess ended, we want to stop the loop.
//!                 stopper.stop();
//!
//!                 // Show why the subprocess ended.
//!                 match res {
//!                     Ok(()) => "Subprocess completed".to_owned(),
//!                     Err(error) => format!("Subprocess error: {:?}", error),
//!                 }
//!             }
//!         };
//!
//!         // Print our formatted event.
//!         println!("{}", msg);
//!
//!         // This callback must return true if the subprocess should be
//!         // killed. We want it to run to completion, so we return false.
//!         false
//!     })
//!     .unwrap();
//!
//! event_loop
//!     .run(None, &mut event_loop.get_signal(), |_| {})
//!     .unwrap();
//! ```
//!
//! [`process_events()`]: calloop::EventSource::process_events

#![forbid(unsafe_code)]

mod chain;
mod listen;

pub use chain::SubprocChain;
pub use listen::{ListenEvent, SubprocListen};
use log::*;
use std::ffi::OsString;
use std::fmt;

/// This is the error type that our event source's callback might receive. If
/// something went wrong, it could be for one of two reasons:
///   - the subprocess returned a non-zero status: `SubprocError(status)`
///   - there was an IO error handling the subprocess: `IoError(ioerror)`
#[derive(thiserror::Error, Debug)]
pub enum ErrorEvent {
    #[error("subprocess returned non-zero status: {0}")]
    SubprocError(async_process::ExitStatus),

    #[error("IO error managing subprocess")]
    IoError(#[from] std::io::Error),
}

/// This is the error type that might be returned from the methods on the event
/// source itself. In general they are more "critical" than [`ErrorEvent`] and
/// mean that the event source is unable to continue.
#[derive(thiserror::Error, Debug)]
#[error("error processing subprocess events")]
pub enum LaunchError {
    CalloopExecutorError(#[from] calloop::futures::ExecutorError),
    CalloopChannelError(#[from] calloop::channel::ChannelError),
    IoError(#[from] std::io::Error),
}

type SrcResult = core::result::Result<(), ErrorEvent>;

/// There's a lot of common manipulation of what we think of as a command. This
/// type factors out some of that, specifically:
/// - conversion from an arbitrary sequence of strings
/// - conversion to async_process's builder type
/// - debugging representation
#[derive(Clone)]
pub struct Command {
    /// The actual command to run.
    command: OsString,
    /// Optional arguments to the command.
    args: Vec<OsString>,
}

impl Command {
    /// Constructs a new `Command` from a program or command name.
    pub fn new<T: Into<OsString>>(command: T) -> Self {
        Self {
            command: command.into(),
            args: Vec::new(),
        }
    }

    /// Adds a sequence of arguments to the command. The trait bound for `T` is:
    ///
    /// ```none,actually-rust-but-see-https://github.com/rust-lang/rust/issues/63193
    /// T where
    ///     T: IntoIterator,
    ///     T::Item: Into<OsString>
    /// ```
    ///
    /// ...and can be understood as:
    /// - `T` is anything that can turn into an iterator
    /// - the iterator items are anything that can turn into an `OsString`
    ///
    /// Commonly this might be a `Vec<String>` or just `[&str]`.
    pub fn with_args<T>(self, args: T) -> Self
    where
        T: IntoIterator,
        T::Item: Into<OsString>,
    {
        Self {
            args: args.into_iter().map(Into::into).collect(),
            ..self
        }
    }
}

impl From<Command> for async_process::Command {
    /// Converts our `Command` type to an `async_process::Command`.
    fn from(command: Command) -> Self {
        let mut async_command = async_process::Command::new(command.command);
        async_command.args(command.args);
        async_command
    }
}

impl fmt::Debug for Command {
    /// Formats the command as it would appear on the command line. No fancy
    /// escaping or anything is done, this is purely for reading in the logs.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_fmt(format_args!(
            "{} {}",
            self.command.to_string_lossy(),
            self.args
                .iter()
                .map(|s| s.to_string_lossy())
                .collect::<Vec<_>>()
                .join(" ")
        ))
    }
}

/// Running a command can result in either `Ok(())` or the error outcomes
/// described above (see `SrcResult`). The `command` is actually just a
/// `String`: the debug representation of the `Command`. This exists just so we
/// can log what command failed and why.
#[derive(Debug)]
struct CommandResult {
    command: String,
    result: SrcResult,
}

#[cfg(test)]
mod test {
    use std::str::FromStr;

    use super::*;

    #[test]
    fn test_cmd() {
        let command = Command::new("/bin/program").with_args(["one", "B 2", "3"]);

        assert_eq!(command.command, OsString::from_str("/bin/program").unwrap());
        assert_eq!(
            command.args,
            vec![
                OsString::from_str("one").unwrap(),
                OsString::from_str("B 2").unwrap(),
                OsString::from_str("3").unwrap(),
            ]
        );
    }
}