use-mineral 0.1.0

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

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

fn non_empty_text(value: impl AsRef<str>) -> Result<String, MineralTextError> {
    let original = value.as_ref();

    if original.trim().is_empty() {
        Err(MineralTextError::Empty)
    } else {
        Ok(original.to_string())
    }
}

fn normalized_token(value: &str) -> String {
    let mut normalized = String::with_capacity(value.len());
    let mut previous_separator = false;

    for character in value.trim().chars() {
        if character.is_ascii_alphanumeric() {
            normalized.push(character.to_ascii_lowercase());
            previous_separator = false;
        } else if (character.is_whitespace() || character == '-' || character == '_')
            && !previous_separator
            && !normalized.is_empty()
        {
            normalized.push('-');
            previous_separator = true;
        }
    }

    if normalized.ends_with('-') {
        let _ = normalized.pop();
    }

    normalized
}

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

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

impl Error for MineralTextError {}

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

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

impl Error for MineralParseError {}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MohsHardnessError {
    InvalidNumber,
    NonFinite,
    OutOfRange,
}

impl fmt::Display for MohsHardnessError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidNumber => formatter.write_str("mohs hardness must be a valid number"),
            Self::NonFinite => formatter.write_str("mohs hardness must be finite"),
            Self::OutOfRange => formatter.write_str("mohs hardness must be in 1.0..=10.0"),
        }
    }
}

impl Error for MohsHardnessError {}

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

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

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

    #[must_use]
    pub fn into_string(self) -> String {
        self.0
    }
}

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

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

impl FromStr for MineralName {
    type Err = MineralTextError;

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

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

impl MineralKind {
    /// Creates a mineral kind from non-empty text.
    ///
    /// # Errors
    ///
    /// Returns [`MineralTextError::Empty`] when the trimmed value is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, MineralTextError> {
        non_empty_text(value).map(Self)
    }

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

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

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

impl FromStr for MineralKind {
    type Err = MineralTextError;

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

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MineralClass {
    Silicate,
    Carbonate,
    Oxide,
    Sulfide,
    Sulfate,
    Halide,
    Phosphate,
    NativeElement,
    Organic,
    Unknown,
    Custom(String),
}

impl fmt::Display for MineralClass {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Silicate => formatter.write_str("silicate"),
            Self::Carbonate => formatter.write_str("carbonate"),
            Self::Oxide => formatter.write_str("oxide"),
            Self::Sulfide => formatter.write_str("sulfide"),
            Self::Sulfate => formatter.write_str("sulfate"),
            Self::Halide => formatter.write_str("halide"),
            Self::Phosphate => formatter.write_str("phosphate"),
            Self::NativeElement => formatter.write_str("native-element"),
            Self::Organic => formatter.write_str("organic"),
            Self::Unknown => formatter.write_str("unknown"),
            Self::Custom(value) => formatter.write_str(value),
        }
    }
}

impl FromStr for MineralClass {
    type Err = MineralParseError;

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

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

        match normalized_token(trimmed).as_str() {
            "silicate" => Ok(Self::Silicate),
            "carbonate" => Ok(Self::Carbonate),
            "oxide" => Ok(Self::Oxide),
            "sulfide" => Ok(Self::Sulfide),
            "sulfate" => Ok(Self::Sulfate),
            "halide" => Ok(Self::Halide),
            "phosphate" => Ok(Self::Phosphate),
            "native-element" => Ok(Self::NativeElement),
            "organic" => Ok(Self::Organic),
            "unknown" => Ok(Self::Unknown),
            _ => Ok(Self::Custom(trimmed.to_string())),
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum CrystalSystem {
    Cubic,
    Tetragonal,
    Orthorhombic,
    Hexagonal,
    Trigonal,
    Monoclinic,
    Triclinic,
    Amorphous,
    Unknown,
    Custom(String),
}

impl fmt::Display for CrystalSystem {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Cubic => formatter.write_str("cubic"),
            Self::Tetragonal => formatter.write_str("tetragonal"),
            Self::Orthorhombic => formatter.write_str("orthorhombic"),
            Self::Hexagonal => formatter.write_str("hexagonal"),
            Self::Trigonal => formatter.write_str("trigonal"),
            Self::Monoclinic => formatter.write_str("monoclinic"),
            Self::Triclinic => formatter.write_str("triclinic"),
            Self::Amorphous => formatter.write_str("amorphous"),
            Self::Unknown => formatter.write_str("unknown"),
            Self::Custom(value) => formatter.write_str(value),
        }
    }
}

impl FromStr for CrystalSystem {
    type Err = MineralParseError;

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

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

        match normalized_token(trimmed).as_str() {
            "cubic" => Ok(Self::Cubic),
            "tetragonal" => Ok(Self::Tetragonal),
            "orthorhombic" => Ok(Self::Orthorhombic),
            "hexagonal" => Ok(Self::Hexagonal),
            "trigonal" => Ok(Self::Trigonal),
            "monoclinic" => Ok(Self::Monoclinic),
            "triclinic" => Ok(Self::Triclinic),
            "amorphous" => Ok(Self::Amorphous),
            "unknown" => Ok(Self::Unknown),
            _ => Ok(Self::Custom(trimmed.to_string())),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct MohsHardness(f64);

impl MohsHardness {
    /// Creates a Mohs hardness value in the inclusive range 1.0 through 10.0.
    ///
    /// # Errors
    ///
    /// Returns [`MohsHardnessError::NonFinite`] when the value is not finite.
    /// Returns [`MohsHardnessError::OutOfRange`] when the value is outside 1.0..=10.0.
    pub fn new(value: f64) -> Result<Self, MohsHardnessError> {
        if !value.is_finite() {
            return Err(MohsHardnessError::NonFinite);
        }

        if !(1.0..=10.0).contains(&value) {
            return Err(MohsHardnessError::OutOfRange);
        }

        Ok(Self(value))
    }

    #[must_use]
    pub const fn value(self) -> f64 {
        self.0
    }
}

impl fmt::Display for MohsHardness {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}", self.0)
    }
}

impl FromStr for MohsHardness {
    type Err = MohsHardnessError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let parsed = value
            .trim()
            .parse::<f64>()
            .map_err(|_| MohsHardnessError::InvalidNumber)?;
        Self::new(parsed)
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CrystalSystem, MineralClass, MineralName, MineralParseError, MineralTextError,
        MohsHardness, MohsHardnessError,
    };

    #[test]
    fn valid_mineral_name() -> Result<(), MineralTextError> {
        let name = MineralName::new("Quartz")?;

        assert_eq!(name.as_str(), "Quartz");
        assert_eq!(name.to_string(), "Quartz");
        Ok(())
    }

    #[test]
    fn empty_mineral_name_rejected() {
        assert_eq!(MineralName::new("   "), Err(MineralTextError::Empty));
    }

    #[test]
    fn mineral_class_display_parse() -> Result<(), MineralParseError> {
        assert_eq!(MineralClass::Carbonate.to_string(), "carbonate");
        assert_eq!(
            "native element".parse::<MineralClass>()?,
            MineralClass::NativeElement
        );
        Ok(())
    }

    #[test]
    fn crystal_system_display_parse() -> Result<(), MineralParseError> {
        assert_eq!(CrystalSystem::Orthorhombic.to_string(), "orthorhombic");
        assert_eq!(
            "hexagonal".parse::<CrystalSystem>()?,
            CrystalSystem::Hexagonal
        );
        Ok(())
    }

    #[test]
    fn valid_mohs_hardness() -> Result<(), MohsHardnessError> {
        let hardness = MohsHardness::new(7.0)?;

        assert!((hardness.value() - 7.0).abs() < f64::EPSILON);
        assert!(("8.5".parse::<MohsHardness>()?.value() - 8.5).abs() < f64::EPSILON);
        Ok(())
    }

    #[test]
    fn invalid_mohs_hardness_rejected() {
        assert_eq!(MohsHardness::new(0.5), Err(MohsHardnessError::OutOfRange));
        assert_eq!(
            MohsHardness::new(f64::NAN),
            Err(MohsHardnessError::NonFinite)
        );
    }
}