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 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 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}