Skip to main content

proto_types/common/
date.rs

1use core::{
2	cmp::{Ord, Ordering, PartialOrd},
3	fmt::Display,
4};
5
6use thiserror::Error;
7
8use crate::{String, ToString, common::Date};
9
10/// Errors that can occur during the creation, conversion or validation of a [`Date`].
11#[derive(Debug, Error, PartialEq, Eq, Clone)]
12#[non_exhaustive]
13pub enum DateError {
14	#[error("{0}")]
15	InvalidYear(String),
16	#[error("{0}")]
17	InvalidMonth(String),
18	#[error("{0}")]
19	InvalidDay(String),
20	#[error("Date conversion error: {0}")]
21	ConversionError(String),
22}
23
24impl Display for Date {
25	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
26		match self.kind() {
27			DateKind::Full => write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day),
28			DateKind::YearAndMonth => write!(f, "{:04}-{:02}", self.year, self.month),
29			DateKind::YearOnly => write!(f, "{:04}", self.year),
30			DateKind::MonthAndDay => write!(f, "{:02}-{:02}", self.month, self.day),
31		}
32	}
33}
34
35/// The kind of combinations that a [`Date`] can contain.
36#[derive(Debug, Clone, Eq, PartialEq, Copy)]
37pub enum DateKind {
38	/// A full date, with non-zero year, month, and day values
39	Full,
40	/// A year on its own, with zero month and day values
41	YearOnly,
42	/// A year and month value, with a zero day, such as a credit card expiration
43	YearAndMonth,
44	/// A month and day value, with a zero year, such as an anniversary
45	MonthAndDay,
46}
47
48impl Date {
49	/// Creates a new [`Date`] instance with validation.
50	/// Allows `year: 0`, `month: 0`, `day: 0` as special cases described in the proto spec.
51	/// Returns an error if any component is out of range or date is invalid (e.g., February 30th).
52	pub fn new(year: i32, month: i32, day: i32) -> Result<Self, DateError> {
53		validate_date(year, month, day)?;
54
55		Ok(Self { year, month, day })
56	}
57
58	/// Returns the kind of values combination for this [`Date`]
59	#[must_use]
60	#[inline]
61	pub const fn kind(&self) -> DateKind {
62		if self.year != 0 && self.month == 0 && self.day == 0 {
63			DateKind::YearOnly
64		} else if self.year != 0 && self.month != 0 && self.day == 0 {
65			DateKind::YearAndMonth
66		} else if self.year == 0 && self.month != 0 && self.day != 0 {
67			DateKind::MonthAndDay
68		} else {
69			DateKind::Full
70		}
71	}
72
73	/// Checks if this [`Date`] instance represents a valid date according to its constraints.
74	#[must_use]
75	pub fn is_valid(&self) -> bool {
76		validate_date(self.year, self.month, self.day).is_ok()
77	}
78
79	#[must_use]
80	#[inline]
81	pub const fn has_year(&self) -> bool {
82		self.year != 0
83	}
84
85	/// Returns `true` if this [`Date`] only indicates a year.
86	#[must_use]
87	#[inline]
88	pub const fn is_year_only(&self) -> bool {
89		self.year != 0 && (self.month == 0 && self.day == 0)
90	}
91
92	/// Returns `true` if this [`Date`] only indicates a year and a month (i.e. for a credit card expiration date).
93	#[must_use]
94	#[inline]
95	pub const fn is_year_and_month(&self) -> bool {
96		self.year != 0 && self.month != 0 && self.day == 0
97	}
98
99	/// Returns `true` if this [`Date`] only indicates a month and a day, with no specific year.
100	#[must_use]
101	#[inline]
102	pub const fn is_month_and_day(&self) -> bool {
103		self.year == 0 && self.month != 0 && self.day != 0
104	}
105}
106
107impl PartialOrd for Date {
108	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
109		if !(self.is_valid() && other.is_valid()) {
110			return None;
111		}
112
113		let self_kind = self.kind();
114		let other_kind = other.kind();
115
116		if self_kind != other_kind {
117			return None;
118		}
119
120		Some(
121			self.year
122				.cmp(&other.year)
123				.then_with(|| self.month.cmp(&other.month))
124				.then_with(|| self.day.cmp(&other.day)),
125		)
126	}
127}
128
129#[cfg(feature = "chrono")]
130mod chrono_impls {
131	use chrono::Utc;
132
133	use super::validate_date;
134	use crate::{Date, ToString, date::DateError, format};
135
136	impl Date {
137		/// Converts this [`Date`] to [`chrono::NaiveDate`]. It fails if the year, month or day are set to zero.
138		pub fn to_naive_date(self) -> Result<::chrono::NaiveDate, DateError> {
139			self.try_into()
140		}
141
142		#[cfg(any(feature = "std", feature = "chrono-wasm"))]
143		/// Returns the current date.
144		#[must_use]
145		#[inline]
146		pub fn today() -> Self {
147			Utc::now().naive_utc().date().into()
148		}
149	}
150
151	impl TryFrom<crate::Date> for chrono::NaiveDate {
152		type Error = DateError;
153
154		fn try_from(date: Date) -> Result<Self, Self::Error> {
155			if date.year == 0 || date.month == 0 || date.day == 0 {
156				return Err(DateError::ConversionError(
157					"Cannot convert Date with year=0, month=0, or day=0 to NaiveDate".to_string(),
158				));
159			}
160
161			validate_date(date.year, date.month, date.day)?;
162
163			// Safe castings after validation
164			Self::from_ymd_opt(
165				date.year,
166				date.month.cast_unsigned(),
167				date.day.cast_unsigned(),
168			)
169			.ok_or_else(|| {
170				DateError::ConversionError(format!(
171					"Invalid date components for NaiveDate: Y:{}, M:{}, D:{}",
172					date.year, date.month, date.day
173				))
174			})
175		}
176	}
177
178	impl From<chrono::NaiveDate> for Date {
179		#[inline]
180		fn from(naive_date: chrono::NaiveDate) -> Self {
181			use chrono::Datelike;
182			// Casting is safe due to chrono's costructor API
183			Self {
184				year: naive_date.year(),
185				month: naive_date.month().cast_signed(),
186				day: naive_date.day().cast_signed(),
187			}
188		}
189	}
190}
191
192const fn is_leap_year(year: i32) -> bool {
193	(year % 4 == 0) && ((year % 100 != 0) || (year % 400 == 0))
194}
195
196pub(crate) const fn days_in_month(month: i32, year: i32) -> i32 {
197	match month {
198		1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
199		4 | 6 | 9 | 11 => 30,
200		2 => {
201			// If year is 0, we assume it's a recurring date (like a birthday),
202			// so we must allow Feb 29th.
203			if year == 0 || is_leap_year(year) {
204				29
205			} else {
206				28
207			}
208		}
209		_ => 0,
210	}
211}
212
213fn validate_date(year: i32, month: i32, day: i32) -> Result<(), DateError> {
214	if !(0..=9999).contains(&year) {
215		return Err(DateError::InvalidYear(
216			"Invalid year value (must be within 0 and 9999)".to_string(),
217		));
218	}
219
220	if !(0..=12).contains(&month) {
221		return Err(DateError::InvalidMonth(
222			"Invalid month value (must be within 0 and 12)".to_string(),
223		));
224	}
225
226	if !(0..=31).contains(&day) {
227		return Err(DateError::InvalidDay(
228			"Invalid day value (must be within 0 and 31)".to_string(),
229		));
230	}
231
232	if year == 0 {
233		if month == 0 {
234			return Err(DateError::InvalidMonth(
235				"The month cannot be set to 0 if the year is also set to 0".to_string(),
236			));
237		}
238		if day == 0 {
239			return Err(DateError::InvalidDay(
240				"The day cannot be set to 0 if the year is also set to 0".to_string(),
241			));
242		}
243	} else if month == 0 {
244		if day != 0 {
245			return Err(DateError::InvalidMonth(
246				"The month cannot be 0 if the day is set".to_string(),
247			));
248		}
249		return Ok(());
250	}
251
252	if day != 0 {
253		let max_days = days_in_month(month, year);
254		if day > max_days {
255			return Err(DateError::InvalidDay(alloc::format!(
256				"Invalid day {day} for month {month} (max is {max_days} for year {year})"
257			)));
258		}
259	}
260
261	Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266	use super::*;
267
268	fn date(y: i32, m: i32, d: i32) -> Result<Date, DateError> {
269		Date::new(y, m, d)
270	}
271
272	#[test]
273	fn test_date_kinds_creation() {
274		// 1. Full Date
275		let full = date(2024, 1, 15).unwrap();
276		assert_eq!(full.kind(), DateKind::Full);
277		assert_eq!(full.to_string(), "2024-01-15");
278		assert!(full.is_valid());
279
280		// 2. Year Only
281		let year = date(2024, 0, 0).unwrap();
282		assert_eq!(year.kind(), DateKind::YearOnly);
283		assert_eq!(year.to_string(), "2024");
284		assert!(year.is_year_only());
285
286		// 3. Year and Month (Credit Card style)
287		let ym = date(2025, 12, 0).unwrap();
288		assert_eq!(ym.kind(), DateKind::YearAndMonth);
289		assert_eq!(ym.to_string(), "2025-12");
290		assert!(ym.is_year_and_month());
291
292		// 4. Month and Day (Birthday style)
293		let md = date(0, 5, 20).unwrap();
294		assert_eq!(md.kind(), DateKind::MonthAndDay);
295		assert_eq!(md.to_string(), "05-20");
296		assert!(md.is_month_and_day());
297	}
298
299	#[test]
300	fn test_validation_failures() {
301		// Bounds checks
302		assert!(matches!(date(-1, 1, 1), Err(DateError::InvalidYear(_))));
303		assert!(matches!(date(10000, 1, 1), Err(DateError::InvalidYear(_))));
304		assert!(matches!(date(2024, 13, 1), Err(DateError::InvalidMonth(_))));
305		assert!(matches!(date(2024, 1, 32), Err(DateError::InvalidDay(_))));
306
307		// Year=0, Month=0 -> Invalid
308		assert!(matches!(date(0, 0, 5), Err(DateError::InvalidMonth(_))));
309
310		// Year=0, Day=0 -> Invalid
311		assert!(matches!(date(0, 5, 0), Err(DateError::InvalidDay(_))));
312
313		// Year set, Month=0, Day set -> Invalid (Cannot have Day without Month)
314		assert!(matches!(date(2024, 0, 5), Err(DateError::InvalidMonth(_))));
315	}
316
317	#[test]
318	fn test_ordering() {
319		// Same Kind Comparison
320		let d1 = date(2024, 5, 10).unwrap();
321		let d2 = date(2024, 5, 11).unwrap();
322		let d3 = date(2025, 1, 1).unwrap();
323
324		assert!(d1 < d2);
325		assert!(d2 < d3);
326		assert!(d1 < d3);
327
328		// Year Only Comparison
329		let y1 = date(2023, 0, 0).unwrap();
330		let y2 = date(2024, 0, 0).unwrap();
331		assert!(y1 < y2);
332
333		// Different Kinds should return None (not comparable)
334		let full = date(2024, 1, 1).unwrap();
335		let year_only = date(2024, 0, 0).unwrap();
336		assert_eq!(full.partial_cmp(&year_only), None);
337
338		// Month-Day Comparison
339		let md1 = date(0, 2, 1).unwrap();
340		let md2 = date(0, 2, 2).unwrap();
341		assert!(md1 < md2);
342	}
343
344	#[test]
345	fn test_calendar_validation() {
346		// Standard Months
347		assert!(Date::new(2023, 1, 31).is_ok());
348		assert!(Date::new(2023, 4, 30).is_ok());
349		assert!(Date::new(2023, 4, 31).is_err()); // April has 30 days
350
351		// Non-Leap Year
352		assert!(Date::new(2023, 2, 28).is_ok());
353		assert!(Date::new(2023, 2, 29).is_err()); // 2023 is not leap
354
355		// Leap Year
356		assert!(Date::new(2024, 2, 29).is_ok());
357		assert!(Date::new(2024, 2, 30).is_err());
358
359		// Century Leap Year rules
360		assert!(Date::new(1900, 2, 29).is_err()); // 1900 not leap (div by 100)
361		assert!(Date::new(2000, 2, 29).is_ok()); // 2000 is leap (div by 400)
362	}
363
364	#[test]
365	fn test_special_zero_cases() {
366		// YearOnly (Year set, Month 0, Day 0) - Should be OK now
367		assert!(Date::new(2024, 0, 0).is_ok());
368
369		// YearAndMonth (Year set, Month set, Day 0) - OK
370		assert!(Date::new(2024, 2, 0).is_ok());
371
372		// Invalid: Year set, Month 0, Day set
373		assert!(Date::new(2024, 0, 5).is_err());
374
375		// Recurrent Date (Year 0) - Leap Day
376		// "Feb 29" without a year is a valid concept (e.g. "My birthday is Feb 29")
377		assert!(Date::new(0, 2, 29).is_ok());
378		assert!(Date::new(0, 2, 30).is_err());
379	}
380
381	#[cfg(feature = "chrono")]
382	mod chrono_tests {
383		use super::*;
384		use chrono::NaiveDate;
385
386		#[test]
387		fn test_to_naive_date() {
388			let d = date(2024, 2, 29).unwrap(); // Leap year
389			let naive = d.to_naive_date().unwrap();
390			assert_eq!(naive, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
391		}
392
393		#[test]
394		fn test_from_naive_date() {
395			let naive = NaiveDate::from_ymd_opt(2023, 10, 25).unwrap();
396			let d: Date = naive.into();
397			assert_eq!(d.year, 2023);
398			assert_eq!(d.month, 10);
399			assert_eq!(d.day, 25);
400			assert_eq!(d.kind(), DateKind::Full);
401		}
402	}
403}