use std::marker::PhantomData;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarAccount {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Calendar {
pub account: String,
pub name: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarEvent {
pub id: EventId,
pub calendar: String,
pub title: String,
pub starts_at: String,
pub ends_at: String,
pub location: Option<String>,
pub description: Option<String>,
pub status: EventStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InviteSummary {
pub event_id: EventId,
pub message_id: String,
pub title: String,
pub organizer: String,
pub starts_at: String,
pub ends_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dirty;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Validated;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Atomic;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EventValidationError {
#[error("event id is required")]
MissingId,
#[error("event calendar is required")]
MissingCalendar,
#[error("event title is required")]
MissingTitle,
#[error("event start timestamp is required")]
MissingStartsAt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventEdit<State> {
pub id: EventId,
pub calendar: String,
pub title: String,
pub starts_at: String,
pub ends_at: String,
pub location: Option<String>,
pub description: Option<String>,
pub status: EventStatus,
_state: PhantomData<State>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarWrite<State> {
event: EventEdit<Validated>,
_state: PhantomData<State>,
}
impl CalendarEvent {
pub fn is_cancelled(&self) -> bool {
self.status == EventStatus::Cancelled
}
}
impl EventEdit<Dirty> {
pub fn new(id: EventId, calendar: impl Into<String>) -> Self {
Self {
id,
calendar: calendar.into(),
title: String::new(),
starts_at: String::new(),
ends_at: String::new(),
location: None,
description: None,
status: EventStatus::Confirmed,
_state: PhantomData,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_starts_at(mut self, starts_at: impl Into<String>) -> Self {
self.starts_at = starts_at.into();
self
}
pub fn with_ends_at(mut self, ends_at: impl Into<String>) -> Self {
self.ends_at = ends_at.into();
self
}
pub fn with_location(mut self, location: Option<String>) -> Self {
self.location = location;
self
}
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
pub fn with_status(mut self, status: EventStatus) -> Self {
self.status = status;
self
}
pub fn validate(self) -> Result<EventEdit<Validated>, EventValidationError> {
if self.id.0.trim().is_empty() {
return Err(EventValidationError::MissingId);
}
if self.calendar.trim().is_empty() {
return Err(EventValidationError::MissingCalendar);
}
if self.title.trim().is_empty() {
return Err(EventValidationError::MissingTitle);
}
if self.starts_at.trim().is_empty() {
return Err(EventValidationError::MissingStartsAt);
}
Ok(EventEdit {
id: EventId(self.id.0.trim().to_owned()),
calendar: self.calendar.trim().to_owned(),
title: self.title.trim().to_owned(),
starts_at: self.starts_at.trim().to_owned(),
ends_at: self.ends_at.trim().to_owned(),
location: self
.location
.and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
description: self
.description
.and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
status: self.status,
_state: PhantomData,
})
}
}
impl EventEdit<Validated> {
pub fn prepare_atomic_write(self) -> CalendarWrite<Atomic> {
CalendarWrite {
event: self,
_state: PhantomData,
}
}
}
impl CalendarWrite<Atomic> {
pub fn event(&self) -> &EventEdit<Validated> {
&self.event
}
pub fn into_event(self) -> EventEdit<Validated> {
self.event
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_cancelled_event() {
let event = CalendarEvent {
id: EventId("evt-1".into()),
calendar: "work".into(),
title: "Interview".into(),
starts_at: "2026-05-11T10:00:00-06:00".into(),
ends_at: "2026-05-11T11:00:00-06:00".into(),
location: None,
description: None,
status: EventStatus::Cancelled,
};
assert!(event.is_cancelled());
}
#[test]
fn event_edit_typestate_requires_validation_before_atomic_write() {
let invalid = EventEdit::<Dirty>::new(EventId(String::new()), "work")
.with_title("Interview")
.with_starts_at("20260511T160000Z");
assert_eq!(invalid.validate(), Err(EventValidationError::MissingId));
let atomic = EventEdit::<Dirty>::new(EventId("evt-1".into()), "work")
.with_title("Interview")
.with_starts_at("20260511T160000Z")
.with_ends_at("20260511T170000Z")
.validate()
.expect("event should validate")
.prepare_atomic_write();
assert_eq!(atomic.event().title, "Interview");
assert_eq!(atomic.event().calendar, "work");
}
}