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