haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Task action: what a task does when it runs.
//!
//! A [`TaskAction`] is either an exec-style [`TaskAction::Command`]
//! (an argv list, no shell interpretation) or a [`TaskAction::Shell`]
//! invocation (a script string handed to a named [`ShellType`]).
//!
//! The argv list is non-empty by construction (via
//! [`nonempty::NonEmpty`]), so the "empty command" case is
//! representationally impossible.
//!
//! The shell name carried by [`ShellType::Other`] is a validated
//! [`NonEmptyAsciiName`] (alphabet `[A-Za-z0-9_-]`, leading
//! `[A-Za-z0-9]`, max 32 bytes). The two well-known shells `sh`
//! and `bash` are first-class variants so the common case avoids
//! a string allocation.

use nonempty::NonEmpty;
use nutype::nutype;

use crate::name::NameError;
use crate::name::validation::validate_short;

/// A validated short ASCII name, used by [`ShellType::Other`].
///
/// Same alphabet and leading-character rules as identifiers
/// (`ID-001` / `ID-002`), but capped at 32 bytes rather than 64.
/// Suitable for shell binary names like `zsh`, `fish`, `pwsh`.
///
/// # Examples
///
/// ```
/// use haz_domain::action::NonEmptyAsciiName;
///
/// let n = NonEmptyAsciiName::try_new("zsh").unwrap();
/// assert_eq!(n.to_string(), "zsh");
/// ```
#[nutype(
    validate(with = validate_short, error = NameError),
    derive(
        Debug,
        Clone,
        PartialEq,
        Eq,
        PartialOrd,
        Ord,
        Hash,
        Display,
        FromStr,
        AsRef,
    ),
)]
pub struct NonEmptyAsciiName(String);

/// Which shell interprets a [`TaskAction::Shell`] script.
///
/// `sh` and `bash` are first-class to avoid a string allocation in
/// the common case. Any other named shell carries a validated
/// [`NonEmptyAsciiName`].
///
/// `Default::default()` returns [`ShellType::Sh`].
///
/// # Examples
///
/// ```
/// use haz_domain::action::{NonEmptyAsciiName, ShellType};
///
/// assert_eq!(ShellType::default(), ShellType::Sh);
///
/// let pwsh = NonEmptyAsciiName::try_new("pwsh").unwrap();
/// let custom = ShellType::Other(pwsh);
/// assert_ne!(custom, ShellType::Bash);
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub enum ShellType {
    /// POSIX `/bin/sh` (default).
    #[default]
    Sh,
    /// GNU `bash`.
    Bash,
    /// Any other named shell, validated.
    Other(NonEmptyAsciiName),
}

/// What a task does when it runs.
///
/// - [`TaskAction::Command`] is an argv list passed to `execvp`-
///   style invocation with no shell interpretation. The list is
///   non-empty by construction (via [`NonEmpty`]).
/// - [`TaskAction::Shell`] is a script string handed to a named
///   shell. The script is an opaque [`String`] at this layer; the
///   shell decides how to interpret it.
///
/// # Examples
///
/// ```
/// use nonempty::NonEmpty;
/// use haz_domain::action::{ShellType, TaskAction};
///
/// let argv = NonEmpty::from_vec(vec!["echo".to_owned(), "hi".to_owned()]).unwrap();
/// let cmd = TaskAction::Command(argv);
/// assert!(matches!(cmd, TaskAction::Command(_)));
///
/// let sh = TaskAction::Shell {
///     script: "echo hi".to_owned(),
///     shell: ShellType::default(),
/// };
/// assert!(matches!(sh, TaskAction::Shell { .. }));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskAction {
    /// An argv-style command. Non-empty by construction.
    Command(NonEmpty<String>),
    /// A shell script invocation.
    Shell {
        /// The script source handed to the shell.
        script: String,
        /// Which shell interprets the script.
        shell: ShellType,
    },
}

#[cfg(test)]
mod tests {
    use nonempty::NonEmpty;

    use crate::action::{NonEmptyAsciiName, ShellType, TaskAction};
    use crate::name::NameError;

    // ShellType.

    #[test]
    fn shell_type_default_is_sh() {
        assert_eq!(ShellType::default(), ShellType::Sh);
    }

    #[test]
    fn shell_type_other_carries_validated_name() {
        let zsh = NonEmptyAsciiName::try_new("zsh").unwrap();
        let st = ShellType::Other(zsh);
        assert!(matches!(st, ShellType::Other(_)));
    }

    #[test]
    fn shell_types_are_distinct() {
        assert_ne!(ShellType::Sh, ShellType::Bash);
        let pwsh = NonEmptyAsciiName::try_new("pwsh").unwrap();
        assert_ne!(ShellType::Sh, ShellType::Other(pwsh));
    }

    // NonEmptyAsciiName: same rules as identifiers but 32-byte cap.

    #[test]
    fn ascii_name_accepts_typical_shell_names() {
        for s in ["sh", "bash", "zsh", "fish", "pwsh", "dash", "ksh"] {
            assert!(
                NonEmptyAsciiName::try_new(s).is_ok(),
                "rejected typical shell name {s:?}"
            );
        }
    }

    #[test]
    fn ascii_name_rejects_empty() {
        assert!(matches!(
            NonEmptyAsciiName::try_new(""),
            Err(NameError::Empty)
        ));
    }

    #[test]
    fn ascii_name_rejects_path_separator() {
        assert!(matches!(
            NonEmptyAsciiName::try_new("/usr/bin/zsh"),
            Err(NameError::InvalidLeadingChar { c: '/' })
        ));
    }

    #[test]
    fn ascii_name_rejects_shell_metacharacter() {
        assert!(matches!(
            NonEmptyAsciiName::try_new("z|h"),
            Err(NameError::InvalidChar { c: '|' })
        ));
    }

    #[test]
    fn ascii_name_rejects_space() {
        assert!(matches!(
            NonEmptyAsciiName::try_new("zsh extra"),
            Err(NameError::InvalidChar { c: ' ' })
        ));
    }

    #[test]
    fn ascii_name_accepts_at_32_byte_cap() {
        let exactly_32 = "a".repeat(32);
        assert!(NonEmptyAsciiName::try_new(&exactly_32).is_ok());
    }

    #[test]
    fn ascii_name_rejects_above_32_bytes() {
        let too_long = "a".repeat(33);
        assert!(matches!(
            NonEmptyAsciiName::try_new(&too_long),
            Err(NameError::TooLong { length: 33 })
        ));
    }

    #[test]
    fn ascii_name_diverges_from_identifier_at_33_bytes() {
        // Sanity: a 33-byte string is a valid identifier
        // (`ID-003` allows up to 64) but NOT a valid shell name.
        let s = "a".repeat(33);
        assert!(crate::name::ProjectName::try_new(&s).is_ok());
        assert!(NonEmptyAsciiName::try_new(&s).is_err());
    }

    // TaskAction::Command: non-empty argv enforced structurally.

    #[test]
    fn command_accepts_non_empty_argv() {
        let argv = NonEmpty::from_vec(vec!["echo".to_owned(), "hi".to_owned()]).unwrap();
        let action = TaskAction::Command(argv);
        match action {
            TaskAction::Command(argv) => {
                assert_eq!(argv.len(), 2);
                assert_eq!(argv.first(), "echo");
            }
            TaskAction::Shell { .. } => unreachable!(),
        }
    }

    #[test]
    fn command_empty_argv_unrepresentable() {
        // `NonEmpty::from_vec` on an empty vec returns None,
        // so `TaskAction::Command` cannot be built without at
        // least one element. The check is structural; this
        // test documents the invariant.
        assert!(NonEmpty::from_vec(Vec::<String>::new()).is_none());
    }

    // TaskAction::Shell.

    #[test]
    fn shell_action_with_default_shell() {
        let action = TaskAction::Shell {
            script: "echo hi".to_owned(),
            shell: ShellType::default(),
        };
        match action {
            TaskAction::Shell { script, shell } => {
                assert_eq!(script, "echo hi");
                assert_eq!(shell, ShellType::Sh);
            }
            TaskAction::Command(_) => unreachable!(),
        }
    }

    #[test]
    fn shell_action_with_named_other_shell() {
        let pwsh = NonEmptyAsciiName::try_new("pwsh").unwrap();
        let action = TaskAction::Shell {
            script: "Write-Host hi".to_owned(),
            shell: ShellType::Other(pwsh),
        };
        assert!(matches!(action, TaskAction::Shell { .. }));
    }
}