Skip to main content

use_geologic_time/
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, GeologicTimeTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(GeologicTimeTextError::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 GeologicTimeTextError {
43    Empty,
44}
45
46impl fmt::Display for GeologicTimeTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("geologic time text cannot be empty"),
50        }
51    }
52}
53
54impl Error for GeologicTimeTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum GeologicTimeParseError {
58    Empty,
59}
60
61impl fmt::Display for GeologicTimeParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("geologic time vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for GeologicTimeParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum GeologicAgeError {
73    InvalidNumber,
74    NonFinite,
75    Negative,
76}
77
78impl fmt::Display for GeologicAgeError {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::InvalidNumber => formatter.write_str("geologic age must be a valid number"),
82            Self::NonFinite => formatter.write_str("geologic age must be finite"),
83            Self::Negative => formatter.write_str("geologic age cannot be negative"),
84        }
85    }
86}
87
88impl Error for GeologicAgeError {}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub enum GeologicTimeUnit {
92    Eon,
93    Era,
94    Period,
95    Epoch,
96    Age,
97    Unknown,
98    Custom(String),
99}
100
101impl fmt::Display for GeologicTimeUnit {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::Eon => formatter.write_str("eon"),
105            Self::Era => formatter.write_str("era"),
106            Self::Period => formatter.write_str("period"),
107            Self::Epoch => formatter.write_str("epoch"),
108            Self::Age => formatter.write_str("age"),
109            Self::Unknown => formatter.write_str("unknown"),
110            Self::Custom(value) => formatter.write_str(value),
111        }
112    }
113}
114
115impl FromStr for GeologicTimeUnit {
116    type Err = GeologicTimeParseError;
117
118    fn from_str(value: &str) -> Result<Self, Self::Err> {
119        let trimmed = value.trim();
120
121        if trimmed.is_empty() {
122            return Err(GeologicTimeParseError::Empty);
123        }
124
125        match normalized_token(trimmed).as_str() {
126            "eon" => Ok(Self::Eon),
127            "era" => Ok(Self::Era),
128            "period" => Ok(Self::Period),
129            "epoch" => Ok(Self::Epoch),
130            "age" => Ok(Self::Age),
131            "unknown" => Ok(Self::Unknown),
132            _ => Ok(Self::Custom(trimmed.to_string())),
133        }
134    }
135}
136
137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub enum GeologicEon {
139    Hadean,
140    Archean,
141    Proterozoic,
142    Phanerozoic,
143    Unknown,
144    Custom(String),
145}
146
147impl fmt::Display for GeologicEon {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::Hadean => formatter.write_str("hadean"),
151            Self::Archean => formatter.write_str("archean"),
152            Self::Proterozoic => formatter.write_str("proterozoic"),
153            Self::Phanerozoic => formatter.write_str("phanerozoic"),
154            Self::Unknown => formatter.write_str("unknown"),
155            Self::Custom(value) => formatter.write_str(value),
156        }
157    }
158}
159
160impl FromStr for GeologicEon {
161    type Err = GeologicTimeParseError;
162
163    fn from_str(value: &str) -> Result<Self, Self::Err> {
164        let trimmed = value.trim();
165
166        if trimmed.is_empty() {
167            return Err(GeologicTimeParseError::Empty);
168        }
169
170        match normalized_token(trimmed).as_str() {
171            "hadean" => Ok(Self::Hadean),
172            "archean" => Ok(Self::Archean),
173            "proterozoic" => Ok(Self::Proterozoic),
174            "phanerozoic" => Ok(Self::Phanerozoic),
175            "unknown" => Ok(Self::Unknown),
176            _ => Ok(Self::Custom(trimmed.to_string())),
177        }
178    }
179}
180
181#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
182pub enum GeologicEra {
183    Precambrian,
184    Paleozoic,
185    Mesozoic,
186    Cenozoic,
187    Unknown,
188    Custom(String),
189}
190
191impl fmt::Display for GeologicEra {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Self::Precambrian => formatter.write_str("precambrian"),
195            Self::Paleozoic => formatter.write_str("paleozoic"),
196            Self::Mesozoic => formatter.write_str("mesozoic"),
197            Self::Cenozoic => formatter.write_str("cenozoic"),
198            Self::Unknown => formatter.write_str("unknown"),
199            Self::Custom(value) => formatter.write_str(value),
200        }
201    }
202}
203
204impl FromStr for GeologicEra {
205    type Err = GeologicTimeParseError;
206
207    fn from_str(value: &str) -> Result<Self, Self::Err> {
208        let trimmed = value.trim();
209
210        if trimmed.is_empty() {
211            return Err(GeologicTimeParseError::Empty);
212        }
213
214        match normalized_token(trimmed).as_str() {
215            "precambrian" => Ok(Self::Precambrian),
216            "paleozoic" => Ok(Self::Paleozoic),
217            "mesozoic" => Ok(Self::Mesozoic),
218            "cenozoic" => Ok(Self::Cenozoic),
219            "unknown" => Ok(Self::Unknown),
220            _ => Ok(Self::Custom(trimmed.to_string())),
221        }
222    }
223}
224
225#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
226pub enum GeologicPeriod {
227    Cambrian,
228    Ordovician,
229    Silurian,
230    Devonian,
231    Carboniferous,
232    Permian,
233    Triassic,
234    Jurassic,
235    Cretaceous,
236    Paleogene,
237    Neogene,
238    Quaternary,
239    Unknown,
240    Custom(String),
241}
242
243impl fmt::Display for GeologicPeriod {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            Self::Cambrian => formatter.write_str("cambrian"),
247            Self::Ordovician => formatter.write_str("ordovician"),
248            Self::Silurian => formatter.write_str("silurian"),
249            Self::Devonian => formatter.write_str("devonian"),
250            Self::Carboniferous => formatter.write_str("carboniferous"),
251            Self::Permian => formatter.write_str("permian"),
252            Self::Triassic => formatter.write_str("triassic"),
253            Self::Jurassic => formatter.write_str("jurassic"),
254            Self::Cretaceous => formatter.write_str("cretaceous"),
255            Self::Paleogene => formatter.write_str("paleogene"),
256            Self::Neogene => formatter.write_str("neogene"),
257            Self::Quaternary => formatter.write_str("quaternary"),
258            Self::Unknown => formatter.write_str("unknown"),
259            Self::Custom(value) => formatter.write_str(value),
260        }
261    }
262}
263
264impl FromStr for GeologicPeriod {
265    type Err = GeologicTimeParseError;
266
267    fn from_str(value: &str) -> Result<Self, Self::Err> {
268        let trimmed = value.trim();
269
270        if trimmed.is_empty() {
271            return Err(GeologicTimeParseError::Empty);
272        }
273
274        match normalized_token(trimmed).as_str() {
275            "cambrian" => Ok(Self::Cambrian),
276            "ordovician" => Ok(Self::Ordovician),
277            "silurian" => Ok(Self::Silurian),
278            "devonian" => Ok(Self::Devonian),
279            "carboniferous" => Ok(Self::Carboniferous),
280            "permian" => Ok(Self::Permian),
281            "triassic" => Ok(Self::Triassic),
282            "jurassic" => Ok(Self::Jurassic),
283            "cretaceous" => Ok(Self::Cretaceous),
284            "paleogene" => Ok(Self::Paleogene),
285            "neogene" => Ok(Self::Neogene),
286            "quaternary" => Ok(Self::Quaternary),
287            "unknown" => Ok(Self::Unknown),
288            _ => Ok(Self::Custom(trimmed.to_string())),
289        }
290    }
291}
292
293#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
294pub struct GeologicEpoch(String);
295
296impl GeologicEpoch {
297    /// Creates a geologic epoch label from non-empty text.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`GeologicTimeTextError::Empty`] when the trimmed value is empty.
302    pub fn new(value: impl AsRef<str>) -> Result<Self, GeologicTimeTextError> {
303        non_empty_text(value).map(Self)
304    }
305
306    #[must_use]
307    pub fn as_str(&self) -> &str {
308        &self.0
309    }
310}
311
312impl AsRef<str> for GeologicEpoch {
313    fn as_ref(&self) -> &str {
314        self.as_str()
315    }
316}
317
318impl fmt::Display for GeologicEpoch {
319    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320        formatter.write_str(self.as_str())
321    }
322}
323
324impl FromStr for GeologicEpoch {
325    type Err = GeologicTimeTextError;
326
327    fn from_str(value: &str) -> Result<Self, Self::Err> {
328        Self::new(value)
329    }
330}
331
332#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
333pub struct GeologicAge(f64);
334
335impl GeologicAge {
336    /// Creates a non-negative geologic age in millions of years before present.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`GeologicAgeError::NonFinite`] when the value is not finite.
341    /// Returns [`GeologicAgeError::Negative`] when the value is negative.
342    pub fn new(millions_of_years_before_present: f64) -> Result<Self, GeologicAgeError> {
343        if !millions_of_years_before_present.is_finite() {
344            return Err(GeologicAgeError::NonFinite);
345        }
346
347        if millions_of_years_before_present < 0.0 {
348            return Err(GeologicAgeError::Negative);
349        }
350
351        Ok(Self(millions_of_years_before_present))
352    }
353
354    #[must_use]
355    pub const fn millions_of_years_before_present(self) -> f64 {
356        self.0
357    }
358}
359
360impl fmt::Display for GeologicAge {
361    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
362        write!(formatter, "{}", self.0)
363    }
364}
365
366impl FromStr for GeologicAge {
367    type Err = GeologicAgeError;
368
369    fn from_str(value: &str) -> Result<Self, Self::Err> {
370        let parsed = value
371            .trim()
372            .parse::<f64>()
373            .map_err(|_| GeologicAgeError::InvalidNumber)?;
374        Self::new(parsed)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::{
381        GeologicAge, GeologicAgeError, GeologicEon, GeologicEpoch, GeologicEra, GeologicPeriod,
382        GeologicTimeParseError, GeologicTimeTextError, GeologicTimeUnit,
383    };
384
385    #[test]
386    fn geologic_time_unit_display_parse() -> Result<(), GeologicTimeParseError> {
387        assert_eq!(GeologicTimeUnit::Epoch.to_string(), "epoch");
388        assert_eq!(
389            "period".parse::<GeologicTimeUnit>()?,
390            GeologicTimeUnit::Period
391        );
392        Ok(())
393    }
394
395    #[test]
396    fn geologic_eon_display_parse() -> Result<(), GeologicTimeParseError> {
397        assert_eq!(GeologicEon::Phanerozoic.to_string(), "phanerozoic");
398        assert_eq!("archean".parse::<GeologicEon>()?, GeologicEon::Archean);
399        Ok(())
400    }
401
402    #[test]
403    fn geologic_era_display_parse() -> Result<(), GeologicTimeParseError> {
404        assert_eq!(GeologicEra::Mesozoic.to_string(), "mesozoic");
405        assert_eq!("cenozoic".parse::<GeologicEra>()?, GeologicEra::Cenozoic);
406        Ok(())
407    }
408
409    #[test]
410    fn geologic_period_display_parse() -> Result<(), GeologicTimeParseError> {
411        assert_eq!(GeologicPeriod::Jurassic.to_string(), "jurassic");
412        assert_eq!(
413            "carboniferous".parse::<GeologicPeriod>()?,
414            GeologicPeriod::Carboniferous
415        );
416        Ok(())
417    }
418
419    #[test]
420    fn geologic_epoch_wrapper() -> Result<(), GeologicTimeTextError> {
421        let epoch = GeologicEpoch::new("Holocene")?;
422
423        assert_eq!(epoch.as_str(), "Holocene");
424        Ok(())
425    }
426
427    #[test]
428    fn valid_geologic_age() -> Result<(), GeologicAgeError> {
429        let age = GeologicAge::new(145.0)?;
430
431        assert!((age.millions_of_years_before_present() - 145.0).abs() < f64::EPSILON);
432        Ok(())
433    }
434
435    #[test]
436    fn negative_geologic_age_rejected() {
437        assert_eq!(GeologicAge::new(-1.0), Err(GeologicAgeError::Negative));
438    }
439}