use-bun 0.0.1

Bun runtime metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Bun version metadata.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BunVersion {
    major: u16,
    minor: Option<u16>,
    patch: Option<u16>,
}

impl BunVersion {
    /// Creates Bun version metadata.
    ///
    /// # Errors
    ///
    /// Returns [`BunVersionParseError::InvalidVersion`] when the major version is zero.
    pub const fn new(
        major: u16,
        minor: Option<u16>,
        patch: Option<u16>,
    ) -> Result<Self, BunVersionParseError> {
        if major == 0 || (minor.is_none() && patch.is_some()) {
            Err(BunVersionParseError::InvalidVersion)
        } else {
            Ok(Self {
                major,
                minor,
                patch,
            })
        }
    }

    /// Returns the major version.
    #[must_use]
    pub const fn major(self) -> u16 {
        self.major
    }
}

impl FromStr for BunVersion {
    type Err = BunVersionParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        parse_bun_version(input)
    }
}

/// Error returned while parsing a Bun version.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BunVersionParseError {
    Empty,
    InvalidVersion,
}

impl fmt::Display for BunVersionParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Bun version cannot be empty"),
            Self::InvalidVersion => formatter.write_str("invalid Bun version"),
        }
    }
}

impl Error for BunVersionParseError {}

/// Common Bun command labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum BunCommand {
    Install,
    Run,
    Test,
    Build,
}

impl BunCommand {
    /// Returns the command label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Install => "install",
            Self::Run => "run",
            Self::Test => "test",
            Self::Build => "build",
        }
    }
}

impl FromStr for BunCommand {
    type Err = BunCommandParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(BunCommandParseError::Empty);
        }
        match trimmed.to_ascii_lowercase().as_str() {
            "install" | "i" => Ok(Self::Install),
            "run" => Ok(Self::Run),
            "test" => Ok(Self::Test),
            "build" => Ok(Self::Build),
            _ => Err(BunCommandParseError::Unknown),
        }
    }
}

/// Error returned while parsing Bun command labels.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BunCommandParseError {
    Empty,
    Unknown,
}

impl fmt::Display for BunCommandParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Bun command cannot be empty"),
            Self::Unknown => formatter.write_str("unknown Bun command"),
        }
    }
}

impl Error for BunCommandParseError {}

/// Common Bun lockfile labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum BunLockfile {
    Text,
    Binary,
}

impl BunLockfile {
    /// Returns the lockfile label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Text => "bun.lock",
            Self::Binary => "bun.lockb",
        }
    }
}

fn parse_bun_version(input: &str) -> Result<BunVersion, BunVersionParseError> {
    let trimmed = input.trim().trim_start_matches('v');
    if trimmed.is_empty() {
        return Err(BunVersionParseError::Empty);
    }
    let parts = trimmed.split('.').collect::<Vec<_>>();
    if parts.len() > 3 || parts.iter().any(|part| part.is_empty()) {
        return Err(BunVersionParseError::InvalidVersion);
    }
    let major = parse_part(parts[0])?;
    let minor = parts.get(1).copied().map(parse_part).transpose()?;
    let patch = parts.get(2).copied().map(parse_part).transpose()?;
    BunVersion::new(major, minor, patch)
}

fn parse_part(input: &str) -> Result<u16, BunVersionParseError> {
    input
        .parse::<u16>()
        .map_err(|_error| BunVersionParseError::InvalidVersion)
}

#[cfg(test)]
mod tests {
    use super::{BunCommand, BunLockfile, BunVersion};

    #[test]
    fn parses_bun_metadata() -> Result<(), Box<dyn std::error::Error>> {
        let version: BunVersion = "1.1.8".parse()?;
        assert_eq!(version.major(), 1);
        assert_eq!("install".parse::<BunCommand>()?, BunCommand::Install);
        assert_eq!(BunLockfile::Binary.as_str(), "bun.lockb");
        Ok(())
    }
}