Skip to main content

use_geologic_process/
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, ProcessTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(ProcessTextError::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 ProcessTextError {
43    Empty,
44}
45
46impl fmt::Display for ProcessTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("geologic process text cannot be empty"),
50        }
51    }
52}
53
54impl Error for ProcessTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum ProcessParseError {
58    Empty,
59}
60
61impl fmt::Display for ProcessParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("geologic process vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for ProcessParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum ProcessRateError {
73    InvalidFormat,
74    InvalidNumber,
75    NonFiniteValue,
76    EmptyUnit,
77}
78
79impl fmt::Display for ProcessRateError {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::InvalidFormat => {
83                formatter.write_str("process rate must be in '<value> <unit>' format")
84            },
85            Self::InvalidNumber => formatter.write_str("process rate value must be a valid number"),
86            Self::NonFiniteValue => formatter.write_str("process rate value must be finite"),
87            Self::EmptyUnit => formatter.write_str("process rate unit cannot be empty"),
88        }
89    }
90}
91
92impl Error for ProcessRateError {}
93
94#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
95pub struct GeologicProcess(String);
96
97impl GeologicProcess {
98    /// Creates a geologic process name from non-empty text.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`ProcessTextError::Empty`] when the trimmed value is empty.
103    pub fn new(value: impl AsRef<str>) -> Result<Self, ProcessTextError> {
104        non_empty_text(value).map(Self)
105    }
106
107    #[must_use]
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113impl AsRef<str> for GeologicProcess {
114    fn as_ref(&self) -> &str {
115        self.as_str()
116    }
117}
118
119impl fmt::Display for GeologicProcess {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        formatter.write_str(self.as_str())
122    }
123}
124
125impl FromStr for GeologicProcess {
126    type Err = ProcessTextError;
127
128    fn from_str(value: &str) -> Result<Self, Self::Err> {
129        Self::new(value)
130    }
131}
132
133#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum ProcessKind {
135    Weathering,
136    Erosion,
137    Deposition,
138    Lithification,
139    Metamorphism,
140    Melting,
141    Uplift,
142    Subsidence,
143    Volcanism,
144    Unknown,
145    Custom(String),
146}
147
148impl fmt::Display for ProcessKind {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::Weathering => formatter.write_str("weathering"),
152            Self::Erosion => formatter.write_str("erosion"),
153            Self::Deposition => formatter.write_str("deposition"),
154            Self::Lithification => formatter.write_str("lithification"),
155            Self::Metamorphism => formatter.write_str("metamorphism"),
156            Self::Melting => formatter.write_str("melting"),
157            Self::Uplift => formatter.write_str("uplift"),
158            Self::Subsidence => formatter.write_str("subsidence"),
159            Self::Volcanism => formatter.write_str("volcanism"),
160            Self::Unknown => formatter.write_str("unknown"),
161            Self::Custom(value) => formatter.write_str(value),
162        }
163    }
164}
165
166impl FromStr for ProcessKind {
167    type Err = ProcessParseError;
168
169    fn from_str(value: &str) -> Result<Self, Self::Err> {
170        let trimmed = value.trim();
171
172        if trimmed.is_empty() {
173            return Err(ProcessParseError::Empty);
174        }
175
176        match normalized_token(trimmed).as_str() {
177            "weathering" => Ok(Self::Weathering),
178            "erosion" => Ok(Self::Erosion),
179            "deposition" => Ok(Self::Deposition),
180            "lithification" => Ok(Self::Lithification),
181            "metamorphism" => Ok(Self::Metamorphism),
182            "melting" => Ok(Self::Melting),
183            "uplift" => Ok(Self::Uplift),
184            "subsidence" => Ok(Self::Subsidence),
185            "volcanism" => Ok(Self::Volcanism),
186            "unknown" => Ok(Self::Unknown),
187            _ => Ok(Self::Custom(trimmed.to_string())),
188        }
189    }
190}
191
192#[derive(Clone, Debug, PartialEq)]
193pub struct ProcessRate {
194    value: f64,
195    unit: String,
196}
197
198impl ProcessRate {
199    /// Creates a process rate from a finite value and non-empty unit.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`ProcessRateError::NonFiniteValue`] when the value is not finite.
204    /// Returns [`ProcessRateError::EmptyUnit`] when the trimmed unit is empty.
205    pub fn new(value: f64, unit: impl AsRef<str>) -> Result<Self, ProcessRateError> {
206        if !value.is_finite() {
207            return Err(ProcessRateError::NonFiniteValue);
208        }
209
210        let unit = unit.as_ref();
211        if unit.trim().is_empty() {
212            return Err(ProcessRateError::EmptyUnit);
213        }
214
215        Ok(Self {
216            value,
217            unit: unit.to_string(),
218        })
219    }
220
221    #[must_use]
222    pub const fn value(&self) -> f64 {
223        self.value
224    }
225
226    #[must_use]
227    pub fn unit(&self) -> &str {
228        &self.unit
229    }
230}
231
232impl fmt::Display for ProcessRate {
233    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234        write!(formatter, "{} {}", self.value, self.unit)
235    }
236}
237
238impl FromStr for ProcessRate {
239    type Err = ProcessRateError;
240
241    fn from_str(value: &str) -> Result<Self, Self::Err> {
242        let trimmed = value.trim();
243        let (numeric_value, unit) = trimmed
244            .split_once(char::is_whitespace)
245            .ok_or(ProcessRateError::InvalidFormat)?;
246
247        let numeric_value = numeric_value
248            .parse::<f64>()
249            .map_err(|_| ProcessRateError::InvalidNumber)?;
250
251        Self::new(numeric_value, unit.trim())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::{
258        GeologicProcess, ProcessKind, ProcessParseError, ProcessRate, ProcessRateError,
259        ProcessTextError,
260    };
261
262    #[test]
263    fn geologic_process_wrapper() -> Result<(), ProcessTextError> {
264        let process = GeologicProcess::new("delta progradation")?;
265
266        assert_eq!(process.as_str(), "delta progradation");
267        Ok(())
268    }
269
270    #[test]
271    fn process_kind_display_parse() -> Result<(), ProcessParseError> {
272        assert_eq!(ProcessKind::Weathering.to_string(), "weathering");
273        assert_eq!("erosion".parse::<ProcessKind>()?, ProcessKind::Erosion);
274        Ok(())
275    }
276
277    #[test]
278    fn custom_process_kind() -> Result<(), ProcessParseError> {
279        assert_eq!(
280            "diagenesis".parse::<ProcessKind>()?,
281            ProcessKind::Custom("diagenesis".to_string())
282        );
283        Ok(())
284    }
285
286    #[test]
287    fn process_rate_construction() -> Result<(), ProcessRateError> {
288        let rate = ProcessRate::new(0.2, "mm/yr")?;
289
290        assert!((rate.value() - 0.2).abs() < f64::EPSILON);
291        assert_eq!(rate.unit(), "mm/yr");
292        Ok(())
293    }
294
295    #[test]
296    fn process_rate_display_is_stable() -> Result<(), ProcessRateError> {
297        let rate = "1.5 mm/yr".parse::<ProcessRate>()?;
298
299        assert_eq!(rate.to_string(), "1.5 mm/yr");
300        Ok(())
301    }
302}