1use std::fmt;
2use std::rc::Rc;
3
4use rhai::{
5 Array, Dynamic, Engine, EvalAltResult, FuncRegistration, ImmutableString, Map, Position,
6};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct GregorianDate {
12 year: i32,
13 month: u8,
14 day: u8,
15}
16
17impl GregorianDate {
18 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DateError> {
25 if !(1..=9_999).contains(&year) {
26 return Err(DateError::Year(year));
27 }
28 if !(1..=12).contains(&month) {
29 return Err(DateError::Month(month));
30 }
31 let max = Self::days_in_month(year, month)?;
32 if !(1..=max).contains(&day) {
33 return Err(DateError::Day { year, month, day });
34 }
35 Ok(Self { year, month, day })
36 }
37
38 pub fn parse_iso(value: &str) -> Result<Self, DateError> {
44 let bytes = value.as_bytes();
45 if bytes.len() != 10
46 || bytes[4] != b'-'
47 || bytes[7] != b'-'
48 || !bytes
49 .iter()
50 .enumerate()
51 .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
52 {
53 return Err(DateError::Format(value.to_owned()));
54 }
55 let year = parse_ascii_number(&bytes[0..4]);
56 let month = parse_ascii_number(&bytes[5..7]);
57 let day = parse_ascii_number(&bytes[8..10]);
58 Self::new(
59 i32::from(year),
60 u8::try_from(month).unwrap_or(u8::MAX),
61 u8::try_from(day).unwrap_or(u8::MAX),
62 )
63 }
64
65 #[must_use]
66 pub const fn year(self) -> i32 {
67 self.year
68 }
69
70 #[must_use]
71 pub const fn month(self) -> u8 {
72 self.month
73 }
74
75 #[must_use]
76 pub const fn day(self) -> u8 {
77 self.day
78 }
79
80 #[must_use]
81 pub const fn is_leap_year(year: i32) -> bool {
82 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
83 }
84
85 pub fn days_in_month(year: i32, month: u8) -> Result<u8, DateError> {
91 if !(1..=9_999).contains(&year) {
92 return Err(DateError::Year(year));
93 }
94 let days = match month {
95 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
96 4 | 6 | 9 | 11 => 30,
97 2 if Self::is_leap_year(year) => 29,
98 2 => 28,
99 _ => return Err(DateError::Month(month)),
100 };
101 Ok(days)
102 }
103
104 #[must_use]
105 pub fn weekday(self) -> Weekday {
106 Weekday::from_sunday_index(days_from_civil(self.year, self.month, self.day) + 4)
107 }
108
109 pub fn checked_add_days(self, days: i64) -> Result<Self, DateError> {
116 let serial = days_from_civil(self.year, self.month, self.day)
117 .checked_add(days)
118 .ok_or(DateError::ArithmeticRange)?;
119 civil_from_days(serial).ok_or(DateError::ArithmeticRange)
120 }
121
122 pub fn checked_add_months(self, months: i32) -> Result<Self, DateError> {
129 let index = self
130 .year
131 .checked_mul(12)
132 .and_then(|value| value.checked_add(i32::from(self.month) - 1))
133 .and_then(|value| value.checked_add(months))
134 .ok_or(DateError::ArithmeticRange)?;
135 let year = index.div_euclid(12);
136 let month =
137 u8::try_from(index.rem_euclid(12) + 1).map_err(|_| DateError::ArithmeticRange)?;
138 if !(1..=9_999).contains(&year) {
139 return Err(DateError::ArithmeticRange);
140 }
141 let day = self.day.min(Self::days_in_month(year, month)?);
142 Self::new(year, month, day)
143 }
144
145 #[must_use]
146 pub fn to_iso(self) -> String {
147 format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
148 }
149}
150
151impl fmt::Display for GregorianDate {
152 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153 formatter.write_str(&self.to_iso())
154 }
155}
156
157fn parse_ascii_number(bytes: &[u8]) -> u16 {
158 bytes.iter().fold(0_u16, |value, byte| {
159 value
160 .saturating_mul(10)
161 .saturating_add(u16::from(*byte - b'0'))
162 })
163}
164
165fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
167 let mut year = i64::from(year);
168 let month = i64::from(month);
169 year -= i64::from(month <= 2);
170 let era = year.div_euclid(400);
171 let year_of_era = year - era * 400;
172 let adjusted_month = month + if month > 2 { -3 } else { 9 };
173 let day_of_year = (153 * adjusted_month + 2) / 5 + i64::from(day) - 1;
174 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
175 era * 146_097 + day_of_era - 719_468
176}
177
178fn civil_from_days(days: i64) -> Option<GregorianDate> {
179 let days = days.checked_add(719_468)?;
180 let era = days.div_euclid(146_097);
181 let day_of_era = days - era * 146_097;
182 let year_of_era =
183 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
184 let mut year = year_of_era + era * 400;
185 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
186 let month_prime = (5 * day_of_year + 2) / 153;
187 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
188 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
189 year += i64::from(month <= 2);
190 GregorianDate::new(
191 i32::try_from(year).ok()?,
192 u8::try_from(month).ok()?,
193 u8::try_from(day).ok()?,
194 )
195 .ok()
196}
197
198#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum Weekday {
201 Sunday,
202 Monday,
203 Tuesday,
204 Wednesday,
205 Thursday,
206 Friday,
207 Saturday,
208}
209
210impl Weekday {
211 #[must_use]
212 pub const fn sunday_index(self) -> usize {
213 match self {
214 Self::Sunday => 0,
215 Self::Monday => 1,
216 Self::Tuesday => 2,
217 Self::Wednesday => 3,
218 Self::Thursday => 4,
219 Self::Friday => 5,
220 Self::Saturday => 6,
221 }
222 }
223
224 #[must_use]
225 pub fn from_sunday_index(index: i64) -> Self {
226 match index.rem_euclid(7) {
227 0 => Self::Sunday,
228 1 => Self::Monday,
229 2 => Self::Tuesday,
230 3 => Self::Wednesday,
231 4 => Self::Thursday,
232 5 => Self::Friday,
233 _ => Self::Saturday,
234 }
235 }
236
237 #[must_use]
238 pub const fn as_str(self) -> &'static str {
239 match self {
240 Self::Sunday => "sunday",
241 Self::Monday => "monday",
242 Self::Tuesday => "tuesday",
243 Self::Wednesday => "wednesday",
244 Self::Thursday => "thursday",
245 Self::Friday => "friday",
246 Self::Saturday => "saturday",
247 }
248 }
249
250 pub fn parse(value: &str) -> Result<Self, DateError> {
256 match value {
257 "sunday" => Ok(Self::Sunday),
258 "monday" => Ok(Self::Monday),
259 "tuesday" => Ok(Self::Tuesday),
260 "wednesday" => Ok(Self::Wednesday),
261 "thursday" => Ok(Self::Thursday),
262 "friday" => Ok(Self::Friday),
263 "saturday" => Ok(Self::Saturday),
264 _ => Err(DateError::Weekday(value.to_owned())),
265 }
266 }
267}
268
269pub(crate) fn register_date_api(engine: &mut Engine) {
270 FuncRegistration::new("date_info")
271 .in_global_namespace()
272 .register_into_engine(engine, date_info);
273 FuncRegistration::new("date_month_start")
274 .in_global_namespace()
275 .register_into_engine(engine, date_month_start);
276 FuncRegistration::new("date_checked_add_days")
277 .in_global_namespace()
278 .register_into_engine(engine, date_checked_add_days);
279 FuncRegistration::new("date_checked_add_months")
280 .in_global_namespace()
281 .register_into_engine(engine, date_checked_add_months);
282 FuncRegistration::new("date_week_edge")
283 .in_global_namespace()
284 .register_into_engine(engine, date_week_edge);
285 FuncRegistration::new("date_month_grid")
286 .in_global_namespace()
287 .register_into_engine(engine, date_month_grid);
288 FuncRegistration::new("date_clamp")
289 .in_global_namespace()
290 .register_into_engine(engine, date_clamp);
291 FuncRegistration::new("date_month_intersects")
292 .in_global_namespace()
293 .register_into_engine(engine, date_month_intersects);
294}
295
296fn date_info(value: ImmutableString) -> Result<Map, Box<EvalAltResult>> {
297 let value: String = value.into();
298 let date = parse_script_date(&value)?;
299 Ok(Map::from_iter([
300 ("iso".into(), Dynamic::from(date.to_iso())),
301 ("year".into(), Dynamic::from_int(i64::from(date.year()))),
302 ("month".into(), Dynamic::from_int(i64::from(date.month()))),
303 ("day".into(), Dynamic::from_int(i64::from(date.day()))),
304 (
305 "weekday".into(),
306 Dynamic::from(date.weekday().as_str().to_owned()),
307 ),
308 ]))
309}
310
311fn date_month_start(value: ImmutableString) -> Result<ImmutableString, Box<EvalAltResult>> {
312 let value: String = value.into();
313 let date = parse_script_date(&value)?;
314 Ok(GregorianDate::new(date.year(), date.month(), 1)
315 .expect("parsed date has a valid month")
316 .to_iso()
317 .into())
318}
319
320fn date_checked_add_days(
321 value: ImmutableString,
322 days: rhai::INT,
323) -> Result<Dynamic, Box<EvalAltResult>> {
324 let value: String = value.into();
325 let date = parse_script_date(&value)?;
326 Ok(date
327 .checked_add_days(days)
328 .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
329}
330
331fn date_checked_add_months(
332 value: ImmutableString,
333 months: rhai::INT,
334) -> Result<Dynamic, Box<EvalAltResult>> {
335 let value: String = value.into();
336 let date = parse_script_date(&value)?;
337 let months = i32::try_from(months)
338 .map_err(|_| Box::new(date_script_error(&DateError::ArithmeticRange)))?;
339 Ok(date
340 .checked_add_months(months)
341 .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
342}
343
344fn date_week_edge(
345 value: ImmutableString,
346 first_weekday: ImmutableString,
347 end: bool,
348) -> Result<Dynamic, Box<EvalAltResult>> {
349 let value: String = value.into();
350 let first_weekday: String = first_weekday.into();
351 let date = parse_script_date(&value)?;
352 let first =
353 Weekday::parse(&first_weekday).map_err(|error| Box::new(date_script_error(&error)))?;
354 let weekday = date.weekday().sunday_index();
355 let from_start = (weekday + 7 - first.sunday_index()) % 7;
356 let delta = if end {
357 i64::try_from(6 - from_start).unwrap_or(0)
358 } else {
359 -i64::try_from(from_start).unwrap_or(0)
360 };
361 Ok(date
362 .checked_add_days(delta)
363 .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
364}
365
366fn date_month_grid(
367 value: ImmutableString,
368 first_weekday: ImmutableString,
369) -> Result<Array, Box<EvalAltResult>> {
370 let value: String = value.into();
371 let first_weekday: String = first_weekday.into();
372 let date = parse_script_date(&value)?;
373 let month =
374 GregorianDate::new(date.year(), date.month(), 1).expect("parsed date has a valid month");
375 let first =
376 Weekday::parse(&first_weekday).map_err(|error| Box::new(date_script_error(&error)))?;
377 let leading = (month.weekday().sunday_index() + 7 - first.sunday_index()) % 7;
378 Ok((0_i64..42)
379 .map(|offset| {
380 let date = month.checked_add_days(offset - i64::try_from(leading).unwrap_or(0));
381 let (iso, day, outside, weekday) = date.map_or_else(
382 |_| (Dynamic::UNIT, Dynamic::UNIT, true, Dynamic::UNIT),
383 |date| {
384 (
385 Dynamic::from(date.to_iso()),
386 Dynamic::from_int(i64::from(date.day())),
387 date.year() != month.year() || date.month() != month.month(),
388 Dynamic::from(date.weekday().as_str().to_owned()),
389 )
390 },
391 );
392 Dynamic::from_map(Map::from_iter([
393 ("date".into(), iso),
394 ("day".into(), day),
395 ("outside".into(), Dynamic::from_bool(outside)),
396 ("weekday".into(), weekday),
397 ]))
398 })
399 .collect())
400}
401
402fn date_clamp(
403 value: ImmutableString,
404 min: Dynamic,
405 max: Dynamic,
406) -> Result<ImmutableString, Box<EvalAltResult>> {
407 let value: String = value.into();
408 let date = parse_script_date(&value)?;
409 let min = optional_script_date(min, "min")?;
410 let max = optional_script_date(max, "max")?;
411 validate_script_range(min, max)?;
412 Ok(min
413 .filter(|min| date < *min)
414 .or_else(|| max.filter(|max| date > *max))
415 .unwrap_or(date)
416 .to_iso()
417 .into())
418}
419
420fn date_month_intersects(
421 value: ImmutableString,
422 min: Dynamic,
423 max: Dynamic,
424) -> Result<bool, Box<EvalAltResult>> {
425 let value: String = value.into();
426 let date = parse_script_date(&value)?;
427 let first =
428 GregorianDate::new(date.year(), date.month(), 1).expect("parsed date has a valid month");
429 let last = GregorianDate::new(
430 date.year(),
431 date.month(),
432 GregorianDate::days_in_month(date.year(), date.month()).expect("parsed valid month"),
433 )
434 .expect("last day is valid");
435 let min = optional_script_date(min, "min")?;
436 let max = optional_script_date(max, "max")?;
437 validate_script_range(min, max)?;
438 Ok(min.is_none_or(|min| last >= min) && max.is_none_or(|max| first <= max))
439}
440
441fn parse_script_date(value: &str) -> Result<GregorianDate, Box<EvalAltResult>> {
442 GregorianDate::parse_iso(value).map_err(|error| Box::new(date_script_error(&error)))
443}
444
445fn optional_script_date(
446 value: Dynamic,
447 name: &str,
448) -> Result<Option<GregorianDate>, Box<EvalAltResult>> {
449 if value.is_unit() {
450 return Ok(None);
451 }
452 value
453 .try_cast::<ImmutableString>()
454 .ok_or_else(|| {
455 Box::new(date_runtime_error(format!(
456 "date range `{name}` must be a string or ()"
457 )))
458 })
459 .and_then(|value| parse_script_date(value.as_str()).map(Some))
460}
461
462fn validate_script_range(
463 min: Option<GregorianDate>,
464 max: Option<GregorianDate>,
465) -> Result<(), Box<EvalAltResult>> {
466 if min.zip(max).is_some_and(|(min, max)| min > max) {
467 Err(Box::new(date_runtime_error(
468 "date range min cannot be after max",
469 )))
470 } else {
471 Ok(())
472 }
473}
474
475fn date_script_error(error: &DateError) -> EvalAltResult {
476 date_runtime_error(error.to_string())
477}
478
479fn date_runtime_error(message: impl Into<String>) -> EvalAltResult {
480 EvalAltResult::ErrorRuntime(message.into().into(), Position::NONE)
481}
482
483pub trait CalendarClockSource: fmt::Debug {
484 fn today(&self) -> GregorianDate;
485}
486
487#[derive(Clone)]
488pub struct CalendarClock(Rc<dyn CalendarClockSource>);
489
490impl CalendarClock {
491 #[must_use]
492 pub fn system() -> Self {
493 Self(Rc::new(SystemCalendarClock))
494 }
495
496 #[must_use]
497 pub fn fixed(date: GregorianDate) -> Self {
498 Self(Rc::new(FixedCalendarClock(date)))
499 }
500
501 #[must_use]
502 pub fn from_source(source: impl CalendarClockSource + 'static) -> Self {
503 Self(Rc::new(source))
504 }
505
506 #[must_use]
507 pub fn today(&self) -> GregorianDate {
508 self.0.today()
509 }
510}
511
512impl Default for CalendarClock {
513 fn default() -> Self {
514 Self::system()
515 }
516}
517
518impl fmt::Debug for CalendarClock {
519 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
520 formatter
521 .debug_tuple("CalendarClock")
522 .field(&self.0)
523 .finish()
524 }
525}
526
527#[derive(Clone, Copy, Debug, Default)]
528struct SystemCalendarClock;
529
530impl CalendarClockSource for SystemCalendarClock {
531 fn today(&self) -> GregorianDate {
532 let date = jiff::Zoned::now().date();
533 GregorianDate::new(
534 i32::from(date.year()),
535 u8::try_from(date.month()).expect("Jiff months fit u8"),
536 u8::try_from(date.day()).expect("Jiff days fit u8"),
537 )
538 .expect("Jiff system dates fit the supported Gregorian range")
539 }
540}
541
542#[derive(Clone, Copy, Debug)]
543struct FixedCalendarClock(GregorianDate);
544
545impl CalendarClockSource for FixedCalendarClock {
546 fn today(&self) -> GregorianDate {
547 self.0
548 }
549}
550
551#[derive(Clone, Debug, Error, Eq, PartialEq)]
552pub enum DateError {
553 #[error("date `{0}` must use strict YYYY-MM-DD format")]
554 Format(String),
555 #[error("Gregorian year {0} must be between 1 and 9999")]
556 Year(i32),
557 #[error("Gregorian month {0} must be between 1 and 12")]
558 Month(u8),
559 #[error("day {day} is invalid for {year:04}-{month:02}")]
560 Day { year: i32, month: u8, day: u8 },
561 #[error("date arithmetic left the supported years 0001 through 9999")]
562 ArithmeticRange,
563 #[error("weekday `{0}` is not in the stable lowercase weekday vocabulary")]
564 Weekday(String),
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn strict_iso_and_leap_year_validation() {
573 assert_eq!(
574 GregorianDate::parse_iso("2024-02-29").unwrap().to_iso(),
575 "2024-02-29"
576 );
577 assert!(GregorianDate::parse_iso("2023-02-29").is_err());
578 assert!(GregorianDate::parse_iso("2024-2-29").is_err());
579 assert!(GregorianDate::parse_iso("0000-01-01").is_err());
580 assert!(GregorianDate::parse_iso("10000-01-01").is_err());
581 }
582
583 #[test]
584 fn arithmetic_crosses_month_year_and_century_boundaries() {
585 assert_eq!(
586 GregorianDate::parse_iso("2024-12-31")
587 .unwrap()
588 .checked_add_days(1)
589 .unwrap()
590 .to_iso(),
591 "2025-01-01"
592 );
593 assert_eq!(
594 GregorianDate::parse_iso("2024-01-31")
595 .unwrap()
596 .checked_add_months(1)
597 .unwrap()
598 .to_iso(),
599 "2024-02-29"
600 );
601 assert_eq!(
602 GregorianDate::parse_iso("2100-01-31")
603 .unwrap()
604 .checked_add_months(1)
605 .unwrap()
606 .to_iso(),
607 "2100-02-28"
608 );
609 let epoch = GregorianDate::parse_iso("1970-01-01").unwrap();
610 assert_eq!(
611 epoch.checked_add_days(i64::MAX),
612 Err(DateError::ArithmeticRange)
613 );
614 assert_eq!(
615 epoch.checked_add_days(i64::MIN),
616 Err(DateError::ArithmeticRange)
617 );
618 }
619
620 #[test]
621 fn weekdays_match_known_dates() {
622 assert_eq!(
623 GregorianDate::parse_iso("1970-01-01").unwrap().weekday(),
624 Weekday::Thursday
625 );
626 assert_eq!(
627 GregorianDate::parse_iso("2024-02-29").unwrap().weekday(),
628 Weekday::Thursday
629 );
630 }
631
632 #[test]
633 fn fixed_clock_is_deterministic() {
634 let date = GregorianDate::parse_iso("2026-08-29").unwrap();
635 assert_eq!(CalendarClock::fixed(date).today(), date);
636 }
637
638 #[test]
639 fn rhai_date_data_api_is_checked_and_month_grid_is_stable() {
640 let runtime = crate::RuntimeEngine::new();
641 let info = runtime
642 .engine()
643 .eval::<Map>(r#"date_info("2024-02-29")"#)
644 .unwrap();
645 assert_eq!(info["weekday"].clone_cast::<ImmutableString>(), "thursday");
646 let grid = runtime
647 .engine()
648 .eval::<Array>(r#"date_month_grid("2024-02-15", "sunday")"#)
649 .unwrap();
650 assert_eq!(grid.len(), 42);
651 let first = grid[0].clone_cast::<Map>();
652 assert_eq!(first["date"].clone_cast::<ImmutableString>(), "2024-01-28");
653 assert!(first["outside"].clone_cast::<bool>());
654 assert!(
655 runtime
656 .engine()
657 .eval::<Dynamic>(r#"date_checked_add_days("9999-12-31", 1)"#)
658 .unwrap()
659 .is_unit()
660 );
661 assert!(
662 runtime
663 .engine()
664 .eval::<bool>(r#"date_month_intersects("2024-02-01", "2024-02-10", "2024-02-20")"#)
665 .unwrap()
666 );
667 assert!(
668 runtime
669 .engine()
670 .eval::<Map>(r#"date_info("2023-02-29")"#)
671 .is_err()
672 );
673 }
674}