use core::fmt;
use chrono::{Datelike, NaiveDate};
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct YearMonth(i32);
impl YearMonth {
pub fn new(year: i32, month: u32) -> Option<YearMonth> {
if !(1..=12).contains(&month) {
return None;
}
let key = year.checked_mul(12)?.checked_add(month as i32 - 1)?;
Some(YearMonth(key))
}
pub fn year(self) -> i32 {
self.0.div_euclid(12)
}
pub fn month(self) -> u32 {
(self.0.rem_euclid(12) + 1) as u32
}
pub fn next(self) -> YearMonth {
YearMonth(self.0.saturating_add(1))
}
pub fn prev(self) -> YearMonth {
YearMonth(self.0.saturating_sub(1))
}
pub(crate) fn key(self) -> i32 {
self.0
}
pub(crate) fn from_key(key: i32) -> YearMonth {
YearMonth(key)
}
}
impl From<NaiveDate> for YearMonth {
fn from(date: NaiveDate) -> YearMonth {
YearMonth(date.year() * 12 + date.month() as i32 - 1)
}
}
impl fmt::Display for YearMonth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}", self.year(), self.month())
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct ParseYearMonthError;
impl fmt::Display for ParseYearMonthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("invalid month, expected YYYY-MM")
}
}
impl core::error::Error for ParseYearMonthError {}
impl core::str::FromStr for YearMonth {
type Err = ParseYearMonthError;
fn from_str(s: &str) -> Result<YearMonth, ParseYearMonthError> {
let (y, m) = s.rsplit_once('-').ok_or(ParseYearMonthError)?;
let parsed = YearMonth::new(
y.parse().map_err(|_| ParseYearMonthError)?,
m.parse().map_err(|_| ParseYearMonthError)?,
);
parsed.ok_or(ParseYearMonthError)
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct YearEnd {
year: i32,
december: bool, }
impl YearEnd {
pub fn march(year: i32) -> YearEnd {
YearEnd {
year,
december: false,
}
}
pub fn december(year: i32) -> YearEnd {
YearEnd {
year,
december: true,
}
}
pub fn from_year_month(year_month: YearMonth) -> Option<YearEnd> {
match year_month.month() {
3 => Some(YearEnd::march(year_month.year())),
12 => Some(YearEnd::december(year_month.year())),
_ => None,
}
}
pub fn year(self) -> i32 {
self.year
}
pub fn is_march(self) -> bool {
!self.december
}
pub fn end_year_month(self) -> YearMonth {
let month = if self.december { 12 } else { 3 };
YearMonth(self.year.saturating_mul(12).saturating_add(month - 1))
}
pub(crate) fn key(self) -> i32 {
self.year
.checked_mul(2)
.map_or(i32::MIN, |doubled| doubled + self.december as i32)
}
pub(crate) fn from_key(key: i32) -> YearEnd {
YearEnd {
year: key.div_euclid(2),
december: key.rem_euclid(2) == 1,
}
}
}
impl fmt::Display for YearEnd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let month = if self.december { 12 } else { 3 };
write!(f, "year ending {:04}-{:02}-31", self.year, month)
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Currency([u8; 3]);
impl Currency {
pub const GBP: Currency = Currency(*b"GBP");
pub fn as_str(&self) -> &str {
core::str::from_utf8(&self.0).unwrap_or("???")
}
pub(crate) fn from_code(code: [u8; 3]) -> Currency {
Currency(code)
}
pub(crate) fn code(&self) -> [u8; 3] {
self.0
}
pub(crate) fn normalize(s: &str) -> Option<[u8; 3]> {
let s = s.trim();
let bytes = s.as_bytes();
if bytes.len() != 3 || !bytes.iter().all(|b| b.is_ascii_alphabetic()) {
return None;
}
Some([
bytes[0].to_ascii_uppercase(),
bytes[1].to_ascii_uppercase(),
bytes[2].to_ascii_uppercase(),
])
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[non_exhaustive]
pub enum RateType {
Monthly,
Spot,
Average,
Weekly,
}
impl fmt::Display for RateType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
RateType::Monthly => "monthly",
RateType::Spot => "spot",
RateType::Average => "average",
RateType::Weekly => "weekly",
})
}
}
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum Period {
YearMonth(YearMonth),
YearEnd(YearEnd),
Week { start: NaiveDate, end: NaiveDate },
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Period::YearMonth(m) => m.fmt(f),
Period::YearEnd(ye) => ye.fmt(f),
Period::Week { start, end } => write!(f, "week {start} to {end}"),
}
}
}
#[cfg(feature = "serde")]
mod serde_impls {
use super::{Currency, YearEnd, YearMonth};
use alloc::format;
use alloc::string::String;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
impl Serialize for YearMonth {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for YearMonth {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearMonth, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse()
.map_err(|_| D::Error::custom(format!("invalid month '{s}', expected YYYY-MM")))
}
}
impl Serialize for YearEnd {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let month = if self.is_march() { 3 } else { 12 };
serializer.collect_str(&format_args!("{:04}-{:02}", self.year(), month))
}
}
impl<'de> Deserialize<'de> for YearEnd {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<YearEnd, D::Error> {
let year_month = YearMonth::deserialize(deserializer)?;
YearEnd::from_year_month(year_month).ok_or_else(|| {
D::Error::custom(format!(
"invalid year end '{year_month}', expected March or December"
))
})
}
}
impl Serialize for Currency {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Currency {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Currency, D::Error> {
let s = String::deserialize(deserializer)?;
Currency::normalize(&s)
.map(Currency::from_code)
.ok_or_else(|| D::Error::custom(format!("invalid currency code '{s}'")))
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn month_roundtrip_and_arithmetic() {
let m = YearMonth::new(2025, 1).unwrap();
assert_eq!((m.year(), m.month()), (2025, 1));
assert_eq!(m.prev(), YearMonth::new(2024, 12).unwrap());
assert_eq!(m.next(), YearMonth::new(2025, 2).unwrap());
assert_eq!(
YearMonth::new(2025, 12).unwrap().next(),
YearMonth::new(2026, 1).unwrap()
);
assert!(YearMonth::new(2025, 0).is_none());
assert!(YearMonth::new(2025, 13).is_none());
assert_eq!(m.to_string(), "2025-01");
}
#[test]
fn month_from_date() {
let date = NaiveDate::from_ymd_opt(2025, 8, 31).unwrap();
assert_eq!(YearMonth::from(date), YearMonth::new(2025, 8).unwrap());
}
#[test]
fn year_end_ordering_and_display() {
assert!(YearEnd::march(2025) < YearEnd::december(2025));
assert!(YearEnd::december(2024) < YearEnd::march(2025));
assert_eq!(
YearEnd::march(2026).end_year_month(),
YearMonth::new(2026, 3).unwrap()
);
assert_eq!(
YearEnd::december(2025).to_string(),
"year ending 2025-12-31"
);
assert_eq!(
YearEnd::from_key(YearEnd::march(2026).key()),
YearEnd::march(2026)
);
}
#[test]
fn currency_normalization() {
assert_eq!(Currency::normalize(" usd "), Some(*b"USD"));
assert_eq!(Currency::normalize("EuR"), Some(*b"EUR"));
assert_eq!(Currency::normalize(""), None);
assert_eq!(Currency::normalize("US"), None);
assert_eq!(Currency::normalize("USDX"), None);
assert_eq!(Currency::normalize("U5D"), None);
assert_eq!(Currency::GBP.as_str(), "GBP");
}
}