use crate::error::{EventixError, Result};
use crate::recurrence::{Recurrence, RecurrenceFilter};
use crate::timezone::{local_day_window, parse_datetime_with_tz, parse_timezone};
use chrono::{DateTime, Duration};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
pub enum EventStatus {
#[default]
Confirmed,
Tentative,
Cancelled,
Blocked,
}
#[derive(Debug, Clone)]
pub struct Event {
pub title: String,
pub description: Option<String>,
pub start_time: DateTime<Tz>,
pub end_time: DateTime<Tz>,
pub timezone: Tz,
pub attendees: Vec<String>,
pub recurrence: Option<Recurrence>,
pub recurrence_filter: Option<RecurrenceFilter>,
pub exdates: Vec<DateTime<Tz>>,
pub location: Option<String>,
pub uid: Option<String>,
pub status: EventStatus,
}
impl Event {
pub fn builder() -> EventBuilder {
EventBuilder::new()
}
pub fn occurrences_between(
&self,
start: DateTime<Tz>,
end: DateTime<Tz>,
max_occurrences: usize,
) -> Result<Vec<DateTime<Tz>>> {
if start > end {
return Err(crate::error::EventixError::ValidationError(
"Start time must be before or equal to end time".to_string(),
));
}
if max_occurrences == 0 {
return Ok(vec![]);
}
if let Some(ref recurrence) = self.recurrence {
let duration = self.duration();
let occurrences: Vec<DateTime<Tz>> = recurrence
.occurrences(self.start_time)
.take_while(|dt| *dt < end)
.filter(|dt| *dt + duration > start)
.filter(|dt| !self.is_occurrence_excluded(dt))
.take(max_occurrences)
.collect();
Ok(occurrences)
} else {
let event_end = self.end_time;
if self.start_time < end && event_end > start {
Ok(vec![self.start_time])
} else {
Ok(vec![])
}
}
}
fn is_occurrence_excluded(&self, dt: &DateTime<Tz>) -> bool {
if let Some(ref filter) = self.recurrence_filter {
if filter.should_skip(dt) {
return true;
}
}
self.exdates.contains(dt)
}
pub fn occurs_on(&self, date: DateTime<Tz>) -> Result<bool> {
let (start_dt, end_dt) = local_day_window(date.date_naive(), self.timezone)?;
let occurrences = self.occurrences_between(start_dt, end_dt, 1)?;
Ok(!occurrences.is_empty())
}
pub fn duration(&self) -> Duration {
self.end_time.signed_duration_since(self.start_time)
}
pub fn is_active(&self) -> bool {
matches!(
self.status,
EventStatus::Confirmed | EventStatus::Tentative | EventStatus::Blocked
)
}
pub fn confirm(&mut self) {
self.status = EventStatus::Confirmed;
}
pub fn cancel(&mut self) {
self.status = EventStatus::Cancelled;
}
pub fn tentative(&mut self) {
self.status = EventStatus::Tentative;
}
pub fn block(&mut self) {
self.status = EventStatus::Blocked;
}
pub fn reschedule(&mut self, new_start: DateTime<Tz>, new_end: DateTime<Tz>) -> Result<()> {
if new_end <= new_start {
return Err(EventixError::ValidationError(
"Event end time must be after start time".to_string(),
));
}
self.start_time = new_start;
self.end_time = new_end;
self.timezone = new_start.timezone();
if self.status == EventStatus::Cancelled {
self.status = EventStatus::Confirmed;
}
Ok(())
}
}
pub struct EventBuilder {
title: Option<String>,
description: Option<String>,
start_time: Option<DateTime<Tz>>,
end_time: Option<DateTime<Tz>>,
timezone: Option<Tz>,
attendees: Vec<String>,
recurrence: Option<Recurrence>,
recurrence_filter: Option<RecurrenceFilter>,
exdates: Vec<DateTime<Tz>>,
location: Option<String>,
uid: Option<String>,
status: EventStatus,
parse_error: Option<EventixError>,
}
impl EventBuilder {
pub fn new() -> Self {
Self {
title: None,
description: None,
start_time: None,
end_time: None,
timezone: None,
attendees: Vec::new(),
recurrence: None,
recurrence_filter: None,
exdates: Vec::new(),
location: None,
uid: None,
status: EventStatus::default(),
parse_error: None,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn start(mut self, datetime: &str, timezone: &str) -> Self {
match parse_timezone(timezone) {
Ok(tz) => {
self.timezone = Some(tz);
match parse_datetime_with_tz(datetime, tz) {
Ok(dt) => self.start_time = Some(dt),
Err(e) => {
if self.parse_error.is_none() {
self.parse_error = Some(e);
}
}
}
}
Err(e) => {
if self.parse_error.is_none() {
self.parse_error = Some(e);
}
}
}
self
}
pub fn start_datetime(mut self, datetime: DateTime<Tz>) -> Self {
self.timezone = Some(datetime.timezone());
self.start_time = Some(datetime);
self
}
pub fn end(mut self, datetime: &str) -> Self {
if let Some(tz) = self.timezone {
match parse_datetime_with_tz(datetime, tz) {
Ok(dt) => self.end_time = Some(dt),
Err(e) => {
if self.parse_error.is_none() {
self.parse_error = Some(e);
}
}
}
} else if self.parse_error.is_none() {
self.parse_error = Some(EventixError::ValidationError(
"Cannot set end time: start() with timezone must be called first".to_string(),
));
}
self
}
pub fn end_datetime(mut self, datetime: DateTime<Tz>) -> Self {
self.end_time = Some(datetime);
self
}
pub fn duration_hours(mut self, hours: i64) -> Self {
if let Some(start) = self.start_time {
self.end_time = Some(start + Duration::hours(hours));
}
self
}
pub fn duration_minutes(mut self, minutes: i64) -> Self {
if let Some(start) = self.start_time {
self.end_time = Some(start + Duration::minutes(minutes));
}
self
}
pub fn duration(mut self, duration: Duration) -> Self {
if let Some(start) = self.start_time {
self.end_time = Some(start + duration);
}
self
}
pub fn attendee(mut self, attendee: impl Into<String>) -> Self {
self.attendees.push(attendee.into());
self
}
pub fn attendees(mut self, attendees: Vec<String>) -> Self {
self.attendees = attendees;
self
}
pub fn recurrence(mut self, recurrence: Recurrence) -> Self {
self.recurrence = Some(recurrence);
self
}
pub fn skip_weekends(mut self, skip: bool) -> Self {
let filter = self.recurrence_filter.unwrap_or_default();
self.recurrence_filter = Some(filter.skip_weekends(skip));
self
}
pub fn exception_dates(mut self, dates: Vec<DateTime<Tz>>) -> Self {
self.exdates = dates;
self
}
pub fn exception_date(mut self, date: DateTime<Tz>) -> Self {
self.exdates.push(date);
self
}
pub fn location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
pub fn uid(mut self, uid: impl Into<String>) -> Self {
self.uid = Some(uid.into());
self
}
pub fn status(mut self, status: EventStatus) -> Self {
self.status = status;
self
}
pub fn build(self) -> Result<Event> {
if let Some(err) = self.parse_error {
return Err(err);
}
let title = self
.title
.ok_or_else(|| EventixError::ValidationError("Event title is required".to_string()))?;
let start_time = self.start_time.ok_or_else(|| {
EventixError::ValidationError("Event start time is required".to_string())
})?;
let end_time = self.end_time.ok_or_else(|| {
EventixError::ValidationError("Event end time is required".to_string())
})?;
let timezone = self.timezone.unwrap_or_else(|| start_time.timezone());
if end_time <= start_time {
return Err(EventixError::ValidationError(
"Event end time must be after start time".to_string(),
));
}
Ok(Event {
title,
description: self.description,
start_time,
end_time,
timezone,
attendees: self.attendees,
recurrence: self.recurrence,
recurrence_filter: self.recurrence_filter,
exdates: self.exdates,
location: self.location,
uid: self.uid,
status: self.status,
})
}
}
impl Default for EventBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_event_builder() {
let event = Event::builder()
.title("Test Event")
.description("A test event")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(2)
.attendee("alice@example.com")
.build()
.unwrap();
assert_eq!(event.title, "Test Event");
assert_eq!(event.description, Some("A test event".to_string()));
assert_eq!(event.attendees.len(), 1);
assert_eq!(event.duration(), Duration::hours(2));
}
#[test]
fn test_event_builder_duration() {
let event = Event::builder()
.title("Test Event")
.description("A test event")
.start("2025-11-01 10:00:00", "UTC")
.duration(Duration::hours(1) + Duration::minutes(10))
.attendee("alice@example.com")
.build()
.unwrap();
assert_eq!(event.title, "Test Event");
assert_eq!(event.description, Some("A test event".to_string()));
assert_eq!(event.attendees.len(), 1);
assert_eq!(event.end_time.to_rfc3339(), "2025-11-01T11:10:00+00:00");
let duration_in_secs = (60.0 * 60.0) + (10.0 * 60.0); assert_eq!(event.duration().as_seconds_f32(), duration_in_secs);
}
#[test]
fn test_event_validation() {
let result = Event::builder().start("2025-11-01 10:00:00", "UTC").duration_hours(1).build();
assert!(result.is_err());
let result = Event::builder()
.title("Test")
.start("2025-11-01 10:00:00", "UTC")
.end("2025-11-01 09:00:00")
.build();
assert!(result.is_err());
let result = Event::builder()
.title("Zero")
.start("2025-11-01 10:00:00", "UTC")
.duration_minutes(0)
.build();
assert!(result.is_err());
}
#[test]
fn test_occurrences_between_filter_before_cap() {
use crate::timezone::parse_timezone;
use crate::Recurrence;
use chrono::Datelike;
let tz = parse_timezone("UTC").unwrap();
let event = Event::builder()
.title("Daily standup")
.start("2025-01-03 09:00:00", "UTC") .duration_hours(1)
.recurrence(Recurrence::daily().count(30))
.skip_weekends(true)
.build()
.unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-03 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-01-15 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 3).unwrap();
assert_eq!(occs.len(), 3);
for occ in &occs {
let wd = occ.weekday();
assert!(wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun);
}
}
#[test]
fn test_dense_secondly_does_not_over_allocate() {
use crate::timezone::parse_timezone;
use crate::Recurrence;
let tz = parse_timezone("UTC").unwrap();
let event = Event::builder()
.title("Tick")
.start("2025-06-01 00:00:00", "UTC")
.duration(Duration::seconds(1))
.recurrence(Recurrence::secondly().interval(1).count(100_000))
.build()
.unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-06-02 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 10).unwrap();
assert_eq!(occs.len(), 10);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], Duration::seconds(1));
}
}
#[test]
fn test_dense_minutely_capped_early() {
use crate::timezone::parse_timezone;
use crate::Recurrence;
let tz = parse_timezone("UTC").unwrap();
let event = Event::builder()
.title("Ping")
.start("2025-06-01 00:00:00", "UTC")
.duration(Duration::seconds(10))
.recurrence(Recurrence::minutely().interval(1).count(100_000))
.build()
.unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-07-01 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 5).unwrap();
assert_eq!(occs.len(), 5);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], Duration::minutes(1));
}
}
#[test]
fn test_dense_hourly_with_weekend_filter() {
use crate::timezone::parse_timezone;
use crate::Recurrence;
use chrono::Datelike;
let tz = parse_timezone("UTC").unwrap();
let event = Event::builder()
.title("Hourly Check")
.start("2025-01-06 08:00:00", "UTC") .duration_minutes(5)
.recurrence(Recurrence::hourly().interval(1).count(100_000))
.skip_weekends(true)
.build()
.unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2026-01-01 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 20).unwrap();
assert_eq!(occs.len(), 20);
for occ in &occs {
let wd = occ.weekday();
assert!(wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun);
}
}
#[test]
fn test_occurrences_between_zero_cap_returns_empty() {
let event = Event::builder()
.title("One-off")
.start("2025-01-10 09:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-10 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-01-11 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 0).unwrap();
assert!(occs.is_empty());
}
#[test]
fn test_builder_surfaces_invalid_timezone() {
let result = Event::builder()
.title("Bad TZ")
.start("2025-01-01 10:00:00", "Invalid/Zone")
.duration_hours(1)
.build();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(!err.contains("required"));
}
#[test]
fn test_builder_surfaces_invalid_datetime() {
let result = Event::builder()
.title("Bad DT")
.start("not-a-date", "UTC")
.duration_hours(1)
.build();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(!err.contains("required"));
}
#[test]
fn test_exdate_precision_subdaily() {
use crate::timezone::parse_timezone;
use crate::Recurrence;
use chrono::Timelike;
let tz = parse_timezone("UTC").unwrap();
let exdate = crate::timezone::parse_datetime_with_tz("2025-06-01 12:00:00", tz).unwrap();
let event = Event::builder()
.title("Hourly")
.start("2025-06-01 10:00:00", "UTC")
.duration_minutes(5)
.recurrence(Recurrence::hourly().count(5))
.exception_date(exdate)
.build()
.unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-06-02 00:00:00", tz).unwrap();
let occs = event.occurrences_between(start, end, 100).unwrap();
assert_eq!(occs.len(), 4);
for occ in &occs {
assert_ne!(occ.hour(), 12, "12:00 should be excluded");
}
}
#[test]
fn test_occurs_on_true_for_matching_day() {
let event = Event::builder()
.title("Meeting")
.start("2025-11-03 10:00:00", "America/New_York")
.duration_hours(1)
.build()
.unwrap();
let tz = crate::timezone::parse_timezone("America/New_York").unwrap();
let date = crate::timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap();
assert!(event.occurs_on(date).unwrap());
}
#[test]
fn test_occurs_on_false_for_non_matching_day() {
let event = Event::builder()
.title("Meeting")
.start("2025-11-03 10:00:00", "America/New_York")
.duration_hours(1)
.build()
.unwrap();
let tz = crate::timezone::parse_timezone("America/New_York").unwrap();
let date = crate::timezone::parse_datetime_with_tz("2025-11-04 00:00:00", tz).unwrap();
assert!(!event.occurs_on(date).unwrap());
}
#[test]
fn test_occurrences_between_invalid_range() {
let tz = crate::timezone::parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-11-03 10:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-11-02 10:00:00", tz).unwrap();
let event = Event::builder()
.title("Meeting")
.start("2025-11-03 09:00:00", "America/New_York")
.duration_hours(1)
.build()
.unwrap();
let result = event.occurrences_between(start, end, 5);
assert!(result.is_err());
}
}