Skip to main content

use_rock/
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, RockTextError> {
8    let original = value.as_ref();
9
10    if original.trim().is_empty() {
11        Err(RockTextError::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 RockTextError {
43    Empty,
44}
45
46impl fmt::Display for RockTextError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Empty => formatter.write_str("rock text cannot be empty"),
50        }
51    }
52}
53
54impl Error for RockTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum RockParseError {
58    Empty,
59}
60
61impl fmt::Display for RockParseError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => formatter.write_str("rock vocabulary cannot be empty"),
65        }
66    }
67}
68
69impl Error for RockParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum RockCompositionError {
73    EmptyLabel,
74    EmptyMineralName,
75    NoMineralNames,
76}
77
78impl fmt::Display for RockCompositionError {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::EmptyLabel => formatter.write_str("rock composition label cannot be empty"),
82            Self::EmptyMineralName => {
83                formatter.write_str("rock composition mineral names cannot be empty")
84            },
85            Self::NoMineralNames => {
86                formatter.write_str("rock composition requires at least one mineral name")
87            },
88        }
89    }
90}
91
92impl Error for RockCompositionError {}
93
94#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
95pub struct RockName(String);
96
97impl RockName {
98    /// Creates a rock name from non-empty text.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`RockTextError::Empty`] when the trimmed value is empty.
103    pub fn new(value: impl AsRef<str>) -> Result<Self, RockTextError> {
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 RockName {
114    fn as_ref(&self) -> &str {
115        self.as_str()
116    }
117}
118
119impl fmt::Display for RockName {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        formatter.write_str(self.as_str())
122    }
123}
124
125impl FromStr for RockName {
126    type Err = RockTextError;
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 RockKind {
135    Igneous,
136    Sedimentary,
137    Metamorphic,
138    Unknown,
139    Custom(String),
140}
141
142impl fmt::Display for RockKind {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::Igneous => formatter.write_str("igneous"),
146            Self::Sedimentary => formatter.write_str("sedimentary"),
147            Self::Metamorphic => formatter.write_str("metamorphic"),
148            Self::Unknown => formatter.write_str("unknown"),
149            Self::Custom(value) => formatter.write_str(value),
150        }
151    }
152}
153
154impl FromStr for RockKind {
155    type Err = RockParseError;
156
157    fn from_str(value: &str) -> Result<Self, Self::Err> {
158        let trimmed = value.trim();
159
160        if trimmed.is_empty() {
161            return Err(RockParseError::Empty);
162        }
163
164        match normalized_token(trimmed).as_str() {
165            "igneous" => Ok(Self::Igneous),
166            "sedimentary" => Ok(Self::Sedimentary),
167            "metamorphic" => Ok(Self::Metamorphic),
168            "unknown" => Ok(Self::Unknown),
169            _ => Ok(Self::Custom(trimmed.to_string())),
170        }
171    }
172}
173
174#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
175pub enum RockTexture {
176    Clastic,
177    Crystalline,
178    Glassy,
179    Vesicular,
180    Foliated,
181    NonFoliated,
182    Porphyritic,
183    FineGrained,
184    CoarseGrained,
185    Unknown,
186    Custom(String),
187}
188
189impl fmt::Display for RockTexture {
190    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            Self::Clastic => formatter.write_str("clastic"),
193            Self::Crystalline => formatter.write_str("crystalline"),
194            Self::Glassy => formatter.write_str("glassy"),
195            Self::Vesicular => formatter.write_str("vesicular"),
196            Self::Foliated => formatter.write_str("foliated"),
197            Self::NonFoliated => formatter.write_str("non-foliated"),
198            Self::Porphyritic => formatter.write_str("porphyritic"),
199            Self::FineGrained => formatter.write_str("fine-grained"),
200            Self::CoarseGrained => formatter.write_str("coarse-grained"),
201            Self::Unknown => formatter.write_str("unknown"),
202            Self::Custom(value) => formatter.write_str(value),
203        }
204    }
205}
206
207impl FromStr for RockTexture {
208    type Err = RockParseError;
209
210    fn from_str(value: &str) -> Result<Self, Self::Err> {
211        let trimmed = value.trim();
212
213        if trimmed.is_empty() {
214            return Err(RockParseError::Empty);
215        }
216
217        match normalized_token(trimmed).as_str() {
218            "clastic" => Ok(Self::Clastic),
219            "crystalline" => Ok(Self::Crystalline),
220            "glassy" => Ok(Self::Glassy),
221            "vesicular" => Ok(Self::Vesicular),
222            "foliated" => Ok(Self::Foliated),
223            "non-foliated" => Ok(Self::NonFoliated),
224            "porphyritic" => Ok(Self::Porphyritic),
225            "fine-grained" => Ok(Self::FineGrained),
226            "coarse-grained" => Ok(Self::CoarseGrained),
227            "unknown" => Ok(Self::Unknown),
228            _ => Ok(Self::Custom(trimmed.to_string())),
229        }
230    }
231}
232
233#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
234pub struct RockComposition {
235    label: Option<String>,
236    mineral_names: Vec<String>,
237}
238
239impl RockComposition {
240    /// Creates a rock composition with a non-empty label and no mineral names.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`RockTextError::Empty`] when the trimmed label is empty.
245    pub fn with_label(label: impl AsRef<str>) -> Result<Self, RockTextError> {
246        Ok(Self {
247            label: Some(non_empty_text(label)?),
248            mineral_names: Vec::new(),
249        })
250    }
251
252    /// Creates a rock composition from at least one non-empty mineral name.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`RockCompositionError::EmptyMineralName`] when any mineral name is empty.
257    /// Returns [`RockCompositionError::NoMineralNames`] when no mineral names are supplied.
258    pub fn with_mineral_names<I, S>(mineral_names: I) -> Result<Self, RockCompositionError>
259    where
260        I: IntoIterator<Item = S>,
261        S: AsRef<str>,
262    {
263        let mineral_names = collect_mineral_names(mineral_names)?;
264
265        Ok(Self {
266            label: None,
267            mineral_names,
268        })
269    }
270
271    /// Creates a labeled rock composition from a non-empty label and mineral list.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`RockCompositionError::EmptyLabel`] when the label is empty.
276    /// Returns [`RockCompositionError::EmptyMineralName`] when any mineral name is empty.
277    /// Returns [`RockCompositionError::NoMineralNames`] when no mineral names are supplied.
278    pub fn describe<I, S>(
279        label: impl AsRef<str>,
280        mineral_names: I,
281    ) -> Result<Self, RockCompositionError>
282    where
283        I: IntoIterator<Item = S>,
284        S: AsRef<str>,
285    {
286        let label = label.as_ref();
287        if label.trim().is_empty() {
288            return Err(RockCompositionError::EmptyLabel);
289        }
290
291        Ok(Self {
292            label: Some(label.to_string()),
293            mineral_names: collect_mineral_names(mineral_names)?,
294        })
295    }
296
297    #[must_use]
298    pub fn label(&self) -> Option<&str> {
299        self.label.as_deref()
300    }
301
302    #[must_use]
303    pub fn mineral_names(&self) -> &[String] {
304        &self.mineral_names
305    }
306}
307
308impl fmt::Display for RockComposition {
309    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match (self.label.as_deref(), self.mineral_names.is_empty()) {
311            (Some(label), true) => formatter.write_str(label),
312            (Some(label), false) => {
313                let mineral_names = self.mineral_names.join(", ");
314                write!(formatter, "{label} [{mineral_names}]")
315            },
316            (None, false) => formatter.write_str(&self.mineral_names.join(", ")),
317            (None, true) => formatter.write_str("unspecified"),
318        }
319    }
320}
321
322fn collect_mineral_names<I, S>(mineral_names: I) -> Result<Vec<String>, RockCompositionError>
323where
324    I: IntoIterator<Item = S>,
325    S: AsRef<str>,
326{
327    let mineral_names = mineral_names
328        .into_iter()
329        .map(|value| {
330            let original = value.as_ref();
331            if original.trim().is_empty() {
332                Err(RockCompositionError::EmptyMineralName)
333            } else {
334                Ok(original.to_string())
335            }
336        })
337        .collect::<Result<Vec<_>, _>>()?;
338
339    if mineral_names.is_empty() {
340        Err(RockCompositionError::NoMineralNames)
341    } else {
342        Ok(mineral_names)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::{
349        RockComposition, RockCompositionError, RockKind, RockName, RockParseError, RockTextError,
350        RockTexture,
351    };
352
353    #[test]
354    fn valid_rock_name() -> Result<(), RockTextError> {
355        let name = RockName::new("Basalt")?;
356
357        assert_eq!(name.as_str(), "Basalt");
358        Ok(())
359    }
360
361    #[test]
362    fn empty_rock_name_rejected() {
363        assert_eq!(RockName::new("\t"), Err(RockTextError::Empty));
364    }
365
366    #[test]
367    fn rock_kind_display_parse() -> Result<(), RockParseError> {
368        assert_eq!(RockKind::Igneous.to_string(), "igneous");
369        assert_eq!("metamorphic".parse::<RockKind>()?, RockKind::Metamorphic);
370        Ok(())
371    }
372
373    #[test]
374    fn rock_texture_display_parse() -> Result<(), RockParseError> {
375        assert_eq!(RockTexture::FineGrained.to_string(), "fine-grained");
376        assert_eq!(
377            "non foliated".parse::<RockTexture>()?,
378            RockTexture::NonFoliated
379        );
380        Ok(())
381    }
382
383    #[test]
384    fn custom_rock_kind() -> Result<(), RockParseError> {
385        assert_eq!(
386            "volcaniclastic".parse::<RockKind>()?,
387            RockKind::Custom("volcaniclastic".to_string())
388        );
389        Ok(())
390    }
391
392    #[test]
393    fn rock_composition_construction() -> Result<(), RockCompositionError> {
394        let composition = RockComposition::describe("felsic", ["Quartz", "Feldspar"])?;
395
396        assert_eq!(composition.label(), Some("felsic"));
397        assert_eq!(composition.mineral_names(), ["Quartz", "Feldspar"]);
398        assert_eq!(composition.to_string(), "felsic [Quartz, Feldspar]");
399        Ok(())
400    }
401}