tastty-driver 0.1.0

Terminal automation driver built on tastty
use std::fmt;

use crate::Signal;

/// Outcome of a child process tracked by the driver.
///
/// Mirrors the exit information reported by the underlying PTY layer
/// without leaking the `portable_pty` dependency type through the
/// driver's public API. Constructed by the driver at the boundary the
/// moment a child exit is observed; callers receive owned values from
/// [`Session::try_wait`](crate::Session::try_wait),
/// [`Session::wait_exit`](crate::Session::wait_exit),
/// [`IoObserver::on_exit`](crate::IoObserver::on_exit), and the
/// `WaitError::ProcessExitedBeforeMatch` payload.
///
/// [`with_exit_code`](Self::with_exit_code) and
/// [`with_signal`](Self::with_signal) are stable construction paths for
/// tests and ad-hoc consumer code.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub struct ExitStatus {
    exit_code: u32,
    signal: Option<Signal>,
}

impl ExitStatus {
    /// Build a status representing a child that exited with `exit_code`
    /// without being killed by a signal.
    #[must_use]
    pub const fn with_exit_code(exit_code: u32) -> Self {
        Self {
            exit_code,
            signal: None,
        }
    }

    /// Build a status representing a child terminated by `signal`.
    ///
    /// The numeric `exit_code` is set to `1` to match the convention used
    /// when `wait(2)` reports no numeric exit code for a signal-terminated
    /// child; callers that want the POSIX shell `128 + signum`
    /// representation should use [`exit_code_posix`](Self::exit_code_posix).
    #[must_use]
    pub const fn with_signal(signal: Signal) -> Self {
        Self {
            exit_code: 1,
            signal: Some(signal),
        }
    }

    /// `true` iff the child exited cleanly with a zero exit code and was
    /// not killed by a signal.
    #[must_use]
    pub const fn success(&self) -> bool {
        self.signal.is_none() && self.exit_code == 0
    }

    /// Raw process exit code as reported by `wait(2)`. On signal-terminated
    /// children this is set to `1` because most platforms do not surface a
    /// distinct numeric code in that case; consult
    /// [`signal`](Self::signal) to disambiguate.
    #[must_use]
    pub const fn exit_code(&self) -> u32 {
        self.exit_code
    }

    /// Signal that terminated the child, when one was observed and
    /// recognised by [`Signal::from_name`].
    ///
    /// Returns `None` for clean exits, on non-Unix platforms (where the
    /// underlying PTY layer does not surface signal info), and for
    /// signal descriptions outside the canonical signal table. The exit
    /// code remains available via [`exit_code`](Self::exit_code) in
    /// every case.
    #[must_use]
    pub const fn signal(&self) -> Option<Signal> {
        self.signal
    }

    /// POSIX-shell exit code: `128 + signum` when a signal is recorded,
    /// otherwise the raw [`exit_code`](Self::exit_code) widened to `i32`.
    ///
    /// The convention only applies on Unix targets, where signal
    /// numbers are well-defined. On other platforms the method falls
    /// back to the raw exit code so callers always observe a sensible
    /// integer.
    #[must_use]
    pub fn exit_code_posix(&self) -> i32 {
        #[cfg(unix)]
        if let Some(signal) = self.signal {
            return 128 + signal.number();
        }
        self.exit_code as i32
    }

    /// Wrap a [`tastty::ExitStatus`], lifting the POSIX signal number
    /// into a [`Signal`].
    pub(crate) fn from_tastty(status: tastty::ExitStatus) -> Self {
        let signal = status.signal().map(|n| Signal::from_number(n as i32));
        Self {
            exit_code: status.exit_code(),
            signal,
        }
    }
}

impl fmt::Display for ExitStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.success() {
            return f.write_str("Success");
        }
        if let Some(signal) = self.signal {
            return match signal.name() {
                Some(name) => write!(f, "Terminated by {name}"),
                None => write!(f, "Terminated by signal {}", signal.number()),
            };
        }
        write!(f, "Exited with code {}", self.exit_code)
    }
}

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

    #[test]
    fn raw_exit_code_passes_through() {
        assert_eq!(ExitStatus::with_exit_code(0).exit_code_posix(), 0);
        assert_eq!(ExitStatus::with_exit_code(42).exit_code_posix(), 42);
    }

    #[cfg(unix)]
    #[test]
    fn signal_exit_uses_unix_convention() {
        assert_eq!(
            ExitStatus::with_signal(Signal::INT).exit_code_posix(),
            128 + 2
        );
        assert_eq!(
            ExitStatus::with_signal(Signal::TERM).exit_code_posix(),
            128 + 15
        );
        assert_eq!(
            ExitStatus::with_signal(Signal::KILL).exit_code_posix(),
            128 + 9
        );
    }

    #[test]
    fn success_distinguishes_zero_exit_from_signal_or_failure() {
        assert!(ExitStatus::with_exit_code(0).success());
        assert!(!ExitStatus::with_exit_code(1).success());
        assert!(!ExitStatus::with_signal(Signal::INT).success());
    }

    #[test]
    fn display_formats_each_outcome() {
        assert_eq!(ExitStatus::with_exit_code(0).to_string(), "Success");
        assert_eq!(
            ExitStatus::with_exit_code(7).to_string(),
            "Exited with code 7"
        );
        assert_eq!(
            ExitStatus::with_signal(Signal::INT).to_string(),
            "Terminated by SIGINT"
        );
        assert_eq!(
            ExitStatus::with_signal(Signal::from_number(99)).to_string(),
            "Terminated by signal 99"
        );
    }

    #[test]
    fn from_tastty_signal_number_lifts_to_canonical_signal() {
        let upstream = tastty::ExitStatus::with_signal(2);
        let wrapped = ExitStatus::from_tastty(upstream);
        assert_eq!(wrapped.signal(), Some(Signal::INT));
        assert_eq!(wrapped.exit_code(), 1);
    }

    #[test]
    fn from_tastty_clean_exit_carries_no_signal() {
        let upstream = tastty::ExitStatus::with_exit_code(0);
        let wrapped = ExitStatus::from_tastty(upstream);
        assert!(wrapped.success());
        assert_eq!(wrapped.signal(), None);
        assert_eq!(wrapped.exit_code(), 0);
    }

    #[test]
    fn from_tastty_uncanonical_signal_number_passes_through() {
        let upstream = tastty::ExitStatus::with_signal(99);
        let wrapped = ExitStatus::from_tastty(upstream);
        assert_eq!(wrapped.signal(), Some(Signal::from_number(99)));
        assert_eq!(wrapped.exit_code(), 1);
    }
}