1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum CloudValueError {
10 CloudCoverOutOfRange(u8),
12 NonFiniteCloudBase(f64),
14 NegativeCloudBase(f64),
16}
17
18impl fmt::Display for CloudValueError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::CloudCoverOutOfRange(value) => {
22 write!(formatter, "cloud cover must be in 0..=8 oktas, got {value}")
23 },
24 Self::NonFiniteCloudBase(value) => {
25 write!(formatter, "cloud base must be finite, got {value}")
26 },
27 Self::NegativeCloudBase(value) => {
28 write!(formatter, "cloud base cannot be negative, got {value}")
29 },
30 }
31 }
32}
33
34impl Error for CloudValueError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub enum CloudKind {
39 Cirrus,
41 Cirrostratus,
43 Cirrocumulus,
45 Altostratus,
47 Altocumulus,
49 Stratus,
51 Stratocumulus,
53 Cumulus,
55 Cumulonimbus,
57 Nimbostratus,
59 Fog,
61 Unknown,
63 Custom(String),
65}
66
67impl fmt::Display for CloudKind {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::Cirrus => formatter.write_str("cirrus"),
71 Self::Cirrostratus => formatter.write_str("cirrostratus"),
72 Self::Cirrocumulus => formatter.write_str("cirrocumulus"),
73 Self::Altostratus => formatter.write_str("altostratus"),
74 Self::Altocumulus => formatter.write_str("altocumulus"),
75 Self::Stratus => formatter.write_str("stratus"),
76 Self::Stratocumulus => formatter.write_str("stratocumulus"),
77 Self::Cumulus => formatter.write_str("cumulus"),
78 Self::Cumulonimbus => formatter.write_str("cumulonimbus"),
79 Self::Nimbostratus => formatter.write_str("nimbostratus"),
80 Self::Fog => formatter.write_str("fog"),
81 Self::Unknown => formatter.write_str("unknown"),
82 Self::Custom(value) => formatter.write_str(value),
83 }
84 }
85}
86
87impl FromStr for CloudKind {
88 type Err = CloudKindParseError;
89
90 fn from_str(value: &str) -> Result<Self, Self::Err> {
91 let trimmed = value.trim();
92
93 if trimmed.is_empty() {
94 return Err(CloudKindParseError::Empty);
95 }
96
97 match trimmed
98 .to_ascii_lowercase()
99 .replace(['_', ' '], "-")
100 .as_str()
101 {
102 "cirrus" => Ok(Self::Cirrus),
103 "cirrostratus" => Ok(Self::Cirrostratus),
104 "cirrocumulus" => Ok(Self::Cirrocumulus),
105 "altostratus" => Ok(Self::Altostratus),
106 "altocumulus" => Ok(Self::Altocumulus),
107 "stratus" => Ok(Self::Stratus),
108 "stratocumulus" => Ok(Self::Stratocumulus),
109 "cumulus" => Ok(Self::Cumulus),
110 "cumulonimbus" => Ok(Self::Cumulonimbus),
111 "nimbostratus" => Ok(Self::Nimbostratus),
112 "fog" => Ok(Self::Fog),
113 "unknown" => Ok(Self::Unknown),
114 _ => Ok(Self::Custom(trimmed.to_string())),
115 }
116 }
117}
118
119#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum CloudKindParseError {
122 Empty,
124}
125
126impl fmt::Display for CloudKindParseError {
127 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128 match self {
129 Self::Empty => formatter.write_str("cloud kind cannot be empty"),
130 }
131 }
132}
133
134impl Error for CloudKindParseError {}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
138pub struct CloudCover(u8);
139
140impl CloudCover {
141 pub fn new(oktas: u8) -> Result<Self, CloudValueError> {
147 if oktas > 8 {
148 Err(CloudValueError::CloudCoverOutOfRange(oktas))
149 } else {
150 Ok(Self(oktas))
151 }
152 }
153
154 #[must_use]
156 pub fn oktas(&self) -> u8 {
157 self.0
158 }
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
163pub struct CloudBase(f64);
164
165impl CloudBase {
166 pub fn new(meters_agl: f64) -> Result<Self, CloudValueError> {
172 if !meters_agl.is_finite() {
173 return Err(CloudValueError::NonFiniteCloudBase(meters_agl));
174 }
175
176 if meters_agl < 0.0 {
177 return Err(CloudValueError::NegativeCloudBase(meters_agl));
178 }
179
180 Ok(Self(meters_agl))
181 }
182
183 #[must_use]
185 pub fn meters_agl(&self) -> f64 {
186 self.0
187 }
188}
189
190#[derive(Clone, Debug, PartialEq)]
192pub struct CloudLayer {
193 kind: CloudKind,
194 cover: CloudCover,
195 base: Option<CloudBase>,
196}
197
198impl CloudLayer {
199 #[must_use]
201 pub fn new(kind: CloudKind, cover: CloudCover) -> Self {
202 Self {
203 kind,
204 cover,
205 base: None,
206 }
207 }
208
209 #[must_use]
211 pub fn with_base(mut self, base: CloudBase) -> Self {
212 self.base = Some(base);
213 self
214 }
215
216 #[must_use]
218 pub fn kind(&self) -> &CloudKind {
219 &self.kind
220 }
221
222 #[must_use]
224 pub fn cover(&self) -> CloudCover {
225 self.cover
226 }
227
228 #[must_use]
230 pub fn base(&self) -> Option<CloudBase> {
231 self.base
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{
238 CloudBase, CloudCover, CloudKind, CloudKindParseError, CloudLayer, CloudValueError,
239 };
240 use core::str::FromStr;
241
242 #[test]
243 fn cloud_kind_display_and_parse() {
244 assert_eq!(CloudKind::Cumulonimbus.to_string(), "cumulonimbus");
245 assert_eq!(
246 CloudKind::from_str("altostratus").unwrap(),
247 CloudKind::Altostratus
248 );
249 assert_eq!(CloudKind::from_str(" "), Err(CloudKindParseError::Empty));
250 }
251
252 #[test]
253 fn custom_cloud_kind() {
254 assert_eq!(
255 CloudKind::from_str("lenticular").unwrap(),
256 CloudKind::Custom(String::from("lenticular"))
257 );
258 }
259
260 #[test]
261 fn valid_cloud_cover() {
262 let cover = CloudCover::new(4).unwrap();
263
264 assert_eq!(cover.oktas(), 4);
265 }
266
267 #[test]
268 fn invalid_cloud_cover_rejected() {
269 assert_eq!(
270 CloudCover::new(9),
271 Err(CloudValueError::CloudCoverOutOfRange(9))
272 );
273 }
274
275 #[test]
276 fn valid_cloud_base() {
277 let base = CloudBase::new(1200.0).unwrap();
278
279 assert_eq!(base.meters_agl(), 1200.0);
280 }
281
282 #[test]
283 fn negative_cloud_base_rejected() {
284 assert_eq!(
285 CloudBase::new(-10.0),
286 Err(CloudValueError::NegativeCloudBase(-10.0))
287 );
288 }
289
290 #[test]
291 fn cloud_layer_composes_values() {
292 let layer = CloudLayer::new(CloudKind::Cumulus, CloudCover::new(3).unwrap())
293 .with_base(CloudBase::new(850.0).unwrap());
294
295 assert_eq!(layer.kind(), &CloudKind::Cumulus);
296 assert_eq!(layer.cover().oktas(), 3);
297 assert_eq!(layer.base().unwrap().meters_agl(), 850.0);
298 }
299}