1use core::fmt;
2
3use chrono::{Datelike, NaiveDate};
4
5#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
14pub struct YearMonth(i32); impl YearMonth {
17 pub fn new(year: i32, month: u32) -> Option<YearMonth> {
19 if !(1..=12).contains(&month) {
20 return None;
21 }
22 let key = year.checked_mul(12)?.checked_add(month as i32 - 1)?;
23 Some(YearMonth(key))
24 }
25
26 pub fn year(self) -> i32 {
28 self.0.div_euclid(12)
29 }
30
31 pub fn month(self) -> u32 {
33 (self.0.rem_euclid(12) + 1) as u32
34 }
35
36 pub fn next(self) -> YearMonth {
38 YearMonth(self.0.saturating_add(1))
39 }
40
41 pub fn prev(self) -> YearMonth {
43 YearMonth(self.0.saturating_sub(1))
44 }
45
46 pub(crate) fn key(self) -> i32 {
47 self.0
48 }
49
50 pub(crate) fn from_key(key: i32) -> YearMonth {
51 YearMonth(key)
52 }
53}
54
55impl From<NaiveDate> for YearMonth {
56 fn from(date: NaiveDate) -> YearMonth {
57 YearMonth(date.year() * 12 + date.month() as i32 - 1)
58 }
59}
60
61impl fmt::Display for YearMonth {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 write!(f, "{:04}-{:02}", self.year(), self.month())
64 }
65}
66
67#[derive(Copy, Clone, PartialEq, Eq, Debug)]
69pub struct ParseYearMonthError;
70
71impl fmt::Display for ParseYearMonthError {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str("invalid month, expected YYYY-MM")
74 }
75}
76
77impl core::error::Error for ParseYearMonthError {}
78
79impl core::str::FromStr for YearMonth {
81 type Err = ParseYearMonthError;
82
83 fn from_str(s: &str) -> Result<YearMonth, ParseYearMonthError> {
84 let (y, m) = s.rsplit_once('-').ok_or(ParseYearMonthError)?;
86 let parsed = YearMonth::new(
87 y.parse().map_err(|_| ParseYearMonthError)?,
88 m.parse().map_err(|_| ParseYearMonthError)?,
89 );
90 parsed.ok_or(ParseYearMonthError)
91 }
92}
93
94#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
104pub struct YearEnd {
105 year: i32,
106 december: bool, }
108
109impl YearEnd {
110 pub fn march(year: i32) -> YearEnd {
112 YearEnd {
113 year,
114 december: false,
115 }
116 }
117
118 pub fn december(year: i32) -> YearEnd {
120 YearEnd {
121 year,
122 december: true,
123 }
124 }
125
126 pub fn from_year_month(year_month: YearMonth) -> Option<YearEnd> {
128 match year_month.month() {
129 3 => Some(YearEnd::march(year_month.year())),
130 12 => Some(YearEnd::december(year_month.year())),
131 _ => None,
132 }
133 }
134
135 pub fn year(self) -> i32 {
137 self.year
138 }
139
140 pub fn is_march(self) -> bool {
142 !self.december
143 }
144
145 pub fn end_year_month(self) -> YearMonth {
147 let month = if self.december { 12 } else { 3 };
148 YearMonth(self.year.saturating_mul(12).saturating_add(month - 1))
150 }
151
152 pub(crate) fn key(self) -> i32 {
153 self.year
155 .checked_mul(2)
156 .map_or(i32::MIN, |doubled| doubled + self.december as i32)
157 }
158
159 pub(crate) fn from_key(key: i32) -> YearEnd {
160 YearEnd {
161 year: key.div_euclid(2),
162 december: key.rem_euclid(2) == 1,
163 }
164 }
165}
166
167impl fmt::Display for YearEnd {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 let month = if self.december { 12 } else { 3 };
170 write!(f, "year ending {:04}-{:02}-31", self.year, month)
171 }
172}
173
174#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
179pub struct Currency([u8; 3]);
180
181impl Currency {
182 pub const GBP: Currency = Currency(*b"GBP");
184
185 pub fn as_str(&self) -> &str {
187 core::str::from_utf8(&self.0).unwrap_or("???")
189 }
190
191 pub(crate) fn from_code(code: [u8; 3]) -> Currency {
192 Currency(code)
193 }
194
195 pub(crate) fn code(&self) -> [u8; 3] {
196 self.0
197 }
198
199 pub(crate) fn normalize(s: &str) -> Option<[u8; 3]> {
201 let s = s.trim();
202 let bytes = s.as_bytes();
203 if bytes.len() != 3 || !bytes.iter().all(|b| b.is_ascii_alphabetic()) {
204 return None;
205 }
206 Some([
207 bytes[0].to_ascii_uppercase(),
208 bytes[1].to_ascii_uppercase(),
209 bytes[2].to_ascii_uppercase(),
210 ])
211 }
212}
213
214impl fmt::Display for Currency {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 f.write_str(self.as_str())
217 }
218}
219
220#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
223#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
224#[non_exhaustive]
225pub enum RateType {
226 Monthly,
228 Spot,
230 Average,
232 Weekly,
234}
235
236impl fmt::Display for RateType {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.write_str(match self {
239 RateType::Monthly => "monthly",
240 RateType::Spot => "spot",
241 RateType::Average => "average",
242 RateType::Weekly => "weekly",
243 })
244 }
245}
246
247#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
249#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
251#[non_exhaustive]
252pub enum Period {
253 YearMonth(YearMonth),
255 YearEnd(YearEnd),
257 Week { start: NaiveDate, end: NaiveDate },
259}
260
261impl fmt::Display for Period {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 match self {
264 Period::YearMonth(m) => m.fmt(f),
265 Period::YearEnd(ye) => ye.fmt(f),
266 Period::Week { start, end } => write!(f, "week {start} to {end}"),
267 }
268 }
269}
270
271#[cfg(feature = "serde")]
273mod serde_impls {
274 use super::{Currency, YearEnd, YearMonth};
275 use alloc::format;
276 use alloc::string::String;
277 use serde::de::Error as _;
278 use serde::{Deserialize, Deserializer, Serialize, Serializer};
279
280 impl Serialize for YearMonth {
281 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
282 serializer.collect_str(self)
283 }
284 }
285
286 impl<'de> Deserialize<'de> for YearMonth {
287 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearMonth, D::Error> {
288 let s = String::deserialize(deserializer)?;
289 s.parse()
290 .map_err(|_| D::Error::custom(format!("invalid month '{s}', expected YYYY-MM")))
291 }
292 }
293
294 impl Serialize for YearEnd {
295 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
296 let month = if self.is_march() { 3 } else { 12 };
297 serializer.collect_str(&format_args!("{:04}-{:02}", self.year(), month))
298 }
299 }
300
301 impl<'de> Deserialize<'de> for YearEnd {
302 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearEnd, D::Error> {
303 let year_month = YearMonth::deserialize(deserializer)?;
304 YearEnd::from_year_month(year_month).ok_or_else(|| {
305 D::Error::custom(format!(
306 "invalid year end '{year_month}', expected March or December"
307 ))
308 })
309 }
310 }
311
312 impl Serialize for Currency {
313 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
314 serializer.serialize_str(self.as_str())
315 }
316 }
317
318 impl<'de> Deserialize<'de> for Currency {
319 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Currency, D::Error> {
320 let s = String::deserialize(deserializer)?;
321 Currency::normalize(&s)
322 .map(Currency::from_code)
323 .ok_or_else(|| D::Error::custom(format!("invalid currency code '{s}'")))
324 }
325 }
326}
327
328#[cfg(test)]
329#[allow(clippy::unwrap_used)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn month_roundtrip_and_arithmetic() {
335 let m = YearMonth::new(2025, 1).unwrap();
336 assert_eq!((m.year(), m.month()), (2025, 1));
337 assert_eq!(m.prev(), YearMonth::new(2024, 12).unwrap());
338 assert_eq!(m.next(), YearMonth::new(2025, 2).unwrap());
339 assert_eq!(
340 YearMonth::new(2025, 12).unwrap().next(),
341 YearMonth::new(2026, 1).unwrap()
342 );
343 assert!(YearMonth::new(2025, 0).is_none());
344 assert!(YearMonth::new(2025, 13).is_none());
345 assert_eq!(m.to_string(), "2025-01");
346 }
347
348 #[test]
349 fn month_from_date() {
350 let date = NaiveDate::from_ymd_opt(2025, 8, 31).unwrap();
351 assert_eq!(YearMonth::from(date), YearMonth::new(2025, 8).unwrap());
352 }
353
354 #[test]
355 fn year_end_ordering_and_display() {
356 assert!(YearEnd::march(2025) < YearEnd::december(2025));
357 assert!(YearEnd::december(2024) < YearEnd::march(2025));
358 assert_eq!(
359 YearEnd::march(2026).end_year_month(),
360 YearMonth::new(2026, 3).unwrap()
361 );
362 assert_eq!(
363 YearEnd::december(2025).to_string(),
364 "year ending 2025-12-31"
365 );
366 assert_eq!(
367 YearEnd::from_key(YearEnd::march(2026).key()),
368 YearEnd::march(2026)
369 );
370 }
371
372 #[test]
373 fn currency_normalization() {
374 assert_eq!(Currency::normalize(" usd "), Some(*b"USD"));
375 assert_eq!(Currency::normalize("EuR"), Some(*b"EUR"));
376 assert_eq!(Currency::normalize(""), None);
377 assert_eq!(Currency::normalize("US"), None);
378 assert_eq!(Currency::normalize("USDX"), None);
379 assert_eq!(Currency::normalize("U5D"), None);
380 assert_eq!(Currency::GBP.as_str(), "GBP");
381 }
382}