use-fossil 0.1.0

Primitive fossil 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, FossilTextError> {
    let original = value.as_ref();

    if original.trim().is_empty() {
        Err(FossilTextError::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 FossilTextError {
    Empty,
}

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

impl Error for FossilTextError {}

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

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

impl Error for FossilParseError {}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FossilOccurrenceError {
    MissingReference,
    EmptyFormation,
    EmptyTimeLabel,
}

impl fmt::Display for FossilOccurrenceError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingReference => {
                formatter.write_str("fossil occurrence requires a formation or time label")
            },
            Self::EmptyFormation => {
                formatter.write_str("fossil occurrence formation cannot be empty")
            },
            Self::EmptyTimeLabel => {
                formatter.write_str("fossil occurrence time label cannot be empty")
            },
        }
    }
}

impl Error for FossilOccurrenceError {}

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

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

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

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

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

impl FromStr for FossilName {
    type Err = FossilTextError;

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

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum FossilKind {
    BodyFossil,
    TraceFossil,
    Mold,
    Cast,
    Impression,
    Compression,
    Amber,
    Unknown,
    Custom(String),
}

impl fmt::Display for FossilKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::BodyFossil => formatter.write_str("body-fossil"),
            Self::TraceFossil => formatter.write_str("trace-fossil"),
            Self::Mold => formatter.write_str("mold"),
            Self::Cast => formatter.write_str("cast"),
            Self::Impression => formatter.write_str("impression"),
            Self::Compression => formatter.write_str("compression"),
            Self::Amber => formatter.write_str("amber"),
            Self::Unknown => formatter.write_str("unknown"),
            Self::Custom(value) => formatter.write_str(value),
        }
    }
}

impl FromStr for FossilKind {
    type Err = FossilParseError;

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

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

        match normalized_token(trimmed).as_str() {
            "body-fossil" => Ok(Self::BodyFossil),
            "trace-fossil" => Ok(Self::TraceFossil),
            "mold" => Ok(Self::Mold),
            "cast" => Ok(Self::Cast),
            "impression" => Ok(Self::Impression),
            "compression" => Ok(Self::Compression),
            "amber" => Ok(Self::Amber),
            "unknown" => Ok(Self::Unknown),
            _ => Ok(Self::Custom(trimmed.to_string())),
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum FossilPreservation {
    Permineralized,
    Carbonized,
    Replaced,
    Unaltered,
    Compressed,
    Unknown,
    Custom(String),
}

impl fmt::Display for FossilPreservation {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Permineralized => formatter.write_str("permineralized"),
            Self::Carbonized => formatter.write_str("carbonized"),
            Self::Replaced => formatter.write_str("replaced"),
            Self::Unaltered => formatter.write_str("unaltered"),
            Self::Compressed => formatter.write_str("compressed"),
            Self::Unknown => formatter.write_str("unknown"),
            Self::Custom(value) => formatter.write_str(value),
        }
    }
}

impl FromStr for FossilPreservation {
    type Err = FossilParseError;

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

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

        match normalized_token(trimmed).as_str() {
            "permineralized" => Ok(Self::Permineralized),
            "carbonized" => Ok(Self::Carbonized),
            "replaced" => Ok(Self::Replaced),
            "unaltered" => Ok(Self::Unaltered),
            "compressed" => Ok(Self::Compressed),
            "unknown" => Ok(Self::Unknown),
            _ => Ok(Self::Custom(trimmed.to_string())),
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct FossilOccurrence {
    formation: Option<String>,
    time_label: Option<String>,
}

impl FossilOccurrence {
    /// Creates a fossil occurrence from at least one formation or time label.
    ///
    /// # Errors
    ///
    /// Returns [`FossilOccurrenceError::EmptyFormation`] when the formation is empty.
    /// Returns [`FossilOccurrenceError::EmptyTimeLabel`] when the time label is empty.
    /// Returns [`FossilOccurrenceError::MissingReference`] when both values are absent.
    pub fn new(
        formation: Option<String>,
        time_label: Option<String>,
    ) -> Result<Self, FossilOccurrenceError> {
        let formation = sanitize_optional_text(formation, FossilOccurrenceError::EmptyFormation)?;
        let time_label = sanitize_optional_text(time_label, FossilOccurrenceError::EmptyTimeLabel)?;

        if formation.is_none() && time_label.is_none() {
            return Err(FossilOccurrenceError::MissingReference);
        }

        Ok(Self {
            formation,
            time_label,
        })
    }

    #[must_use]
    pub fn formation(&self) -> Option<&str> {
        self.formation.as_deref()
    }

    #[must_use]
    pub fn time_label(&self) -> Option<&str> {
        self.time_label.as_deref()
    }
}

impl fmt::Display for FossilOccurrence {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (self.formation.as_deref(), self.time_label.as_deref()) {
            (Some(formation), Some(time_label)) => {
                write!(formatter, "{formation} @ {time_label}")
            },
            (Some(formation), None) => formatter.write_str(formation),
            (None, Some(time_label)) => formatter.write_str(time_label),
            (None, None) => formatter.write_str("unspecified"),
        }
    }
}

fn sanitize_optional_text(
    value: Option<String>,
    empty_error: FossilOccurrenceError,
) -> Result<Option<String>, FossilOccurrenceError> {
    match value {
        Some(text) if text.trim().is_empty() => Err(empty_error),
        Some(text) => Ok(Some(text)),
        None => Ok(None),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        FossilKind, FossilName, FossilOccurrence, FossilOccurrenceError, FossilParseError,
        FossilPreservation, FossilTextError,
    };

    #[test]
    fn valid_fossil_name() -> Result<(), FossilTextError> {
        let name = FossilName::new("Trilobite pygidium")?;

        assert_eq!(name.as_str(), "Trilobite pygidium");
        Ok(())
    }

    #[test]
    fn empty_fossil_name_rejected() {
        assert_eq!(FossilName::new(""), Err(FossilTextError::Empty));
    }

    #[test]
    fn fossil_kind_display_parse() -> Result<(), FossilParseError> {
        assert_eq!(FossilKind::TraceFossil.to_string(), "trace-fossil");
        assert_eq!("body fossil".parse::<FossilKind>()?, FossilKind::BodyFossil);
        Ok(())
    }

    #[test]
    fn fossil_preservation_display_parse() -> Result<(), FossilParseError> {
        assert_eq!(
            FossilPreservation::Permineralized.to_string(),
            "permineralized"
        );
        assert_eq!(
            "replaced".parse::<FossilPreservation>()?,
            FossilPreservation::Replaced
        );
        Ok(())
    }

    #[test]
    fn fossil_occurrence_construction() -> Result<(), FossilOccurrenceError> {
        let occurrence = FossilOccurrence::new(
            Some("Morrison Formation".to_string()),
            Some("Late Jurassic".to_string()),
        )?;

        assert_eq!(occurrence.formation(), Some("Morrison Formation"));
        assert_eq!(occurrence.time_label(), Some("Late Jurassic"));
        assert_eq!(occurrence.to_string(), "Morrison Formation @ Late Jurassic");
        Ok(())
    }
}