tastty 0.1.0

Embeddable pseudoterminal sessions for Rust applications
/// Outcome of a child process spawned through [`Terminal::spawn`].
///
/// [`signal`](Self::signal) is `Some(n)` only when the child was killed
/// by a signal whose `libc::strsignal` description matches the decode
/// table; clean exits and unrecognised descriptions both surface as
/// `None`, with the failure marker still readable via
/// [`exit_code`](Self::exit_code). The `tastty_driver::Signal` wrapper
/// in the layer above lifts the number into a typed signal with a
/// [`Display`](std::fmt::Display) impl.
///
/// `n` is a [POSIX signal number][posix-signal].
///
/// [`Terminal::spawn`]: crate::Terminal::spawn
/// [posix-signal]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/signal.h.html
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ExitStatus {
    exit_code: u32,
    signal: Option<u32>,
}

impl ExitStatus {
    /// Clean exit with `exit_code`, no signal.
    #[must_use]
    pub const fn with_exit_code(exit_code: u32) -> Self {
        Self {
            exit_code,
            signal: None,
        }
    }

    /// Signal-terminated child. `exit_code` is set to `1` because
    /// `wait(2)` reports no distinct numeric code for this case.
    #[must_use]
    pub const fn with_signal(signal: u32) -> 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)`.
    #[must_use]
    pub const fn exit_code(&self) -> u32 {
        self.exit_code
    }

    /// Signal that terminated the child, decoded to a [POSIX signal
    /// number][posix-signal].
    ///
    /// Returns `None` for clean exits, on non-Unix platforms, and when
    /// the underlying PTY layer reports a description string outside
    /// the decode table.
    ///
    /// [posix-signal]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/signal.h.html
    #[must_use]
    pub const fn signal(&self) -> Option<u32> {
        self.signal
    }

    /// Decode the upstream PTY exit status returned by `child.wait()`.
    pub(crate) fn from_portable(status: portable_pty::ExitStatus) -> Self {
        let signal = status.signal().and_then(signal_name_to_number);
        Self {
            exit_code: status.exit_code(),
            signal,
        }
    }
}

/// The table only needs to match what the underlying PTY layer emits
/// (libc::strsignal descriptions on Unix); richer parsing of user input
/// lives in `tastty_driver::Signal::from_name` one layer up.
fn signal_name_to_number(name: &str) -> Option<u32> {
    let trimmed = name.trim();
    let upper = trimmed.to_ascii_uppercase();
    let stripped = upper.strip_prefix("SIG").unwrap_or(&upper);
    SIGNAL_TABLE
        .iter()
        .find(|entry| entry.aliases.iter().any(|a| *a == stripped || *a == upper))
        .map(|entry| entry.number)
}

struct SignalEntry {
    aliases: &'static [&'static str],
    number: u32,
}

static SIGNAL_TABLE: &[SignalEntry] = &[
    SignalEntry {
        aliases: &["HUP", "HANGUP"],
        number: 1,
    },
    SignalEntry {
        aliases: &["INT", "INTERRUPT"],
        number: 2,
    },
    SignalEntry {
        aliases: &["QUIT"],
        number: 3,
    },
    SignalEntry {
        aliases: &["KILL", "KILLED"],
        number: 9,
    },
    SignalEntry {
        aliases: &["USR1", "USER DEFINED SIGNAL 1"],
        number: 10,
    },
    SignalEntry {
        aliases: &["SEGV", "SEGMENTATION FAULT"],
        number: 11,
    },
    SignalEntry {
        aliases: &["USR2", "USER DEFINED SIGNAL 2"],
        number: 12,
    },
    SignalEntry {
        aliases: &["PIPE", "BROKEN PIPE"],
        number: 13,
    },
    SignalEntry {
        aliases: &["ALRM", "ALARM CLOCK"],
        number: 14,
    },
    SignalEntry {
        aliases: &["TERM", "TERMINATED"],
        number: 15,
    },
    SignalEntry {
        aliases: &["CONT", "CONTINUED"],
        number: 18,
    },
    SignalEntry {
        aliases: &["STOP", "STOPPED", "STOPPED (SIGNAL)"],
        number: 19,
    },
];

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

    #[test]
    fn portable_pty_clean_exit_decodes_to_no_signal() {
        let upstream = portable_pty::ExitStatus::with_exit_code(0);
        let wrapped = ExitStatus::from_portable(upstream);
        assert_eq!(wrapped.exit_code(), 0);
        assert_eq!(wrapped.signal(), None);
    }

    #[test]
    fn portable_pty_named_signal_decodes_to_number() {
        let upstream = portable_pty::ExitStatus::with_signal("Interrupt");
        let wrapped = ExitStatus::from_portable(upstream);
        assert_eq!(wrapped.signal(), Some(2));
        assert_eq!(wrapped.exit_code(), 1);
    }

    #[test]
    fn portable_pty_unknown_description_falls_back_to_no_signal() {
        let upstream = portable_pty::ExitStatus::with_signal("Bus error");
        let wrapped = ExitStatus::from_portable(upstream);
        assert_eq!(wrapped.signal(), None);
        assert_eq!(wrapped.exit_code(), 1);
    }

    #[test]
    fn constructors_round_trip() {
        let s = ExitStatus::with_exit_code(42);
        assert_eq!(s.exit_code(), 42);
        assert_eq!(s.signal(), None);

        let s = ExitStatus::with_signal(15);
        assert_eq!(s.exit_code(), 1);
        assert_eq!(s.signal(), Some(15));
    }
}