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, SedimentTextError> {
8 let original = value.as_ref();
9
10 if original.trim().is_empty() {
11 Err(SedimentTextError::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 SedimentTextError {
43 Empty,
44}
45
46impl fmt::Display for SedimentTextError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::Empty => formatter.write_str("sediment text cannot be empty"),
50 }
51 }
52}
53
54impl Error for SedimentTextError {}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum SedimentParseError {
58 Empty,
59}
60
61impl fmt::Display for SedimentParseError {
62 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::Empty => formatter.write_str("sediment vocabulary cannot be empty"),
65 }
66 }
67}
68
69impl Error for SedimentParseError {}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum GrainSizeError {
73 InvalidNumber,
74 NonFinite,
75 Negative,
76}
77
78impl fmt::Display for GrainSizeError {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::InvalidNumber => formatter.write_str("grain size must be a valid number"),
82 Self::NonFinite => formatter.write_str("grain size must be finite"),
83 Self::Negative => formatter.write_str("grain size cannot be negative"),
84 }
85 }
86}
87
88impl Error for GrainSizeError {}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct SedimentName(String);
92
93impl SedimentName {
94 pub fn new(value: impl AsRef<str>) -> Result<Self, SedimentTextError> {
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
109impl AsRef<str> for SedimentName {
110 fn as_ref(&self) -> &str {
111 self.as_str()
112 }
113}
114
115impl fmt::Display for SedimentName {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter.write_str(self.as_str())
118 }
119}
120
121impl FromStr for SedimentName {
122 type Err = SedimentTextError;
123
124 fn from_str(value: &str) -> Result<Self, Self::Err> {
125 Self::new(value)
126 }
127}
128
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
130pub enum SedimentKind {
131 Clay,
132 Silt,
133 Sand,
134 Gravel,
135 Pebble,
136 Cobble,
137 Boulder,
138 Mud,
139 Unknown,
140 Custom(String),
141}
142
143impl fmt::Display for SedimentKind {
144 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Self::Clay => formatter.write_str("clay"),
147 Self::Silt => formatter.write_str("silt"),
148 Self::Sand => formatter.write_str("sand"),
149 Self::Gravel => formatter.write_str("gravel"),
150 Self::Pebble => formatter.write_str("pebble"),
151 Self::Cobble => formatter.write_str("cobble"),
152 Self::Boulder => formatter.write_str("boulder"),
153 Self::Mud => formatter.write_str("mud"),
154 Self::Unknown => formatter.write_str("unknown"),
155 Self::Custom(value) => formatter.write_str(value),
156 }
157 }
158}
159
160impl FromStr for SedimentKind {
161 type Err = SedimentParseError;
162
163 fn from_str(value: &str) -> Result<Self, Self::Err> {
164 let trimmed = value.trim();
165
166 if trimmed.is_empty() {
167 return Err(SedimentParseError::Empty);
168 }
169
170 match normalized_token(trimmed).as_str() {
171 "clay" => Ok(Self::Clay),
172 "silt" => Ok(Self::Silt),
173 "sand" => Ok(Self::Sand),
174 "gravel" => Ok(Self::Gravel),
175 "pebble" => Ok(Self::Pebble),
176 "cobble" => Ok(Self::Cobble),
177 "boulder" => Ok(Self::Boulder),
178 "mud" => Ok(Self::Mud),
179 "unknown" => Ok(Self::Unknown),
180 _ => Ok(Self::Custom(trimmed.to_string())),
181 }
182 }
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
186pub struct GrainSize(f64);
187
188impl GrainSize {
189 pub fn new(millimeters: f64) -> Result<Self, GrainSizeError> {
196 if !millimeters.is_finite() {
197 return Err(GrainSizeError::NonFinite);
198 }
199
200 if millimeters < 0.0 {
201 return Err(GrainSizeError::Negative);
202 }
203
204 Ok(Self(millimeters))
205 }
206
207 #[must_use]
208 pub const fn millimeters(self) -> f64 {
209 self.0
210 }
211}
212
213impl fmt::Display for GrainSize {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 write!(formatter, "{}", self.0)
216 }
217}
218
219impl FromStr for GrainSize {
220 type Err = GrainSizeError;
221
222 fn from_str(value: &str) -> Result<Self, Self::Err> {
223 let parsed = value
224 .trim()
225 .parse::<f64>()
226 .map_err(|_| GrainSizeError::InvalidNumber)?;
227 Self::new(parsed)
228 }
229}
230
231#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
232pub enum Sorting {
233 WellSorted,
234 ModeratelySorted,
235 PoorlySorted,
236 Unknown,
237 Custom(String),
238}
239
240impl fmt::Display for Sorting {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 match self {
243 Self::WellSorted => formatter.write_str("well-sorted"),
244 Self::ModeratelySorted => formatter.write_str("moderately-sorted"),
245 Self::PoorlySorted => formatter.write_str("poorly-sorted"),
246 Self::Unknown => formatter.write_str("unknown"),
247 Self::Custom(value) => formatter.write_str(value),
248 }
249 }
250}
251
252impl FromStr for Sorting {
253 type Err = SedimentParseError;
254
255 fn from_str(value: &str) -> Result<Self, Self::Err> {
256 let trimmed = value.trim();
257
258 if trimmed.is_empty() {
259 return Err(SedimentParseError::Empty);
260 }
261
262 match normalized_token(trimmed).as_str() {
263 "well-sorted" => Ok(Self::WellSorted),
264 "moderately-sorted" => Ok(Self::ModeratelySorted),
265 "poorly-sorted" => Ok(Self::PoorlySorted),
266 "unknown" => Ok(Self::Unknown),
267 _ => Ok(Self::Custom(trimmed.to_string())),
268 }
269 }
270}
271
272#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
273pub enum Roundness {
274 Angular,
275 SubAngular,
276 SubRounded,
277 Rounded,
278 WellRounded,
279 Unknown,
280 Custom(String),
281}
282
283impl fmt::Display for Roundness {
284 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285 match self {
286 Self::Angular => formatter.write_str("angular"),
287 Self::SubAngular => formatter.write_str("sub-angular"),
288 Self::SubRounded => formatter.write_str("sub-rounded"),
289 Self::Rounded => formatter.write_str("rounded"),
290 Self::WellRounded => formatter.write_str("well-rounded"),
291 Self::Unknown => formatter.write_str("unknown"),
292 Self::Custom(value) => formatter.write_str(value),
293 }
294 }
295}
296
297impl FromStr for Roundness {
298 type Err = SedimentParseError;
299
300 fn from_str(value: &str) -> Result<Self, Self::Err> {
301 let trimmed = value.trim();
302
303 if trimmed.is_empty() {
304 return Err(SedimentParseError::Empty);
305 }
306
307 match normalized_token(trimmed).as_str() {
308 "angular" => Ok(Self::Angular),
309 "sub-angular" => Ok(Self::SubAngular),
310 "sub-rounded" => Ok(Self::SubRounded),
311 "rounded" => Ok(Self::Rounded),
312 "well-rounded" => Ok(Self::WellRounded),
313 "unknown" => Ok(Self::Unknown),
314 _ => Ok(Self::Custom(trimmed.to_string())),
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::{
322 GrainSize, GrainSizeError, Roundness, SedimentKind, SedimentName, SedimentParseError,
323 SedimentTextError, Sorting,
324 };
325
326 #[test]
327 fn valid_sediment_name() -> Result<(), SedimentTextError> {
328 let name = SedimentName::new("Alluvial sand")?;
329
330 assert_eq!(name.as_str(), "Alluvial sand");
331 Ok(())
332 }
333
334 #[test]
335 fn empty_sediment_name_rejected() {
336 assert_eq!(SedimentName::new(" "), Err(SedimentTextError::Empty));
337 }
338
339 #[test]
340 fn sediment_kind_display_parse() -> Result<(), SedimentParseError> {
341 assert_eq!(SedimentKind::Gravel.to_string(), "gravel");
342 assert_eq!("mud".parse::<SedimentKind>()?, SedimentKind::Mud);
343 Ok(())
344 }
345
346 #[test]
347 fn valid_grain_size() -> Result<(), GrainSizeError> {
348 let grain_size = GrainSize::new(0.0625)?;
349
350 assert!((grain_size.millimeters() - 0.0625).abs() < f64::EPSILON);
351 assert!(("2.0".parse::<GrainSize>()?.millimeters() - 2.0).abs() < f64::EPSILON);
352 Ok(())
353 }
354
355 #[test]
356 fn negative_grain_size_rejected() {
357 assert_eq!(GrainSize::new(-1.0), Err(GrainSizeError::Negative));
358 }
359
360 #[test]
361 fn sorting_display_parse() -> Result<(), SedimentParseError> {
362 assert_eq!(Sorting::WellSorted.to_string(), "well-sorted");
363 assert_eq!(
364 "moderately sorted".parse::<Sorting>()?,
365 Sorting::ModeratelySorted
366 );
367 Ok(())
368 }
369
370 #[test]
371 fn roundness_display_parse() -> Result<(), SedimentParseError> {
372 assert_eq!(Roundness::WellRounded.to_string(), "well-rounded");
373 assert_eq!("sub rounded".parse::<Roundness>()?, Roundness::SubRounded);
374 Ok(())
375 }
376}