Skip to main content

use_weather_observation/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn normalized_key(value: &str) -> String {
8    value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
9}
10
11fn non_empty_text(
12    value: impl AsRef<str>,
13    error: WeatherObservationError,
14) -> Result<String, WeatherObservationError> {
15    let trimmed = value.as_ref().trim();
16
17    if trimmed.is_empty() {
18        Err(error)
19    } else {
20        Ok(trimmed.to_string())
21    }
22}
23
24/// Errors returned by weather observation constructors.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum WeatherObservationError {
27    /// The observation identifier was empty after trimming whitespace.
28    EmptyObservationId,
29    /// The observation source text was empty after trimming whitespace.
30    EmptyObservationSource,
31}
32
33impl fmt::Display for WeatherObservationError {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::EmptyObservationId => {
37                formatter.write_str("weather observation identifier cannot be empty")
38            },
39            Self::EmptyObservationSource => {
40                formatter.write_str("weather observation source cannot be empty")
41            },
42        }
43    }
44}
45
46impl Error for WeatherObservationError {}
47
48/// A non-empty weather observation identifier.
49#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct WeatherObservationId(String);
51
52impl WeatherObservationId {
53    /// Creates a weather observation identifier from non-empty text.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`WeatherObservationError::EmptyObservationId`] when the identifier is empty.
58    pub fn new(value: impl AsRef<str>) -> Result<Self, WeatherObservationError> {
59        non_empty_text(value, WeatherObservationError::EmptyObservationId).map(Self)
60    }
61
62    /// Returns the stored identifier text.
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67
68    /// Consumes the identifier and returns the owned string.
69    #[must_use]
70    pub fn into_string(self) -> String {
71        self.0
72    }
73}
74
75impl AsRef<str> for WeatherObservationId {
76    fn as_ref(&self) -> &str {
77        self.as_str()
78    }
79}
80
81impl fmt::Display for WeatherObservationId {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter.write_str(self.as_str())
84    }
85}
86
87impl FromStr for WeatherObservationId {
88    type Err = WeatherObservationError;
89
90    fn from_str(value: &str) -> Result<Self, Self::Err> {
91        Self::new(value)
92    }
93}
94
95/// A descriptive non-empty observation source label.
96#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub struct ObservationSource(String);
98
99impl ObservationSource {
100    /// Creates an observation source label from non-empty text.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`WeatherObservationError::EmptyObservationSource`] when the source is empty.
105    pub fn new(value: impl AsRef<str>) -> Result<Self, WeatherObservationError> {
106        non_empty_text(value, WeatherObservationError::EmptyObservationSource).map(Self)
107    }
108
109    /// Returns the stored source label.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114
115    /// Consumes the source and returns the owned string.
116    #[must_use]
117    pub fn into_string(self) -> String {
118        self.0
119    }
120}
121
122impl AsRef<str> for ObservationSource {
123    fn as_ref(&self) -> &str {
124        self.as_str()
125    }
126}
127
128impl fmt::Display for ObservationSource {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(self.as_str())
131    }
132}
133
134impl FromStr for ObservationSource {
135    type Err = WeatherObservationError;
136
137    fn from_str(value: &str) -> Result<Self, Self::Err> {
138        Self::new(value)
139    }
140}
141
142/// Stable weather observation kind vocabulary.
143#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
144pub enum ObservationKind {
145    /// Surface observation.
146    Surface,
147    /// Upper-air observation.
148    UpperAir,
149    /// Radar observation.
150    Radar,
151    /// Satellite observation.
152    Satellite,
153    /// Buoy observation.
154    Buoy,
155    /// Station observation.
156    Station,
157    /// Manual observation.
158    Manual,
159    /// Automated observation.
160    Automated,
161    /// Unknown observation kind.
162    Unknown,
163    /// Caller-defined observation kind text.
164    Custom(String),
165}
166
167impl fmt::Display for ObservationKind {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::Surface => formatter.write_str("surface"),
171            Self::UpperAir => formatter.write_str("upper-air"),
172            Self::Radar => formatter.write_str("radar"),
173            Self::Satellite => formatter.write_str("satellite"),
174            Self::Buoy => formatter.write_str("buoy"),
175            Self::Station => formatter.write_str("station"),
176            Self::Manual => formatter.write_str("manual"),
177            Self::Automated => formatter.write_str("automated"),
178            Self::Unknown => formatter.write_str("unknown"),
179            Self::Custom(value) => formatter.write_str(value),
180        }
181    }
182}
183
184impl FromStr for ObservationKind {
185    type Err = ObservationKindParseError;
186
187    fn from_str(value: &str) -> Result<Self, Self::Err> {
188        let trimmed = value.trim();
189
190        if trimmed.is_empty() {
191            return Err(ObservationKindParseError::Empty);
192        }
193
194        match normalized_key(trimmed).as_str() {
195            "surface" => Ok(Self::Surface),
196            "upper-air" | "upperair" => Ok(Self::UpperAir),
197            "radar" => Ok(Self::Radar),
198            "satellite" => Ok(Self::Satellite),
199            "buoy" => Ok(Self::Buoy),
200            "station" => Ok(Self::Station),
201            "manual" => Ok(Self::Manual),
202            "automated" => Ok(Self::Automated),
203            "unknown" => Ok(Self::Unknown),
204            _ => Ok(Self::Custom(trimmed.to_string())),
205        }
206    }
207}
208
209/// Error returned when parsing observation kinds fails.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum ObservationKindParseError {
212    /// The observation kind was empty after trimming whitespace.
213    Empty,
214}
215
216impl fmt::Display for ObservationKindParseError {
217    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            Self::Empty => formatter.write_str("observation kind cannot be empty"),
220        }
221    }
222}
223
224impl Error for ObservationKindParseError {}
225
226/// Stable weather observation quality vocabulary.
227#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub enum ObservationQuality {
229    /// Raw observation.
230    Raw,
231    /// Estimated observation.
232    Estimated,
233    /// Corrected observation.
234    Corrected,
235    /// Verified observation.
236    Verified,
237    /// Questionable observation.
238    Questionable,
239    /// Missing observation.
240    Missing,
241    /// Unknown observation quality.
242    Unknown,
243    /// Caller-defined observation quality text.
244    Custom(String),
245}
246
247impl fmt::Display for ObservationQuality {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            Self::Raw => formatter.write_str("raw"),
251            Self::Estimated => formatter.write_str("estimated"),
252            Self::Corrected => formatter.write_str("corrected"),
253            Self::Verified => formatter.write_str("verified"),
254            Self::Questionable => formatter.write_str("questionable"),
255            Self::Missing => formatter.write_str("missing"),
256            Self::Unknown => formatter.write_str("unknown"),
257            Self::Custom(value) => formatter.write_str(value),
258        }
259    }
260}
261
262impl FromStr for ObservationQuality {
263    type Err = ObservationQualityParseError;
264
265    fn from_str(value: &str) -> Result<Self, Self::Err> {
266        let trimmed = value.trim();
267
268        if trimmed.is_empty() {
269            return Err(ObservationQualityParseError::Empty);
270        }
271
272        match normalized_key(trimmed).as_str() {
273            "raw" => Ok(Self::Raw),
274            "estimated" => Ok(Self::Estimated),
275            "corrected" => Ok(Self::Corrected),
276            "verified" => Ok(Self::Verified),
277            "questionable" => Ok(Self::Questionable),
278            "missing" => Ok(Self::Missing),
279            "unknown" => Ok(Self::Unknown),
280            _ => Ok(Self::Custom(trimmed.to_string())),
281        }
282    }
283}
284
285/// Error returned when parsing observation quality fails.
286#[derive(Clone, Copy, Debug, Eq, PartialEq)]
287pub enum ObservationQualityParseError {
288    /// The observation quality was empty after trimming whitespace.
289    Empty,
290}
291
292impl fmt::Display for ObservationQualityParseError {
293    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            Self::Empty => formatter.write_str("observation quality cannot be empty"),
296        }
297    }
298}
299
300impl Error for ObservationQualityParseError {}
301
302/// Descriptive weather observation metadata.
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct WeatherObservation {
305    id: WeatherObservationId,
306    kind: ObservationKind,
307    source: ObservationSource,
308    quality: ObservationQuality,
309}
310
311impl WeatherObservation {
312    /// Creates descriptive weather observation metadata.
313    #[must_use]
314    pub fn new(
315        id: WeatherObservationId,
316        kind: ObservationKind,
317        source: ObservationSource,
318        quality: ObservationQuality,
319    ) -> Self {
320        Self {
321            id,
322            kind,
323            source,
324            quality,
325        }
326    }
327
328    /// Returns the observation identifier.
329    #[must_use]
330    pub fn id(&self) -> &WeatherObservationId {
331        &self.id
332    }
333
334    /// Returns the observation kind.
335    #[must_use]
336    pub fn kind(&self) -> &ObservationKind {
337        &self.kind
338    }
339
340    /// Returns the descriptive observation source.
341    #[must_use]
342    pub fn source(&self) -> &ObservationSource {
343        &self.source
344    }
345
346    /// Returns the observation quality.
347    #[must_use]
348    pub fn quality(&self) -> &ObservationQuality {
349        &self.quality
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::{
356        ObservationKind, ObservationKindParseError, ObservationQuality,
357        ObservationQualityParseError, ObservationSource, WeatherObservation,
358        WeatherObservationError, WeatherObservationId,
359    };
360    use core::str::FromStr;
361
362    #[test]
363    fn valid_observation_id() {
364        let identifier = WeatherObservationId::new(" obs-001 ").unwrap();
365
366        assert_eq!(identifier.as_str(), "obs-001");
367    }
368
369    #[test]
370    fn empty_observation_id_rejected() {
371        assert_eq!(
372            WeatherObservationId::new("   "),
373            Err(WeatherObservationError::EmptyObservationId)
374        );
375    }
376
377    #[test]
378    fn observation_kind_display_and_parse() {
379        assert_eq!(ObservationKind::UpperAir.to_string(), "upper-air");
380        assert_eq!(
381            ObservationKind::from_str("upper air").unwrap(),
382            ObservationKind::UpperAir
383        );
384        assert_eq!(
385            ObservationKind::from_str(" "),
386            Err(ObservationKindParseError::Empty)
387        );
388    }
389
390    #[test]
391    fn observation_quality_display_and_parse() {
392        assert_eq!(ObservationQuality::Questionable.to_string(), "questionable");
393        assert_eq!(
394            ObservationQuality::from_str("verified").unwrap(),
395            ObservationQuality::Verified
396        );
397        assert_eq!(
398            ObservationQuality::from_str(" "),
399            Err(ObservationQualityParseError::Empty)
400        );
401    }
402
403    #[test]
404    fn custom_observation_kind() {
405        assert_eq!(
406            ObservationKind::from_str("pilot report").unwrap(),
407            ObservationKind::Custom(String::from("pilot report"))
408        );
409    }
410
411    #[test]
412    fn constructs_weather_observation_metadata() {
413        let observation = WeatherObservation::new(
414            WeatherObservationId::new("obs-002").unwrap(),
415            ObservationKind::Radar,
416            ObservationSource::new("regional radar composite").unwrap(),
417            ObservationQuality::Estimated,
418        );
419
420        assert_eq!(observation.id().as_str(), "obs-002");
421        assert_eq!(observation.kind(), &ObservationKind::Radar);
422        assert_eq!(observation.source().as_str(), "regional radar composite");
423        assert_eq!(observation.quality(), &ObservationQuality::Estimated);
424    }
425}