Skip to main content

use_weather_forecast/
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(
8    value: impl AsRef<str>,
9    error: ForecastValueError,
10) -> Result<String, ForecastValueError> {
11    let trimmed = value.as_ref().trim();
12
13    if trimmed.is_empty() {
14        Err(error)
15    } else {
16        Ok(trimmed.to_string())
17    }
18}
19
20fn normalized_key(value: &str) -> String {
21    value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
22}
23
24/// Errors returned by forecast constructors.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum ForecastValueError {
27    /// Forecast identifiers cannot be empty.
28    EmptyForecastId,
29    /// Forecast periods cannot be empty.
30    EmptyForecastPeriod,
31}
32
33impl fmt::Display for ForecastValueError {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::EmptyForecastId => formatter.write_str("forecast identifier cannot be empty"),
37            Self::EmptyForecastPeriod => formatter.write_str("forecast period cannot be empty"),
38        }
39    }
40}
41
42impl Error for ForecastValueError {}
43
44/// Stable forecast kind vocabulary.
45#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum ForecastKind {
47    /// Nowcast.
48    Nowcast,
49    /// Short-range forecast.
50    ShortRange,
51    /// Medium-range forecast.
52    MediumRange,
53    /// Long-range forecast.
54    LongRange,
55    /// Seasonal forecast.
56    Seasonal,
57    /// Climate outlook.
58    ClimateOutlook,
59    /// Unknown forecast kind.
60    Unknown,
61    /// Caller-defined forecast kind.
62    Custom(String),
63}
64
65impl fmt::Display for ForecastKind {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Nowcast => formatter.write_str("nowcast"),
69            Self::ShortRange => formatter.write_str("short-range"),
70            Self::MediumRange => formatter.write_str("medium-range"),
71            Self::LongRange => formatter.write_str("long-range"),
72            Self::Seasonal => formatter.write_str("seasonal"),
73            Self::ClimateOutlook => formatter.write_str("climate-outlook"),
74            Self::Unknown => formatter.write_str("unknown"),
75            Self::Custom(value) => formatter.write_str(value),
76        }
77    }
78}
79
80impl FromStr for ForecastKind {
81    type Err = ForecastKindParseError;
82
83    fn from_str(value: &str) -> Result<Self, Self::Err> {
84        let trimmed = value.trim();
85
86        if trimmed.is_empty() {
87            return Err(ForecastKindParseError::Empty);
88        }
89
90        match normalized_key(trimmed).as_str() {
91            "nowcast" => Ok(Self::Nowcast),
92            "short-range" | "shortrange" => Ok(Self::ShortRange),
93            "medium-range" | "mediumrange" => Ok(Self::MediumRange),
94            "long-range" | "longrange" => Ok(Self::LongRange),
95            "seasonal" => Ok(Self::Seasonal),
96            "climate-outlook" | "climateoutlook" => Ok(Self::ClimateOutlook),
97            "unknown" => Ok(Self::Unknown),
98            _ => Ok(Self::Custom(trimmed.to_string())),
99        }
100    }
101}
102
103/// Error returned when parsing forecast kinds fails.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum ForecastKindParseError {
106    /// The forecast kind was empty after trimming whitespace.
107    Empty,
108}
109
110impl fmt::Display for ForecastKindParseError {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Empty => formatter.write_str("forecast kind cannot be empty"),
114        }
115    }
116}
117
118impl Error for ForecastKindParseError {}
119
120/// Stable forecast confidence vocabulary.
121#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub enum ForecastConfidence {
123    /// Low confidence.
124    Low,
125    /// Medium confidence.
126    Medium,
127    /// High confidence.
128    High,
129    /// Unknown confidence.
130    Unknown,
131    /// Caller-defined confidence.
132    Custom(String),
133}
134
135impl fmt::Display for ForecastConfidence {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::Low => formatter.write_str("low"),
139            Self::Medium => formatter.write_str("medium"),
140            Self::High => formatter.write_str("high"),
141            Self::Unknown => formatter.write_str("unknown"),
142            Self::Custom(value) => formatter.write_str(value),
143        }
144    }
145}
146
147impl FromStr for ForecastConfidence {
148    type Err = ForecastConfidenceParseError;
149
150    fn from_str(value: &str) -> Result<Self, Self::Err> {
151        let trimmed = value.trim();
152
153        if trimmed.is_empty() {
154            return Err(ForecastConfidenceParseError::Empty);
155        }
156
157        match normalized_key(trimmed).as_str() {
158            "low" => Ok(Self::Low),
159            "medium" => Ok(Self::Medium),
160            "high" => Ok(Self::High),
161            "unknown" => Ok(Self::Unknown),
162            _ => Ok(Self::Custom(trimmed.to_string())),
163        }
164    }
165}
166
167/// Error returned when parsing forecast confidence fails.
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum ForecastConfidenceParseError {
170    /// The forecast confidence was empty after trimming whitespace.
171    Empty,
172}
173
174impl fmt::Display for ForecastConfidenceParseError {
175    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::Empty => formatter.write_str("forecast confidence cannot be empty"),
178        }
179    }
180}
181
182impl Error for ForecastConfidenceParseError {}
183
184/// A non-empty forecast identifier.
185#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
186pub struct ForecastId(String);
187
188impl ForecastId {
189    /// Creates a forecast identifier from non-empty text.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ForecastValueError::EmptyForecastId`] when the identifier is empty.
194    pub fn new(value: impl AsRef<str>) -> Result<Self, ForecastValueError> {
195        non_empty_text(value, ForecastValueError::EmptyForecastId).map(Self)
196    }
197
198    /// Returns the stored identifier.
199    #[must_use]
200    pub fn as_str(&self) -> &str {
201        &self.0
202    }
203}
204
205impl fmt::Display for ForecastId {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(self.as_str())
208    }
209}
210
211impl FromStr for ForecastId {
212    type Err = ForecastValueError;
213
214    fn from_str(value: &str) -> Result<Self, Self::Err> {
215        Self::new(value)
216    }
217}
218
219/// Forecast horizon stored as hours.
220#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
221pub struct ForecastHorizon(u32);
222
223impl ForecastHorizon {
224    /// Creates a forecast horizon from hours.
225    #[must_use]
226    pub const fn new(hours: u32) -> Self {
227        Self(hours)
228    }
229
230    /// Returns the stored horizon in hours.
231    #[must_use]
232    pub const fn hours(&self) -> u32 {
233        self.0
234    }
235}
236
237/// A non-empty descriptive forecast period label.
238#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
239pub struct ForecastPeriod(String);
240
241impl ForecastPeriod {
242    /// Creates a forecast period label from non-empty text.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`ForecastValueError::EmptyForecastPeriod`] when the period is empty.
247    pub fn new(value: impl AsRef<str>) -> Result<Self, ForecastValueError> {
248        non_empty_text(value, ForecastValueError::EmptyForecastPeriod).map(Self)
249    }
250
251    /// Returns the stored period label.
252    #[must_use]
253    pub fn as_str(&self) -> &str {
254        &self.0
255    }
256}
257
258impl fmt::Display for ForecastPeriod {
259    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
260        formatter.write_str(self.as_str())
261    }
262}
263
264impl FromStr for ForecastPeriod {
265    type Err = ForecastValueError;
266
267    fn from_str(value: &str) -> Result<Self, Self::Err> {
268        Self::new(value)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{
275        ForecastConfidence, ForecastConfidenceParseError, ForecastHorizon, ForecastId,
276        ForecastKind, ForecastKindParseError, ForecastValueError,
277    };
278    use core::str::FromStr;
279
280    #[test]
281    fn valid_forecast_id() {
282        let identifier = ForecastId::new("fcst-001").unwrap();
283
284        assert_eq!(identifier.as_str(), "fcst-001");
285    }
286
287    #[test]
288    fn empty_forecast_id_rejected() {
289        assert_eq!(
290            ForecastId::new(" "),
291            Err(ForecastValueError::EmptyForecastId)
292        );
293    }
294
295    #[test]
296    fn forecast_kind_display_and_parse() {
297        assert_eq!(ForecastKind::ClimateOutlook.to_string(), "climate-outlook");
298        assert_eq!(
299            ForecastKind::from_str("short range").unwrap(),
300            ForecastKind::ShortRange
301        );
302        assert_eq!(
303            ForecastKind::from_str(" "),
304            Err(ForecastKindParseError::Empty)
305        );
306    }
307
308    #[test]
309    fn forecast_confidence_display_and_parse() {
310        assert_eq!(ForecastConfidence::Medium.to_string(), "medium");
311        assert_eq!(
312            ForecastConfidence::from_str("high").unwrap(),
313            ForecastConfidence::High
314        );
315        assert_eq!(
316            ForecastConfidence::from_str(" "),
317            Err(ForecastConfidenceParseError::Empty)
318        );
319    }
320
321    #[test]
322    fn forecast_horizon_construction() {
323        let horizon = ForecastHorizon::new(48);
324
325        assert_eq!(horizon.hours(), 48);
326    }
327
328    #[test]
329    fn custom_forecast_kind() {
330        assert_eq!(
331            ForecastKind::from_str("convective outlook").unwrap(),
332            ForecastKind::Custom(String::from("convective outlook"))
333        );
334    }
335}