tastty-driver 0.1.0

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

/// Platform signal identifier.
///
/// # References
///
/// - [POSIX `signal.h` (Open Group Base Specifications)][posix-signal]:
///   canonical signal numbers, symbolic names, and delivery semantics.
///
/// [posix-signal]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/signal.h.html
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct Signal {
    number: i32,
}

impl Signal {
    /// Interrupt signal.
    pub const INT: Self = Self { number: 2 };
    /// Termination signal.
    pub const TERM: Self = Self { number: 15 };
    /// Force-kill signal.
    pub const KILL: Self = Self { number: 9 };

    /// Build a signal from its platform number.
    #[must_use]
    pub const fn from_number(number: i32) -> Self {
        Self { number }
    }

    /// Return the platform signal number.
    #[must_use]
    pub const fn number(self) -> i32 {
        self.number
    }

    /// Resolve a textual signal name into a [`Signal`].
    ///
    /// Accepts three forms, all matched case-insensitively after trimming:
    /// - decimal numeric strings (e.g. `"15"`); only positive values are
    ///   accepted, since signal `0` is a `kill(2)` liveness probe rather than
    ///   a deliverable signal,
    /// - canonical short names with or without the `SIG` prefix (e.g.
    ///   `"INT"`, `"sigint"`, `"SIGTERM"`),
    /// - `libc::strsignal` descriptions on Unix (e.g. `"Interrupt"`,
    ///   `"Terminated"`, `"User defined signal 1"`), retained so DSL
    ///   inputs that quote `libc::strsignal` output still resolve.
    ///
    /// Returns `None` for unrecognized names so callers can produce the
    /// rejection diagnostic in their own error type.
    #[must_use]
    pub fn from_name(name: &str) -> Option<Self> {
        let trimmed = name.trim();
        if let Ok(number) = trimmed.parse::<i32>() {
            return (number > 0).then_some(Self::from_number(number));
        }

        let upper = trimmed.to_ascii_uppercase();
        let stripped = upper.strip_prefix("SIG").unwrap_or(&upper);
        SIGNAL_TABLE
            .iter()
            .find(|entry| {
                entry
                    .aliases
                    .iter()
                    .any(|alias| *alias == upper || *alias == stripped)
            })
            .map(|entry| Self::from_number(entry.number))
    }

    /// Return the canonical symbolic name (`"SIGINT"`, `"SIGTERM"`, ...) for
    /// well-known signals.
    ///
    /// Returns `None` for signal numbers outside the canonical table; the
    /// numeric form is still available via [`Signal::number`].
    #[must_use]
    pub fn name(self) -> Option<&'static str> {
        SIGNAL_TABLE
            .iter()
            .find(|entry| entry.number == self.number)
            .map(|entry| entry.canonical)
    }
}

impl fmt::Display for Signal {
    /// Emit the canonical signal name (`SIGKILL`, `SIGTERM`, ...) when the
    /// number maps to a well-known entry; fall back to `signal(<n>)` for
    /// out-of-table numbers so the diagnostic still identifies the kind.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.name() {
            Some(name) => f.write_str(name),
            None => write!(f, "signal({})", self.number),
        }
    }
}

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

/// Aliases are stored without the `SIG` prefix; [`Signal::from_name`] strips
/// any prefix on the input before comparing. Each row carries both the
/// canonical short name and the `libc::strsignal` description so DSL
/// callers that quote either form resolve to the same number.
static SIGNAL_TABLE: &[SignalEntry] = &[
    SignalEntry {
        canonical: "SIGHUP",
        aliases: &["HUP", "HANGUP"],
        number: 1,
    },
    SignalEntry {
        canonical: "SIGINT",
        aliases: &["INT", "INTERRUPT"],
        number: 2,
    },
    SignalEntry {
        canonical: "SIGQUIT",
        aliases: &["QUIT"],
        number: 3,
    },
    SignalEntry {
        canonical: "SIGKILL",
        aliases: &["KILL", "KILLED"],
        number: 9,
    },
    SignalEntry {
        canonical: "SIGUSR1",
        aliases: &["USR1", "USER DEFINED SIGNAL 1"],
        number: 10,
    },
    SignalEntry {
        canonical: "SIGSEGV",
        aliases: &["SEGV", "SEGMENTATION FAULT"],
        number: 11,
    },
    SignalEntry {
        canonical: "SIGUSR2",
        aliases: &["USR2", "USER DEFINED SIGNAL 2"],
        number: 12,
    },
    SignalEntry {
        canonical: "SIGPIPE",
        aliases: &["PIPE", "BROKEN PIPE"],
        number: 13,
    },
    SignalEntry {
        canonical: "SIGALRM",
        aliases: &["ALRM", "ALARM CLOCK"],
        number: 14,
    },
    SignalEntry {
        canonical: "SIGTERM",
        aliases: &["TERM", "TERMINATED"],
        number: 15,
    },
    SignalEntry {
        canonical: "SIGCONT",
        aliases: &["CONT", "CONTINUED"],
        number: 18,
    },
    SignalEntry {
        canonical: "SIGSTOP",
        aliases: &["STOP", "STOPPED", "STOPPED (SIGNAL)"],
        number: 19,
    },
];

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

    #[test]
    fn from_name_accepts_short_names() {
        assert_eq!(Signal::from_name("INT"), Some(Signal::INT));
        assert_eq!(Signal::from_name("TERM"), Some(Signal::TERM));
        assert_eq!(Signal::from_name("KILL"), Some(Signal::KILL));
    }

    #[test]
    fn from_name_strips_sig_prefix() {
        assert_eq!(Signal::from_name("SIGINT"), Some(Signal::INT));
        assert_eq!(Signal::from_name("SIGTERM"), Some(Signal::TERM));
    }

    #[test]
    fn from_name_is_case_insensitive() {
        assert_eq!(Signal::from_name("sigint"), Some(Signal::INT));
        assert_eq!(Signal::from_name("Hangup"), Some(Signal::from_number(1)));
    }

    #[test]
    fn from_name_accepts_strsignal_descriptions() {
        assert_eq!(Signal::from_name("Interrupt"), Some(Signal::INT));
        assert_eq!(Signal::from_name("Terminated"), Some(Signal::TERM));
        assert_eq!(
            Signal::from_name("User defined signal 1"),
            Some(Signal::from_number(10))
        );
        assert_eq!(
            Signal::from_name("Stopped (signal)"),
            Some(Signal::from_number(19))
        );
    }

    #[test]
    fn from_name_accepts_positive_numbers() {
        assert_eq!(Signal::from_name("9"), Some(Signal::KILL));
        assert_eq!(Signal::from_name(" 15 "), Some(Signal::TERM));
    }

    #[test]
    fn from_name_rejects_zero_and_negative_numbers() {
        assert_eq!(Signal::from_name("0"), None);
        assert_eq!(Signal::from_name("-5"), None);
    }

    #[test]
    fn from_name_rejects_unknown_names() {
        assert_eq!(Signal::from_name("BOGUS"), None);
        assert_eq!(Signal::from_name(""), None);
        assert_eq!(Signal::from_name("SIG"), None);
    }

    #[test]
    fn name_returns_canonical_form() {
        assert_eq!(Signal::INT.name(), Some("SIGINT"));
        assert_eq!(Signal::TERM.name(), Some("SIGTERM"));
        assert_eq!(Signal::KILL.name(), Some("SIGKILL"));
        assert_eq!(Signal::from_number(11).name(), Some("SIGSEGV"));
    }

    #[test]
    fn name_returns_none_for_unknown_numbers() {
        assert_eq!(Signal::from_number(255).name(), None);
    }

    #[test]
    fn display_emits_canonical_name() {
        assert_eq!(Signal::KILL.to_string(), "SIGKILL");
        assert_eq!(Signal::TERM.to_string(), "SIGTERM");
        assert_eq!(Signal::INT.to_string(), "SIGINT");
        assert_eq!(Signal::from_number(11).to_string(), "SIGSEGV");
    }

    #[test]
    fn display_falls_back_for_unknown_numbers() {
        assert_eq!(Signal::from_number(255).to_string(), "signal(255)");
    }
}