use crate::error::{EventixError, Result};
use crate::event::Event;
use crate::recurrence::Recurrence;
use crate::timezone::local_day_window;
use crate::views::{DayIterator, WeekIterator};
use chrono::DateTime;
use chrono_tz::Tz;
use rrule::Frequency;
#[derive(Debug, Clone)]
pub struct Calendar {
pub name: String,
pub description: Option<String>,
pub events: Vec<Event>,
pub timezone: Option<Tz>,
}
impl Calendar {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
events: Vec::new(),
timezone: None,
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn timezone(mut self, tz: Tz) -> Self {
self.timezone = Some(tz);
self
}
pub fn add_event(&mut self, event: Event) {
self.events.push(event);
}
pub fn add_events(&mut self, events: Vec<Event>) {
self.events.extend(events);
}
pub fn remove_event(&mut self, index: usize) -> Option<Event> {
if index < self.events.len() {
Some(self.events.remove(index))
} else {
None
}
}
pub fn update_event<F>(&mut self, index: usize, f: F) -> Option<()>
where
F: FnOnce(&mut Event),
{
self.events.get_mut(index).map(f)
}
pub fn get_events(&self) -> &[Event] {
&self.events
}
pub fn find_events_by_title(&self, title: &str) -> Vec<&Event> {
let title_lower = title.to_lowercase();
self.events
.iter()
.filter(|e| e.title.to_lowercase().contains(&title_lower))
.collect()
}
pub fn events_between(
&self,
start: DateTime<Tz>,
end: DateTime<Tz>,
) -> Result<Vec<EventOccurrence<'_>>> {
self.events_between_capped(start, end, 100_000)
}
pub fn events_between_capped(
&self,
start: DateTime<Tz>,
end: DateTime<Tz>,
max_per_event: usize,
) -> Result<Vec<EventOccurrence<'_>>> {
if start > end {
return Err(crate::error::EventixError::ValidationError(
"Start time must be before or equal to end time".to_string(),
));
}
let mut occurrences = Vec::new();
for (index, event) in self.events.iter().enumerate() {
let event_occurrences = event.occurrences_between(start, end, max_per_event)?;
for occurrence_time in event_occurrences {
occurrences.push(EventOccurrence {
event_index: index,
event,
occurrence_time,
});
}
}
occurrences.sort_by_key(|o| o.occurrence_time);
Ok(occurrences)
}
pub fn events_on_date(&self, date: DateTime<Tz>) -> Result<Vec<EventOccurrence<'_>>> {
let (start_dt, end_dt) = local_day_window(date.date_naive(), date.timezone())?;
self.events_between(start_dt, end_dt)
}
pub fn days(&self, start: DateTime<Tz>) -> DayIterator<'_> {
DayIterator::new(self, start)
}
pub fn days_back(&self, start: DateTime<Tz>) -> DayIterator<'_> {
DayIterator::backward(self, start)
}
pub fn weeks(&self, start: DateTime<Tz>) -> WeekIterator<'_> {
WeekIterator::new(self, start)
}
pub fn weeks_back(&self, start: DateTime<Tz>) -> WeekIterator<'_> {
WeekIterator::backward(self, start)
}
pub fn event_count(&self) -> usize {
self.events.len()
}
pub fn clear_events(&mut self) {
self.events.clear();
}
pub fn to_json(&self) -> Result<String> {
let json_val = serde_json::json!({
"name": self.name,
"description": self.description,
"events": self.events.iter().map(|e| {
let mut ev = serde_json::json!({
"title": e.title,
"description": e.description,
"start_time": e.start_time.to_rfc3339(),
"end_time": e.end_time.to_rfc3339(),
"timezone": e.timezone.name(),
"attendees": e.attendees,
"location": e.location,
"uid": e.uid,
"status": e.status,
});
if let Some(ref rec) = e.recurrence {
ev["recurrence"] = recurrence_to_json(rec);
}
if !e.exdates.is_empty() {
ev["exdates"] = serde_json::json!(
e.exdates.iter().map(|d| d.to_rfc3339()).collect::<Vec<_>>()
);
}
ev
}).collect::<Vec<_>>(),
"timezone": self.timezone.map(|tz| tz.name()),
});
serde_json::to_string_pretty(&json_val)
.map_err(|e| EventixError::Other(format!("JSON serialization error: {}", e)))
}
pub fn from_json(json: &str) -> Result<Self> {
use crate::timezone::parse_timezone;
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| crate::error::EventixError::Other(format!("JSON parse error: {}", e)))?;
let name = value["name"]
.as_str()
.ok_or_else(|| crate::error::EventixError::Other("Missing 'name' field".to_string()))?
.to_string();
let description = value["description"].as_str().map(|s| s.to_string());
let timezone = value["timezone"].as_str().and_then(|tz_str| parse_timezone(tz_str).ok());
let mut calendar = Calendar {
name,
description,
events: Vec::new(),
timezone,
};
if let Some(events_array) = value["events"].as_array() {
for event_val in events_array {
let title = event_val["title"].as_str().ok_or_else(|| {
crate::error::EventixError::Other("Event missing 'title'".to_string())
})?;
let start_str = event_val["start_time"].as_str().ok_or_else(|| {
crate::error::EventixError::Other("Event missing 'start_time'".to_string())
})?;
let end_str = event_val["end_time"].as_str().ok_or_else(|| {
crate::error::EventixError::Other("Event missing 'end_time'".to_string())
})?;
let tz_str = event_val["timezone"].as_str().ok_or_else(|| {
crate::error::EventixError::Other("Event missing 'timezone'".to_string())
})?;
let tz = parse_timezone(tz_str)?;
let start_time: DateTime<chrono::Utc> =
chrono::DateTime::parse_from_rfc3339(start_str)
.map_err(|e| crate::error::EventixError::DateTimeParse(e.to_string()))?
.with_timezone(&chrono::Utc);
let end_time: DateTime<chrono::Utc> = chrono::DateTime::parse_from_rfc3339(end_str)
.map_err(|e| crate::error::EventixError::DateTimeParse(e.to_string()))?
.with_timezone(&chrono::Utc);
let start_time_tz = start_time.with_timezone(&tz);
let end_time_tz = end_time.with_timezone(&tz);
let event = Event {
title: title.to_string(),
description: event_val["description"].as_str().map(|s| s.to_string()),
start_time: start_time_tz,
end_time: end_time_tz,
timezone: tz,
attendees: event_val["attendees"]
.as_array()
.map(|arr| {
arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()
})
.unwrap_or_default(),
recurrence: match event_val.get("recurrence") {
Some(v) => Some(json_to_recurrence(v, tz)?),
None => None,
},
recurrence_filter: None,
exdates: match event_val["exdates"].as_array() {
Some(arr) => {
let mut dates = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let s = v.as_str().ok_or_else(|| {
EventixError::Other(format!("exdates[{}]: expected string", i))
})?;
let dt = chrono::DateTime::parse_from_rfc3339(s).map_err(|e| {
EventixError::DateTimeParse(format!("exdates[{}]: {}", i, e))
})?;
dates.push(dt.with_timezone(&tz));
}
dates
}
None => Vec::new(),
},
location: event_val["location"].as_str().map(|s| s.to_string()),
uid: event_val["uid"].as_str().map(|s| s.to_string()),
status: match event_val.get("status") {
None => crate::event::EventStatus::default(),
Some(v) => serde_json::from_value(v.clone()).map_err(|e| {
crate::error::EventixError::Other(format!(
"Invalid event status '{}': {}",
v, e
))
})?,
},
};
calendar.add_event(event);
}
}
Ok(calendar)
}
}
#[derive(Debug, Clone)]
pub struct EventOccurrence<'a> {
pub event_index: usize,
pub event: &'a Event,
pub occurrence_time: DateTime<Tz>,
}
impl<'a> EventOccurrence<'a> {
pub fn end_time(&self) -> DateTime<Tz> {
let duration = self.event.duration();
self.occurrence_time + duration
}
pub fn title(&self) -> &str {
&self.event.title
}
pub fn description(&self) -> Option<&str> {
self.event.description.as_deref()
}
}
fn recurrence_to_json(rec: &Recurrence) -> serde_json::Value {
let freq_str = match rec.frequency() {
Frequency::Secondly => "secondly",
Frequency::Minutely => "minutely",
Frequency::Hourly => "hourly",
Frequency::Daily => "daily",
Frequency::Weekly => "weekly",
Frequency::Monthly => "monthly",
Frequency::Yearly => "yearly",
};
let mut obj = serde_json::json!({
"frequency": freq_str,
"interval": rec.get_interval(),
});
if let Some(c) = rec.get_count() {
obj["count"] = serde_json::json!(c);
}
if let Some(u) = rec.get_until() {
obj["until"] = serde_json::json!(u.to_rfc3339());
}
if let Some(weekdays) = rec.get_weekdays() {
let days: Vec<&str> = weekdays
.iter()
.map(|wd| match *wd {
chrono::Weekday::Mon => "MO",
chrono::Weekday::Tue => "TU",
chrono::Weekday::Wed => "WE",
chrono::Weekday::Thu => "TH",
chrono::Weekday::Fri => "FR",
chrono::Weekday::Sat => "SA",
chrono::Weekday::Sun => "SU",
})
.collect();
obj["weekdays"] = serde_json::json!(days);
}
obj
}
fn json_to_recurrence(val: &serde_json::Value, tz: Tz) -> crate::error::Result<Recurrence> {
let freq_str = val["frequency"]
.as_str()
.ok_or_else(|| EventixError::Other("Recurrence missing 'frequency'".to_string()))?;
let frequency = match freq_str {
"secondly" => Frequency::Secondly,
"minutely" => Frequency::Minutely,
"hourly" => Frequency::Hourly,
"daily" => Frequency::Daily,
"weekly" => Frequency::Weekly,
"monthly" => Frequency::Monthly,
"yearly" => Frequency::Yearly,
_ => return Err(EventixError::Other(format!("Unknown frequency: {}", freq_str))),
};
let interval_raw = val["interval"].as_u64().unwrap_or(1);
let interval = u16::try_from(interval_raw).map_err(|_| {
EventixError::Other(format!("Recurrence interval {} exceeds u16::MAX", interval_raw))
})?;
if !val["count"].is_null() && !val["until"].is_null() {
return Err(EventixError::Other(
"Recurrence cannot have both 'count' and 'until'".to_string(),
));
}
let mut rec = Recurrence::new(frequency).interval(interval);
if let Some(c) = val["count"].as_u64() {
let count = u32::try_from(c)
.map_err(|_| EventixError::Other(format!("Recurrence count {} exceeds u32::MAX", c)))?;
rec = rec.count(count);
}
if let Some(until_str) = val["until"].as_str() {
let parsed = chrono::DateTime::parse_from_rfc3339(until_str)
.map_err(|e| EventixError::DateTimeParse(format!("recurrence until: {}", e)))?;
rec = rec.until(parsed.with_timezone(&tz));
}
if let Some(weekdays_arr) = val["weekdays"].as_array() {
let mut weekdays = Vec::new();
for wd_val in weekdays_arr {
if let Some(wd_str) = wd_val.as_str() {
let wd = match wd_str {
"MO" => chrono::Weekday::Mon,
"TU" => chrono::Weekday::Tue,
"WE" => chrono::Weekday::Wed,
"TH" => chrono::Weekday::Thu,
"FR" => chrono::Weekday::Fri,
"SA" => chrono::Weekday::Sat,
"SU" => chrono::Weekday::Sun,
_ => return Err(EventixError::Other(format!("Unknown weekday: {}", wd_str))),
};
weekdays.push(wd);
}
}
if !weekdays.is_empty() {
rec = rec.weekdays(weekdays);
}
}
Ok(rec)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::Event;
#[test]
fn test_calendar_creation() {
let cal = Calendar::new("Test Calendar").description("A test calendar");
assert_eq!(cal.name, "Test Calendar");
assert_eq!(cal.description, Some("A test calendar".to_string()));
assert_eq!(cal.event_count(), 0);
}
#[test]
fn test_add_events() {
let mut cal = Calendar::new("My Calendar");
let event = Event::builder()
.title("Event 1")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event);
assert_eq!(cal.event_count(), 1);
}
#[test]
fn test_update_event() {
let mut cal = Calendar::new("My Calendar");
let event = Event::builder()
.title("Event 1")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event);
let updated = cal.update_event(0, |e| {
e.cancel(); e.title = "Updated Title".to_string();
});
assert!(updated.is_some());
assert_eq!(cal.events[0].title, "Updated Title");
assert!(!cal.events[0].is_active());
let result = cal.update_event(99, |_| {});
assert!(result.is_none());
}
#[test]
fn test_find_events() {
let mut cal = Calendar::new("My Calendar");
let event1 = Event::builder()
.title("Team Meeting")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
let event2 = Event::builder()
.title("Code Review")
.start("2025-11-02 14:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event1);
cal.add_event(event2);
let found = cal.find_events_by_title("meeting");
assert_eq!(found.len(), 1);
assert_eq!(found[0].title, "Team Meeting");
}
#[test]
fn test_json_serialization() {
let mut cal = Calendar::new("Test");
let event = Event::builder()
.title("Event")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event);
let json = cal.to_json().unwrap();
let restored = Calendar::from_json(&json).unwrap();
assert_eq!(restored.name, "Test");
assert_eq!(restored.event_count(), 1);
}
#[test]
fn test_json_recurrence_roundtrip() {
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let exdate = crate::timezone::parse_datetime_with_tz("2025-01-08 09:00:00", tz).unwrap();
let mut cal = Calendar::new("Recurrence JSON");
let event = Event::builder()
.title("Daily Standup")
.start("2025-01-06 09:00:00", "UTC")
.duration_minutes(15)
.recurrence(Recurrence::daily().interval(2).count(10))
.exception_date(exdate)
.build()
.unwrap();
cal.add_event(event);
let json = cal.to_json().unwrap();
assert!(json.contains("\"frequency\""));
assert!(json.contains("\"exdates\""), "JSON should contain exdates");
let restored = Calendar::from_json(&json).unwrap();
assert_eq!(restored.event_count(), 1);
let ev = &restored.events[0];
let rec = ev.recurrence.as_ref().unwrap();
assert_eq!(rec.frequency(), rrule::Frequency::Daily);
assert_eq!(rec.get_interval(), 2);
assert_eq!(rec.get_count(), Some(10));
assert_eq!(ev.exdates.len(), 1);
}
#[test]
fn test_json_import_rejects_bad_recurrence() {
let json = r#"{
"name": "Test",
"events": [{
"title": "Bad Recurrence",
"start_time": "2025-01-06T09:00:00+00:00",
"end_time": "2025-01-06T10:00:00+00:00",
"timezone": "UTC",
"recurrence": { "frequency": "biweekly", "interval": 1 }
}]
}"#;
let result = Calendar::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_json_import_rejects_bad_exdate() {
let json = r#"{
"name": "Test",
"events": [{
"title": "Bad Exdate",
"start_time": "2025-01-06T09:00:00+00:00",
"end_time": "2025-01-06T10:00:00+00:00",
"timezone": "UTC",
"exdates": ["not-a-date"]
}]
}"#;
let result = Calendar::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_json_import_rejects_overflowing_interval() {
let json = r#"{
"name": "Test",
"events": [{
"title": "Big Interval",
"start_time": "2025-01-06T09:00:00+00:00",
"end_time": "2025-01-06T10:00:00+00:00",
"timezone": "UTC",
"recurrence": { "frequency": "daily", "interval": 999999, "count": 5 }
}]
}"#;
let result = Calendar::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_json_import_rejects_count_and_until() {
let json = r#"{
"name": "Test",
"events": [{
"title": "Both",
"start_time": "2025-01-06T09:00:00+00:00",
"end_time": "2025-01-06T10:00:00+00:00",
"timezone": "UTC",
"recurrence": { "frequency": "daily", "count": "10", "until": "2025-02-01T00:00:00+00:00" }
}]
}"#;
let result = Calendar::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_from_json_allows_missing_events_array() {
let calendar = Calendar::from_json(r#"{"name":"Empty Calendar"}"#).unwrap();
assert!(calendar.events.is_empty());
}
#[test]
fn test_from_json_rejects_malformed_json() {
let err = Calendar::from_json("not valid json {{{").unwrap_err();
assert!(
matches!(err, crate::error::EventixError::Other(message) if message.contains("JSON parse error"))
);
}
#[test]
fn test_to_json_roundtrip_preserves_calendar_level_timezone() {
let tz = crate::timezone::parse_timezone("Europe/London").unwrap();
let mut cal = Calendar::new("TZ Calendar").timezone(tz);
cal.add_event(
Event::builder()
.title("Meeting")
.start("2025-06-01 14:00:00", "Europe/London")
.duration_hours(1)
.build()
.unwrap(),
);
let json = cal.to_json().unwrap();
assert!(json.contains("\"timezone\""));
assert!(json.contains("Europe/London"));
let restored = Calendar::from_json(&json).unwrap();
assert_eq!(restored.timezone, Some(tz));
assert_eq!(restored.event_count(), 1);
}
#[test]
fn test_recurrence_to_json_covers_all_frequencies_and_optional_fields() {
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let until = crate::timezone::parse_datetime_with_tz("2025-02-01 00:00:00", tz).unwrap();
for (recurrence, expected) in [
(Recurrence::secondly(), "secondly"),
(Recurrence::minutely(), "minutely"),
(Recurrence::hourly(), "hourly"),
(Recurrence::daily(), "daily"),
(Recurrence::weekly(), "weekly"),
(Recurrence::monthly(), "monthly"),
(Recurrence::yearly(), "yearly"),
] {
assert_eq!(recurrence_to_json(&recurrence)["frequency"], expected);
}
let recurrence = Recurrence::yearly().interval(3).until(until).weekdays(vec![
chrono::Weekday::Mon,
chrono::Weekday::Tue,
chrono::Weekday::Wed,
chrono::Weekday::Thu,
chrono::Weekday::Fri,
chrono::Weekday::Sat,
chrono::Weekday::Sun,
]);
let json = recurrence_to_json(&recurrence);
assert_eq!(json["frequency"], "yearly");
assert_eq!(json["interval"], 3);
assert_eq!(json["until"], until.to_rfc3339());
assert_eq!(json["weekdays"], serde_json::json!(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]));
}
#[test]
fn test_json_to_recurrence_parses_count_until_and_weekdays() {
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let recurrence = json_to_recurrence(
&serde_json::json!({
"frequency": "weekly",
"interval": 2,
"count": 7,
"weekdays": ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
}),
tz,
)
.unwrap();
assert_eq!(recurrence.frequency(), Frequency::Weekly);
assert_eq!(recurrence.get_interval(), 2);
assert_eq!(recurrence.get_count(), Some(7));
assert_eq!(
recurrence.get_weekdays().unwrap(),
[
chrono::Weekday::Mon,
chrono::Weekday::Tue,
chrono::Weekday::Wed,
chrono::Weekday::Thu,
chrono::Weekday::Fri,
chrono::Weekday::Sat,
chrono::Weekday::Sun,
]
);
let until = json_to_recurrence(
&serde_json::json!({
"frequency": "monthly",
"interval": 1,
"until": "2025-02-01T00:00:00+00:00"
}),
tz,
)
.unwrap();
assert_eq!(
until.get_until(),
Some(crate::timezone::parse_datetime_with_tz("2025-02-01 00:00:00", tz).unwrap())
);
}
#[test]
fn test_json_to_recurrence_rejects_unknown_weekday() {
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let err = json_to_recurrence(
&serde_json::json!({
"frequency": "weekly",
"interval": 1,
"weekdays": ["XX"]
}),
tz,
)
.unwrap_err();
assert!(
matches!(err, EventixError::Other(message) if message.contains("Unknown weekday: XX"))
);
}
#[test]
fn test_events_between_invalid_range() {
use crate::timezone::parse_datetime_with_tz;
use crate::timezone::parse_timezone;
let cal = Calendar::new("Test");
let tz = parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap();
let result = cal.events_between(start, end);
assert!(result.is_err());
}
#[test]
fn test_events_on_date_valid() {
use crate::timezone::parse_datetime_with_tz;
use crate::timezone::parse_timezone;
let mut cal = Calendar::new("Test");
let event = crate::event::Event::builder()
.title("Test Event")
.start("2025-11-01 15:00:00", "America/New_York")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event);
let tz = parse_timezone("America/New_York").unwrap();
let query_date = parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap();
let occurrences = cal.events_on_date(query_date).unwrap();
assert_eq!(occurrences.len(), 1);
}
}