use chrono::{Datelike, Duration, NaiveDateTime, Timelike};
use evalexpr::*;
use serde::{Deserialize, Serialize};
pub trait Calendar: Send + Sync {
fn format_date(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String;
fn format_time(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String;
fn format_datetime(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String;
fn get_date(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime) -> (i32, u32, u32);
fn get_time(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime) -> (u32, u32, u32);
fn seconds_per_day(&self) -> u32 {
86400
}
fn seconds_per_hour(&self) -> u32 {
3600
}
fn seconds_per_week(&self) -> u32 {
self.seconds_per_day() * 7
}
}
#[derive(Debug, Clone)]
pub struct GregorianCalendar;
impl Calendar for GregorianCalendar {
fn format_date(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let dt = start_datetime + Duration::milliseconds((elapsed_seconds * 1000.0) as i64);
let fmt = format.unwrap_or("%Y-%m-%d");
dt.format(fmt).to_string()
}
fn format_time(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let dt = start_datetime + Duration::milliseconds((elapsed_seconds * 1000.0) as i64);
let fmt = format.unwrap_or("%H:%M:%S");
dt.format(fmt).to_string()
}
fn format_datetime(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let dt = start_datetime + Duration::milliseconds((elapsed_seconds * 1000.0) as i64);
let fmt = format.unwrap_or("%Y-%m-%d %H:%M:%S");
dt.format(fmt).to_string()
}
fn get_date(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime) -> (i32, u32, u32) {
let dt = start_datetime + Duration::milliseconds((elapsed_seconds * 1000.0) as i64);
(dt.year(), dt.month(), dt.day())
}
fn get_time(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime) -> (u32, u32, u32) {
let dt = start_datetime + Duration::milliseconds((elapsed_seconds * 1000.0) as i64);
(dt.hour(), dt.minute(), dt.second())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Month {
pub name: String,
pub days: u32,
pub leap_days: u32,
}
impl Month {
pub fn new(name: impl Into<String>, days: u32, leap_days: u32) -> Self {
Self {
name: name.into(),
days,
leap_days,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Epoch {
pub name: String,
pub start_year: i64,
}
impl Epoch {
pub fn new(name: impl Into<String>, start_year: i64) -> Self {
Self {
name: name.into(),
start_year,
}
}
}
fn default_leap_years() -> String {
"false".to_string()
}
fn default_epoch() -> Epoch {
Epoch::new("Common Epoch", 1)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomCalendar {
pub minutes_per_hour: u32,
pub hours_per_day: u32,
pub months: Vec<Month>,
pub weekdays: Vec<String>,
#[serde(default = "default_leap_years")]
pub leap_years: String,
pub epoch: Epoch,
}
#[derive(Debug, Clone, Default)]
pub struct CustomCalendarBuilder {
minutes_per_hour: Option<u32>,
hours_per_day: Option<u32>,
months: Vec<Month>,
weekdays: Vec<String>,
leap_years: Option<String>,
epoch: Option<Epoch>,
}
impl CustomCalendarBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn minutes_per_hour(mut self, minutes: u32) -> Self {
self.minutes_per_hour = Some(minutes);
self
}
pub fn hours_per_day(mut self, hours: u32) -> Self {
self.hours_per_day = Some(hours);
self
}
pub fn month(mut self, month: Month) -> Self {
self.months.push(month);
self
}
pub fn months(mut self, months: Vec<Month>) -> Self {
self.months = months;
self
}
pub fn weekday(mut self, name: impl Into<String>) -> Self {
self.weekdays.push(name.into());
self
}
pub fn weekdays(mut self, names: Vec<String>) -> Self {
self.weekdays = names;
self
}
pub fn leap_years(mut self, expression: impl Into<String>) -> Self {
self.leap_years = Some(expression.into());
self
}
pub fn epoch(mut self, epoch: Epoch) -> Self {
self.epoch = Some(epoch);
self
}
pub fn build(self) -> CustomCalendar {
let minutes_per_hour = self.minutes_per_hour.unwrap_or(60);
let hours_per_day = self.hours_per_day.unwrap_or(24);
let leap_years = self.leap_years.unwrap_or_else(default_leap_years);
let epoch = self.epoch.unwrap_or_else(default_epoch);
assert!(!self.months.is_empty(), "Must have at least one month");
assert!(!self.weekdays.is_empty(), "Must have at least one weekday name");
CustomCalendar {
minutes_per_hour,
hours_per_day,
months: self.months,
weekdays: self.weekdays,
leap_years,
epoch,
}
}
}
impl CustomCalendar {
pub fn builder() -> CustomCalendarBuilder {
CustomCalendarBuilder::default()
}
fn days_per_year(&self) -> u32 {
self.months.iter().map(|m| m.days).sum()
}
pub fn is_leap_year(&self, year: i32) -> bool {
let expression = self.leap_years.replace("#", &year.to_string());
eval_boolean(&expression)
.unwrap_or(false)
}
fn seconds_per_minute(&self) -> u32 {
60 }
fn get_weekday(&self, elapsed_seconds: f64) -> String {
let total_days = (elapsed_seconds / self.seconds_per_day() as f64).floor() as i64;
let weekday_index = (total_days % self.weekdays.len() as i64) as usize;
self.weekdays[weekday_index].clone()
}
}
impl Calendar for CustomCalendar {
fn seconds_per_day(&self) -> u32 {
self.seconds_per_hour() * self.hours_per_day
}
fn seconds_per_hour(&self) -> u32 {
self.seconds_per_minute() * self.minutes_per_hour
}
fn seconds_per_week(&self) -> u32 {
self.seconds_per_day() * self.weekdays.len() as u32
}
fn get_date(&self, elapsed_seconds: f64, _start_datetime: NaiveDateTime) -> (i32, u32, u32) {
let total_days = (elapsed_seconds / self.seconds_per_day() as f64).floor() as i64;
let days_per_year = self.days_per_year() as i64;
let years_since_epoch = total_days / days_per_year;
let year = self.epoch.start_year + years_since_epoch;
let day_of_year = (total_days % days_per_year) as u32;
let is_leap_year = self.is_leap_year(year as i32);
let mut days_remaining = day_of_year;
let mut month = 1u32;
for (idx, month_def) in self.months.iter().enumerate() {
if is_leap_year {
if days_remaining < month_def.days + month_def.leap_days {
month = (idx + 1) as u32;
break;
}
days_remaining -= month_def.days + month_def.leap_days;
} else {
if days_remaining < month_def.days {
month = (idx + 1) as u32;
break;
}
days_remaining -= month_def.days;
}
}
let day = days_remaining + 1;
(year as i32, month, day)
}
fn get_time(&self, elapsed_seconds: f64, _start_datetime: NaiveDateTime) -> (u32, u32, u32) {
let seconds_per_day = self.seconds_per_day() as f64;
let seconds_today = elapsed_seconds % seconds_per_day;
let seconds_per_hour = self.seconds_per_hour() as f64;
let seconds_per_minute = self.seconds_per_minute() as f64;
let hour = (seconds_today / seconds_per_hour).floor() as u32;
let remaining = seconds_today % seconds_per_hour;
let minute = (remaining / seconds_per_minute).floor() as u32;
let second = (remaining % seconds_per_minute).floor() as u32;
(hour, minute, second)
}
fn format_date(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let (year, month, day) = self.get_date(elapsed_seconds, start_datetime);
let weekday = self.get_weekday(elapsed_seconds);
if let Some(fmt) = format {
fmt.replace("%Y", &year.to_string())
.replace("%m", &format!("{:02}", month))
.replace("%d", &format!("{:02}", day))
.replace("%B", &self.months[(month - 1) as usize].name)
.replace("%E", &self.epoch.name)
.replace("%A", &weekday)
} else {
format!("{:04}-{:02}-{:02}", year, month, day)
}
}
fn format_time(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let (hour, minute, second) = self.get_time(elapsed_seconds, start_datetime);
if let Some(fmt) = format {
fmt.replace("%H", &format!("{:02}", hour))
.replace("%M", &format!("{:02}", minute))
.replace("%S", &format!("{:02}", second))
} else {
format!("{:02}:{:02}:{:02}", hour, minute, second)
}
}
fn format_datetime(&self, elapsed_seconds: f64, start_datetime: NaiveDateTime, format: Option<&str>) -> String {
let date = self.format_date(elapsed_seconds, start_datetime, None);
let time = self.format_time(elapsed_seconds, start_datetime, None);
if let Some(fmt) = format {
let (year, month, day) = self.get_date(elapsed_seconds, start_datetime);
let (hour, minute, second) = self.get_time(elapsed_seconds, start_datetime);
let weekday = self.get_weekday(elapsed_seconds);
fmt.replace("%Y", &year.to_string())
.replace("%m", &format!("{:02}", month))
.replace("%d", &format!("{:02}", day))
.replace("%B", &self.months[(month - 1) as usize].name)
.replace("%E", &self.epoch.name)
.replace("%A", &weekday)
.replace("%H", &format!("{:02}", hour))
.replace("%M", &format!("{:02}", minute))
.replace("%S", &format!("{:02}", second))
} else {
format!("{} {}", date, time)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_custom_calendar_intervals() {
let custom_calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(20)
.month(Month::new("Month1", 20, 0))
.weekdays(vec![
"Day1".to_string(),
"Day2".to_string(),
"Day3".to_string(),
"Day4".to_string(),
"Day5".to_string(),
])
.leap_years("false")
.epoch(Epoch::new("Test Epoch", 0))
.build();
assert_eq!(custom_calendar.seconds_per_hour(), 3600); assert_eq!(custom_calendar.seconds_per_day(), 72000); assert_eq!(custom_calendar.seconds_per_week(), 360000); }
#[test]
fn test_custom_calendar_leap_years() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 2)) .month(Month::new("Month2", 30, 1)) .month(Month::new("Month3", 30, 0)) .weekdays(vec![
"Day1".to_string(), "Day2".to_string(), "Day3".to_string(),
"Day4".to_string(), "Day5".to_string(), "Day6".to_string(),
"Day7".to_string()
])
.leap_years("# % 2 == 0") .epoch(Epoch::new("Test Epoch", 1000))
.build();
assert!(calendar.is_leap_year(1000)); assert!(!calendar.is_leap_year(1001)); assert!(calendar.is_leap_year(1002)); assert!(!calendar.is_leap_year(1003));
assert!(calendar.is_leap_year(1004));
assert!(calendar.is_leap_year(0));
assert!(calendar.is_leap_year(-2));
assert!(!calendar.is_leap_year(-1));
}
#[test]
fn test_custom_calendar_no_leap_years() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 5)) .weekdays(vec![
"Day1".to_string(), "Day2".to_string(), "Day3".to_string(),
"Day4".to_string(), "Day5".to_string(), "Day6".to_string(),
"Day7".to_string()
])
.leap_years("false") .epoch(Epoch::new("No Leap Epoch", 0))
.build();
assert!(!calendar.is_leap_year(0));
assert!(!calendar.is_leap_year(4));
assert!(!calendar.is_leap_year(100));
assert!(!calendar.is_leap_year(1000));
}
#[test]
fn test_fantasy_calendar_date_calculation() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(20)
.hours_per_day(8)
.month(Month::new("Frostmoon", 20, 3))
.month(Month::new("Thawmoon", 21, 0))
.month(Month::new("Bloomtide", 19, 2))
.weekdays(vec!["Moonday".to_string(), "Fireday".to_string(), "Waterday".to_string(),
"Earthday".to_string(), "Starday".to_string()])
.leap_years("# % 2 == 0") .epoch(Epoch::new("Age of Magic", 1000))
.build();
let start_datetime = chrono::NaiveDateTime::new(
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
);
let (year, month, day) = calendar.get_date(0.0, start_datetime);
assert_eq!(year, 1000);
assert_eq!(month, 1);
assert_eq!(day, 1);
assert!(calendar.is_leap_year(1000));
let seconds_per_day = 8 * 20 * 60;
let elapsed = 23.0 * seconds_per_day as f64;
let (year, month, day) = calendar.get_date(elapsed, start_datetime);
assert_eq!(year, 1000);
assert_eq!(month, 2); assert_eq!(day, 1);
}
#[test]
fn test_expression_based_leap_year_gregorian() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Jan", 31, 0))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("# % 4 == 0 && (# % 100 != 0 || # % 400 == 0)")
.epoch(Epoch::new("CE", 0))
.build();
assert!(calendar.is_leap_year(2000)); assert!(!calendar.is_leap_year(1900)); assert!(calendar.is_leap_year(2004)); assert!(!calendar.is_leap_year(2001)); assert!(calendar.is_leap_year(2024)); assert!(!calendar.is_leap_year(2100)); assert!(calendar.is_leap_year(2400)); }
#[test]
fn test_expression_based_leap_year_custom() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 1))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("# % 5 == 0")
.epoch(Epoch::new("Test Epoch", 0))
.build();
assert!(calendar.is_leap_year(0));
assert!(!calendar.is_leap_year(1));
assert!(!calendar.is_leap_year(4));
assert!(calendar.is_leap_year(5));
assert!(calendar.is_leap_year(10));
assert!(!calendar.is_leap_year(13));
}
#[test]
fn test_expression_based_leap_year_complex() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 1))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("(# % 3 == 0 && # % 9 != 0) || # % 27 == 0")
.epoch(Epoch::new("Complex Epoch", 0))
.build();
assert!(calendar.is_leap_year(3)); assert!(calendar.is_leap_year(6)); assert!(!calendar.is_leap_year(9)); assert!(calendar.is_leap_year(12)); assert!(!calendar.is_leap_year(18)); assert!(calendar.is_leap_year(27)); assert!(calendar.is_leap_year(54)); }
#[test]
fn test_expression_invalid_returns_false() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 1))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("invalid expression here")
.epoch(Epoch::new("Test Epoch", 0))
.build();
assert!(!calendar.is_leap_year(2000));
assert!(!calendar.is_leap_year(2004));
}
#[test]
fn test_leap_year_simple_expression() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 1))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("# % 4 == 0")
.epoch(Epoch::new("Test Epoch", 0))
.build();
assert!(calendar.is_leap_year(0));
assert!(calendar.is_leap_year(4));
assert!(!calendar.is_leap_year(1));
}
#[test]
fn test_leap_year_serde_expression() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 1))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.leap_years("# % 3 == 0")
.epoch(Epoch::new("Test Epoch", 0))
.build();
assert!(calendar.is_leap_year(0));
assert!(calendar.is_leap_year(3));
assert!(!calendar.is_leap_year(1));
}
#[test]
fn test_custom_calendar_builder() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(20)
.hours_per_day(8)
.month(Month::new("Frostmoon", 20, 3))
.month(Month::new("Thawmoon", 21, 0))
.month(Month::new("Bloomtide", 19, 2))
.weekday("Moonday")
.weekday("Fireday")
.weekday("Waterday")
.weekday("Earthday")
.weekday("Starday")
.leap_years("# % 2 == 0")
.epoch(Epoch::new("Age of Magic", 1000))
.build();
assert_eq!(calendar.minutes_per_hour, 20);
assert_eq!(calendar.hours_per_day, 8);
assert_eq!(calendar.months.len(), 3);
assert_eq!(calendar.weekdays.len(), 5);
assert_eq!(calendar.leap_years, "# % 2 == 0");
assert_eq!(calendar.epoch.name, "Age of Magic");
assert_eq!(calendar.epoch.start_year, 1000);
}
#[test]
fn test_custom_calendar_builder_with_bulk_methods() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.months(vec![
Month::new("January", 31, 0),
Month::new("February", 28, 1),
])
.weekdays(vec![
"Monday".to_string(),
"Tuesday".to_string(),
"Wednesday".to_string(),
"Thursday".to_string(),
"Friday".to_string(),
"Saturday".to_string(),
"Sunday".to_string(),
])
.leap_years("# % 4 == 0 && (# % 100 != 0 || # % 400 == 0)")
.epoch(Epoch::new("Common Epoch", 1))
.build();
assert_eq!(calendar.months.len(), 2);
assert_eq!(calendar.weekdays.len(), 7);
}
#[test]
fn test_custom_calendar_builder_default_leap_years() {
let calendar = CustomCalendar::builder()
.minutes_per_hour(60)
.hours_per_day(24)
.month(Month::new("Month1", 30, 0))
.weekdays(vec![
"Mon".to_string(), "Tue".to_string(), "Wed".to_string(),
"Thu".to_string(), "Fri".to_string(), "Sat".to_string(), "Sun".to_string()
])
.epoch(Epoch::new("Test", 0))
.build();
assert_eq!(calendar.leap_years, "false");
assert!(!calendar.is_leap_year(2000));
assert!(!calendar.is_leap_year(1900));
assert!(!calendar.is_leap_year(2004));
}
#[test]
fn test_custom_calendar_builder_with_time_defaults() {
let calendar = CustomCalendar::builder()
.month(Month::new("Frostmoon", 30, 0))
.month(Month::new("Thawmoon", 31, 0))
.weekday("Moonday")
.weekday("Fireday")
.build();
assert_eq!(calendar.minutes_per_hour, 60);
assert_eq!(calendar.hours_per_day, 24);
assert_eq!(calendar.months.len(), 2);
assert_eq!(calendar.months[0].name, "Frostmoon");
assert_eq!(calendar.weekdays.len(), 2);
assert_eq!(calendar.weekdays[0], "Moonday");
assert_eq!(calendar.leap_years, "false");
}
#[test]
#[should_panic(expected = "Must have at least one month")]
fn test_custom_calendar_builder_no_months() {
CustomCalendar::builder()
.weekday("Monday")
.build();
}
#[test]
#[should_panic(expected = "Must have at least one weekday name")]
fn test_custom_calendar_builder_no_weekdays() {
CustomCalendar::builder()
.month(Month::new("Month1", 30, 0))
.build();
}
}