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