Skip to main content

use_sediment/
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, SedimentTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(SedimentTextError::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 SedimentTextError {
43    Empty,
44}
45
46impl fmt::Display for SedimentTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("sediment text cannot be empty"),
50        }
51    }
52}
53
54impl Error for SedimentTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum SedimentParseError {
58    Empty,
59}
60
61impl fmt::Display for SedimentParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("sediment vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for SedimentParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum GrainSizeError {
73    InvalidNumber,
74    NonFinite,
75    Negative,
76}
77
78impl fmt::Display for GrainSizeError {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::InvalidNumber => formatter.write_str("grain size must be a valid number"),
82            Self::NonFinite => formatter.write_str("grain size must be finite"),
83            Self::Negative => formatter.write_str("grain size cannot be negative"),
84        }
85    }
86}
87
88impl Error for GrainSizeError {}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct SedimentName(String);
92
93impl SedimentName {
94    /// Creates a sediment name from non-empty text.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`SedimentTextError::Empty`] when the trimmed value is empty.
99    pub fn new(value: impl AsRef<str>) -> Result<Self, SedimentTextError> {
100        non_empty_text(value).map(Self)
101    }
102
103    #[must_use]
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107}
108
109impl AsRef<str> for SedimentName {
110    fn as_ref(&self) -> &str {
111        self.as_str()
112    }
113}
114
115impl fmt::Display for SedimentName {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter.write_str(self.as_str())
118    }
119}
120
121impl FromStr for SedimentName {
122    type Err = SedimentTextError;
123
124    fn from_str(value: &str) -> Result<Self, Self::Err> {
125        Self::new(value)
126    }
127}
128
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
130pub enum SedimentKind {
131    Clay,
132    Silt,
133    Sand,
134    Gravel,
135    Pebble,
136    Cobble,
137    Boulder,
138    Mud,
139    Unknown,
140    Custom(String),
141}
142
143impl fmt::Display for SedimentKind {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::Clay => formatter.write_str("clay"),
147            Self::Silt => formatter.write_str("silt"),
148            Self::Sand => formatter.write_str("sand"),
149            Self::Gravel => formatter.write_str("gravel"),
150            Self::Pebble => formatter.write_str("pebble"),
151            Self::Cobble => formatter.write_str("cobble"),
152            Self::Boulder => formatter.write_str("boulder"),
153            Self::Mud => formatter.write_str("mud"),
154            Self::Unknown => formatter.write_str("unknown"),
155            Self::Custom(value) => formatter.write_str(value),
156        }
157    }
158}
159
160impl FromStr for SedimentKind {
161    type Err = SedimentParseError;
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(SedimentParseError::Empty);
168        }
169
170        match normalized_token(trimmed).as_str() {
171            "clay" => Ok(Self::Clay),
172            "silt" => Ok(Self::Silt),
173            "sand" => Ok(Self::Sand),
174            "gravel" => Ok(Self::Gravel),
175            "pebble" => Ok(Self::Pebble),
176            "cobble" => Ok(Self::Cobble),
177            "boulder" => Ok(Self::Boulder),
178            "mud" => Ok(Self::Mud),
179            "unknown" => Ok(Self::Unknown),
180            _ => Ok(Self::Custom(trimmed.to_string())),
181        }
182    }
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
186pub struct GrainSize(f64);
187
188impl GrainSize {
189    /// Creates a non-negative grain size in millimeters.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`GrainSizeError::NonFinite`] when the value is not finite.
194    /// Returns [`GrainSizeError::Negative`] when the value is negative.
195    pub fn new(millimeters: f64) -> Result<Self, GrainSizeError> {
196        if !millimeters.is_finite() {
197            return Err(GrainSizeError::NonFinite);
198        }
199
200        if millimeters < 0.0 {
201            return Err(GrainSizeError::Negative);
202        }
203
204        Ok(Self(millimeters))
205    }
206
207    #[must_use]
208    pub const fn millimeters(self) -> f64 {
209        self.0
210    }
211}
212
213impl fmt::Display for GrainSize {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(formatter, "{}", self.0)
216    }
217}
218
219impl FromStr for GrainSize {
220    type Err = GrainSizeError;
221
222    fn from_str(value: &str) -> Result<Self, Self::Err> {
223        let parsed = value
224            .trim()
225            .parse::<f64>()
226            .map_err(|_| GrainSizeError::InvalidNumber)?;
227        Self::new(parsed)
228    }
229}
230
231#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
232pub enum Sorting {
233    WellSorted,
234    ModeratelySorted,
235    PoorlySorted,
236    Unknown,
237    Custom(String),
238}
239
240impl fmt::Display for Sorting {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::WellSorted => formatter.write_str("well-sorted"),
244            Self::ModeratelySorted => formatter.write_str("moderately-sorted"),
245            Self::PoorlySorted => formatter.write_str("poorly-sorted"),
246            Self::Unknown => formatter.write_str("unknown"),
247            Self::Custom(value) => formatter.write_str(value),
248        }
249    }
250}
251
252impl FromStr for Sorting {
253    type Err = SedimentParseError;
254
255    fn from_str(value: &str) -> Result<Self, Self::Err> {
256        let trimmed = value.trim();
257
258        if trimmed.is_empty() {
259            return Err(SedimentParseError::Empty);
260        }
261
262        match normalized_token(trimmed).as_str() {
263            "well-sorted" => Ok(Self::WellSorted),
264            "moderately-sorted" => Ok(Self::ModeratelySorted),
265            "poorly-sorted" => Ok(Self::PoorlySorted),
266            "unknown" => Ok(Self::Unknown),
267            _ => Ok(Self::Custom(trimmed.to_string())),
268        }
269    }
270}
271
272#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
273pub enum Roundness {
274    Angular,
275    SubAngular,
276    SubRounded,
277    Rounded,
278    WellRounded,
279    Unknown,
280    Custom(String),
281}
282
283impl fmt::Display for Roundness {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        match self {
286            Self::Angular => formatter.write_str("angular"),
287            Self::SubAngular => formatter.write_str("sub-angular"),
288            Self::SubRounded => formatter.write_str("sub-rounded"),
289            Self::Rounded => formatter.write_str("rounded"),
290            Self::WellRounded => formatter.write_str("well-rounded"),
291            Self::Unknown => formatter.write_str("unknown"),
292            Self::Custom(value) => formatter.write_str(value),
293        }
294    }
295}
296
297impl FromStr for Roundness {
298    type Err = SedimentParseError;
299
300    fn from_str(value: &str) -> Result<Self, Self::Err> {
301        let trimmed = value.trim();
302
303        if trimmed.is_empty() {
304            return Err(SedimentParseError::Empty);
305        }
306
307        match normalized_token(trimmed).as_str() {
308            "angular" => Ok(Self::Angular),
309            "sub-angular" => Ok(Self::SubAngular),
310            "sub-rounded" => Ok(Self::SubRounded),
311            "rounded" => Ok(Self::Rounded),
312            "well-rounded" => Ok(Self::WellRounded),
313            "unknown" => Ok(Self::Unknown),
314            _ => Ok(Self::Custom(trimmed.to_string())),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::{
322        GrainSize, GrainSizeError, Roundness, SedimentKind, SedimentName, SedimentParseError,
323        SedimentTextError, Sorting,
324    };
325
326    #[test]
327    fn valid_sediment_name() -> Result<(), SedimentTextError> {
328        let name = SedimentName::new("Alluvial sand")?;
329
330        assert_eq!(name.as_str(), "Alluvial sand");
331        Ok(())
332    }
333
334    #[test]
335    fn empty_sediment_name_rejected() {
336        assert_eq!(SedimentName::new("  "), Err(SedimentTextError::Empty));
337    }
338
339    #[test]
340    fn sediment_kind_display_parse() -> Result<(), SedimentParseError> {
341        assert_eq!(SedimentKind::Gravel.to_string(), "gravel");
342        assert_eq!("mud".parse::<SedimentKind>()?, SedimentKind::Mud);
343        Ok(())
344    }
345
346    #[test]
347    fn valid_grain_size() -> Result<(), GrainSizeError> {
348        let grain_size = GrainSize::new(0.0625)?;
349
350        assert!((grain_size.millimeters() - 0.0625).abs() < f64::EPSILON);
351        assert!(("2.0".parse::<GrainSize>()?.millimeters() - 2.0).abs() < f64::EPSILON);
352        Ok(())
353    }
354
355    #[test]
356    fn negative_grain_size_rejected() {
357        assert_eq!(GrainSize::new(-1.0), Err(GrainSizeError::Negative));
358    }
359
360    #[test]
361    fn sorting_display_parse() -> Result<(), SedimentParseError> {
362        assert_eq!(Sorting::WellSorted.to_string(), "well-sorted");
363        assert_eq!(
364            "moderately sorted".parse::<Sorting>()?,
365            Sorting::ModeratelySorted
366        );
367        Ok(())
368    }
369
370    #[test]
371    fn roundness_display_parse() -> Result<(), SedimentParseError> {
372        assert_eq!(Roundness::WellRounded.to_string(), "well-rounded");
373        assert_eq!("sub rounded".parse::<Roundness>()?, Roundness::SubRounded);
374        Ok(())
375    }
376}