1use crate::{Decimal, NumericValue, TypeParseError};
7use candid::CandidType;
8use derive_more::{Add, AddAssign, Sub, SubAssign};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::fmt::{self, Debug, Display};
11use time::{Date as TimeDate, Duration as TimeDuration, Month};
12
13#[derive(
26 Add,
27 AddAssign,
28 CandidType,
29 Clone,
30 Copy,
31 Default,
32 Eq,
33 PartialEq,
34 Hash,
35 Ord,
36 PartialOrd,
37 Sub,
38 SubAssign,
39)]
40#[repr(transparent)]
41pub struct Date(i32);
42
43impl Date {
44 pub const EPOCH: Self = Self(0);
46 pub const MIN: Self = Self(i32::MIN);
48 pub const MAX: Self = Self(i32::MAX);
50
51 const fn epoch_date() -> TimeDate {
52 match TimeDate::from_calendar_date(1970, Month::January, 1) {
54 Ok(d) => d,
55 Err(_) => unreachable!(),
56 }
57 }
58
59 #[must_use]
63 pub fn try_new(y: i32, m: u8, d: u8) -> Option<Self> {
64 let month = Month::try_from(m).ok()?;
65 let date = TimeDate::from_calendar_date(y, month, d).ok()?;
66 Some(Self::from_time_date(date))
67 }
68
69 #[must_use]
71 pub const fn from_days_since_epoch(days: i32) -> Self {
72 Self(days)
73 }
74
75 #[must_use]
77 pub const fn as_days_since_epoch(self) -> i32 {
78 self.0
79 }
80
81 #[must_use]
83 pub fn try_from_i64(days: i64) -> Option<Self> {
84 i32::try_from(days).ok().map(Self)
85 }
86
87 #[must_use]
89 pub fn try_from_u64(days: u64) -> Option<Self> {
90 i32::try_from(days).ok().map(Self)
91 }
92
93 #[must_use]
95 pub fn year(self) -> i32 {
96 self.to_time_date().year()
97 }
98
99 #[must_use]
101 pub fn month(self) -> u8 {
102 self.to_time_date().month().into()
103 }
104
105 #[must_use]
107 pub fn day(self) -> u8 {
108 self.to_time_date().day()
109 }
110
111 #[must_use]
113 pub fn parse(s: &str) -> Option<Self> {
114 let bytes = s.as_bytes();
115 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
116 return None;
117 }
118
119 let year = parse_ascii_i32(&bytes[0..4])?;
122 let month = parse_ascii_u8(&bytes[5..7])?;
123 let day = parse_ascii_u8(&bytes[8..10])?;
124
125 Self::try_new(year, month, day)
126 }
127
128 #[expect(clippy::cast_possible_truncation)]
130 fn from_time_date(date: TimeDate) -> Self {
131 let epoch = Self::epoch_date();
132 let days = (date - epoch).whole_days();
133 Self(days as i32)
134 }
135
136 fn to_time_date(self) -> TimeDate {
138 let epoch = Self::epoch_date();
139 let delta = TimeDuration::days(self.0.into());
140 epoch.checked_add(delta).unwrap_or({
141 if self.0 >= 0 {
142 TimeDate::MAX
143 } else {
144 TimeDate::MIN
145 }
146 })
147 }
148}
149
150impl Debug for Date {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 write!(f, "Date({self})")
153 }
154}
155
156impl Display for Date {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 let d = self.to_time_date();
159 let month: u8 = d.month().into();
160 write!(f, "{:04}-{:02}-{:02}", d.year(), month, d.day())
161 }
162}
163
164impl NumericValue for Date {
165 fn try_to_decimal(&self) -> Option<Decimal> {
166 Decimal::from_i64(i64::from(self.0))
167 }
168
169 fn try_from_decimal(value: Decimal) -> Option<Self> {
170 value.to_i32().map(Self)
171 }
172}
173
174impl<'de> Deserialize<'de> for Date {
175 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176 where
177 D: Deserializer<'de>,
178 {
179 struct DateVisitor;
180
181 impl serde::de::Visitor<'_> for DateVisitor {
182 type Value = Date;
183
184 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter.write_str("ISO date text or canonical epoch-day integer")
186 }
187
188 fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E> {
189 Ok(Date::from_days_since_epoch(value))
190 }
191
192 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
193 where
194 E: serde::de::Error,
195 {
196 Date::try_from_i64(value).ok_or_else(|| E::custom(TypeParseError::InvalidDate))
197 }
198
199 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
200 where
201 E: serde::de::Error,
202 {
203 Date::parse(value).ok_or_else(|| E::custom(TypeParseError::InvalidDate))
204 }
205 }
206
207 deserializer.deserialize_any(DateVisitor)
208 }
209}
210
211impl Serialize for Date {
212 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
213 where
214 S: serde::Serializer,
215 {
216 serializer.serialize_str(&self.to_string())
217 }
218}
219
220fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
221 bytes.iter().try_fold(0_i32, |value, byte| {
222 byte.checked_sub(b'0')
223 .filter(|digit| *digit <= 9)
224 .and_then(|digit| value.checked_mul(10)?.checked_add(i32::from(digit)))
225 })
226}
227
228fn parse_ascii_u8(bytes: &[u8]) -> Option<u8> {
229 bytes.iter().try_fold(0_u8, |value, byte| {
230 byte.checked_sub(b'0')
231 .filter(|digit| *digit <= 9)
232 .and_then(|digit| value.checked_mul(10)?.checked_add(digit))
233 })
234}
235
236#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
247 fn from_ymd_and_to_naive_date_round_trip() {
248 let date = Date::try_new(2024, 10, 19).expect("valid calendar date should construct");
249 assert_eq!(date.year(), 2024);
250 assert_eq!(date.month(), 10);
251 assert_eq!(date.day(), 19);
252 }
253
254 #[test]
255 fn try_new_rejects_out_of_range_month_and_day() {
256 assert!(Date::try_new(2025, 13, 99).is_none());
257 }
258
259 #[test]
260 fn invalid_date_parse_returns_none() {
261 assert!(Date::parse("2025-13-40").is_none());
262 assert!(Date::try_new(2025, 2, 30).is_none());
263 }
264
265 #[test]
266 fn try_new_rejects_out_of_range_year() {
267 assert!(Date::try_new(i32::MAX, 1, 1).is_none());
268 }
269
270 #[test]
271 fn overflow_protection_in_try_from_u64() {
272 let too_large = (i32::MAX as u64) + 1;
274 assert!(Date::try_from_u64(too_large).is_none());
275 }
276
277 #[test]
278 fn ordering_and_equality_follow_internal_day_count() {
279 let d1 = Date::try_new(2020, 1, 1).unwrap();
280 let d2 = Date::try_new(2021, 1, 1).unwrap();
281
282 assert!(d1 < d2);
283 assert!(d1.as_days_since_epoch() < d2.as_days_since_epoch());
284 assert_eq!(d1, d1);
285 }
286
287 #[test]
288 fn internal_day_count_helpers_round_trip() {
289 let days = -365;
290 let date = Date::from_days_since_epoch(days);
291 assert_eq!(date.as_days_since_epoch(), days);
292 assert_eq!(date.as_days_since_epoch(), days);
293 }
294
295 #[test]
296 fn display_formats_as_iso_date() {
297 let date = Date::try_new(2025, 10, 19).unwrap();
298 assert_eq!(format!("{date}"), "2025-10-19");
299 }
300
301 #[test]
302 fn parse_stays_iso_strict() {
303 assert_eq!(Date::parse("2025-10-19"), Date::try_new(2025, 10, 19));
304 assert!(Date::parse("10/19/2025").is_none());
305 assert!(Date::parse("2025-10-19T00:00:00Z").is_none());
306 }
307
308 #[test]
309 fn parse_supports_pre_epoch_and_leap_year_cases() {
310 assert_eq!(
311 Date::parse("1900-01-01"),
312 Date::try_new(1900, 1, 1),
313 "expected non-leap-century date to parse",
314 );
315 assert_eq!(
316 Date::parse("1969-12-31"),
317 Date::try_new(1969, 12, 31),
318 "expected pre-epoch date to parse",
319 );
320 assert_eq!(
321 Date::parse("2000-02-29"),
322 Date::try_new(2000, 2, 29),
323 "expected leap-day date to parse",
324 );
325 }
326
327 #[test]
328 fn parse_rejects_invalid_non_leap_day() {
329 assert!(Date::parse("1900-02-29").is_none());
330 }
331
332 #[test]
333 fn extreme_internal_day_values_format_without_panicking() {
334 let min_rendered = Date::MIN.to_string();
335 let max_rendered = Date::MAX.to_string();
336
337 assert!(!min_rendered.is_empty());
338 assert!(!max_rendered.is_empty());
339 }
340}