1use 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}