tapid-core 0.0.1

Core domain types and deterministic validation for the Tapid package manager
Documentation
use std::{fmt, str::FromStr};

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageName(String);

impl PackageName {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl FromStr for PackageName {
    type Err = DomainError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        if value.is_empty()
            || value.len() > 214
            || value.starts_with('.')
            || value.starts_with('_')
            || value.ends_with('.')
            || value.ends_with('_')
            || value.chars().any(char::is_whitespace)
        {
            return Err(DomainError::InvalidPackageName(value.to_owned()));
        }

        if value.starts_with('@') {
            let mut parts = value.split('/');
            let scope = parts.next().unwrap_or_default();
            let name = parts.next().unwrap_or_default();
            if parts.next().is_some()
                || scope.len() < 2
                || name.is_empty()
                || scope[1..].chars().any(|c| !is_name_character(c))
                || name.chars().any(|c| !is_name_character(c))
            {
                return Err(DomainError::InvalidPackageName(value.to_owned()));
            }
        } else if value.chars().any(|c| !is_name_character(c)) {
            return Err(DomainError::InvalidPackageName(value.to_owned()));
        }

        Ok(Self(value.to_owned()))
    }
}

impl fmt::Display for PackageName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PackageVersion {
    pub major: u64,
    pub minor: u64,
    pub patch: u64,
}

impl FromStr for PackageVersion {
    type Err = DomainError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let mut parts = value.split('.');
        let numbers = [parts.next(), parts.next(), parts.next()];
        if parts.next().is_some() || numbers.iter().any(Option::is_none) {
            return Err(DomainError::InvalidPackageVersion(value.to_owned()));
        }

        let [Some(major), Some(minor), Some(patch)] = numbers else {
            unreachable!("checked above");
        };
        let parse = |part: &str| {
            if part.is_empty() || (part.len() > 1 && part.starts_with('0')) {
                return Err(DomainError::InvalidPackageVersion(value.to_owned()));
            }
            part.parse::<u64>()
                .map_err(|_| DomainError::InvalidPackageVersion(value.to_owned()))
        };

        Ok(Self {
            major: parse(major)?,
            minor: parse(minor)?,
            patch: parse(patch)?,
        })
    }
}

impl fmt::Display for PackageVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ArtifactDigest(String);

impl ArtifactDigest {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl FromStr for ArtifactDigest {
    type Err = DomainError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let Some(hex) = value.strip_prefix("sha256-") else {
            return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
        };
        if hex.len() != 64 || hex.chars().any(|c| !c.is_ascii_hexdigit()) {
            return Err(DomainError::InvalidArtifactDigest(value.to_owned()));
        }
        Ok(Self(value.to_ascii_lowercase()))
    }
}

impl fmt::Display for ArtifactDigest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainError {
    InvalidPackageName(String),
    InvalidPackageVersion(String),
    InvalidArtifactDigest(String),
}

impl fmt::Display for DomainError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidPackageName(value) => write!(f, "invalid package name: {value}"),
            Self::InvalidPackageVersion(value) => write!(f, "invalid package version: {value}"),
            Self::InvalidArtifactDigest(value) => write!(f, "invalid artifact digest: {value}"),
        }
    }
}

impl std::error::Error for DomainError {}

fn is_name_character(character: char) -> bool {
    character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
}

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

    #[test]
    fn accepts_scoped_and_unscoped_package_names() {
        assert!("tapid".parse::<PackageName>().is_ok());
        assert!("@tapid/core".parse::<PackageName>().is_ok());
    }

    #[test]
    fn rejects_unsafe_package_names() {
        for value in [
            "",
            "../tapid",
            "@tapid",
            "@tapid/core/extra",
            "tap id",
            "tapid/core",
        ] {
            assert!(value.parse::<PackageName>().is_err(), "accepted {value}");
        }
    }

    #[test]
    fn parses_canonical_versions() {
        let version = "1.2.3".parse::<PackageVersion>().unwrap();
        assert_eq!(version.to_string(), "1.2.3");
        assert!("01.2.3".parse::<PackageVersion>().is_err());
    }

    #[test]
    fn accepts_only_sha256_digests() {
        let digest = format!("sha256-{}", "A".repeat(64))
            .parse::<ArtifactDigest>()
            .unwrap();
        assert_eq!(digest.to_string(), format!("sha256-{}", "a".repeat(64)));
        assert!("sha512-deadbeef".parse::<ArtifactDigest>().is_err());
    }
}