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