use-moon 0.1.0

Primitive moon vocabulary for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

fn normalized_key(value: &str) -> String {
    value
        .trim()
        .chars()
        .map(|character| match character {
            '_' | ' ' => '-',
            other => other.to_ascii_lowercase(),
        })
        .collect()
}

fn non_empty_text(value: impl AsRef<str>, error: MoonTextError) -> Result<String, MoonTextError> {
    let trimmed = value.as_ref().trim();

    if trimmed.is_empty() {
        Err(error)
    } else {
        Ok(trimmed.to_string())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MoonTextError {
    EmptyName,
    EmptyParentIdentifier,
    EmptySatelliteIdentifier,
}

impl fmt::Display for MoonTextError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyName => formatter.write_str("moon name cannot be empty"),
            Self::EmptyParentIdentifier => formatter.write_str("parent identifier cannot be empty"),
            Self::EmptySatelliteIdentifier => {
                formatter.write_str("satellite identifier cannot be empty")
            },
        }
    }
}

impl Error for MoonTextError {}

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

impl MoonName {
    /// Creates a moon name from non-empty text.
    ///
    /// # Errors
    ///
    /// Returns [`MoonTextError::EmptyName`] when the trimmed input is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, MoonTextError> {
        non_empty_text(value, MoonTextError::EmptyName).map(Self)
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for MoonName {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for MoonName {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for MoonName {
    type Err = MoonTextError;

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

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MoonKind {
    Regular,
    Irregular,
    Captured,
    Shepherd,
    Trojan,
    Unknown,
    Custom(String),
}

impl fmt::Display for MoonKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Regular => formatter.write_str("regular"),
            Self::Irregular => formatter.write_str("irregular"),
            Self::Captured => formatter.write_str("captured"),
            Self::Shepherd => formatter.write_str("shepherd"),
            Self::Trojan => formatter.write_str("trojan"),
            Self::Unknown => formatter.write_str("unknown"),
            Self::Custom(value) => formatter.write_str(value),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MoonKindParseError {
    Empty,
}

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

impl Error for MoonKindParseError {}

impl FromStr for MoonKind {
    type Err = MoonKindParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = value.trim();

        if trimmed.is_empty() {
            return Err(MoonKindParseError::Empty);
        }

        match normalized_key(trimmed).as_str() {
            "regular" => Ok(Self::Regular),
            "irregular" => Ok(Self::Irregular),
            "captured" => Ok(Self::Captured),
            "shepherd" => Ok(Self::Shepherd),
            "trojan" => Ok(Self::Trojan),
            "unknown" => Ok(Self::Unknown),
            _ => Ok(Self::Custom(trimmed.to_string())),
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SatelliteRelation {
    parent_identifier: String,
    satellite_identifier: String,
}

impl SatelliteRelation {
    /// Creates a relation between non-empty parent and satellite identifiers.
    ///
    /// # Errors
    ///
    /// Returns [`MoonTextError::EmptyParentIdentifier`] when the trimmed parent identifier is empty,
    /// or [`MoonTextError::EmptySatelliteIdentifier`] when the trimmed satellite identifier is empty.
    pub fn new(
        parent_identifier: impl AsRef<str>,
        satellite_identifier: impl AsRef<str>,
    ) -> Result<Self, MoonTextError> {
        Ok(Self {
            parent_identifier: non_empty_text(
                parent_identifier,
                MoonTextError::EmptyParentIdentifier,
            )?,
            satellite_identifier: non_empty_text(
                satellite_identifier,
                MoonTextError::EmptySatelliteIdentifier,
            )?,
        })
    }

    #[must_use]
    pub fn parent_identifier(&self) -> &str {
        &self.parent_identifier
    }

    #[must_use]
    pub fn satellite_identifier(&self) -> &str {
        &self.satellite_identifier
    }
}

#[cfg(test)]
mod tests {
    use super::{MoonKind, MoonName, MoonTextError, SatelliteRelation};

    #[test]
    fn valid_moon_name() {
        let name = MoonName::new("Europa").unwrap();

        assert_eq!(name.as_str(), "Europa");
    }

    #[test]
    fn empty_moon_name_rejected() {
        assert_eq!(MoonName::new("   "), Err(MoonTextError::EmptyName));
    }

    #[test]
    fn moon_kind_display_and_parse() {
        assert_eq!(MoonKind::Regular.to_string(), "regular");
        assert_eq!("captured".parse::<MoonKind>().unwrap(), MoonKind::Captured);
    }

    #[test]
    fn custom_moon_kind() {
        assert_eq!(
            "resonant".parse::<MoonKind>().unwrap(),
            MoonKind::Custom("resonant".to_string())
        );
    }

    #[test]
    fn satellite_relation_construction() {
        let relation = SatelliteRelation::new("Jupiter", "Europa").unwrap();

        assert_eq!(relation.parent_identifier(), "Jupiter");
        assert_eq!(relation.satellite_identifier(), "Europa");
    }
}