Skip to main content

use_fossil/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn non_empty_text(value: impl AsRef<str>) -> Result<String, FossilTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(FossilTextError::Empty)
12    } else {
13        Ok(original.to_string())
14    }
15}
16
17fn normalized_token(value: &str) -> String {
18    let mut normalized = String::with_capacity(value.len());
19    let mut previous_separator = false;
20
21    for character in value.trim().chars() {
22        if character.is_ascii_alphanumeric() {
23            normalized.push(character.to_ascii_lowercase());
24            previous_separator = false;
25        } else if (character.is_whitespace() || character == '-' || character == '_')
26            && !previous_separator
27            && !normalized.is_empty()
28        {
29            normalized.push('-');
30            previous_separator = true;
31        }
32    }
33
34    if normalized.ends_with('-') {
35        let _ = normalized.pop();
36    }
37
38    normalized
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum FossilTextError {
43    Empty,
44}
45
46impl fmt::Display for FossilTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("fossil text cannot be empty"),
50        }
51    }
52}
53
54impl Error for FossilTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum FossilParseError {
58    Empty,
59}
60
61impl fmt::Display for FossilParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("fossil vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for FossilParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum FossilOccurrenceError {
73    MissingReference,
74    EmptyFormation,
75    EmptyTimeLabel,
76}
77
78impl fmt::Display for FossilOccurrenceError {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::MissingReference => {
82                formatter.write_str("fossil occurrence requires a formation or time label")
83            },
84            Self::EmptyFormation => {
85                formatter.write_str("fossil occurrence formation cannot be empty")
86            },
87            Self::EmptyTimeLabel => {
88                formatter.write_str("fossil occurrence time label cannot be empty")
89            },
90        }
91    }
92}
93
94impl Error for FossilOccurrenceError {}
95
96#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub struct FossilName(String);
98
99impl FossilName {
100    /// Creates a fossil name from non-empty text.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`FossilTextError::Empty`] when the trimmed value is empty.
105    pub fn new(value: impl AsRef<str>) -> Result<Self, FossilTextError> {
106        non_empty_text(value).map(Self)
107    }
108
109    #[must_use]
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113}
114
115impl AsRef<str> for FossilName {
116    fn as_ref(&self) -> &str {
117        self.as_str()
118    }
119}
120
121impl fmt::Display for FossilName {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        formatter.write_str(self.as_str())
124    }
125}
126
127impl FromStr for FossilName {
128    type Err = FossilTextError;
129
130    fn from_str(value: &str) -> Result<Self, Self::Err> {
131        Self::new(value)
132    }
133}
134
135#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub enum FossilKind {
137    BodyFossil,
138    TraceFossil,
139    Mold,
140    Cast,
141    Impression,
142    Compression,
143    Amber,
144    Unknown,
145    Custom(String),
146}
147
148impl fmt::Display for FossilKind {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::BodyFossil => formatter.write_str("body-fossil"),
152            Self::TraceFossil => formatter.write_str("trace-fossil"),
153            Self::Mold => formatter.write_str("mold"),
154            Self::Cast => formatter.write_str("cast"),
155            Self::Impression => formatter.write_str("impression"),
156            Self::Compression => formatter.write_str("compression"),
157            Self::Amber => formatter.write_str("amber"),
158            Self::Unknown => formatter.write_str("unknown"),
159            Self::Custom(value) => formatter.write_str(value),
160        }
161    }
162}
163
164impl FromStr for FossilKind {
165    type Err = FossilParseError;
166
167    fn from_str(value: &str) -> Result<Self, Self::Err> {
168        let trimmed = value.trim();
169
170        if trimmed.is_empty() {
171            return Err(FossilParseError::Empty);
172        }
173
174        match normalized_token(trimmed).as_str() {
175            "body-fossil" => Ok(Self::BodyFossil),
176            "trace-fossil" => Ok(Self::TraceFossil),
177            "mold" => Ok(Self::Mold),
178            "cast" => Ok(Self::Cast),
179            "impression" => Ok(Self::Impression),
180            "compression" => Ok(Self::Compression),
181            "amber" => Ok(Self::Amber),
182            "unknown" => Ok(Self::Unknown),
183            _ => Ok(Self::Custom(trimmed.to_string())),
184        }
185    }
186}
187
188#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
189pub enum FossilPreservation {
190    Permineralized,
191    Carbonized,
192    Replaced,
193    Unaltered,
194    Compressed,
195    Unknown,
196    Custom(String),
197}
198
199impl fmt::Display for FossilPreservation {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            Self::Permineralized => formatter.write_str("permineralized"),
203            Self::Carbonized => formatter.write_str("carbonized"),
204            Self::Replaced => formatter.write_str("replaced"),
205            Self::Unaltered => formatter.write_str("unaltered"),
206            Self::Compressed => formatter.write_str("compressed"),
207            Self::Unknown => formatter.write_str("unknown"),
208            Self::Custom(value) => formatter.write_str(value),
209        }
210    }
211}
212
213impl FromStr for FossilPreservation {
214    type Err = FossilParseError;
215
216    fn from_str(value: &str) -> Result<Self, Self::Err> {
217        let trimmed = value.trim();
218
219        if trimmed.is_empty() {
220            return Err(FossilParseError::Empty);
221        }
222
223        match normalized_token(trimmed).as_str() {
224            "permineralized" => Ok(Self::Permineralized),
225            "carbonized" => Ok(Self::Carbonized),
226            "replaced" => Ok(Self::Replaced),
227            "unaltered" => Ok(Self::Unaltered),
228            "compressed" => Ok(Self::Compressed),
229            "unknown" => Ok(Self::Unknown),
230            _ => Ok(Self::Custom(trimmed.to_string())),
231        }
232    }
233}
234
235#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
236pub struct FossilOccurrence {
237    formation: Option<String>,
238    time_label: Option<String>,
239}
240
241impl FossilOccurrence {
242    /// Creates a fossil occurrence from at least one formation or time label.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`FossilOccurrenceError::EmptyFormation`] when the formation is empty.
247    /// Returns [`FossilOccurrenceError::EmptyTimeLabel`] when the time label is empty.
248    /// Returns [`FossilOccurrenceError::MissingReference`] when both values are absent.
249    pub fn new(
250        formation: Option<String>,
251        time_label: Option<String>,
252    ) -> Result<Self, FossilOccurrenceError> {
253        let formation = sanitize_optional_text(formation, FossilOccurrenceError::EmptyFormation)?;
254        let time_label = sanitize_optional_text(time_label, FossilOccurrenceError::EmptyTimeLabel)?;
255
256        if formation.is_none() && time_label.is_none() {
257            return Err(FossilOccurrenceError::MissingReference);
258        }
259
260        Ok(Self {
261            formation,
262            time_label,
263        })
264    }
265
266    #[must_use]
267    pub fn formation(&self) -> Option<&str> {
268        self.formation.as_deref()
269    }
270
271    #[must_use]
272    pub fn time_label(&self) -> Option<&str> {
273        self.time_label.as_deref()
274    }
275}
276
277impl fmt::Display for FossilOccurrence {
278    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279        match (self.formation.as_deref(), self.time_label.as_deref()) {
280            (Some(formation), Some(time_label)) => {
281                write!(formatter, "{formation} @ {time_label}")
282            },
283            (Some(formation), None) => formatter.write_str(formation),
284            (None, Some(time_label)) => formatter.write_str(time_label),
285            (None, None) => formatter.write_str("unspecified"),
286        }
287    }
288}
289
290fn sanitize_optional_text(
291    value: Option<String>,
292    empty_error: FossilOccurrenceError,
293) -> Result<Option<String>, FossilOccurrenceError> {
294    match value {
295        Some(text) if text.trim().is_empty() => Err(empty_error),
296        Some(text) => Ok(Some(text)),
297        None => Ok(None),
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::{
304        FossilKind, FossilName, FossilOccurrence, FossilOccurrenceError, FossilParseError,
305        FossilPreservation, FossilTextError,
306    };
307
308    #[test]
309    fn valid_fossil_name() -> Result<(), FossilTextError> {
310        let name = FossilName::new("Trilobite pygidium")?;
311
312        assert_eq!(name.as_str(), "Trilobite pygidium");
313        Ok(())
314    }
315
316    #[test]
317    fn empty_fossil_name_rejected() {
318        assert_eq!(FossilName::new(""), Err(FossilTextError::Empty));
319    }
320
321    #[test]
322    fn fossil_kind_display_parse() -> Result<(), FossilParseError> {
323        assert_eq!(FossilKind::TraceFossil.to_string(), "trace-fossil");
324        assert_eq!("body fossil".parse::<FossilKind>()?, FossilKind::BodyFossil);
325        Ok(())
326    }
327
328    #[test]
329    fn fossil_preservation_display_parse() -> Result<(), FossilParseError> {
330        assert_eq!(
331            FossilPreservation::Permineralized.to_string(),
332            "permineralized"
333        );
334        assert_eq!(
335            "replaced".parse::<FossilPreservation>()?,
336            FossilPreservation::Replaced
337        );
338        Ok(())
339    }
340
341    #[test]
342    fn fossil_occurrence_construction() -> Result<(), FossilOccurrenceError> {
343        let occurrence = FossilOccurrence::new(
344            Some("Morrison Formation".to_string()),
345            Some("Late Jurassic".to_string()),
346        )?;
347
348        assert_eq!(occurrence.formation(), Some("Morrison Formation"));
349        assert_eq!(occurrence.time_label(), Some("Late Jurassic"));
350        assert_eq!(occurrence.to_string(), "Morrison Formation @ Late Jurassic");
351        Ok(())
352    }
353}