1use crate::{Decimal, NumericValue, TypeParseError};
7use candid::CandidType;
8use serde::{Deserialize, Deserializer, Serialize};
9use std::fmt::{self, Debug, Display};
10use time::{Date as TimeDate, Duration as TimeDuration, Month};
11
12#[derive(CandidType, Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
26#[repr(transparent)]
27pub struct Date(i32);
28
29impl Date {
30 pub const EPOCH: Self = Self(0);
32 pub const MIN: Self = Self(-719_528);
34 pub const MAX: Self = Self(2_932_896);
36
37 const fn epoch_date() -> TimeDate {
38 match TimeDate::from_calendar_date(1970, Month::January, 1) {
40 Ok(d) => d,
41 Err(_) => unreachable!(),
42 }
43 }
44
45 #[must_use]
49 pub fn try_new(y: i32, m: u8, d: u8) -> Option<Self> {
50 if !(0..=9_999).contains(&y) {
51 return None;
52 }
53 let month = Month::try_from(m).ok()?;
54 let date = TimeDate::from_calendar_date(y, month, d).ok()?;
55 Self::from_time_date(date)
56 }
57
58 #[must_use]
60 pub const fn try_from_days_since_epoch(days: i32) -> Option<Self> {
61 if days < Self::MIN.0 || days > Self::MAX.0 {
62 None
63 } else {
64 Some(Self(days))
65 }
66 }
67
68 #[must_use]
70 pub const fn as_days_since_epoch(self) -> i32 {
71 self.0
72 }
73
74 #[must_use]
76 pub fn try_from_i64(days: i64) -> Option<Self> {
77 i32::try_from(days)
78 .ok()
79 .and_then(Self::try_from_days_since_epoch)
80 }
81
82 #[must_use]
84 pub fn try_from_u64(days: u64) -> Option<Self> {
85 i32::try_from(days)
86 .ok()
87 .and_then(Self::try_from_days_since_epoch)
88 }
89
90 #[must_use]
92 pub fn checked_add_days(self, days: i64) -> Option<Self> {
93 i64::from(self.0)
94 .checked_add(days)
95 .and_then(Self::try_from_i64)
96 }
97
98 #[must_use]
100 pub fn checked_sub_days(self, days: i64) -> Option<Self> {
101 i64::from(self.0)
102 .checked_sub(days)
103 .and_then(Self::try_from_i64)
104 }
105
106 #[must_use]
108 pub fn days_since(self, earlier: Self) -> i64 {
109 i64::from(self.0) - i64::from(earlier.0)
110 }
111
112 #[must_use]
114 pub fn year(self) -> i32 {
115 self.to_time_date().year()
116 }
117
118 #[must_use]
120 pub fn month(self) -> u8 {
121 self.to_time_date().month().into()
122 }
123
124 #[must_use]
126 pub fn day(self) -> u8 {
127 self.to_time_date().day()
128 }
129
130 #[must_use]
132 pub fn parse(s: &str) -> Option<Self> {
133 let bytes = s.as_bytes();
134 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
135 return None;
136 }
137
138 let year = parse_ascii_i32(&bytes[0..4])?;
141 let month = parse_ascii_u8(&bytes[5..7])?;
142 let day = parse_ascii_u8(&bytes[8..10])?;
143
144 Self::try_new(year, month, day)
145 }
146
147 fn from_time_date(date: TimeDate) -> Option<Self> {
148 let epoch = Self::epoch_date();
149 let days = (date - epoch).whole_days();
150 Self::try_from_i64(days)
151 }
152
153 fn to_time_date(self) -> TimeDate {
156 let epoch = Self::epoch_date();
157 let delta = TimeDuration::days(self.0.into());
158 match epoch.checked_add(delta) {
159 Some(date) => date,
160 None => unreachable!("bounded Date invariant must produce a supported calendar date"),
161 }
162 }
163}
164
165impl Debug for Date {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 write!(f, "Date({self})")
168 }
169}
170
171impl Display for Date {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 let d = self.to_time_date();
174 let month: u8 = d.month().into();
175 write!(f, "{:04}-{:02}-{:02}", d.year(), month, d.day())
176 }
177}
178
179impl NumericValue for Date {
180 fn try_to_decimal(&self) -> Option<Decimal> {
181 Decimal::from_i64(i64::from(self.0))
182 }
183
184 fn try_from_decimal(value: Decimal) -> Option<Self> {
185 value.to_i32().and_then(Self::try_from_days_since_epoch)
186 }
187}
188
189impl<'de> Deserialize<'de> for Date {
190 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191 where
192 D: Deserializer<'de>,
193 {
194 if deserializer.is_human_readable() {
195 return Self::parse(&String::deserialize(deserializer)?)
196 .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate));
197 }
198 Self::try_from_days_since_epoch(i32::deserialize(deserializer)?)
200 .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate))
201 }
202}
203
204impl Serialize for Date {
205 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
206 where
207 S: serde::Serializer,
208 {
209 if serializer.is_human_readable() {
210 serializer.collect_str(self)
211 } else {
212 serializer.serialize_i32(self.0)
213 }
214 }
215}
216
217fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
218 bytes.iter().try_fold(0_i32, |value, byte| {
219 byte.checked_sub(b'0')
220 .filter(|digit| *digit <= 9)
221 .and_then(|digit| value.checked_mul(10)?.checked_add(i32::from(digit)))
222 })
223}
224
225fn parse_ascii_u8(bytes: &[u8]) -> Option<u8> {
226 bytes.iter().try_fold(0_u8, |value, byte| {
227 byte.checked_sub(b'0')
228 .filter(|digit| *digit <= 9)
229 .and_then(|digit| value.checked_mul(10)?.checked_add(digit))
230 })
231}
232
233#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn serde_calendar_boundaries_and_batches_use_compact_days() {
243 let today = Date::try_new(2026, 9, 12).unwrap();
244 for (date, width) in [(Date::EPOCH, 1), (today, 3), (Date::MIN, 5), (Date::MAX, 5)] {
245 let mut encoded = Vec::new();
246 ciborium::into_writer(&date, &mut encoded).unwrap();
247 assert_eq!(encoded.len(), width);
248 let wire: ciborium::Value = ciborium::from_reader(encoded.as_slice()).unwrap();
249 assert_eq!(
250 wire,
251 ciborium::Value::Integer(date.as_days_since_epoch().into())
252 );
253 assert_eq!(
254 ciborium::from_reader::<Date, _>(encoded.as_slice()).unwrap(),
255 date
256 );
257
258 let json = serde_json::to_string(&date).unwrap();
259 assert_eq!(json, format!("\"{date}\""));
260 assert_eq!(serde_json::from_str::<Date>(&json).unwrap(), date);
261
262 let candid = candid::encode_one(date).unwrap();
263 assert_eq!(
264 candid,
265 candid::encode_one(date.as_days_since_epoch()).unwrap()
266 );
267 assert_eq!(candid::decode_one::<Date>(&candid).unwrap(), date);
268
269 let batch = vec![date; 1_000];
270 encoded.clear();
271 ciborium::into_writer(&batch, &mut encoded).unwrap();
272 assert_eq!(encoded.len(), 3 + 1_000 * width);
273 assert_eq!(
274 ciborium::from_reader::<Vec<Date>, _>(encoded.as_slice()).unwrap(),
275 batch
276 );
277 }
278 }
279
280 #[test]
281 fn binary_serde_rejects_days_outside_calendar_domain() {
282 for day in [
283 i64::MIN,
284 i64::from(Date::MIN.as_days_since_epoch()) - 1,
285 i64::from(Date::MAX.as_days_since_epoch()) + 1,
286 i64::MAX,
287 ] {
288 let mut encoded = Vec::new();
289 ciborium::into_writer(&day, &mut encoded).unwrap();
290 assert!(ciborium::from_reader::<Date, _>(encoded.as_slice()).is_err());
291 }
292 }
293
294 #[test]
297 fn from_ymd_and_to_naive_date_round_trip() {
298 let date = Date::try_new(2024, 10, 19).expect("valid calendar date should construct");
299 assert_eq!(date.year(), 2024);
300 assert_eq!(date.month(), 10);
301 assert_eq!(date.day(), 19);
302 }
303
304 #[test]
305 fn try_new_rejects_out_of_range_month_and_day() {
306 assert!(Date::try_new(2025, 13, 99).is_none());
307 }
308
309 #[test]
310 fn invalid_date_parse_returns_none() {
311 assert!(Date::parse("2025-13-40").is_none());
312 assert!(Date::try_new(2025, 2, 30).is_none());
313 }
314
315 #[test]
316 fn try_new_rejects_out_of_range_year() {
317 assert!(Date::try_new(-1, 1, 1).is_none());
318 assert!(Date::try_new(10_000, 1, 1).is_none());
319 assert!(Date::try_new(i32::MAX, 1, 1).is_none());
320 }
321
322 #[test]
323 fn overflow_protection_in_try_from_u64() {
324 let too_large = (i32::MAX as u64) + 1;
326 assert!(Date::try_from_u64(too_large).is_none());
327 }
328
329 #[test]
330 fn ordering_and_equality_follow_internal_day_count() {
331 let d1 = Date::try_new(2020, 1, 1).unwrap();
332 let d2 = Date::try_new(2021, 1, 1).unwrap();
333
334 assert!(d1 < d2);
335 assert!(d1.as_days_since_epoch() < d2.as_days_since_epoch());
336 assert_eq!(d1, d1);
337 }
338
339 #[test]
340 fn internal_day_count_helpers_round_trip() {
341 let days = -365;
342 let date = Date::try_from_days_since_epoch(days).expect("bounded day should construct");
343 assert_eq!(date.as_days_since_epoch(), days);
344 }
345
346 #[test]
347 fn raw_day_construction_rejects_values_outside_calendar_bounds() {
348 assert_eq!(
349 Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch()),
350 Some(Date::MIN),
351 );
352 assert_eq!(
353 Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch()),
354 Some(Date::MAX),
355 );
356 assert!(Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch() - 1).is_none(),);
357 assert!(Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch() + 1).is_none(),);
358 }
359
360 #[test]
361 fn checked_day_arithmetic_obeys_calendar_bounds() {
362 let leap_day = Date::try_new(2024, 2, 29).expect("leap day should construct");
363 let march_first = Date::try_new(2024, 3, 1).expect("next day should construct");
364
365 assert_eq!(leap_day.checked_add_days(1), Some(march_first));
366 assert_eq!(march_first.checked_sub_days(1), Some(leap_day));
367 assert_eq!(march_first.days_since(leap_day), 1);
368 assert!(Date::MAX.checked_add_days(1).is_none());
369 assert!(Date::MIN.checked_sub_days(1).is_none());
370 assert!(Date::EPOCH.checked_add_days(i64::MAX).is_none());
371 assert!(Date::EPOCH.checked_sub_days(i64::MIN).is_none());
372 }
373
374 #[test]
375 fn display_formats_as_iso_date() {
376 let date = Date::try_new(2025, 10, 19).unwrap();
377 assert_eq!(format!("{date}"), "2025-10-19");
378 }
379
380 #[test]
381 fn parse_stays_iso_strict() {
382 assert_eq!(Date::parse("2025-10-19"), Date::try_new(2025, 10, 19));
383 assert!(Date::parse("10/19/2025").is_none());
384 assert!(Date::parse("2025-10-19T00:00:00Z").is_none());
385 }
386
387 #[test]
388 fn parse_supports_pre_epoch_and_leap_year_cases() {
389 assert_eq!(
390 Date::parse("1900-01-01"),
391 Date::try_new(1900, 1, 1),
392 "expected non-leap-century date to parse",
393 );
394 assert_eq!(
395 Date::parse("1969-12-31"),
396 Date::try_new(1969, 12, 31),
397 "expected pre-epoch date to parse",
398 );
399 assert_eq!(
400 Date::parse("2000-02-29"),
401 Date::try_new(2000, 2, 29),
402 "expected leap-day date to parse",
403 );
404 }
405
406 #[test]
407 fn parse_rejects_invalid_non_leap_day() {
408 assert!(Date::parse("1900-02-29").is_none());
409 }
410
411 #[test]
412 fn calendar_boundaries_format_and_parse_exactly() {
413 assert_eq!(Date::MIN.to_string(), "0000-01-01");
414 assert_eq!(Date::MAX.to_string(), "9999-12-31");
415 assert_eq!(Date::parse(Date::MIN.to_string().as_str()), Some(Date::MIN));
416 assert_eq!(Date::parse(Date::MAX.to_string().as_str()), Some(Date::MAX));
417 }
418
419 #[test]
420 fn candid_decode_rejects_out_of_range_epoch_days() {
421 let min =
422 candid::encode_one(Date::MIN.as_days_since_epoch()).expect("minimum day should encode");
423 let max =
424 candid::encode_one(Date::MAX.as_days_since_epoch()).expect("maximum day should encode");
425 let below = candid::encode_one(Date::MIN.as_days_since_epoch() - 1)
426 .expect("out-of-range day should encode as raw i32");
427 let above = candid::encode_one(Date::MAX.as_days_since_epoch() + 1)
428 .expect("out-of-range day should encode as raw i32");
429
430 assert_eq!(
431 candid::decode_one::<Date>(&min).expect("minimum day should decode"),
432 Date::MIN,
433 );
434 assert_eq!(
435 candid::decode_one::<Date>(&max).expect("maximum day should decode"),
436 Date::MAX,
437 );
438 assert!(candid::decode_one::<Date>(&below).is_err());
439 assert!(candid::decode_one::<Date>(&above).is_err());
440 }
441}