use-node-js 0.0.1

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

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

/// Node.js major version metadata.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NodeMajorVersion(u16);

impl NodeMajorVersion {
    /// Creates a Node.js major version.
    ///
    /// # Errors
    ///
    /// Returns [`NodeVersionParseError::InvalidVersion`] when `major` is zero.
    pub const fn new(major: u16) -> Result<Self, NodeVersionParseError> {
        if major == 0 {
            Err(NodeVersionParseError::InvalidVersion)
        } else {
            Ok(Self(major))
        }
    }

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

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

impl NodeVersion {
    /// Creates Node.js version metadata.
    ///
    /// # Errors
    ///
    /// Returns [`NodeVersionParseError::InvalidVersion`] when the major version is invalid.
    pub const fn new(
        major: u16,
        minor: Option<u16>,
        patch: Option<u16>,
    ) -> Result<Self, NodeVersionParseError> {
        if minor.is_none() && patch.is_some() {
            return Err(NodeVersionParseError::InvalidVersion);
        }
        match NodeMajorVersion::new(major) {
            Ok(major) => Ok(Self {
                major,
                minor,
                patch,
            }),
            Err(error) => Err(error),
        }
    }

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

    /// Returns the optional minor version number.
    #[must_use]
    pub const fn minor(self) -> Option<u16> {
        self.minor
    }

    /// Returns the optional patch version number.
    #[must_use]
    pub const fn patch(self) -> Option<u16> {
        self.patch
    }
}

impl fmt::Display for NodeVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (self.minor, self.patch) {
            (Some(minor), Some(patch)) => write!(formatter, "{}.{}.{}", self.major(), minor, patch),
            (Some(minor), None) => write!(formatter, "{}.{}", self.major(), minor),
            (None, _) => write!(formatter, "{}", self.major()),
        }
    }
}

impl FromStr for NodeVersion {
    type Err = NodeVersionParseError;

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

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

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

impl Error for NodeVersionParseError {}

/// Node.js runtime metadata.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NodeRuntime {
    version: Option<NodeVersion>,
}

impl NodeRuntime {
    /// Creates runtime metadata with an optional version.
    #[must_use]
    pub const fn new(version: Option<NodeVersion>) -> Self {
        Self { version }
    }

    /// Returns the optional version.
    #[must_use]
    pub const fn version(self) -> Option<NodeVersion> {
        self.version
    }
}

/// Preferred package manager for a Node-oriented project.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NodePackageManagerPreference {
    Npm,
    Pnpm,
    Yarn,
    Bun,
}

impl NodePackageManagerPreference {
    /// Returns the package manager label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Npm => "npm",
            Self::Pnpm => "pnpm",
            Self::Yarn => "yarn",
            Self::Bun => "bun",
        }
    }
}

fn parse_version(input: &str) -> Result<NodeVersion, NodeVersionParseError> {
    let trimmed = input.trim().trim_start_matches('v');
    if trimmed.is_empty() {
        return Err(NodeVersionParseError::Empty);
    }
    let parts = trimmed.split('.').collect::<Vec<_>>();
    if parts.len() > 3 || parts.iter().any(|part| part.is_empty()) {
        return Err(NodeVersionParseError::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()?;
    NodeVersion::new(major, minor, patch)
}

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

#[cfg(test)]
mod tests {
    use super::{NodePackageManagerPreference, NodeRuntime, NodeVersion, NodeVersionParseError};

    #[test]
    fn parses_node_versions() -> Result<(), NodeVersionParseError> {
        let version: NodeVersion = "v20.11.1".parse()?;
        assert_eq!(version.major(), 20);
        assert_eq!(version.minor(), Some(11));
        assert_eq!(version.patch(), Some(1));
        assert_eq!(version.to_string(), "20.11.1");
        assert_eq!("20".parse::<NodeVersion>()?.major(), 20);
        Ok(())
    }

    #[test]
    fn stores_runtime_metadata() -> Result<(), NodeVersionParseError> {
        let version: NodeVersion = "20".parse()?;
        let runtime = NodeRuntime::new(Some(version));
        assert_eq!(runtime.version().map(NodeVersion::major), Some(20));
        assert_eq!(NodePackageManagerPreference::Pnpm.as_str(), "pnpm");
        Ok(())
    }
}