Skip to main content

cardinal_core/
calendar.rs

1//! Calendar domain types.
2//!
3//! Cardinal starts with local vdir/`.ics` data, but these types should not care
4//! whether events came from CalDAV, Google, JMAP, or fixtures.
5
6use std::marker::PhantomData;
7
8use thiserror::Error;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CalendarAccount {
12    pub name: String,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Calendar {
17    pub account: String,
18    pub name: String,
19    pub path: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct EventId(pub String);
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CalendarEvent {
27    pub id: EventId,
28    pub calendar: String,
29    pub title: String,
30    pub starts_at: String,
31    pub ends_at: String,
32    pub location: Option<String>,
33    pub description: Option<String>,
34    pub status: EventStatus,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum EventStatus {
39    Confirmed,
40    Tentative,
41    Cancelled,
42    Unknown,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct InviteSummary {
47    pub event_id: EventId,
48    pub message_id: String,
49    pub title: String,
50    pub organizer: String,
51    pub starts_at: String,
52    pub ends_at: String,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct Dirty;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct Validated;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Atomic;
63
64#[derive(Debug, Clone, PartialEq, Eq, Error)]
65pub enum EventValidationError {
66    #[error("event id is required")]
67    MissingId,
68    #[error("event calendar is required")]
69    MissingCalendar,
70    #[error("event title is required")]
71    MissingTitle,
72    #[error("event start timestamp is required")]
73    MissingStartsAt,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct EventEdit<State> {
78    pub id: EventId,
79    pub calendar: String,
80    pub title: String,
81    pub starts_at: String,
82    pub ends_at: String,
83    pub location: Option<String>,
84    pub description: Option<String>,
85    pub status: EventStatus,
86    _state: PhantomData<State>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CalendarWrite<State> {
91    event: EventEdit<Validated>,
92    _state: PhantomData<State>,
93}
94
95impl CalendarEvent {
96    pub fn is_cancelled(&self) -> bool {
97        self.status == EventStatus::Cancelled
98    }
99}
100
101impl EventEdit<Dirty> {
102    pub fn new(id: EventId, calendar: impl Into<String>) -> Self {
103        Self {
104            id,
105            calendar: calendar.into(),
106            title: String::new(),
107            starts_at: String::new(),
108            ends_at: String::new(),
109            location: None,
110            description: None,
111            status: EventStatus::Confirmed,
112            _state: PhantomData,
113        }
114    }
115
116    pub fn with_title(mut self, title: impl Into<String>) -> Self {
117        self.title = title.into();
118        self
119    }
120
121    pub fn with_starts_at(mut self, starts_at: impl Into<String>) -> Self {
122        self.starts_at = starts_at.into();
123        self
124    }
125
126    pub fn with_ends_at(mut self, ends_at: impl Into<String>) -> Self {
127        self.ends_at = ends_at.into();
128        self
129    }
130
131    pub fn with_location(mut self, location: Option<String>) -> Self {
132        self.location = location;
133        self
134    }
135
136    pub fn with_description(mut self, description: Option<String>) -> Self {
137        self.description = description;
138        self
139    }
140
141    pub fn with_status(mut self, status: EventStatus) -> Self {
142        self.status = status;
143        self
144    }
145
146    pub fn validate(self) -> Result<EventEdit<Validated>, EventValidationError> {
147        if self.id.0.trim().is_empty() {
148            return Err(EventValidationError::MissingId);
149        }
150        if self.calendar.trim().is_empty() {
151            return Err(EventValidationError::MissingCalendar);
152        }
153        if self.title.trim().is_empty() {
154            return Err(EventValidationError::MissingTitle);
155        }
156        if self.starts_at.trim().is_empty() {
157            return Err(EventValidationError::MissingStartsAt);
158        }
159
160        Ok(EventEdit {
161            id: EventId(self.id.0.trim().to_owned()),
162            calendar: self.calendar.trim().to_owned(),
163            title: self.title.trim().to_owned(),
164            starts_at: self.starts_at.trim().to_owned(),
165            ends_at: self.ends_at.trim().to_owned(),
166            location: self
167                .location
168                .and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
169            description: self
170                .description
171                .and_then(|value| (!value.trim().is_empty()).then_some(value.trim().to_owned())),
172            status: self.status,
173            _state: PhantomData,
174        })
175    }
176}
177
178impl EventEdit<Validated> {
179    pub fn prepare_atomic_write(self) -> CalendarWrite<Atomic> {
180        CalendarWrite {
181            event: self,
182            _state: PhantomData,
183        }
184    }
185}
186
187impl CalendarWrite<Atomic> {
188    pub fn event(&self) -> &EventEdit<Validated> {
189        &self.event
190    }
191
192    pub fn into_event(self) -> EventEdit<Validated> {
193        self.event
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn recognizes_cancelled_event() {
203        let event = CalendarEvent {
204            id: EventId("evt-1".into()),
205            calendar: "work".into(),
206            title: "Interview".into(),
207            starts_at: "2026-05-11T10:00:00-06:00".into(),
208            ends_at: "2026-05-11T11:00:00-06:00".into(),
209            location: None,
210            description: None,
211            status: EventStatus::Cancelled,
212        };
213
214        assert!(event.is_cancelled());
215    }
216
217    #[test]
218    fn event_edit_typestate_requires_validation_before_atomic_write() {
219        let invalid = EventEdit::<Dirty>::new(EventId(String::new()), "work")
220            .with_title("Interview")
221            .with_starts_at("20260511T160000Z");
222        assert_eq!(invalid.validate(), Err(EventValidationError::MissingId));
223
224        let atomic = EventEdit::<Dirty>::new(EventId("evt-1".into()), "work")
225            .with_title("Interview")
226            .with_starts_at("20260511T160000Z")
227            .with_ends_at("20260511T170000Z")
228            .validate()
229            .expect("event should validate")
230            .prepare_atomic_write();
231        assert_eq!(atomic.event().title, "Interview");
232        assert_eq!(atomic.event().calendar, "work");
233    }
234}