Skip to main content

use_mineral/
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, MineralTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(MineralTextError::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 MineralTextError {
43    Empty,
44}
45
46impl fmt::Display for MineralTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("mineral text cannot be empty"),
50        }
51    }
52}
53
54impl Error for MineralTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum MineralParseError {
58    Empty,
59}
60
61impl fmt::Display for MineralParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("mineral vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for MineralParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum MohsHardnessError {
73    InvalidNumber,
74    NonFinite,
75    OutOfRange,
76}
77
78impl fmt::Display for MohsHardnessError {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::InvalidNumber => formatter.write_str("mohs hardness must be a valid number"),
82            Self::NonFinite => formatter.write_str("mohs hardness must be finite"),
83            Self::OutOfRange => formatter.write_str("mohs hardness must be in 1.0..=10.0"),
84        }
85    }
86}
87
88impl Error for MohsHardnessError {}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct MineralName(String);
92
93impl MineralName {
94    /// Creates a mineral name from non-empty text.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`MineralTextError::Empty`] when the trimmed value is empty.
99    pub fn new(value: impl AsRef<str>) -> Result<Self, MineralTextError> {
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    #[must_use]
109    pub fn into_string(self) -> String {
110        self.0
111    }
112}
113
114impl AsRef<str> for MineralName {
115    fn as_ref(&self) -> &str {
116        self.as_str()
117    }
118}
119
120impl fmt::Display for MineralName {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str(self.as_str())
123    }
124}
125
126impl FromStr for MineralName {
127    type Err = MineralTextError;
128
129    fn from_str(value: &str) -> Result<Self, Self::Err> {
130        Self::new(value)
131    }
132}
133
134#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub struct MineralKind(String);
136
137impl MineralKind {
138    /// Creates a mineral kind from non-empty text.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`MineralTextError::Empty`] when the trimmed value is empty.
143    pub fn new(value: impl AsRef<str>) -> Result<Self, MineralTextError> {
144        non_empty_text(value).map(Self)
145    }
146
147    #[must_use]
148    pub fn as_str(&self) -> &str {
149        &self.0
150    }
151}
152
153impl AsRef<str> for MineralKind {
154    fn as_ref(&self) -> &str {
155        self.as_str()
156    }
157}
158
159impl fmt::Display for MineralKind {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        formatter.write_str(self.as_str())
162    }
163}
164
165impl FromStr for MineralKind {
166    type Err = MineralTextError;
167
168    fn from_str(value: &str) -> Result<Self, Self::Err> {
169        Self::new(value)
170    }
171}
172
173#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174pub enum MineralClass {
175    Silicate,
176    Carbonate,
177    Oxide,
178    Sulfide,
179    Sulfate,
180    Halide,
181    Phosphate,
182    NativeElement,
183    Organic,
184    Unknown,
185    Custom(String),
186}
187
188impl fmt::Display for MineralClass {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            Self::Silicate => formatter.write_str("silicate"),
192            Self::Carbonate => formatter.write_str("carbonate"),
193            Self::Oxide => formatter.write_str("oxide"),
194            Self::Sulfide => formatter.write_str("sulfide"),
195            Self::Sulfate => formatter.write_str("sulfate"),
196            Self::Halide => formatter.write_str("halide"),
197            Self::Phosphate => formatter.write_str("phosphate"),
198            Self::NativeElement => formatter.write_str("native-element"),
199            Self::Organic => formatter.write_str("organic"),
200            Self::Unknown => formatter.write_str("unknown"),
201            Self::Custom(value) => formatter.write_str(value),
202        }
203    }
204}
205
206impl FromStr for MineralClass {
207    type Err = MineralParseError;
208
209    fn from_str(value: &str) -> Result<Self, Self::Err> {
210        let trimmed = value.trim();
211
212        if trimmed.is_empty() {
213            return Err(MineralParseError::Empty);
214        }
215
216        match normalized_token(trimmed).as_str() {
217            "silicate" => Ok(Self::Silicate),
218            "carbonate" => Ok(Self::Carbonate),
219            "oxide" => Ok(Self::Oxide),
220            "sulfide" => Ok(Self::Sulfide),
221            "sulfate" => Ok(Self::Sulfate),
222            "halide" => Ok(Self::Halide),
223            "phosphate" => Ok(Self::Phosphate),
224            "native-element" => Ok(Self::NativeElement),
225            "organic" => Ok(Self::Organic),
226            "unknown" => Ok(Self::Unknown),
227            _ => Ok(Self::Custom(trimmed.to_string())),
228        }
229    }
230}
231
232#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
233pub enum CrystalSystem {
234    Cubic,
235    Tetragonal,
236    Orthorhombic,
237    Hexagonal,
238    Trigonal,
239    Monoclinic,
240    Triclinic,
241    Amorphous,
242    Unknown,
243    Custom(String),
244}
245
246impl fmt::Display for CrystalSystem {
247    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248        match self {
249            Self::Cubic => formatter.write_str("cubic"),
250            Self::Tetragonal => formatter.write_str("tetragonal"),
251            Self::Orthorhombic => formatter.write_str("orthorhombic"),
252            Self::Hexagonal => formatter.write_str("hexagonal"),
253            Self::Trigonal => formatter.write_str("trigonal"),
254            Self::Monoclinic => formatter.write_str("monoclinic"),
255            Self::Triclinic => formatter.write_str("triclinic"),
256            Self::Amorphous => formatter.write_str("amorphous"),
257            Self::Unknown => formatter.write_str("unknown"),
258            Self::Custom(value) => formatter.write_str(value),
259        }
260    }
261}
262
263impl FromStr for CrystalSystem {
264    type Err = MineralParseError;
265
266    fn from_str(value: &str) -> Result<Self, Self::Err> {
267        let trimmed = value.trim();
268
269        if trimmed.is_empty() {
270            return Err(MineralParseError::Empty);
271        }
272
273        match normalized_token(trimmed).as_str() {
274            "cubic" => Ok(Self::Cubic),
275            "tetragonal" => Ok(Self::Tetragonal),
276            "orthorhombic" => Ok(Self::Orthorhombic),
277            "hexagonal" => Ok(Self::Hexagonal),
278            "trigonal" => Ok(Self::Trigonal),
279            "monoclinic" => Ok(Self::Monoclinic),
280            "triclinic" => Ok(Self::Triclinic),
281            "amorphous" => Ok(Self::Amorphous),
282            "unknown" => Ok(Self::Unknown),
283            _ => Ok(Self::Custom(trimmed.to_string())),
284        }
285    }
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
289pub struct MohsHardness(f64);
290
291impl MohsHardness {
292    /// Creates a Mohs hardness value in the inclusive range 1.0 through 10.0.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`MohsHardnessError::NonFinite`] when the value is not finite.
297    /// Returns [`MohsHardnessError::OutOfRange`] when the value is outside 1.0..=10.0.
298    pub fn new(value: f64) -> Result<Self, MohsHardnessError> {
299        if !value.is_finite() {
300            return Err(MohsHardnessError::NonFinite);
301        }
302
303        if !(1.0..=10.0).contains(&value) {
304            return Err(MohsHardnessError::OutOfRange);
305        }
306
307        Ok(Self(value))
308    }
309
310    #[must_use]
311    pub const fn value(self) -> f64 {
312        self.0
313    }
314}
315
316impl fmt::Display for MohsHardness {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        write!(formatter, "{}", self.0)
319    }
320}
321
322impl FromStr for MohsHardness {
323    type Err = MohsHardnessError;
324
325    fn from_str(value: &str) -> Result<Self, Self::Err> {
326        let parsed = value
327            .trim()
328            .parse::<f64>()
329            .map_err(|_| MohsHardnessError::InvalidNumber)?;
330        Self::new(parsed)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::{
337        CrystalSystem, MineralClass, MineralName, MineralParseError, MineralTextError,
338        MohsHardness, MohsHardnessError,
339    };
340
341    #[test]
342    fn valid_mineral_name() -> Result<(), MineralTextError> {
343        let name = MineralName::new("Quartz")?;
344
345        assert_eq!(name.as_str(), "Quartz");
346        assert_eq!(name.to_string(), "Quartz");
347        Ok(())
348    }
349
350    #[test]
351    fn empty_mineral_name_rejected() {
352        assert_eq!(MineralName::new("   "), Err(MineralTextError::Empty));
353    }
354
355    #[test]
356    fn mineral_class_display_parse() -> Result<(), MineralParseError> {
357        assert_eq!(MineralClass::Carbonate.to_string(), "carbonate");
358        assert_eq!(
359            "native element".parse::<MineralClass>()?,
360            MineralClass::NativeElement
361        );
362        Ok(())
363    }
364
365    #[test]
366    fn crystal_system_display_parse() -> Result<(), MineralParseError> {
367        assert_eq!(CrystalSystem::Orthorhombic.to_string(), "orthorhombic");
368        assert_eq!(
369            "hexagonal".parse::<CrystalSystem>()?,
370            CrystalSystem::Hexagonal
371        );
372        Ok(())
373    }
374
375    #[test]
376    fn valid_mohs_hardness() -> Result<(), MohsHardnessError> {
377        let hardness = MohsHardness::new(7.0)?;
378
379        assert!((hardness.value() - 7.0).abs() < f64::EPSILON);
380        assert!(("8.5".parse::<MohsHardness>()?.value() - 8.5).abs() < f64::EPSILON);
381        Ok(())
382    }
383
384    #[test]
385    fn invalid_mohs_hardness_rejected() {
386        assert_eq!(MohsHardness::new(0.5), Err(MohsHardnessError::OutOfRange));
387        assert_eq!(
388            MohsHardness::new(f64::NAN),
389            Err(MohsHardnessError::NonFinite)
390        );
391    }
392}