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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum ForecastValueError {
27 EmptyForecastId,
29 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum ForecastKind {
47 Nowcast,
49 ShortRange,
51 MediumRange,
53 LongRange,
55 Seasonal,
57 ClimateOutlook,
59 Unknown,
61 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum ForecastKindParseError {
106 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub enum ForecastConfidence {
123 Low,
125 Medium,
127 High,
129 Unknown,
131 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum ForecastConfidenceParseError {
170 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
186pub struct ForecastId(String);
187
188impl ForecastId {
189 pub fn new(value: impl AsRef<str>) -> Result<Self, ForecastValueError> {
195 non_empty_text(value, ForecastValueError::EmptyForecastId).map(Self)
196 }
197
198 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
221pub struct ForecastHorizon(u32);
222
223impl ForecastHorizon {
224 #[must_use]
226 pub const fn new(hours: u32) -> Self {
227 Self(hours)
228 }
229
230 #[must_use]
232 pub const fn hours(&self) -> u32 {
233 self.0
234 }
235}
236
237#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
239pub struct ForecastPeriod(String);
240
241impl ForecastPeriod {
242 pub fn new(value: impl AsRef<str>) -> Result<Self, ForecastValueError> {
248 non_empty_text(value, ForecastValueError::EmptyForecastPeriod).map(Self)
249 }
250
251 #[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}