Skip to main content

use_cloud/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Errors returned by cloud constructors.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum CloudValueError {
10    /// Cloud cover must stay in `0..=8` oktas.
11    CloudCoverOutOfRange(u8),
12    /// Cloud base must be finite.
13    NonFiniteCloudBase(f64),
14    /// Cloud base cannot be negative.
15    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/// Stable cloud kind vocabulary.
37#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub enum CloudKind {
39    /// Cirrus clouds.
40    Cirrus,
41    /// Cirrostratus clouds.
42    Cirrostratus,
43    /// Cirrocumulus clouds.
44    Cirrocumulus,
45    /// Altostratus clouds.
46    Altostratus,
47    /// Altocumulus clouds.
48    Altocumulus,
49    /// Stratus clouds.
50    Stratus,
51    /// Stratocumulus clouds.
52    Stratocumulus,
53    /// Cumulus clouds.
54    Cumulus,
55    /// Cumulonimbus clouds.
56    Cumulonimbus,
57    /// Nimbostratus clouds.
58    Nimbostratus,
59    /// Fog.
60    Fog,
61    /// Unknown cloud kind.
62    Unknown,
63    /// Caller-defined cloud kind.
64    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/// Error returned when parsing cloud kinds fails.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum CloudKindParseError {
122    /// The cloud kind was empty after trimming whitespace.
123    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/// Cloud cover stored in oktas.
137#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
138pub struct CloudCover(u8);
139
140impl CloudCover {
141    /// Creates cloud cover from oktas in `0..=8`.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`CloudValueError::CloudCoverOutOfRange`] when the value exceeds `8`.
146    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    /// Returns the stored cloud cover in oktas.
155    #[must_use]
156    pub fn oktas(&self) -> u8 {
157        self.0
158    }
159}
160
161/// Cloud base stored in meters above ground level.
162#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
163pub struct CloudBase(f64);
164
165impl CloudBase {
166    /// Creates cloud base from a finite non-negative meters-above-ground value.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`CloudValueError`] when the base is invalid.
171    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    /// Returns the stored base in meters above ground level.
184    #[must_use]
185    pub fn meters_agl(&self) -> f64 {
186        self.0
187    }
188}
189
190/// A descriptive cloud layer built from kind, cover, and optional base.
191#[derive(Clone, Debug, PartialEq)]
192pub struct CloudLayer {
193    kind: CloudKind,
194    cover: CloudCover,
195    base: Option<CloudBase>,
196}
197
198impl CloudLayer {
199    /// Creates a cloud layer from cloud kind and cover.
200    #[must_use]
201    pub fn new(kind: CloudKind, cover: CloudCover) -> Self {
202        Self {
203            kind,
204            cover,
205            base: None,
206        }
207    }
208
209    /// Adds a cloud base to the layer.
210    #[must_use]
211    pub fn with_base(mut self, base: CloudBase) -> Self {
212        self.base = Some(base);
213        self
214    }
215
216    /// Returns the cloud kind.
217    #[must_use]
218    pub fn kind(&self) -> &CloudKind {
219        &self.kind
220    }
221
222    /// Returns the cloud cover.
223    #[must_use]
224    pub fn cover(&self) -> CloudCover {
225        self.cover
226    }
227
228    /// Returns the cloud base, if present.
229    #[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}