use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
pub use crate::event_time::EventTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recurrence {
pub rrule: String,
pub exdates: Vec<EventTime>,
}
impl PartialEq for Recurrence {
fn eq(&self, other: &Self) -> bool {
if self.rrule != other.rrule {
return false;
}
let mut a = self.exdates.clone();
let mut b = other.exdates.clone();
a.sort_by_key(|x| x.to_utc());
b.sort_by_key(|x| x.to_utc());
a == b
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub uid: String,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start: EventTime,
pub end: EventTime,
pub status: EventStatus,
pub recurrence: Option<Recurrence>,
pub recurrence_id: Option<EventTime>,
pub reminders: Reminders,
pub transparency: Transparency,
pub organizer: Option<Attendee>,
pub attendees: Vec<Attendee>,
pub conference_url: Option<String>,
pub updated: Option<DateTime<Utc>>,
pub sequence: Option<i64>,
pub custom_properties: Vec<CustomProperty>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomProperty {
pub name: String,
pub value: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<(String, String)>,
}
impl CustomProperty {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
params: Vec::new(),
}
}
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.push((key.into(), value.into()));
self
}
}
impl PartialEq for Event {
fn eq(&self, other: &Self) -> bool {
self.uid == other.uid
&& self.summary == other.summary
&& self.description == other.description
&& self.location == other.location
&& self.start == other.start
&& self.end == other.end
&& self.status == other.status
&& self.recurrence == other.recurrence
&& self.recurrence_id == other.recurrence_id
&& self.reminders == other.reminders
&& self.transparency == other.transparency
&& self.organizer == other.organizer
&& self.attendees == other.attendees
&& self.conference_url == other.conference_url
}
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.summary.is_empty() {
write!(f, "(Unknown Event)")
} else {
write!(f, "{}", self.summary)
}
}
}
impl Event {
fn generate_uid() -> String {
format!("{}@caldir", uuid::Uuid::new_v4())
}
pub fn new(
summary: String,
start: EventTime,
end: EventTime,
description: Option<String>,
location: Option<String>,
recurrence: Option<Recurrence>,
reminders: Vec<Reminder>,
) -> Self {
let uid = Self::generate_uid();
Event {
uid,
summary,
description,
location,
start,
end,
status: EventStatus::Confirmed,
recurrence,
recurrence_id: None,
reminders: Reminders(reminders),
transparency: Transparency::Opaque,
organizer: None,
attendees: Vec::new(),
conference_url: None,
updated: None,
sequence: None,
custom_properties: Vec::new(),
}
}
pub fn with_new_uid(&self) -> Self {
Event {
uid: Self::generate_uid(),
..self.clone()
}
}
pub fn unique_id(&self) -> String {
match &self.recurrence_id {
Some(rid) => format!("{}__{}", self.uid, rid.to_ics_string()),
None => self.uid.clone(),
}
}
pub fn find_attendee(&self, email: &str) -> Option<&Attendee> {
self.attendees
.iter()
.find(|a| a.email.eq_ignore_ascii_case(email))
}
pub fn is_invite_for(&self, email: &str) -> bool {
let is_attendee = self.find_attendee(email).is_some();
let is_organizer = self
.organizer
.as_ref()
.is_some_and(|o| o.email.eq_ignore_ascii_case(email));
is_attendee && !is_organizer
}
pub fn my_status(&self, email: &str) -> Option<ParticipationStatus> {
self.find_attendee(email)?.response_status
}
pub fn is_pending_invite_for(&self, email: &str) -> bool {
self.is_invite_for(email) && self.my_status(email) == Some(ParticipationStatus::NeedsAction)
}
pub fn with_response(&self, email: &str, status: ParticipationStatus) -> Option<Event> {
self.find_attendee(email)?;
let mut updated = self.clone();
for attendee in &mut updated.attendees {
if attendee.email.eq_ignore_ascii_case(email) {
attendee.response_status = Some(status);
}
}
updated.sequence = Some(self.sequence.unwrap_or(0) + 1);
updated.updated = Some(Utc::now());
Some(updated)
}
pub fn custom_property(&self, name: &str) -> Option<&str> {
self.custom_properties
.iter()
.find(|p| p.name == name)
.map(|p| p.value.as_str())
}
pub fn starts_in_range<Tz: TimeZone>(
&self,
from: DateTime<Utc>,
to: DateTime<Utc>,
host_tz: &Tz,
) -> bool {
if self.status == EventStatus::Cancelled {
return false;
}
let Some(instant) = self.start.resolve_instant_in_zone(host_tz) else {
return false;
};
instant >= from && instant <= to
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Attendee {
pub name: Option<String>,
pub email: String,
pub response_status: Option<ParticipationStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING-KEBAB-CASE")]
pub enum ParticipationStatus {
Accepted,
Declined,
Tentative,
NeedsAction,
}
impl ParticipationStatus {
pub fn as_ics_str(&self) -> &'static str {
match self {
Self::Accepted => "ACCEPTED",
Self::Declined => "DECLINED",
Self::Tentative => "TENTATIVE",
Self::NeedsAction => "NEEDS-ACTION",
}
}
pub fn from_ics_str(s: &str) -> Option<Self> {
match s {
"ACCEPTED" => Some(Self::Accepted),
"DECLINED" => Some(Self::Declined),
"TENTATIVE" => Some(Self::Tentative),
"NEEDS-ACTION" => Some(Self::NeedsAction),
_ => None,
}
}
}
impl fmt::Display for ParticipationStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Accepted => "accepted",
Self::Declined => "declined",
Self::Tentative => "maybe",
Self::NeedsAction => "pending",
};
write!(f, "{}", s)
}
}
impl FromStr for ParticipationStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"accepted" | "accept" | "yes" | "y" => Ok(Self::Accepted),
"declined" | "decline" | "no" | "n" => Ok(Self::Declined),
"tentative" | "maybe" | "m" => Ok(Self::Tentative),
"needs-action" | "needs_action" | "pending" => Ok(Self::NeedsAction),
_ => Err(format!("Unknown participation status: '{}'", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reminder {
pub minutes: i64,
}
impl Reminder {
pub fn from_duration_str(input: &str) -> Result<Self, String> {
let dur = humantime::parse_duration(input)
.map_err(|_| format!("Could not parse reminder: \"{}\"", input))?;
let minutes = (dur.as_secs() / 60) as i64;
Ok(Reminder { minutes })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reminders(pub Vec<Reminder>);
impl PartialEq for Reminders {
fn eq(&self, other: &Self) -> bool {
let mut a = self.0.clone();
let mut b = other.0.clone();
a.sort_by_key(|r| r.minutes);
b.sort_by_key(|r| r.minutes);
a == b
}
}
impl std::ops::Deref for Reminders {
type Target = Vec<Reminder>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Reminders {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl FromIterator<Reminder> for Reminders {
fn from_iter<I: IntoIterator<Item = Reminder>>(iter: I) -> Self {
Reminders(iter.into_iter().collect())
}
}
impl<'a> IntoIterator for &'a Reminders {
type Item = &'a Reminder;
type IntoIter = std::slice::Iter<'a, Reminder>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Transparency {
Opaque,
Transparent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
#[cfg(test)]
mod tests {
use crate::event_time::EventTime;
use super::*;
use chrono::NaiveDate;
fn make_event_with_attendees() -> Event {
let start = EventTime::Date(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap());
let end = EventTime::Date(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap());
let mut event = Event::new("Team standup".into(), start, end, None, None, None, vec![]);
event.organizer = Some(Attendee {
name: Some("Alice".into()),
email: "alice@example.com".into(),
response_status: Some(ParticipationStatus::Accepted),
});
event.attendees = vec![
Attendee {
name: Some("Alice".into()),
email: "alice@example.com".into(),
response_status: Some(ParticipationStatus::Accepted),
},
Attendee {
name: Some("Bob".into()),
email: "bob@example.com".into(),
response_status: Some(ParticipationStatus::NeedsAction),
},
Attendee {
name: Some("Carol".into()),
email: "carol@example.com".into(),
response_status: Some(ParticipationStatus::Tentative),
},
];
event
}
#[test]
fn find_attendee_case_insensitive() {
let event = make_event_with_attendees();
assert!(event.find_attendee("bob@example.com").is_some());
assert!(event.find_attendee("BOB@EXAMPLE.COM").is_some());
assert!(event.find_attendee("unknown@example.com").is_none());
}
#[test]
fn is_invite_for_attendee_not_organizer() {
let event = make_event_with_attendees();
assert!(event.is_invite_for("bob@example.com"));
assert!(!event.is_invite_for("alice@example.com"));
assert!(!event.is_invite_for("unknown@example.com"));
}
#[test]
fn my_status_returns_correct_status() {
let event = make_event_with_attendees();
assert_eq!(
event.my_status("bob@example.com"),
Some(ParticipationStatus::NeedsAction)
);
assert_eq!(
event.my_status("carol@example.com"),
Some(ParticipationStatus::Tentative)
);
assert_eq!(event.my_status("unknown@example.com"), None);
}
#[test]
fn is_pending_invite_for() {
let event = make_event_with_attendees();
assert!(event.is_pending_invite_for("bob@example.com"));
assert!(!event.is_pending_invite_for("carol@example.com"));
assert!(!event.is_pending_invite_for("alice@example.com"));
}
#[test]
fn with_response_updates_status() {
let event = make_event_with_attendees();
let updated = event
.with_response("bob@example.com", ParticipationStatus::Accepted)
.unwrap();
assert_eq!(
updated
.find_attendee("bob@example.com")
.unwrap()
.response_status,
Some(ParticipationStatus::Accepted)
);
assert_eq!(updated.sequence, Some(1));
assert!(updated.updated.is_some());
}
#[test]
fn with_response_returns_none_for_unknown() {
let event = make_event_with_attendees();
assert!(
event
.with_response("unknown@example.com", ParticipationStatus::Accepted)
.is_none()
);
}
#[test]
fn with_response_bumps_existing_sequence() {
let mut event = make_event_with_attendees();
event.sequence = Some(5);
let updated = event
.with_response("bob@example.com", ParticipationStatus::Declined)
.unwrap();
assert_eq!(updated.sequence, Some(6));
}
#[test]
fn participation_status_display() {
assert_eq!(ParticipationStatus::Accepted.to_string(), "accepted");
assert_eq!(ParticipationStatus::Declined.to_string(), "declined");
assert_eq!(ParticipationStatus::Tentative.to_string(), "maybe");
assert_eq!(ParticipationStatus::NeedsAction.to_string(), "pending");
}
#[test]
fn starts_in_range_resolves_floating_in_host_zone() {
use chrono::FixedOffset;
let pacific = FixedOffset::west_opt(8 * 3600).unwrap();
let naive = NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap();
let event = Event::new(
"Test".into(),
EventTime::DateTimeFloating(naive),
EventTime::DateTimeFloating(naive),
None,
None,
None,
vec![],
);
let from = Utc.with_ymd_and_hms(2026, 1, 15, 16, 0, 0).unwrap();
let to = Utc.with_ymd_and_hms(2026, 1, 15, 18, 0, 0).unwrap();
assert!(event.starts_in_range(from, to, &pacific));
}
#[test]
fn participation_status_from_str() {
assert_eq!("accepted".parse(), Ok(ParticipationStatus::Accepted));
assert_eq!("declined".parse(), Ok(ParticipationStatus::Declined));
assert_eq!("tentative".parse(), Ok(ParticipationStatus::Tentative));
assert_eq!("accept".parse(), Ok(ParticipationStatus::Accepted));
assert_eq!("yes".parse(), Ok(ParticipationStatus::Accepted));
assert_eq!("y".parse(), Ok(ParticipationStatus::Accepted));
assert_eq!("decline".parse(), Ok(ParticipationStatus::Declined));
assert_eq!("no".parse(), Ok(ParticipationStatus::Declined));
assert_eq!("n".parse(), Ok(ParticipationStatus::Declined));
assert_eq!("maybe".parse(), Ok(ParticipationStatus::Tentative));
assert_eq!("m".parse(), Ok(ParticipationStatus::Tentative));
assert_eq!("ACCEPT".parse(), Ok(ParticipationStatus::Accepted));
assert_eq!("Yes".parse(), Ok(ParticipationStatus::Accepted));
assert!("invalid".parse::<ParticipationStatus>().is_err());
}
}