Rust · 147521 bytes Raw Blame History
1 use std::{
2 cell::RefCell,
3 collections::{HashMap, HashSet},
4 env,
5 error::Error,
6 fmt, fs, io,
7 path::{Path, PathBuf},
8 time::{Duration, SystemTime, UNIX_EPOCH},
9 };
10
11 use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
12 use time::{Month, Time, Weekday};
13
14 use crate::{
15 calendar::CalendarDate,
16 providers::{
17 GoogleProviderConfig, GoogleProviderRuntime, KeyringGoogleTokenStore,
18 KeyringMicrosoftTokenStore, MicrosoftProviderConfig, MicrosoftProviderRuntime,
19 ProviderCreateTarget, ProviderError, ReqwestMicrosoftHttpClient,
20 },
21 };
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 pub struct DateRange {
25 pub start: CalendarDate,
26 pub end: CalendarDate,
27 }
28
29 impl DateRange {
30 pub fn day(date: CalendarDate) -> Self {
31 Self {
32 start: date,
33 end: date.add_days(1),
34 }
35 }
36
37 pub fn new(start: CalendarDate, end: CalendarDate) -> Result<Self, AgendaError> {
38 if start < end {
39 Ok(Self { start, end })
40 } else {
41 Err(AgendaError::InvalidDateRange { start, end })
42 }
43 }
44
45 pub fn contains_date(self, date: CalendarDate) -> bool {
46 self.start <= date && date < self.end
47 }
48 }
49
50 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51 pub struct EventDateTime {
52 pub date: CalendarDate,
53 pub time: Time,
54 }
55
56 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
57 pub struct DayMinute(u16);
58
59 impl DayMinute {
60 pub const START: Self = Self(0);
61 pub const END: Self = Self(24 * 60);
62
63 pub const fn from_minutes(minutes: u16) -> Self {
64 Self(minutes)
65 }
66
67 pub fn from_time(time: Time) -> Self {
68 Self(u16::from(time.hour()) * 60 + u16::from(time.minute()))
69 }
70
71 pub const fn as_minutes(self) -> u16 {
72 self.0
73 }
74 }
75
76 impl EventDateTime {
77 pub const fn new(date: CalendarDate, time: Time) -> Self {
78 Self { date, time }
79 }
80 }
81
82 impl Serialize for EventDateTime {
83 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
84 where
85 S: Serializer,
86 {
87 serializer.serialize_str(&format!(
88 "{}T{:02}:{:02}",
89 self.date,
90 self.time.hour(),
91 self.time.minute()
92 ))
93 }
94 }
95
96 impl<'de> Deserialize<'de> for EventDateTime {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98 where
99 D: Deserializer<'de>,
100 {
101 let value = String::deserialize(deserializer)?;
102 parse_event_datetime_record(&value)
103 .ok_or_else(|| de::Error::custom(format!("invalid event datetime '{value}'")))
104 }
105 }
106
107 #[derive(Debug, Clone, PartialEq, Eq)]
108 pub struct SourceMetadata {
109 pub source_id: String,
110 pub source_name: String,
111 pub external_id: Option<String>,
112 }
113
114 impl SourceMetadata {
115 pub fn new(source_id: impl Into<String>, source_name: impl Into<String>) -> Self {
116 Self {
117 source_id: source_id.into(),
118 source_name: source_name.into(),
119 external_id: None,
120 }
121 }
122
123 pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
124 self.external_id = Some(external_id.into());
125 self
126 }
127
128 pub fn fixture() -> Self {
129 Self::new("fixture", "In-memory fixture")
130 }
131
132 pub fn local() -> Self {
133 Self::new("local", "Local events")
134 }
135 }
136
137 #[derive(Debug, Clone, PartialEq, Eq)]
138 pub struct EventWriteTarget {
139 pub id: EventWriteTargetId,
140 pub label: String,
141 }
142
143 impl EventWriteTarget {
144 pub fn local() -> Self {
145 Self {
146 id: EventWriteTargetId::Local,
147 label: "Local".to_string(),
148 }
149 }
150
151 pub fn provider(
152 provider_id: impl Into<String>,
153 account_id: impl Into<String>,
154 calendar_id: impl Into<String>,
155 label: impl Into<String>,
156 ) -> Self {
157 Self {
158 id: EventWriteTargetId::provider(provider_id, account_id, calendar_id),
159 label: label.into(),
160 }
161 }
162
163 pub fn microsoft(
164 account_id: impl Into<String>,
165 calendar_id: impl Into<String>,
166 label: impl Into<String>,
167 ) -> Self {
168 Self::provider("microsoft", account_id, calendar_id, label)
169 }
170 }
171
172 #[derive(Debug, Clone, PartialEq, Eq)]
173 pub enum EventWriteTargetId {
174 Local,
175 Provider {
176 provider_id: String,
177 account_id: String,
178 calendar_id: String,
179 },
180 }
181
182 impl EventWriteTargetId {
183 pub const fn is_local(&self) -> bool {
184 matches!(self, Self::Local)
185 }
186
187 pub fn provider(
188 provider_id: impl Into<String>,
189 account_id: impl Into<String>,
190 calendar_id: impl Into<String>,
191 ) -> Self {
192 Self::Provider {
193 provider_id: provider_id.into(),
194 account_id: account_id.into(),
195 calendar_id: calendar_id.into(),
196 }
197 }
198
199 pub fn microsoft(account_id: impl Into<String>, calendar_id: impl Into<String>) -> Self {
200 Self::provider("microsoft", account_id, calendar_id)
201 }
202
203 pub fn provider_id(&self) -> Option<&str> {
204 match self {
205 Self::Local => None,
206 Self::Provider { provider_id, .. } => Some(provider_id),
207 }
208 }
209
210 pub fn account_id(&self) -> Option<&str> {
211 match self {
212 Self::Local => None,
213 Self::Provider { account_id, .. } => Some(account_id),
214 }
215 }
216
217 pub fn calendar_id(&self) -> Option<&str> {
218 match self {
219 Self::Local => None,
220 Self::Provider { calendar_id, .. } => Some(calendar_id),
221 }
222 }
223
224 pub fn provider_parts(&self) -> Option<(&str, &str, &str)> {
225 match self {
226 Self::Local => None,
227 Self::Provider {
228 provider_id,
229 account_id,
230 calendar_id,
231 } => Some((provider_id, account_id, calendar_id)),
232 }
233 }
234
235 pub fn is_provider(&self, provider: &str) -> bool {
236 self.provider_id() == Some(provider)
237 }
238
239 pub fn is_microsoft(&self) -> bool {
240 self.is_provider("microsoft")
241 }
242
243 pub fn microsoft_parts(&self) -> Option<(&str, &str)> {
244 let (provider, account, calendar) = self.provider_parts()?;
245 (provider == "microsoft").then_some((account, calendar))
246 }
247
248 pub fn source_id(&self) -> Option<String> {
249 self.provider_parts()
250 .map(|(provider, account, calendar)| format!("{provider}:{account}:{calendar}"))
251 }
252
253 pub fn from_event(event: &Event) -> Option<Self> {
254 if event.is_local() {
255 return Some(Self::Local);
256 }
257 let mut parts = event.source.source_id.splitn(3, ':');
258 let provider_id = parts.next()?;
259 let account_id = parts.next()?;
260 let calendar_id = parts.next()?;
261 if provider_id.is_empty() || account_id.is_empty() || calendar_id.is_empty() {
262 return None;
263 }
264 Some(Self::provider(provider_id, account_id, calendar_id))
265 }
266 }
267
268 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
269 pub struct Reminder {
270 pub minutes_before: u16,
271 }
272
273 impl Reminder {
274 pub const fn minutes_before(minutes_before: u16) -> Self {
275 Self { minutes_before }
276 }
277 }
278
279 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
280 pub enum EventTiming {
281 AllDay {
282 date: CalendarDate,
283 },
284 Timed {
285 start: EventDateTime,
286 end: EventDateTime,
287 },
288 }
289
290 impl EventTiming {
291 pub const fn date(self) -> Option<CalendarDate> {
292 match self {
293 Self::AllDay { date } => Some(date),
294 Self::Timed { .. } => None,
295 }
296 }
297
298 pub const fn is_all_day(self) -> bool {
299 matches!(self, Self::AllDay { .. })
300 }
301
302 pub const fn is_timed(self) -> bool {
303 matches!(self, Self::Timed { .. })
304 }
305 }
306
307 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
308 pub enum RecurrenceFrequency {
309 Daily,
310 Weekly,
311 Monthly,
312 Yearly,
313 }
314
315 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
316 pub enum RecurrenceEnd {
317 Never,
318 Until(CalendarDate),
319 Count(u32),
320 }
321
322 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
323 pub enum RecurrenceOrdinal {
324 Number(u8),
325 Last,
326 }
327
328 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
329 pub enum RecurrenceMonthlyRule {
330 DayOfMonth(u8),
331 WeekdayOrdinal {
332 ordinal: RecurrenceOrdinal,
333 weekday: Weekday,
334 },
335 }
336
337 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
338 pub enum RecurrenceYearlyRule {
339 Date {
340 month: Month,
341 day: u8,
342 },
343 WeekdayOrdinal {
344 month: Month,
345 ordinal: RecurrenceOrdinal,
346 weekday: Weekday,
347 },
348 }
349
350 #[derive(Debug, Clone, PartialEq, Eq)]
351 pub struct RecurrenceRule {
352 pub frequency: RecurrenceFrequency,
353 pub interval: u16,
354 pub end: RecurrenceEnd,
355 pub weekdays: Vec<Weekday>,
356 pub monthly: Option<RecurrenceMonthlyRule>,
357 pub yearly: Option<RecurrenceYearlyRule>,
358 }
359
360 impl RecurrenceRule {
361 pub fn new(frequency: RecurrenceFrequency) -> Self {
362 Self {
363 frequency,
364 interval: 1,
365 end: RecurrenceEnd::Never,
366 weekdays: Vec::new(),
367 monthly: None,
368 yearly: None,
369 }
370 }
371
372 pub fn with_interval(mut self, interval: u16) -> Self {
373 self.interval = interval.max(1);
374 self
375 }
376
377 pub fn interval(&self) -> u16 {
378 self.interval.max(1)
379 }
380 }
381
382 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
383 pub enum OccurrenceAnchor {
384 AllDay { date: CalendarDate },
385 Timed { start: EventDateTime },
386 }
387
388 impl OccurrenceAnchor {
389 pub const fn date(self) -> CalendarDate {
390 match self {
391 Self::AllDay { date } => date,
392 Self::Timed { start } => start.date,
393 }
394 }
395
396 fn storage_key(self) -> String {
397 match self {
398 Self::AllDay { date } => format!("{date}"),
399 Self::Timed { start } => {
400 format!("{}T{}", start.date, format_time(start.time))
401 }
402 }
403 }
404 }
405
406 #[derive(Debug, Clone, PartialEq, Eq)]
407 pub struct OccurrenceMetadata {
408 pub series_id: String,
409 pub anchor: OccurrenceAnchor,
410 }
411
412 #[derive(Debug, Clone, PartialEq, Eq)]
413 pub struct OccurrenceOverride {
414 pub anchor: OccurrenceAnchor,
415 pub draft: CreateEventDraft,
416 }
417
418 #[derive(Debug, Clone, PartialEq, Eq)]
419 pub struct Event {
420 pub id: String,
421 pub title: String,
422 pub location: Option<String>,
423 pub notes: Option<String>,
424 pub reminders: Vec<Reminder>,
425 pub source: SourceMetadata,
426 pub timing: EventTiming,
427 pub recurrence: Option<RecurrenceRule>,
428 pub occurrence: Option<OccurrenceMetadata>,
429 pub occurrence_overrides: Vec<OccurrenceOverride>,
430 pub deleted_occurrences: Vec<OccurrenceAnchor>,
431 }
432
433 impl Event {
434 pub fn all_day(
435 id: impl Into<String>,
436 title: impl Into<String>,
437 date: CalendarDate,
438 source: SourceMetadata,
439 ) -> Self {
440 Self {
441 id: id.into(),
442 title: title.into(),
443 location: None,
444 notes: None,
445 reminders: Vec::new(),
446 source,
447 timing: EventTiming::AllDay { date },
448 recurrence: None,
449 occurrence: None,
450 occurrence_overrides: Vec::new(),
451 deleted_occurrences: Vec::new(),
452 }
453 }
454
455 pub fn timed(
456 id: impl Into<String>,
457 title: impl Into<String>,
458 start: EventDateTime,
459 end: EventDateTime,
460 source: SourceMetadata,
461 ) -> Result<Self, AgendaError> {
462 if start >= end {
463 return Err(AgendaError::InvalidEventRange { start, end });
464 }
465
466 Ok(Self {
467 id: id.into(),
468 title: title.into(),
469 location: None,
470 notes: None,
471 reminders: Vec::new(),
472 source,
473 timing: EventTiming::Timed { start, end },
474 recurrence: None,
475 occurrence: None,
476 occurrence_overrides: Vec::new(),
477 deleted_occurrences: Vec::new(),
478 })
479 }
480
481 pub fn with_location(mut self, location: impl Into<String>) -> Self {
482 self.location = Some(location.into());
483 self
484 }
485
486 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
487 self.notes = Some(notes.into());
488 self
489 }
490
491 pub fn with_reminders(mut self, reminders: Vec<Reminder>) -> Self {
492 self.reminders = reminders;
493 self
494 }
495
496 pub fn with_recurrence(mut self, recurrence: RecurrenceRule) -> Self {
497 self.recurrence = Some(recurrence);
498 self
499 }
500
501 pub const fn is_all_day(&self) -> bool {
502 self.timing.is_all_day()
503 }
504
505 pub const fn is_timed(&self) -> bool {
506 self.timing.is_timed()
507 }
508
509 pub fn is_local(&self) -> bool {
510 self.source.source_id == "local"
511 }
512
513 pub fn is_microsoft(&self) -> bool {
514 self.source.source_id.starts_with("microsoft:")
515 }
516
517 pub fn is_provider_backed(&self) -> bool {
518 EventWriteTargetId::from_event(self)
519 .as_ref()
520 .is_some_and(|target| !target.is_local())
521 }
522
523 pub fn is_editable(&self) -> bool {
524 self.is_local() || self.is_provider_backed()
525 }
526
527 pub const fn is_recurring_series(&self) -> bool {
528 self.recurrence.is_some()
529 }
530
531 pub const fn occurrence(&self) -> Option<&OccurrenceMetadata> {
532 self.occurrence.as_ref()
533 }
534
535 pub fn intersects_range(&self, range: DateRange) -> bool {
536 match self.timing {
537 EventTiming::AllDay { date } => range.contains_date(date),
538 EventTiming::Timed { start, end } => {
539 let range_start = EventDateTime::new(range.start, Time::MIDNIGHT);
540 let range_end = EventDateTime::new(range.end, Time::MIDNIGHT);
541
542 start < range_end && end > range_start
543 }
544 }
545 }
546 }
547
548 #[derive(Debug, Clone, PartialEq, Eq)]
549 pub struct CreateEventDraft {
550 pub title: String,
551 pub timing: CreateEventTiming,
552 pub location: Option<String>,
553 pub notes: Option<String>,
554 pub reminders: Vec<Reminder>,
555 pub recurrence: Option<RecurrenceRule>,
556 }
557
558 impl CreateEventDraft {
559 pub fn from_event(event: &Event) -> Self {
560 Self {
561 title: event.title.clone(),
562 timing: match event.timing {
563 EventTiming::AllDay { date } => CreateEventTiming::AllDay { date },
564 EventTiming::Timed { start, end } => CreateEventTiming::Timed { start, end },
565 },
566 location: event.location.clone(),
567 notes: event.notes.clone(),
568 reminders: event.reminders.clone(),
569 recurrence: event.recurrence.clone(),
570 }
571 }
572
573 pub fn into_event(self, id: String) -> Result<Event, AgendaError> {
574 let source = SourceMetadata::local().with_external_id(id.clone());
575 let mut event = match self.timing {
576 CreateEventTiming::AllDay { date } => Event::all_day(id, self.title, date, source),
577 CreateEventTiming::Timed { start, end } => {
578 Event::timed(id, self.title, start, end, source)?
579 }
580 };
581
582 event.location = self.location;
583 event.notes = self.notes;
584 event.reminders = self.reminders;
585 event.recurrence = self.recurrence;
586 Ok(event)
587 }
588
589 fn without_recurrence(mut self) -> Self {
590 self.recurrence = None;
591 self
592 }
593 }
594
595 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
596 pub enum CreateEventTiming {
597 AllDay {
598 date: CalendarDate,
599 },
600 Timed {
601 start: EventDateTime,
602 end: EventDateTime,
603 },
604 }
605
606 #[derive(Debug, Clone, PartialEq, Eq)]
607 pub struct Holiday {
608 pub id: String,
609 pub name: String,
610 pub date: CalendarDate,
611 pub source: SourceMetadata,
612 }
613
614 impl Holiday {
615 pub fn new(
616 id: impl Into<String>,
617 name: impl Into<String>,
618 date: CalendarDate,
619 source: SourceMetadata,
620 ) -> Self {
621 Self {
622 id: id.into(),
623 name: name.into(),
624 date,
625 source,
626 }
627 }
628 }
629
630 #[derive(Debug, Clone, PartialEq, Eq)]
631 pub struct TimedAgendaEvent {
632 pub event: Event,
633 pub visible_start: DayMinute,
634 pub visible_end: DayMinute,
635 pub starts_before_day: bool,
636 pub ends_after_day: bool,
637 pub overlap_group: usize,
638 }
639
640 impl TimedAgendaEvent {
641 pub fn overlaps(&self, other: &Self) -> bool {
642 self.visible_start < other.visible_end && other.visible_start < self.visible_end
643 }
644 }
645
646 #[derive(Debug, Clone, PartialEq, Eq)]
647 pub struct DayAgenda {
648 pub date: CalendarDate,
649 pub holidays: Vec<Holiday>,
650 pub all_day_events: Vec<Event>,
651 pub timed_events: Vec<TimedAgendaEvent>,
652 }
653
654 impl DayAgenda {
655 pub fn empty(date: CalendarDate) -> Self {
656 Self {
657 date,
658 holidays: Vec::new(),
659 all_day_events: Vec::new(),
660 timed_events: Vec::new(),
661 }
662 }
663
664 pub fn build(
665 date: CalendarDate,
666 events: impl IntoIterator<Item = Event>,
667 holidays: impl IntoIterator<Item = Holiday>,
668 ) -> Self {
669 let range = DateRange::day(date);
670 let events = events
671 .into_iter()
672 .filter(|event| event.intersects_range(range))
673 .collect::<Vec<_>>();
674
675 let mut agenda = Self {
676 date,
677 holidays: holidays
678 .into_iter()
679 .filter(|holiday| holiday.date == date)
680 .collect(),
681 all_day_events: events
682 .iter()
683 .cloned()
684 .filter_map(|event| match event.timing {
685 EventTiming::AllDay { date: event_date } if event_date == date => Some(event),
686 _ => None,
687 })
688 .collect(),
689 timed_events: events_to_timed_agenda_events(date, events),
690 };
691
692 agenda.sort();
693 agenda
694 }
695
696 pub fn from_source<S>(date: CalendarDate, source: &S) -> Self
697 where
698 S: AgendaSource + ?Sized,
699 {
700 let range = DateRange::day(date);
701 Self::build(
702 date,
703 source.events_intersecting(range),
704 source.holidays_in(range),
705 )
706 }
707
708 pub fn is_empty(&self) -> bool {
709 self.holidays.is_empty() && self.all_day_events.is_empty() && self.timed_events.is_empty()
710 }
711
712 fn sort(&mut self) {
713 self.holidays
714 .sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
715 self.all_day_events
716 .sort_by(|left, right| left.title.cmp(&right.title).then(left.id.cmp(&right.id)));
717 self.timed_events.sort_by(|left, right| {
718 left.visible_start
719 .cmp(&right.visible_start)
720 .then(left.visible_end.cmp(&right.visible_end))
721 .then(left.event.title.cmp(&right.event.title))
722 .then(left.event.id.cmp(&right.event.id))
723 });
724 assign_overlap_groups(&mut self.timed_events);
725 }
726 }
727
728 pub trait AgendaSource {
729 fn events_intersecting(&self, range: DateRange) -> Vec<Event>;
730
731 fn holidays_in(&self, range: DateRange) -> Vec<Holiday>;
732
733 fn event_write_targets(&self) -> Vec<EventWriteTarget> {
734 vec![EventWriteTarget::local()]
735 }
736
737 fn default_event_write_target(&self) -> EventWriteTargetId {
738 EventWriteTargetId::Local
739 }
740
741 fn local_event_by_id(&self, _id: &str) -> Option<Event> {
742 None
743 }
744
745 fn editable_event_by_id(&self, id: &str) -> Option<Event> {
746 self.local_event_by_id(id)
747 }
748 }
749
750 #[derive(Debug)]
751 pub struct ConfiguredAgendaSource {
752 events: InMemoryAgendaSource,
753 holidays: HolidayProvider,
754 events_file: Option<PathBuf>,
755 create_target: ProviderCreateTarget,
756 microsoft: Option<MicrosoftProviderRuntime>,
757 google: Option<GoogleProviderRuntime>,
758 }
759
760 impl ConfiguredAgendaSource {
761 pub fn development(holidays: HolidayProvider) -> Self {
762 Self::new(InMemoryAgendaSource::new(), holidays)
763 }
764
765 pub fn new(events: InMemoryAgendaSource, holidays: HolidayProvider) -> Self {
766 Self {
767 events,
768 holidays,
769 events_file: None,
770 create_target: ProviderCreateTarget::Local,
771 microsoft: None,
772 google: None,
773 }
774 }
775
776 pub fn from_events_file(
777 events_file: impl Into<PathBuf>,
778 holidays: HolidayProvider,
779 ) -> Result<Self, LocalEventStoreError> {
780 let events_file = events_file.into();
781 let events = load_events_file(&events_file)?;
782 Ok(Self {
783 events,
784 holidays,
785 events_file: Some(events_file),
786 create_target: ProviderCreateTarget::Local,
787 microsoft: None,
788 google: None,
789 })
790 }
791
792 pub fn with_microsoft_provider(
793 mut self,
794 config: MicrosoftProviderConfig,
795 create_target: ProviderCreateTarget,
796 ) -> Result<Self, LocalEventStoreError> {
797 if config.enabled {
798 self.microsoft = Some(MicrosoftProviderRuntime::load(config).map_err(provider_error)?);
799 self.create_target = create_target;
800 }
801 Ok(self)
802 }
803
804 pub fn with_google_provider(
805 mut self,
806 config: GoogleProviderConfig,
807 create_target: ProviderCreateTarget,
808 ) -> Result<Self, LocalEventStoreError> {
809 if config.enabled {
810 self.google = Some(GoogleProviderRuntime::load(config).map_err(provider_error)?);
811 self.create_target = create_target;
812 }
813 Ok(self)
814 }
815
816 pub fn create_event(&mut self, draft: CreateEventDraft) -> Result<Event, LocalEventStoreError> {
817 let target = self.default_event_write_target();
818 self.create_event_with_target(draft, &target)
819 }
820
821 pub fn create_event_with_target(
822 &mut self,
823 draft: CreateEventDraft,
824 target: &EventWriteTargetId,
825 ) -> Result<Event, LocalEventStoreError> {
826 if let Some(provider_id) = target.provider_id() {
827 if provider_id == "microsoft"
828 && let Some(microsoft) = &mut self.microsoft
829 {
830 let http = ReqwestMicrosoftHttpClient;
831 let token_store = KeyringMicrosoftTokenStore;
832 return microsoft
833 .create_event_in_target(draft, target, &http, &token_store)
834 .map_err(provider_error);
835 }
836 if provider_id == "google"
837 && let Some(google) = &mut self.google
838 {
839 let http = ReqwestMicrosoftHttpClient;
840 let token_store = KeyringGoogleTokenStore;
841 return google
842 .create_event_in_target(draft, target, &http, &token_store)
843 .map_err(provider_error);
844 }
845 return Err(provider_not_configured(provider_id));
846 }
847
848 self.create_local_event(draft)
849 }
850
851 fn create_local_event(
852 &mut self,
853 draft: CreateEventDraft,
854 ) -> Result<Event, LocalEventStoreError> {
855 let id = self.next_local_event_id(&draft.title);
856 let event = draft
857 .into_event(id)
858 .map_err(|err| LocalEventStoreError::Encode {
859 path: self.events_file.clone(),
860 reason: err.to_string(),
861 })?;
862 if let Some(path) = &self.events_file {
863 let mut events = self.events.events().to_vec();
864 events.push(event.clone());
865 write_events_file(path, &events)?;
866 }
867 self.events.push_event(event.clone());
868 Ok(event)
869 }
870
871 pub fn update_event(
872 &mut self,
873 id: &str,
874 draft: CreateEventDraft,
875 ) -> Result<Event, LocalEventStoreError> {
876 let target = self
877 .event_target_for_id(id)
878 .unwrap_or(EventWriteTargetId::Local);
879 self.update_event_with_target(id, draft, &target)
880 }
881
882 pub fn update_event_with_target(
883 &mut self,
884 id: &str,
885 draft: CreateEventDraft,
886 target: &EventWriteTargetId,
887 ) -> Result<Event, LocalEventStoreError> {
888 let current_target = self.event_target_for_id(id);
889 if current_target
890 .as_ref()
891 .is_some_and(|current| current != target)
892 {
893 let created = self.create_event_with_target(draft, target)?;
894 self.delete_event(id)?;
895 return Ok(created);
896 }
897
898 if self.events.local_event_by_id(id).is_none()
899 && let Some(provider_id) = event_id_provider(id)
900 {
901 if provider_id == "microsoft"
902 && let Some(microsoft) = &mut self.microsoft
903 {
904 let http = ReqwestMicrosoftHttpClient;
905 let token_store = KeyringMicrosoftTokenStore;
906 return microsoft
907 .update_event(id, draft, &http, &token_store)
908 .map_err(provider_error);
909 }
910 if provider_id == "google"
911 && let Some(google) = &mut self.google
912 {
913 let http = ReqwestMicrosoftHttpClient;
914 let token_store = KeyringGoogleTokenStore;
915 return google
916 .update_event(id, draft, &http, &token_store)
917 .map_err(provider_error);
918 }
919 return Err(provider_not_configured(provider_id));
920 }
921
922 let mut events = self.events.events().to_vec();
923 let Some(index) = events.iter().position(|event| event.id == id) else {
924 return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
925 };
926 if !events[index].is_local() {
927 return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() });
928 }
929
930 let mut event =
931 draft
932 .into_event(id.to_string())
933 .map_err(|err| LocalEventStoreError::Encode {
934 path: self.events_file.clone(),
935 reason: err.to_string(),
936 })?;
937 let existing_overrides = std::mem::take(&mut events[index].occurrence_overrides);
938 event.occurrence_overrides = existing_overrides
939 .into_iter()
940 .filter(|override_record| event_generates_anchor(&event, override_record.anchor))
941 .collect();
942 let existing_deleted_occurrences = std::mem::take(&mut events[index].deleted_occurrences);
943 event.deleted_occurrences = existing_deleted_occurrences
944 .into_iter()
945 .filter(|anchor| event_generates_anchor(&event, *anchor))
946 .collect();
947 events[index] = event.clone();
948
949 if let Some(path) = &self.events_file {
950 write_events_file(path, &events)?;
951 }
952 self.events.events = events;
953 Ok(event)
954 }
955
956 pub fn update_occurrence(
957 &mut self,
958 series_id: &str,
959 anchor: OccurrenceAnchor,
960 draft: CreateEventDraft,
961 ) -> Result<Event, LocalEventStoreError> {
962 if self.events.local_event_by_id(series_id).is_none()
963 && let Some(provider_id) = event_id_provider(series_id)
964 {
965 if provider_id == "microsoft"
966 && let Some(microsoft) = &mut self.microsoft
967 {
968 let http = ReqwestMicrosoftHttpClient;
969 let token_store = KeyringMicrosoftTokenStore;
970 return microsoft
971 .update_occurrence(series_id, anchor, draft, &http, &token_store)
972 .map_err(provider_error);
973 }
974 if provider_id == "google"
975 && let Some(google) = &mut self.google
976 {
977 let http = ReqwestMicrosoftHttpClient;
978 let token_store = KeyringGoogleTokenStore;
979 return google
980 .update_occurrence(series_id, anchor, draft, &http, &token_store)
981 .map_err(provider_error);
982 }
983 return Err(provider_not_configured(provider_id));
984 }
985
986 let mut events = self.events.events().to_vec();
987 let Some(index) = events.iter().position(|event| event.id == series_id) else {
988 return Err(LocalEventStoreError::EventNotFound {
989 id: series_id.to_string(),
990 });
991 };
992 if !events[index].is_local() || !events[index].is_recurring_series() {
993 return Err(LocalEventStoreError::EventNotEditable {
994 id: series_id.to_string(),
995 });
996 }
997 if !event_generates_anchor(&events[index], anchor) {
998 return Err(LocalEventStoreError::OccurrenceNotFound {
999 id: series_id.to_string(),
1000 anchor: anchor.storage_key(),
1001 });
1002 }
1003
1004 let override_record = OccurrenceOverride {
1005 anchor,
1006 draft: draft.without_recurrence(),
1007 };
1008 if let Some(existing) = events[index]
1009 .occurrence_overrides
1010 .iter_mut()
1011 .find(|existing| existing.anchor == anchor)
1012 {
1013 *existing = override_record;
1014 } else {
1015 events[index].occurrence_overrides.push(override_record);
1016 }
1017 events[index]
1018 .deleted_occurrences
1019 .retain(|deleted_anchor| *deleted_anchor != anchor);
1020
1021 let event = occurrence_override_event(&events[index], anchor).ok_or_else(|| {
1022 LocalEventStoreError::OccurrenceNotFound {
1023 id: series_id.to_string(),
1024 anchor: anchor.storage_key(),
1025 }
1026 })?;
1027
1028 if let Some(path) = &self.events_file {
1029 write_events_file(path, &events)?;
1030 }
1031 self.events.events = events;
1032 Ok(event)
1033 }
1034
1035 pub fn delete_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
1036 if self.events.local_event_by_id(id).is_none()
1037 && let Some(provider_id) = event_id_provider(id)
1038 {
1039 if provider_id == "microsoft"
1040 && let Some(microsoft) = &mut self.microsoft
1041 {
1042 let http = ReqwestMicrosoftHttpClient;
1043 let token_store = KeyringMicrosoftTokenStore;
1044 return microsoft
1045 .delete_event(id, &http, &token_store)
1046 .map_err(provider_error);
1047 }
1048 if provider_id == "google"
1049 && let Some(google) = &mut self.google
1050 {
1051 let http = ReqwestMicrosoftHttpClient;
1052 let token_store = KeyringGoogleTokenStore;
1053 return google
1054 .delete_event(id, &http, &token_store)
1055 .map_err(provider_error);
1056 }
1057 return Err(provider_not_configured(provider_id));
1058 }
1059
1060 let mut events = self.events.events().to_vec();
1061 let Some(index) = events.iter().position(|event| event.id == id) else {
1062 return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
1063 };
1064 if !events[index].is_local() {
1065 return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() });
1066 }
1067
1068 let deleted = events.remove(index);
1069 if let Some(path) = &self.events_file {
1070 write_events_file(path, &events)?;
1071 }
1072 self.events.events = events;
1073 Ok(deleted)
1074 }
1075
1076 pub fn duplicate_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
1077 if self.events.local_event_by_id(id).is_none()
1078 && let Some(provider_id) = event_id_provider(id)
1079 {
1080 if provider_id == "microsoft"
1081 && let Some(microsoft) = &mut self.microsoft
1082 {
1083 let http = ReqwestMicrosoftHttpClient;
1084 let token_store = KeyringMicrosoftTokenStore;
1085 return microsoft
1086 .duplicate_event(id, &http, &token_store)
1087 .map_err(provider_error);
1088 }
1089 if provider_id == "google"
1090 && let Some(google) = &mut self.google
1091 {
1092 let http = ReqwestMicrosoftHttpClient;
1093 let token_store = KeyringGoogleTokenStore;
1094 return google
1095 .duplicate_event(id, &http, &token_store)
1096 .map_err(provider_error);
1097 }
1098 return Err(provider_not_configured(provider_id));
1099 }
1100
1101 let event = self
1102 .events
1103 .local_event_by_id(id)
1104 .ok_or_else(|| LocalEventStoreError::EventNotFound { id: id.to_string() })?;
1105 self.insert_event_copy(event)
1106 }
1107
1108 pub fn duplicate_occurrence(
1109 &mut self,
1110 series_id: &str,
1111 anchor: OccurrenceAnchor,
1112 ) -> Result<Event, LocalEventStoreError> {
1113 if self.events.local_event_by_id(series_id).is_none()
1114 && let Some(provider_id) = event_id_provider(series_id)
1115 {
1116 if provider_id == "microsoft"
1117 && let Some(microsoft) = &mut self.microsoft
1118 {
1119 let http = ReqwestMicrosoftHttpClient;
1120 let token_store = KeyringMicrosoftTokenStore;
1121 return microsoft
1122 .duplicate_occurrence(series_id, anchor, &http, &token_store)
1123 .map_err(provider_error);
1124 }
1125 if provider_id == "google"
1126 && let Some(google) = &mut self.google
1127 {
1128 let http = ReqwestMicrosoftHttpClient;
1129 let token_store = KeyringGoogleTokenStore;
1130 return google
1131 .duplicate_occurrence(series_id, anchor, &http, &token_store)
1132 .map_err(provider_error);
1133 }
1134 return Err(provider_not_configured(provider_id));
1135 }
1136
1137 let series = self.events.local_event_by_id(series_id).ok_or_else(|| {
1138 LocalEventStoreError::EventNotFound {
1139 id: series_id.to_string(),
1140 }
1141 })?;
1142 if !series.is_recurring_series() {
1143 return Err(LocalEventStoreError::EventNotEditable {
1144 id: series_id.to_string(),
1145 });
1146 }
1147 if !event_generates_anchor(&series, anchor) {
1148 return Err(LocalEventStoreError::OccurrenceNotFound {
1149 id: series_id.to_string(),
1150 anchor: anchor.storage_key(),
1151 });
1152 }
1153
1154 let occurrence = occurrence_override_event(&series, anchor)
1155 .unwrap_or_else(|| generated_occurrence_event(&series, anchor));
1156 let draft = CreateEventDraft::from_event(&occurrence).without_recurrence();
1157 self.create_event(draft)
1158 }
1159
1160 pub fn delete_occurrence(
1161 &mut self,
1162 series_id: &str,
1163 anchor: OccurrenceAnchor,
1164 ) -> Result<(), LocalEventStoreError> {
1165 if self.events.local_event_by_id(series_id).is_none()
1166 && let Some(provider_id) = event_id_provider(series_id)
1167 {
1168 if provider_id == "microsoft"
1169 && let Some(microsoft) = &mut self.microsoft
1170 {
1171 let http = ReqwestMicrosoftHttpClient;
1172 let token_store = KeyringMicrosoftTokenStore;
1173 return microsoft
1174 .delete_occurrence(series_id, anchor, &http, &token_store)
1175 .map_err(provider_error);
1176 }
1177 if provider_id == "google"
1178 && let Some(google) = &mut self.google
1179 {
1180 let http = ReqwestMicrosoftHttpClient;
1181 let token_store = KeyringGoogleTokenStore;
1182 return google
1183 .delete_occurrence(series_id, anchor, &http, &token_store)
1184 .map_err(provider_error);
1185 }
1186 return Err(provider_not_configured(provider_id));
1187 }
1188
1189 let mut events = self.events.events().to_vec();
1190 let Some(index) = events.iter().position(|event| event.id == series_id) else {
1191 return Err(LocalEventStoreError::EventNotFound {
1192 id: series_id.to_string(),
1193 });
1194 };
1195 if !events[index].is_local() || !events[index].is_recurring_series() {
1196 return Err(LocalEventStoreError::EventNotEditable {
1197 id: series_id.to_string(),
1198 });
1199 }
1200 if !event_generates_anchor(&events[index], anchor) {
1201 return Err(LocalEventStoreError::OccurrenceNotFound {
1202 id: series_id.to_string(),
1203 anchor: anchor.storage_key(),
1204 });
1205 }
1206
1207 events[index]
1208 .occurrence_overrides
1209 .retain(|override_record| override_record.anchor != anchor);
1210 if !events[index].deleted_occurrences.contains(&anchor) {
1211 events[index].deleted_occurrences.push(anchor);
1212 }
1213
1214 if let Some(path) = &self.events_file {
1215 write_events_file(path, &events)?;
1216 }
1217 self.events.events = events;
1218 Ok(())
1219 }
1220
1221 fn insert_event_copy(&mut self, mut event: Event) -> Result<Event, LocalEventStoreError> {
1222 let id = self.next_local_event_id(&event.title);
1223 event.id = id.clone();
1224 event.source = SourceMetadata::local().with_external_id(id);
1225 event.occurrence = None;
1226
1227 if let Some(path) = &self.events_file {
1228 let mut events = self.events.events().to_vec();
1229 events.push(event.clone());
1230 write_events_file(path, &events)?;
1231 }
1232 self.events.push_event(event.clone());
1233 Ok(event)
1234 }
1235
1236 fn event_target_for_id(&self, id: &str) -> Option<EventWriteTargetId> {
1237 self.events
1238 .local_event_by_id(id)
1239 .as_ref()
1240 .and_then(EventWriteTargetId::from_event)
1241 .or_else(|| {
1242 let microsoft = self.microsoft.as_ref()?;
1243 let event = microsoft.agenda_source().editable_event_by_id(id)?;
1244 EventWriteTargetId::from_event(&event)
1245 })
1246 .or_else(|| {
1247 let google = self.google.as_ref()?;
1248 let event = google.agenda_source().editable_event_by_id(id)?;
1249 EventWriteTargetId::from_event(&event)
1250 })
1251 }
1252
1253 fn next_local_event_id(&self, title: &str) -> String {
1254 let now = SystemTime::now()
1255 .duration_since(UNIX_EPOCH)
1256 .map(|duration| duration.as_millis())
1257 .unwrap_or_default();
1258 let counter = self.events.events().len() + 1;
1259 let slug = slugify(title);
1260 if slug.is_empty() {
1261 format!("local-{now}-{counter}")
1262 } else {
1263 format!("local-{now}-{counter}-{slug}")
1264 }
1265 }
1266 }
1267
1268 impl AgendaSource for ConfiguredAgendaSource {
1269 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
1270 let mut events = self.events.events_intersecting(range);
1271 if let Some(microsoft) = &self.microsoft {
1272 events.extend(microsoft.agenda_source().events_intersecting(range));
1273 }
1274 if let Some(google) = &self.google {
1275 events.extend(google.agenda_source().events_intersecting(range));
1276 }
1277 events.sort_by(|left, right| {
1278 event_sort_key(left)
1279 .cmp(&event_sort_key(right))
1280 .then(left.id.cmp(&right.id))
1281 });
1282 events
1283 }
1284
1285 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
1286 self.holidays.holidays_in(range)
1287 }
1288
1289 fn event_write_targets(&self) -> Vec<EventWriteTarget> {
1290 let mut targets = vec![EventWriteTarget::local()];
1291 if let Some(microsoft) = &self.microsoft {
1292 targets.extend(microsoft.write_targets());
1293 }
1294 if let Some(google) = &self.google {
1295 targets.extend(google.write_targets());
1296 }
1297 targets
1298 }
1299
1300 fn default_event_write_target(&self) -> EventWriteTargetId {
1301 if self.create_target == ProviderCreateTarget::Microsoft
1302 && let Some(target) = self
1303 .microsoft
1304 .as_ref()
1305 .and_then(MicrosoftProviderRuntime::default_write_target)
1306 {
1307 return target;
1308 }
1309 if self.create_target == ProviderCreateTarget::Google
1310 && let Some(target) = self
1311 .google
1312 .as_ref()
1313 .and_then(GoogleProviderRuntime::default_write_target)
1314 {
1315 return target;
1316 }
1317 EventWriteTargetId::Local
1318 }
1319
1320 fn local_event_by_id(&self, id: &str) -> Option<Event> {
1321 self.events.local_event_by_id(id)
1322 }
1323
1324 fn editable_event_by_id(&self, id: &str) -> Option<Event> {
1325 self.events
1326 .local_event_by_id(id)
1327 .or_else(|| {
1328 self.microsoft
1329 .as_ref()
1330 .and_then(|microsoft| microsoft.agenda_source().event_by_id(id))
1331 })
1332 .or_else(|| {
1333 self.google
1334 .as_ref()
1335 .and_then(|google| google.agenda_source().event_by_id(id))
1336 })
1337 }
1338 }
1339
1340 fn provider_error(err: ProviderError) -> LocalEventStoreError {
1341 LocalEventStoreError::Provider {
1342 reason: err.to_string(),
1343 }
1344 }
1345
1346 fn provider_not_configured(provider_id: &str) -> LocalEventStoreError {
1347 LocalEventStoreError::Provider {
1348 reason: format!("provider '{provider_id}' is not configured"),
1349 }
1350 }
1351
1352 fn event_id_provider(id: &str) -> Option<&str> {
1353 id.split_once(':')
1354 .map(|(provider, _)| provider)
1355 .filter(|provider| !provider.is_empty())
1356 }
1357
1358 #[derive(Debug)]
1359 pub enum HolidayProvider {
1360 Off(EmptyAgendaSource),
1361 UsFederal(UsFederalHolidaySource),
1362 Nager(NagerHolidaySource),
1363 }
1364
1365 impl HolidayProvider {
1366 pub const fn off() -> Self {
1367 Self::Off(EmptyAgendaSource)
1368 }
1369
1370 pub const fn us_federal() -> Self {
1371 Self::UsFederal(UsFederalHolidaySource)
1372 }
1373
1374 pub fn nager(country_code: impl Into<String>) -> Self {
1375 Self::Nager(NagerHolidaySource::new(country_code))
1376 }
1377 }
1378
1379 impl AgendaSource for HolidayProvider {
1380 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
1381 Vec::new()
1382 }
1383
1384 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
1385 match self {
1386 Self::Off(source) => source.holidays_in(range),
1387 Self::UsFederal(source) => source.holidays_in(range),
1388 Self::Nager(source) => source.holidays_in(range),
1389 }
1390 }
1391 }
1392
1393 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1394 pub struct EmptyAgendaSource;
1395
1396 impl AgendaSource for EmptyAgendaSource {
1397 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
1398 Vec::new()
1399 }
1400
1401 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
1402 Vec::new()
1403 }
1404 }
1405
1406 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1407 pub struct UsFederalHolidaySource;
1408
1409 impl AgendaSource for UsFederalHolidaySource {
1410 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
1411 Vec::new()
1412 }
1413
1414 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
1415 let mut holidays = Vec::new();
1416
1417 for year in range.start.year() - 1..=range.end.year() + 1 {
1418 holidays.extend(us_federal_holidays_for_year(year));
1419 }
1420
1421 holidays.retain(|holiday| range.contains_date(holiday.date));
1422 holidays.sort_by(|left, right| left.date.cmp(&right.date).then(left.name.cmp(&right.name)));
1423 holidays.dedup_by(|left, right| left.id == right.id);
1424 holidays
1425 }
1426 }
1427
1428 #[derive(Debug)]
1429 pub struct NagerHolidaySource {
1430 country_code: String,
1431 cache_dir: PathBuf,
1432 timeout: Duration,
1433 state: RefCell<NagerHolidayState>,
1434 }
1435
1436 impl NagerHolidaySource {
1437 pub fn new(country_code: impl Into<String>) -> Self {
1438 Self::with_cache_dir(
1439 country_code,
1440 default_nager_cache_dir(),
1441 Duration::from_millis(1500),
1442 )
1443 }
1444
1445 pub fn with_cache_dir(
1446 country_code: impl Into<String>,
1447 cache_dir: impl Into<PathBuf>,
1448 timeout: Duration,
1449 ) -> Self {
1450 Self {
1451 country_code: country_code.into().to_ascii_uppercase(),
1452 cache_dir: cache_dir.into(),
1453 timeout,
1454 state: RefCell::new(NagerHolidayState::default()),
1455 }
1456 }
1457
1458 fn ensure_year_loaded(&self, year: i32) {
1459 if self.state.borrow().attempted_years.contains(&year) {
1460 return;
1461 }
1462
1463 let holidays = self.load_year(year).unwrap_or_default();
1464 let mut state = self.state.borrow_mut();
1465 state.attempted_years.insert(year);
1466 state.holidays_by_year.insert(year, holidays);
1467 }
1468
1469 fn load_year(&self, year: i32) -> Option<Vec<Holiday>> {
1470 if let Some(cached) = self.load_year_from_cache(year) {
1471 return Some(cached);
1472 }
1473
1474 let body = self.fetch_year(year)?;
1475 let holidays = parse_nager_holidays(&self.country_code, &body)?;
1476 self.write_year_cache(year, &body);
1477 Some(holidays)
1478 }
1479
1480 fn load_year_from_cache(&self, year: i32) -> Option<Vec<Holiday>> {
1481 let body = fs::read_to_string(self.cache_file(year)).ok()?;
1482 parse_nager_holidays(&self.country_code, &body)
1483 }
1484
1485 fn fetch_year(&self, year: i32) -> Option<String> {
1486 let client = reqwest::blocking::Client::builder()
1487 .timeout(self.timeout)
1488 .user_agent("rcal/0.1")
1489 .build()
1490 .ok()?;
1491 let url = format!(
1492 "https://date.nager.at/api/v3/PublicHolidays/{year}/{}",
1493 self.country_code
1494 );
1495 let response = client.get(url).send().ok()?;
1496
1497 if !response.status().is_success() {
1498 return None;
1499 }
1500
1501 response.text().ok()
1502 }
1503
1504 fn write_year_cache(&self, year: i32, body: &str) {
1505 let file = self.cache_file(year);
1506 if let Some(parent) = file.parent() {
1507 let _ = fs::create_dir_all(parent);
1508 }
1509 let _ = fs::write(file, body);
1510 }
1511
1512 fn cache_file(&self, year: i32) -> PathBuf {
1513 self.cache_dir
1514 .join(&self.country_code)
1515 .join(format!("{year}.json"))
1516 }
1517 }
1518
1519 impl AgendaSource for NagerHolidaySource {
1520 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
1521 Vec::new()
1522 }
1523
1524 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
1525 let final_date = range.end.add_days(-1);
1526 for year in range.start.year()..=final_date.year() {
1527 self.ensure_year_loaded(year);
1528 }
1529
1530 let state = self.state.borrow();
1531 let mut holidays = state
1532 .holidays_by_year
1533 .values()
1534 .flatten()
1535 .filter(|holiday| range.contains_date(holiday.date))
1536 .cloned()
1537 .collect::<Vec<_>>();
1538 holidays.sort_by(|left, right| left.date.cmp(&right.date).then(left.name.cmp(&right.name)));
1539 holidays
1540 }
1541 }
1542
1543 #[derive(Debug, Default)]
1544 struct NagerHolidayState {
1545 holidays_by_year: HashMap<i32, Vec<Holiday>>,
1546 attempted_years: HashSet<i32>,
1547 }
1548
1549 #[derive(Debug, Default, Clone, PartialEq, Eq)]
1550 pub struct InMemoryAgendaSource {
1551 events: Vec<Event>,
1552 holidays: Vec<Holiday>,
1553 }
1554
1555 impl InMemoryAgendaSource {
1556 pub fn new() -> Self {
1557 Self {
1558 events: Vec::new(),
1559 holidays: Vec::new(),
1560 }
1561 }
1562
1563 pub fn with_events_and_holidays(events: Vec<Event>, holidays: Vec<Holiday>) -> Self {
1564 Self { events, holidays }
1565 }
1566
1567 pub fn push_event(&mut self, event: Event) {
1568 self.events.push(event);
1569 }
1570
1571 pub fn events(&self) -> &[Event] {
1572 &self.events
1573 }
1574
1575 pub fn push_holiday(&mut self, holiday: Holiday) {
1576 self.holidays.push(holiday);
1577 }
1578
1579 pub fn development_fixture() -> Self {
1580 let date = CalendarDate::from_ymd(2026, Month::April, 23).expect("fixture date is valid");
1581 let source = SourceMetadata::fixture();
1582
1583 Self {
1584 events: vec![
1585 Event::all_day("release-day", "Release day", date, source.clone()),
1586 Event::timed(
1587 "standup",
1588 "Standup",
1589 EventDateTime::new(date, time(9, 0)),
1590 EventDateTime::new(date, time(9, 30)),
1591 source.clone(),
1592 )
1593 .expect("fixture event range is valid"),
1594 Event::timed(
1595 "review",
1596 "Review",
1597 EventDateTime::new(date, time(9, 15)),
1598 EventDateTime::new(date, time(10, 0)),
1599 source.clone(),
1600 )
1601 .expect("fixture event range is valid"),
1602 Event::timed(
1603 "deploy",
1604 "Late deploy",
1605 EventDateTime::new(date, time(23, 0)),
1606 EventDateTime::new(date.add_days(1), time(1, 0)),
1607 source.clone(),
1608 )
1609 .expect("fixture event range is valid"),
1610 ],
1611 holidays: Vec::new(),
1612 }
1613 }
1614 }
1615
1616 impl AgendaSource for InMemoryAgendaSource {
1617 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
1618 let mut events = self
1619 .events
1620 .iter()
1621 .flat_map(|event| events_intersecting_range(event, range))
1622 .collect::<Vec<_>>();
1623 events.sort_by(|left, right| {
1624 event_sort_key(left)
1625 .cmp(&event_sort_key(right))
1626 .then(left.id.cmp(&right.id))
1627 });
1628 events
1629 }
1630
1631 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
1632 self.holidays
1633 .iter()
1634 .filter(|holiday| range.contains_date(holiday.date))
1635 .cloned()
1636 .collect()
1637 }
1638
1639 fn local_event_by_id(&self, id: &str) -> Option<Event> {
1640 self.events
1641 .iter()
1642 .find(|event| event.id == id && event.is_local())
1643 .cloned()
1644 }
1645 }
1646
1647 fn events_intersecting_range(event: &Event, range: DateRange) -> Vec<Event> {
1648 if event.recurrence.is_none() {
1649 return event
1650 .intersects_range(range)
1651 .then(|| event.clone())
1652 .into_iter()
1653 .collect();
1654 }
1655
1656 expand_recurring_event(event, range)
1657 .into_iter()
1658 .filter(|event| event.intersects_range(range))
1659 .collect()
1660 }
1661
1662 fn expand_recurring_event(event: &Event, range: DateRange) -> Vec<Event> {
1663 let Some(recurrence) = &event.recurrence else {
1664 return Vec::new();
1665 };
1666 let Some(start_date) = event_start_date(event) else {
1667 return Vec::new();
1668 };
1669
1670 let mut events = Vec::new();
1671 let final_date = range.end.add_days(-1);
1672 let mut date = start_date;
1673 let mut generated_count = 0_u32;
1674
1675 while date <= final_date {
1676 if let RecurrenceEnd::Until(until) = recurrence.end
1677 && date > until
1678 {
1679 break;
1680 }
1681
1682 if recurs_on_date(date, start_date, recurrence) {
1683 generated_count = generated_count.saturating_add(1);
1684 if let RecurrenceEnd::Count(max_count) = recurrence.end
1685 && generated_count > max_count
1686 {
1687 break;
1688 }
1689
1690 let anchor = occurrence_anchor_for_date(event, date);
1691 if event.deleted_occurrences.contains(&anchor) {
1692 date = date.add_days(1);
1693 continue;
1694 }
1695 let instance = occurrence_override_event(event, anchor)
1696 .unwrap_or_else(|| generated_occurrence_event(event, anchor));
1697 events.push(instance);
1698 }
1699
1700 date = date.add_days(1);
1701 }
1702
1703 events
1704 }
1705
1706 fn event_generates_anchor(event: &Event, anchor: OccurrenceAnchor) -> bool {
1707 let Some(recurrence) = &event.recurrence else {
1708 return false;
1709 };
1710 let Some(start_date) = event_start_date(event) else {
1711 return false;
1712 };
1713 if anchor.date() < start_date || !recurs_on_date(anchor.date(), start_date, recurrence) {
1714 return false;
1715 }
1716 if !anchor_is_within_recurrence_end(anchor.date(), start_date, recurrence) {
1717 return false;
1718 }
1719 occurrence_anchor_for_date(event, anchor.date()) == anchor
1720 }
1721
1722 fn anchor_is_within_recurrence_end(
1723 anchor_date: CalendarDate,
1724 start_date: CalendarDate,
1725 recurrence: &RecurrenceRule,
1726 ) -> bool {
1727 if let RecurrenceEnd::Until(until) = recurrence.end
1728 && anchor_date > until
1729 {
1730 return false;
1731 }
1732
1733 if let RecurrenceEnd::Count(max_count) = recurrence.end {
1734 let mut count = 0_u32;
1735 let mut date = start_date;
1736 while date <= anchor_date {
1737 if recurs_on_date(date, start_date, recurrence) {
1738 count = count.saturating_add(1);
1739 }
1740 date = date.add_days(1);
1741 }
1742 return count <= max_count;
1743 }
1744
1745 true
1746 }
1747
1748 fn occurrence_override_event(series: &Event, anchor: OccurrenceAnchor) -> Option<Event> {
1749 let override_record = series
1750 .occurrence_overrides
1751 .iter()
1752 .find(|override_record| override_record.anchor == anchor)?;
1753 occurrence_event_from_draft(series, anchor, override_record.draft.clone()).ok()
1754 }
1755
1756 fn generated_occurrence_event(series: &Event, anchor: OccurrenceAnchor) -> Event {
1757 let mut event = series.clone();
1758 event.id = occurrence_event_id(series, anchor);
1759 event.timing = occurrence_timing(series, anchor);
1760 event.occurrence = Some(OccurrenceMetadata {
1761 series_id: series.id.clone(),
1762 anchor,
1763 });
1764 event.recurrence = None;
1765 event.occurrence_overrides = Vec::new();
1766 event.deleted_occurrences = Vec::new();
1767 event
1768 }
1769
1770 fn occurrence_event_from_draft(
1771 series: &Event,
1772 anchor: OccurrenceAnchor,
1773 draft: CreateEventDraft,
1774 ) -> Result<Event, AgendaError> {
1775 let mut event = draft
1776 .without_recurrence()
1777 .into_event(occurrence_event_id(series, anchor))?;
1778 event.source = series.source.clone();
1779 event.occurrence = Some(OccurrenceMetadata {
1780 series_id: series.id.clone(),
1781 anchor,
1782 });
1783 Ok(event)
1784 }
1785
1786 fn occurrence_event_id(series: &Event, anchor: OccurrenceAnchor) -> String {
1787 format!("{}#{}", series.id, anchor.storage_key())
1788 }
1789
1790 fn occurrence_anchor_for_date(event: &Event, date: CalendarDate) -> OccurrenceAnchor {
1791 match event.timing {
1792 EventTiming::AllDay { .. } => OccurrenceAnchor::AllDay { date },
1793 EventTiming::Timed { start, .. } => OccurrenceAnchor::Timed {
1794 start: EventDateTime::new(date, start.time),
1795 },
1796 }
1797 }
1798
1799 fn occurrence_timing(event: &Event, anchor: OccurrenceAnchor) -> EventTiming {
1800 match (event.timing, anchor) {
1801 (EventTiming::AllDay { .. }, OccurrenceAnchor::AllDay { date }) => {
1802 EventTiming::AllDay { date }
1803 }
1804 (
1805 EventTiming::Timed { start, end },
1806 OccurrenceAnchor::Timed {
1807 start: anchor_start,
1808 },
1809 ) => {
1810 let duration_minutes = datetime_distance_minutes(start, end);
1811 EventTiming::Timed {
1812 start: anchor_start,
1813 end: add_minutes(anchor_start, duration_minutes),
1814 }
1815 }
1816 _ => event.timing,
1817 }
1818 }
1819
1820 fn event_start_date(event: &Event) -> Option<CalendarDate> {
1821 match event.timing {
1822 EventTiming::AllDay { date } => Some(date),
1823 EventTiming::Timed { start, .. } => Some(start.date),
1824 }
1825 }
1826
1827 fn recurs_on_date(date: CalendarDate, start_date: CalendarDate, rule: &RecurrenceRule) -> bool {
1828 if date < start_date {
1829 return false;
1830 }
1831
1832 match rule.frequency {
1833 RecurrenceFrequency::Daily => {
1834 days_between(start_date, date) % i32::from(rule.interval()) == 0
1835 }
1836 RecurrenceFrequency::Weekly => {
1837 let week_index = calendar_weeks_between(start_date, date);
1838 let weekdays = recurrence_weekdays(rule, start_date);
1839 week_index % i32::from(rule.interval()) == 0 && weekdays.contains(&date.weekday())
1840 }
1841 RecurrenceFrequency::Monthly => {
1842 let months = months_between(start_date, date);
1843 if months < 0 || months % i32::from(rule.interval()) != 0 {
1844 return false;
1845 }
1846 let monthly = rule
1847 .monthly
1848 .unwrap_or(RecurrenceMonthlyRule::DayOfMonth(start_date.day()));
1849 match monthly {
1850 RecurrenceMonthlyRule::DayOfMonth(day) => {
1851 CalendarDate::from_ymd(date.year(), date.month(), day).ok() == Some(date)
1852 }
1853 RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => {
1854 weekday_ordinal_date(date.year(), date.month(), ordinal, weekday) == Some(date)
1855 }
1856 }
1857 }
1858 RecurrenceFrequency::Yearly => {
1859 let years = date.year() - start_date.year();
1860 if years < 0 || years % i32::from(rule.interval()) != 0 {
1861 return false;
1862 }
1863 let yearly = rule.yearly.unwrap_or(RecurrenceYearlyRule::Date {
1864 month: start_date.month(),
1865 day: start_date.day(),
1866 });
1867 match yearly {
1868 RecurrenceYearlyRule::Date { month, day } => {
1869 CalendarDate::from_ymd(date.year(), month, day).ok() == Some(date)
1870 }
1871 RecurrenceYearlyRule::WeekdayOrdinal {
1872 month,
1873 ordinal,
1874 weekday,
1875 } => {
1876 date.month() == month
1877 && weekday_ordinal_date(date.year(), month, ordinal, weekday) == Some(date)
1878 }
1879 }
1880 }
1881 }
1882 }
1883
1884 fn recurrence_weekdays(rule: &RecurrenceRule, start_date: CalendarDate) -> Vec<Weekday> {
1885 if rule.weekdays.is_empty() {
1886 vec![start_date.weekday()]
1887 } else {
1888 rule.weekdays.clone()
1889 }
1890 }
1891
1892 fn calendar_weeks_between(start: CalendarDate, end: CalendarDate) -> i32 {
1893 let start_week = sunday_of_week(start);
1894 let end_week = sunday_of_week(end);
1895 days_between(start_week, end_week) / 7
1896 }
1897
1898 fn sunday_of_week(date: CalendarDate) -> CalendarDate {
1899 date.add_days(-i32::from(date.weekday().number_days_from_sunday()))
1900 }
1901
1902 fn weekday_ordinal_date(
1903 year: i32,
1904 month: Month,
1905 ordinal: RecurrenceOrdinal,
1906 weekday: Weekday,
1907 ) -> Option<CalendarDate> {
1908 match ordinal {
1909 RecurrenceOrdinal::Number(number) if (1..=4).contains(&number) => {
1910 let first = CalendarDate::from_ymd(year, month, 1).ok()?;
1911 let first_weekday = first.weekday().number_days_from_sunday();
1912 let target_weekday = weekday.number_days_from_sunday();
1913 let offset = (target_weekday + 7 - first_weekday) % 7;
1914 let day = 1 + offset + (number - 1) * 7;
1915 CalendarDate::from_ymd(year, month, day).ok()
1916 }
1917 RecurrenceOrdinal::Last => {
1918 let mut date = CalendarDate::from_ymd(year, month, month.length(year)).ok()?;
1919 while date.weekday() != weekday {
1920 date = date.add_days(-1);
1921 }
1922 Some(date)
1923 }
1924 _ => None,
1925 }
1926 }
1927
1928 pub fn recurrence_ordinal_for_date(date: CalendarDate) -> RecurrenceOrdinal {
1929 if date.day().saturating_add(7) > date.month().length(date.year()) {
1930 RecurrenceOrdinal::Last
1931 } else {
1932 RecurrenceOrdinal::Number(((date.day() - 1) / 7) + 1)
1933 }
1934 }
1935
1936 fn days_between(start: CalendarDate, end: CalendarDate) -> i32 {
1937 end.inner().to_julian_day() - start.inner().to_julian_day()
1938 }
1939
1940 fn months_between(start: CalendarDate, end: CalendarDate) -> i32 {
1941 (end.year() - start.year()) * 12 + i32::from(u8::from(end.month()))
1942 - i32::from(u8::from(start.month()))
1943 }
1944
1945 fn datetime_distance_minutes(start: EventDateTime, end: EventDateTime) -> i32 {
1946 days_between(start.date, end.date) * 24 * 60 + time_minutes(end.time) - time_minutes(start.time)
1947 }
1948
1949 fn add_minutes(start: EventDateTime, duration_minutes: i32) -> EventDateTime {
1950 let absolute_minutes = time_minutes(start.time) + duration_minutes;
1951 let day_offset = absolute_minutes.div_euclid(24 * 60);
1952 let minute_of_day = absolute_minutes.rem_euclid(24 * 60);
1953 EventDateTime::new(
1954 start.date.add_days(day_offset),
1955 Time::from_hms(
1956 u8::try_from(minute_of_day / 60).expect("hour stays in range"),
1957 u8::try_from(minute_of_day % 60).expect("minute stays in range"),
1958 0,
1959 )
1960 .expect("computed time is valid"),
1961 )
1962 }
1963
1964 fn time_minutes(time: Time) -> i32 {
1965 i32::from(time.hour()) * 60 + i32::from(time.minute())
1966 }
1967
1968 fn event_sort_key(event: &Event) -> (CalendarDate, DayMinute, String) {
1969 match event.timing {
1970 EventTiming::AllDay { date } => (date, DayMinute::START, event.title.clone()),
1971 EventTiming::Timed { start, .. } => (
1972 start.date,
1973 DayMinute::from_time(start.time),
1974 event.title.clone(),
1975 ),
1976 }
1977 }
1978
1979 pub fn default_events_file() -> PathBuf {
1980 if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
1981 return PathBuf::from(data_home).join("rcal").join("events.json");
1982 }
1983
1984 if let Some(home) = env::var_os("HOME") {
1985 return PathBuf::from(home)
1986 .join(".local")
1987 .join("share")
1988 .join("rcal")
1989 .join("events.json");
1990 }
1991
1992 env::temp_dir().join("rcal").join("events.json")
1993 }
1994
1995 #[derive(Debug, Clone, PartialEq, Eq)]
1996 pub enum LocalEventStoreError {
1997 Read {
1998 path: PathBuf,
1999 reason: String,
2000 },
2001 Parse {
2002 path: PathBuf,
2003 reason: String,
2004 },
2005 UnsupportedVersion {
2006 path: PathBuf,
2007 version: u8,
2008 },
2009 EventNotFound {
2010 id: String,
2011 },
2012 OccurrenceNotFound {
2013 id: String,
2014 anchor: String,
2015 },
2016 EventNotEditable {
2017 id: String,
2018 },
2019 Encode {
2020 path: Option<PathBuf>,
2021 reason: String,
2022 },
2023 Provider {
2024 reason: String,
2025 },
2026 Write {
2027 path: PathBuf,
2028 reason: String,
2029 },
2030 }
2031
2032 impl fmt::Display for LocalEventStoreError {
2033 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2034 match self {
2035 Self::Read { path, reason } => {
2036 write!(f, "failed to read {}: {reason}", path.display())
2037 }
2038 Self::Parse { path, reason } => {
2039 write!(f, "failed to parse {}: {reason}", path.display())
2040 }
2041 Self::UnsupportedVersion { path, version } => write!(
2042 f,
2043 "unsupported local events file version {version} in {}",
2044 path.display()
2045 ),
2046 Self::EventNotFound { id } => write!(f, "local event '{id}' was not found"),
2047 Self::OccurrenceNotFound { id, anchor } => {
2048 write!(
2049 f,
2050 "recurring occurrence '{anchor}' was not found for local event '{id}'"
2051 )
2052 }
2053 Self::EventNotEditable { id } => write!(f, "event '{id}' is not editable locally"),
2054 Self::Encode { path, reason } => {
2055 if let Some(path) = path {
2056 write!(f, "failed to encode {}: {reason}", path.display())
2057 } else {
2058 write!(f, "failed to encode local event: {reason}")
2059 }
2060 }
2061 Self::Provider { reason } => write!(f, "{reason}"),
2062 Self::Write { path, reason } => {
2063 write!(f, "failed to write {}: {reason}", path.display())
2064 }
2065 }
2066 }
2067 }
2068
2069 impl Error for LocalEventStoreError {}
2070
2071 fn load_events_file(path: &Path) -> Result<InMemoryAgendaSource, LocalEventStoreError> {
2072 let body = match fs::read_to_string(path) {
2073 Ok(body) => body,
2074 Err(err) if err.kind() == io::ErrorKind::NotFound => {
2075 return Ok(InMemoryAgendaSource::new());
2076 }
2077 Err(err) => {
2078 return Err(LocalEventStoreError::Read {
2079 path: path.to_path_buf(),
2080 reason: err.to_string(),
2081 });
2082 }
2083 };
2084
2085 let file = serde_json::from_str::<LocalEventsFile>(&body).map_err(|err| {
2086 LocalEventStoreError::Parse {
2087 path: path.to_path_buf(),
2088 reason: err.to_string(),
2089 }
2090 })?;
2091
2092 if !matches!(file.version, 1 | LOCAL_EVENTS_VERSION) {
2093 return Err(LocalEventStoreError::UnsupportedVersion {
2094 path: path.to_path_buf(),
2095 version: file.version,
2096 });
2097 }
2098
2099 let events = file
2100 .events
2101 .into_iter()
2102 .map(|record| record.into_event(path))
2103 .collect::<Result<Vec<_>, _>>()?;
2104 Ok(InMemoryAgendaSource::with_events_and_holidays(
2105 events,
2106 Vec::new(),
2107 ))
2108 }
2109
2110 fn write_events_file(path: &Path, events: &[Event]) -> Result<(), LocalEventStoreError> {
2111 if let Some(parent) = path.parent() {
2112 fs::create_dir_all(parent).map_err(|err| LocalEventStoreError::Write {
2113 path: parent.to_path_buf(),
2114 reason: err.to_string(),
2115 })?;
2116 }
2117
2118 let file = LocalEventsFile {
2119 version: LOCAL_EVENTS_VERSION,
2120 events: events.iter().map(LocalEventRecord::from_event).collect(),
2121 };
2122 let body = serde_json::to_string_pretty(&file).map_err(|err| LocalEventStoreError::Encode {
2123 path: Some(path.to_path_buf()),
2124 reason: err.to_string(),
2125 })?;
2126 let temp_path = path.with_extension(format!(
2127 "{}.tmp",
2128 path.extension()
2129 .and_then(|extension| extension.to_str())
2130 .unwrap_or("json")
2131 ));
2132
2133 fs::write(&temp_path, body).map_err(|err| LocalEventStoreError::Write {
2134 path: temp_path.clone(),
2135 reason: err.to_string(),
2136 })?;
2137 fs::rename(&temp_path, path).map_err(|err| LocalEventStoreError::Write {
2138 path: path.to_path_buf(),
2139 reason: err.to_string(),
2140 })
2141 }
2142
2143 const LOCAL_EVENTS_VERSION: u8 = 2;
2144
2145 #[derive(Debug, Serialize, Deserialize)]
2146 struct LocalEventsFile {
2147 version: u8,
2148 #[serde(default)]
2149 events: Vec<LocalEventRecord>,
2150 }
2151
2152 #[derive(Debug, Serialize, Deserialize)]
2153 #[serde(untagged)]
2154 enum LocalEventRecord {
2155 Timed {
2156 id: String,
2157 title: String,
2158 start_date: String,
2159 start_time: String,
2160 end_date: String,
2161 end_time: String,
2162 #[serde(default, skip_serializing_if = "Option::is_none")]
2163 location: Option<String>,
2164 #[serde(default, skip_serializing_if = "Option::is_none")]
2165 notes: Option<String>,
2166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2167 reminders_minutes_before: Vec<u16>,
2168 #[serde(default, skip_serializing_if = "Option::is_none")]
2169 recurrence: Option<LocalRecurrenceRecord>,
2170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2171 overrides: Vec<LocalOccurrenceOverrideRecord>,
2172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2173 deleted_occurrences: Vec<LocalOccurrenceAnchorRecord>,
2174 },
2175 AllDay {
2176 id: String,
2177 title: String,
2178 date: String,
2179 #[serde(default, skip_serializing_if = "Option::is_none")]
2180 location: Option<String>,
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2182 notes: Option<String>,
2183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2184 reminders_minutes_before: Vec<u16>,
2185 #[serde(default, skip_serializing_if = "Option::is_none")]
2186 recurrence: Option<LocalRecurrenceRecord>,
2187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2188 overrides: Vec<LocalOccurrenceOverrideRecord>,
2189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2190 deleted_occurrences: Vec<LocalOccurrenceAnchorRecord>,
2191 },
2192 }
2193
2194 impl LocalEventRecord {
2195 fn from_event(event: &Event) -> Self {
2196 let reminders_minutes_before = event
2197 .reminders
2198 .iter()
2199 .map(|reminder| reminder.minutes_before)
2200 .collect::<Vec<_>>();
2201 let recurrence = event
2202 .recurrence
2203 .as_ref()
2204 .map(LocalRecurrenceRecord::from_rule);
2205 let overrides = event
2206 .occurrence_overrides
2207 .iter()
2208 .map(LocalOccurrenceOverrideRecord::from_override)
2209 .collect::<Vec<_>>();
2210 let deleted_occurrences = event
2211 .deleted_occurrences
2212 .iter()
2213 .copied()
2214 .map(LocalOccurrenceAnchorRecord::from_anchor)
2215 .collect::<Vec<_>>();
2216
2217 match event.timing {
2218 EventTiming::AllDay { date } => Self::AllDay {
2219 id: event.id.clone(),
2220 title: event.title.clone(),
2221 date: date.to_string(),
2222 location: event.location.clone(),
2223 notes: event.notes.clone(),
2224 reminders_minutes_before,
2225 recurrence,
2226 overrides,
2227 deleted_occurrences,
2228 },
2229 EventTiming::Timed { start, end } => Self::Timed {
2230 id: event.id.clone(),
2231 title: event.title.clone(),
2232 start_date: start.date.to_string(),
2233 start_time: format_time(start.time),
2234 end_date: end.date.to_string(),
2235 end_time: format_time(end.time),
2236 location: event.location.clone(),
2237 notes: event.notes.clone(),
2238 reminders_minutes_before,
2239 recurrence,
2240 overrides,
2241 deleted_occurrences,
2242 },
2243 }
2244 }
2245
2246 fn into_event(self, path: &Path) -> Result<Event, LocalEventStoreError> {
2247 match self {
2248 Self::Timed {
2249 id,
2250 title,
2251 start_date,
2252 start_time,
2253 end_date,
2254 end_time,
2255 location,
2256 notes,
2257 reminders_minutes_before,
2258 recurrence,
2259 overrides,
2260 deleted_occurrences,
2261 } => {
2262 let start = EventDateTime::new(
2263 parse_local_date(&start_date, path)?,
2264 parse_local_time(&start_time, path)?,
2265 );
2266 let end = EventDateTime::new(
2267 parse_local_date(&end_date, path)?,
2268 parse_local_time(&end_time, path)?,
2269 );
2270 let mut event = Event::timed(
2271 id.clone(),
2272 title,
2273 start,
2274 end,
2275 SourceMetadata::local().with_external_id(id),
2276 )
2277 .map_err(|err| LocalEventStoreError::Parse {
2278 path: path.to_path_buf(),
2279 reason: err.to_string(),
2280 })?;
2281 event.location = empty_to_none(location);
2282 event.notes = empty_to_none(notes);
2283 event.reminders = reminders_from_minutes(reminders_minutes_before);
2284 event.recurrence = recurrence
2285 .map(|recurrence| recurrence.into_rule(path))
2286 .transpose()?;
2287 event.occurrence_overrides = overrides
2288 .into_iter()
2289 .map(|override_record| override_record.into_override(path))
2290 .collect::<Result<Vec<_>, _>>()?;
2291 event.deleted_occurrences = deleted_occurrences
2292 .into_iter()
2293 .map(|anchor| anchor.into_anchor(path))
2294 .collect::<Result<Vec<_>, _>>()?;
2295 Ok(event)
2296 }
2297 Self::AllDay {
2298 id,
2299 title,
2300 date,
2301 location,
2302 notes,
2303 reminders_minutes_before,
2304 recurrence,
2305 overrides,
2306 deleted_occurrences,
2307 } => {
2308 let mut event = Event::all_day(
2309 id.clone(),
2310 title,
2311 parse_local_date(&date, path)?,
2312 SourceMetadata::local().with_external_id(id),
2313 );
2314 event.location = empty_to_none(location);
2315 event.notes = empty_to_none(notes);
2316 event.reminders = reminders_from_minutes(reminders_minutes_before);
2317 event.recurrence = recurrence
2318 .map(|recurrence| recurrence.into_rule(path))
2319 .transpose()?;
2320 event.occurrence_overrides = overrides
2321 .into_iter()
2322 .map(|override_record| override_record.into_override(path))
2323 .collect::<Result<Vec<_>, _>>()?;
2324 event.deleted_occurrences = deleted_occurrences
2325 .into_iter()
2326 .map(|anchor| anchor.into_anchor(path))
2327 .collect::<Result<Vec<_>, _>>()?;
2328 Ok(event)
2329 }
2330 }
2331 }
2332 }
2333
2334 #[derive(Debug, Clone, Serialize, Deserialize)]
2335 struct LocalRecurrenceRecord {
2336 frequency: String,
2337 interval: u16,
2338 #[serde(default)]
2339 weekdays: Vec<String>,
2340 #[serde(default, skip_serializing_if = "Option::is_none")]
2341 monthly: Option<LocalRecurrenceMonthlyRecord>,
2342 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 yearly: Option<LocalRecurrenceYearlyRecord>,
2344 end: LocalRecurrenceEndRecord,
2345 }
2346
2347 impl LocalRecurrenceRecord {
2348 fn from_rule(rule: &RecurrenceRule) -> Self {
2349 Self {
2350 frequency: match rule.frequency {
2351 RecurrenceFrequency::Daily => "daily",
2352 RecurrenceFrequency::Weekly => "weekly",
2353 RecurrenceFrequency::Monthly => "monthly",
2354 RecurrenceFrequency::Yearly => "yearly",
2355 }
2356 .to_string(),
2357 interval: rule.interval(),
2358 weekdays: rule
2359 .weekdays
2360 .iter()
2361 .map(|weekday| weekday_name(*weekday))
2362 .collect(),
2363 monthly: rule.monthly.map(LocalRecurrenceMonthlyRecord::from_rule),
2364 yearly: rule.yearly.map(LocalRecurrenceYearlyRecord::from_rule),
2365 end: LocalRecurrenceEndRecord::from_rule(rule.end),
2366 }
2367 }
2368
2369 fn into_rule(self, path: &Path) -> Result<RecurrenceRule, LocalEventStoreError> {
2370 let frequency = match self.frequency.as_str() {
2371 "daily" => RecurrenceFrequency::Daily,
2372 "weekly" => RecurrenceFrequency::Weekly,
2373 "monthly" => RecurrenceFrequency::Monthly,
2374 "yearly" => RecurrenceFrequency::Yearly,
2375 value => {
2376 return Err(LocalEventStoreError::Parse {
2377 path: path.to_path_buf(),
2378 reason: format!("invalid recurrence frequency '{value}'"),
2379 });
2380 }
2381 };
2382 let weekdays = self
2383 .weekdays
2384 .into_iter()
2385 .map(|weekday| parse_weekday_record(&weekday, path))
2386 .collect::<Result<Vec<_>, _>>()?;
2387
2388 Ok(RecurrenceRule {
2389 frequency,
2390 interval: self.interval.max(1),
2391 end: self.end.into_rule(path)?,
2392 weekdays,
2393 monthly: self
2394 .monthly
2395 .map(|monthly| monthly.into_rule(path))
2396 .transpose()?,
2397 yearly: self
2398 .yearly
2399 .map(|yearly| yearly.into_rule(path))
2400 .transpose()?,
2401 })
2402 }
2403 }
2404
2405 #[derive(Debug, Clone, Serialize, Deserialize)]
2406 #[serde(tag = "mode", rename_all = "snake_case")]
2407 enum LocalRecurrenceEndRecord {
2408 Never,
2409 Until { date: String },
2410 Count { count: u32 },
2411 }
2412
2413 impl LocalRecurrenceEndRecord {
2414 fn from_rule(end: RecurrenceEnd) -> Self {
2415 match end {
2416 RecurrenceEnd::Never => Self::Never,
2417 RecurrenceEnd::Until(date) => Self::Until {
2418 date: date.to_string(),
2419 },
2420 RecurrenceEnd::Count(count) => Self::Count { count },
2421 }
2422 }
2423
2424 fn into_rule(self, path: &Path) -> Result<RecurrenceEnd, LocalEventStoreError> {
2425 match self {
2426 Self::Never => Ok(RecurrenceEnd::Never),
2427 Self::Until { date } => Ok(RecurrenceEnd::Until(parse_local_date(&date, path)?)),
2428 Self::Count { count } => Ok(RecurrenceEnd::Count(count.max(1))),
2429 }
2430 }
2431 }
2432
2433 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2434 #[serde(tag = "mode", rename_all = "snake_case")]
2435 enum LocalRecurrenceMonthlyRecord {
2436 DayOfMonth {
2437 day: u8,
2438 },
2439 WeekdayOrdinal {
2440 ordinal: LocalRecurrenceOrdinalRecord,
2441 weekday: LocalWeekdayRecord,
2442 },
2443 }
2444
2445 impl LocalRecurrenceMonthlyRecord {
2446 fn from_rule(rule: RecurrenceMonthlyRule) -> Self {
2447 match rule {
2448 RecurrenceMonthlyRule::DayOfMonth(day) => Self::DayOfMonth { day },
2449 RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => Self::WeekdayOrdinal {
2450 ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
2451 weekday: LocalWeekdayRecord::from_weekday(weekday),
2452 },
2453 }
2454 }
2455
2456 fn into_rule(self, _path: &Path) -> Result<RecurrenceMonthlyRule, LocalEventStoreError> {
2457 Ok(match self {
2458 Self::DayOfMonth { day } => RecurrenceMonthlyRule::DayOfMonth(day),
2459 Self::WeekdayOrdinal { ordinal, weekday } => RecurrenceMonthlyRule::WeekdayOrdinal {
2460 ordinal: ordinal.into_rule(),
2461 weekday: weekday.into_weekday(),
2462 },
2463 })
2464 }
2465 }
2466
2467 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2468 #[serde(tag = "mode", rename_all = "snake_case")]
2469 enum LocalRecurrenceYearlyRecord {
2470 Date {
2471 month: u8,
2472 day: u8,
2473 },
2474 WeekdayOrdinal {
2475 month: u8,
2476 ordinal: LocalRecurrenceOrdinalRecord,
2477 weekday: LocalWeekdayRecord,
2478 },
2479 }
2480
2481 impl LocalRecurrenceYearlyRecord {
2482 fn from_rule(rule: RecurrenceYearlyRule) -> Self {
2483 match rule {
2484 RecurrenceYearlyRule::Date { month, day } => Self::Date {
2485 month: u8::from(month),
2486 day,
2487 },
2488 RecurrenceYearlyRule::WeekdayOrdinal {
2489 month,
2490 ordinal,
2491 weekday,
2492 } => Self::WeekdayOrdinal {
2493 month: u8::from(month),
2494 ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
2495 weekday: LocalWeekdayRecord::from_weekday(weekday),
2496 },
2497 }
2498 }
2499
2500 fn into_rule(self, path: &Path) -> Result<RecurrenceYearlyRule, LocalEventStoreError> {
2501 Ok(match self {
2502 Self::Date { month, day } => RecurrenceYearlyRule::Date {
2503 month: parse_month_record(month, path)?,
2504 day,
2505 },
2506 Self::WeekdayOrdinal {
2507 month,
2508 ordinal,
2509 weekday,
2510 } => RecurrenceYearlyRule::WeekdayOrdinal {
2511 month: parse_month_record(month, path)?,
2512 ordinal: ordinal.into_rule(),
2513 weekday: weekday.into_weekday(),
2514 },
2515 })
2516 }
2517 }
2518
2519 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2520 #[serde(rename_all = "snake_case")]
2521 enum LocalRecurrenceOrdinalRecord {
2522 First,
2523 Second,
2524 Third,
2525 Fourth,
2526 Last,
2527 }
2528
2529 impl LocalRecurrenceOrdinalRecord {
2530 fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
2531 match ordinal {
2532 RecurrenceOrdinal::Number(1) => Self::First,
2533 RecurrenceOrdinal::Number(2) => Self::Second,
2534 RecurrenceOrdinal::Number(3) => Self::Third,
2535 RecurrenceOrdinal::Number(4) => Self::Fourth,
2536 RecurrenceOrdinal::Last | RecurrenceOrdinal::Number(_) => Self::Last,
2537 }
2538 }
2539
2540 const fn into_rule(self) -> RecurrenceOrdinal {
2541 match self {
2542 Self::First => RecurrenceOrdinal::Number(1),
2543 Self::Second => RecurrenceOrdinal::Number(2),
2544 Self::Third => RecurrenceOrdinal::Number(3),
2545 Self::Fourth => RecurrenceOrdinal::Number(4),
2546 Self::Last => RecurrenceOrdinal::Last,
2547 }
2548 }
2549 }
2550
2551 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2552 #[serde(rename_all = "snake_case")]
2553 enum LocalWeekdayRecord {
2554 Sunday,
2555 Monday,
2556 Tuesday,
2557 Wednesday,
2558 Thursday,
2559 Friday,
2560 Saturday,
2561 }
2562
2563 impl LocalWeekdayRecord {
2564 const fn from_weekday(weekday: Weekday) -> Self {
2565 match weekday {
2566 Weekday::Sunday => Self::Sunday,
2567 Weekday::Monday => Self::Monday,
2568 Weekday::Tuesday => Self::Tuesday,
2569 Weekday::Wednesday => Self::Wednesday,
2570 Weekday::Thursday => Self::Thursday,
2571 Weekday::Friday => Self::Friday,
2572 Weekday::Saturday => Self::Saturday,
2573 }
2574 }
2575
2576 const fn into_weekday(self) -> Weekday {
2577 match self {
2578 Self::Sunday => Weekday::Sunday,
2579 Self::Monday => Weekday::Monday,
2580 Self::Tuesday => Weekday::Tuesday,
2581 Self::Wednesday => Weekday::Wednesday,
2582 Self::Thursday => Weekday::Thursday,
2583 Self::Friday => Weekday::Friday,
2584 Self::Saturday => Weekday::Saturday,
2585 }
2586 }
2587 }
2588
2589 #[derive(Debug, Clone, Serialize, Deserialize)]
2590 struct LocalOccurrenceOverrideRecord {
2591 anchor: LocalOccurrenceAnchorRecord,
2592 event: LocalEventDraftRecord,
2593 }
2594
2595 impl LocalOccurrenceOverrideRecord {
2596 fn from_override(override_record: &OccurrenceOverride) -> Self {
2597 Self {
2598 anchor: LocalOccurrenceAnchorRecord::from_anchor(override_record.anchor),
2599 event: LocalEventDraftRecord::from_draft(&override_record.draft),
2600 }
2601 }
2602
2603 fn into_override(self, path: &Path) -> Result<OccurrenceOverride, LocalEventStoreError> {
2604 Ok(OccurrenceOverride {
2605 anchor: self.anchor.into_anchor(path)?,
2606 draft: self.event.into_draft(path)?,
2607 })
2608 }
2609 }
2610
2611 #[derive(Debug, Clone, Serialize, Deserialize)]
2612 #[serde(tag = "kind", rename_all = "snake_case")]
2613 enum LocalOccurrenceAnchorRecord {
2614 AllDay { date: String },
2615 Timed { date: String, time: String },
2616 }
2617
2618 impl LocalOccurrenceAnchorRecord {
2619 fn from_anchor(anchor: OccurrenceAnchor) -> Self {
2620 match anchor {
2621 OccurrenceAnchor::AllDay { date } => Self::AllDay {
2622 date: date.to_string(),
2623 },
2624 OccurrenceAnchor::Timed { start } => Self::Timed {
2625 date: start.date.to_string(),
2626 time: format_time(start.time),
2627 },
2628 }
2629 }
2630
2631 fn into_anchor(self, path: &Path) -> Result<OccurrenceAnchor, LocalEventStoreError> {
2632 Ok(match self {
2633 Self::AllDay { date } => OccurrenceAnchor::AllDay {
2634 date: parse_local_date(&date, path)?,
2635 },
2636 Self::Timed { date, time } => OccurrenceAnchor::Timed {
2637 start: EventDateTime::new(
2638 parse_local_date(&date, path)?,
2639 parse_local_time(&time, path)?,
2640 ),
2641 },
2642 })
2643 }
2644 }
2645
2646 #[derive(Debug, Clone, Serialize, Deserialize)]
2647 #[serde(tag = "timing", rename_all = "snake_case")]
2648 enum LocalEventDraftRecord {
2649 Timed {
2650 title: String,
2651 start_date: String,
2652 start_time: String,
2653 end_date: String,
2654 end_time: String,
2655 #[serde(default, skip_serializing_if = "Option::is_none")]
2656 location: Option<String>,
2657 #[serde(default, skip_serializing_if = "Option::is_none")]
2658 notes: Option<String>,
2659 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2660 reminders_minutes_before: Vec<u16>,
2661 },
2662 AllDay {
2663 title: String,
2664 date: String,
2665 #[serde(default, skip_serializing_if = "Option::is_none")]
2666 location: Option<String>,
2667 #[serde(default, skip_serializing_if = "Option::is_none")]
2668 notes: Option<String>,
2669 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2670 reminders_minutes_before: Vec<u16>,
2671 },
2672 }
2673
2674 impl LocalEventDraftRecord {
2675 fn from_draft(draft: &CreateEventDraft) -> Self {
2676 let reminders_minutes_before = draft
2677 .reminders
2678 .iter()
2679 .map(|reminder| reminder.minutes_before)
2680 .collect::<Vec<_>>();
2681 match draft.timing {
2682 CreateEventTiming::Timed { start, end } => Self::Timed {
2683 title: draft.title.clone(),
2684 start_date: start.date.to_string(),
2685 start_time: format_time(start.time),
2686 end_date: end.date.to_string(),
2687 end_time: format_time(end.time),
2688 location: draft.location.clone(),
2689 notes: draft.notes.clone(),
2690 reminders_minutes_before,
2691 },
2692 CreateEventTiming::AllDay { date } => Self::AllDay {
2693 title: draft.title.clone(),
2694 date: date.to_string(),
2695 location: draft.location.clone(),
2696 notes: draft.notes.clone(),
2697 reminders_minutes_before,
2698 },
2699 }
2700 }
2701
2702 fn into_draft(self, path: &Path) -> Result<CreateEventDraft, LocalEventStoreError> {
2703 Ok(match self {
2704 Self::Timed {
2705 title,
2706 start_date,
2707 start_time,
2708 end_date,
2709 end_time,
2710 location,
2711 notes,
2712 reminders_minutes_before,
2713 } => {
2714 let start = EventDateTime::new(
2715 parse_local_date(&start_date, path)?,
2716 parse_local_time(&start_time, path)?,
2717 );
2718 let end = EventDateTime::new(
2719 parse_local_date(&end_date, path)?,
2720 parse_local_time(&end_time, path)?,
2721 );
2722 if start >= end {
2723 return Err(LocalEventStoreError::Parse {
2724 path: path.to_path_buf(),
2725 reason: format!(
2726 "invalid override range: start {start:?} must be before end {end:?}"
2727 ),
2728 });
2729 }
2730
2731 CreateEventDraft {
2732 title,
2733 timing: CreateEventTiming::Timed { start, end },
2734 location: empty_to_none(location),
2735 notes: empty_to_none(notes),
2736 reminders: reminders_from_minutes(reminders_minutes_before),
2737 recurrence: None,
2738 }
2739 }
2740 Self::AllDay {
2741 title,
2742 date,
2743 location,
2744 notes,
2745 reminders_minutes_before,
2746 } => CreateEventDraft {
2747 title,
2748 timing: CreateEventTiming::AllDay {
2749 date: parse_local_date(&date, path)?,
2750 },
2751 location: empty_to_none(location),
2752 notes: empty_to_none(notes),
2753 reminders: reminders_from_minutes(reminders_minutes_before),
2754 recurrence: None,
2755 },
2756 })
2757 }
2758 }
2759
2760 fn reminders_from_minutes(minutes: Vec<u16>) -> Vec<Reminder> {
2761 let mut reminders = minutes
2762 .into_iter()
2763 .map(Reminder::minutes_before)
2764 .collect::<Vec<_>>();
2765 reminders.sort();
2766 reminders.dedup();
2767 reminders
2768 }
2769
2770 fn empty_to_none(value: Option<String>) -> Option<String> {
2771 value.and_then(|value| {
2772 let trimmed = value.trim();
2773 if trimmed.is_empty() {
2774 None
2775 } else {
2776 Some(trimmed.to_string())
2777 }
2778 })
2779 }
2780
2781 fn weekday_name(weekday: Weekday) -> String {
2782 match weekday {
2783 Weekday::Sunday => "sunday",
2784 Weekday::Monday => "monday",
2785 Weekday::Tuesday => "tuesday",
2786 Weekday::Wednesday => "wednesday",
2787 Weekday::Thursday => "thursday",
2788 Weekday::Friday => "friday",
2789 Weekday::Saturday => "saturday",
2790 }
2791 .to_string()
2792 }
2793
2794 fn parse_weekday_record(value: &str, path: &Path) -> Result<Weekday, LocalEventStoreError> {
2795 match value {
2796 "sunday" => Ok(Weekday::Sunday),
2797 "monday" => Ok(Weekday::Monday),
2798 "tuesday" => Ok(Weekday::Tuesday),
2799 "wednesday" => Ok(Weekday::Wednesday),
2800 "thursday" => Ok(Weekday::Thursday),
2801 "friday" => Ok(Weekday::Friday),
2802 "saturday" => Ok(Weekday::Saturday),
2803 _ => Err(LocalEventStoreError::Parse {
2804 path: path.to_path_buf(),
2805 reason: format!("invalid weekday '{value}'"),
2806 }),
2807 }
2808 }
2809
2810 fn parse_month_record(value: u8, path: &Path) -> Result<Month, LocalEventStoreError> {
2811 Month::try_from(value).map_err(|_| LocalEventStoreError::Parse {
2812 path: path.to_path_buf(),
2813 reason: format!("invalid month '{value}'"),
2814 })
2815 }
2816
2817 fn parse_event_datetime_record(value: &str) -> Option<EventDateTime> {
2818 let (date, time) = value.split_once('T')?;
2819 Some(EventDateTime::new(
2820 parse_iso_date(date)?,
2821 parse_hhmm_time(time)?,
2822 ))
2823 }
2824
2825 fn parse_local_date(value: &str, path: &Path) -> Result<CalendarDate, LocalEventStoreError> {
2826 parse_iso_date(value).ok_or_else(|| LocalEventStoreError::Parse {
2827 path: path.to_path_buf(),
2828 reason: format!("invalid date '{value}'"),
2829 })
2830 }
2831
2832 fn parse_local_time(value: &str, path: &Path) -> Result<Time, LocalEventStoreError> {
2833 parse_hhmm_time(value).ok_or_else(|| LocalEventStoreError::Parse {
2834 path: path.to_path_buf(),
2835 reason: format!("invalid time '{value}'"),
2836 })
2837 }
2838
2839 fn parse_hhmm_time(value: &str) -> Option<Time> {
2840 let mut parts = value.split(':');
2841 let hour = parts.next()?.parse::<u8>().ok()?;
2842 let minute = parts.next()?.parse::<u8>().ok()?;
2843 if parts.next().is_some() {
2844 return None;
2845 }
2846
2847 Time::from_hms(hour, minute, 0).ok()
2848 }
2849
2850 fn format_time(time: Time) -> String {
2851 format!("{:02}:{:02}", time.hour(), time.minute())
2852 }
2853
2854 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2855 pub enum AgendaError {
2856 InvalidDateRange {
2857 start: CalendarDate,
2858 end: CalendarDate,
2859 },
2860 InvalidEventRange {
2861 start: EventDateTime,
2862 end: EventDateTime,
2863 },
2864 }
2865
2866 impl fmt::Display for AgendaError {
2867 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2868 match self {
2869 Self::InvalidDateRange { start, end } => {
2870 write!(
2871 f,
2872 "invalid date range: start {start} must be before end {end}"
2873 )
2874 }
2875 Self::InvalidEventRange { start, end } => write!(
2876 f,
2877 "invalid event range: start {start:?} must be before end {end:?}"
2878 ),
2879 }
2880 }
2881 }
2882
2883 impl Error for AgendaError {}
2884
2885 fn events_to_timed_agenda_events(
2886 date: CalendarDate,
2887 events: impl IntoIterator<Item = Event>,
2888 ) -> Vec<TimedAgendaEvent> {
2889 let day_start = EventDateTime::new(date, Time::MIDNIGHT);
2890 let day_end = EventDateTime::new(date.add_days(1), Time::MIDNIGHT);
2891
2892 events
2893 .into_iter()
2894 .filter_map(|event| match event.timing {
2895 EventTiming::Timed { start, end } if start < day_end && end > day_start => {
2896 let starts_before_day = start < day_start;
2897 let ends_after_day = end > day_end;
2898 let visible_start = if starts_before_day {
2899 DayMinute::START
2900 } else {
2901 DayMinute::from_time(start.time)
2902 };
2903 let visible_end = if end >= day_end {
2904 DayMinute::END
2905 } else {
2906 DayMinute::from_time(end.time)
2907 };
2908
2909 Some(TimedAgendaEvent {
2910 event,
2911 visible_start,
2912 visible_end,
2913 starts_before_day,
2914 ends_after_day,
2915 overlap_group: 0,
2916 })
2917 }
2918 _ => None,
2919 })
2920 .collect()
2921 }
2922
2923 fn assign_overlap_groups(events: &mut [TimedAgendaEvent]) {
2924 let mut group_end: Option<DayMinute> = None;
2925 let mut group_index = 0;
2926
2927 for event in events {
2928 if let Some(end) = group_end {
2929 if event.visible_start >= end {
2930 group_index += 1;
2931 group_end = Some(event.visible_end);
2932 } else if event.visible_end > end {
2933 group_end = Some(event.visible_end);
2934 }
2935 } else {
2936 group_end = Some(event.visible_end);
2937 }
2938
2939 event.overlap_group = group_index;
2940 }
2941 }
2942
2943 fn time(hour: u8, minute: u8) -> Time {
2944 Time::from_hms(hour, minute, 0).expect("fixture time is valid")
2945 }
2946
2947 fn us_federal_holidays_for_year(year: i32) -> Vec<Holiday> {
2948 [
2949 fixed_us_federal_holiday(year, "new-years-day", "New Year's Day", Month::January, 1),
2950 weekday_us_federal_holiday(
2951 year,
2952 "martin-luther-king-jr-day",
2953 "Birthday of Martin Luther King, Jr.",
2954 Month::January,
2955 Weekday::Monday,
2956 3,
2957 ),
2958 weekday_us_federal_holiday(
2959 year,
2960 "washingtons-birthday",
2961 "Washington's Birthday",
2962 Month::February,
2963 Weekday::Monday,
2964 3,
2965 ),
2966 last_weekday_us_federal_holiday(
2967 year,
2968 "memorial-day",
2969 "Memorial Day",
2970 Month::May,
2971 Weekday::Monday,
2972 ),
2973 fixed_us_federal_holiday(
2974 year,
2975 "juneteenth",
2976 "Juneteenth National Independence Day",
2977 Month::June,
2978 19,
2979 ),
2980 fixed_us_federal_holiday(year, "independence-day", "Independence Day", Month::July, 4),
2981 weekday_us_federal_holiday(
2982 year,
2983 "labor-day",
2984 "Labor Day",
2985 Month::September,
2986 Weekday::Monday,
2987 1,
2988 ),
2989 weekday_us_federal_holiday(
2990 year,
2991 "columbus-day",
2992 "Columbus Day",
2993 Month::October,
2994 Weekday::Monday,
2995 2,
2996 ),
2997 fixed_us_federal_holiday(year, "veterans-day", "Veterans Day", Month::November, 11),
2998 weekday_us_federal_holiday(
2999 year,
3000 "thanksgiving-day",
3001 "Thanksgiving Day",
3002 Month::November,
3003 Weekday::Thursday,
3004 4,
3005 ),
3006 fixed_us_federal_holiday(year, "christmas-day", "Christmas Day", Month::December, 25),
3007 ]
3008 .into()
3009 }
3010
3011 fn fixed_us_federal_holiday(year: i32, slug: &str, name: &str, month: Month, day: u8) -> Holiday {
3012 let actual = CalendarDate::from_ymd(year, month, day).expect("fixed holiday date is valid");
3013 holiday_with_source(
3014 format!("us-federal-{year}-{slug}"),
3015 name,
3016 observed_date(actual),
3017 "us-federal",
3018 "U.S. federal holidays",
3019 )
3020 }
3021
3022 fn weekday_us_federal_holiday(
3023 year: i32,
3024 slug: &str,
3025 name: &str,
3026 month: Month,
3027 weekday: Weekday,
3028 nth: u8,
3029 ) -> Holiday {
3030 let date = nth_weekday_of_month(year, month, weekday, nth);
3031 holiday_with_source(
3032 format!("us-federal-{year}-{slug}"),
3033 name,
3034 date,
3035 "us-federal",
3036 "U.S. federal holidays",
3037 )
3038 }
3039
3040 fn last_weekday_us_federal_holiday(
3041 year: i32,
3042 slug: &str,
3043 name: &str,
3044 month: Month,
3045 weekday: Weekday,
3046 ) -> Holiday {
3047 let date = last_weekday_of_month(year, month, weekday);
3048 holiday_with_source(
3049 format!("us-federal-{year}-{slug}"),
3050 name,
3051 date,
3052 "us-federal",
3053 "U.S. federal holidays",
3054 )
3055 }
3056
3057 fn observed_date(actual: CalendarDate) -> CalendarDate {
3058 match actual.weekday() {
3059 Weekday::Saturday => actual.add_days(-1),
3060 Weekday::Sunday => actual.add_days(1),
3061 _ => actual,
3062 }
3063 }
3064
3065 fn nth_weekday_of_month(year: i32, month: Month, weekday: Weekday, nth: u8) -> CalendarDate {
3066 let first = CalendarDate::from_ymd(year, month, 1).expect("month start date is valid");
3067 let first_weekday = first.weekday().number_days_from_sunday();
3068 let target_weekday = weekday.number_days_from_sunday();
3069 let offset = (target_weekday + 7 - first_weekday) % 7;
3070 let day = 1 + offset + (nth - 1) * 7;
3071
3072 CalendarDate::from_ymd(year, month, day).expect("nth weekday date is valid")
3073 }
3074
3075 fn last_weekday_of_month(year: i32, month: Month, weekday: Weekday) -> CalendarDate {
3076 let mut date =
3077 CalendarDate::from_ymd(year, month, month.length(year)).expect("month end date is valid");
3078
3079 while date.weekday() != weekday {
3080 date = date.add_days(-1);
3081 }
3082
3083 date
3084 }
3085
3086 fn holiday_with_source(
3087 id: impl Into<String>,
3088 name: impl Into<String>,
3089 date: CalendarDate,
3090 source_id: impl Into<String>,
3091 source_name: impl Into<String>,
3092 ) -> Holiday {
3093 Holiday::new(id, name, date, SourceMetadata::new(source_id, source_name))
3094 }
3095
3096 fn parse_nager_holidays(country_code: &str, body: &str) -> Option<Vec<Holiday>> {
3097 let records = serde_json::from_str::<Vec<NagerHolidayRecord>>(body).ok()?;
3098 let mut holidays = Vec::with_capacity(records.len());
3099
3100 for record in records {
3101 let date = parse_iso_date(&record.date)?;
3102 holidays.push(Holiday::new(
3103 format!(
3104 "nager-{country_code}-{}-{}",
3105 record.date,
3106 slugify(&record.name)
3107 ),
3108 record.name,
3109 date,
3110 SourceMetadata::new(format!("nager-{country_code}"), "Nager.Date")
3111 .with_external_id(format!("{country_code}:{}", record.date)),
3112 ));
3113 }
3114
3115 Some(holidays)
3116 }
3117
3118 fn parse_iso_date(value: &str) -> Option<CalendarDate> {
3119 let mut parts = value.split('-');
3120 let year = parts.next()?.parse::<i32>().ok()?;
3121 let month = parts.next()?.parse::<u8>().ok()?;
3122 let day = parts.next()?.parse::<u8>().ok()?;
3123
3124 if parts.next().is_some() {
3125 return None;
3126 }
3127
3128 CalendarDate::from_ymd(year, Month::try_from(month).ok()?, day).ok()
3129 }
3130
3131 fn default_nager_cache_dir() -> PathBuf {
3132 if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
3133 return PathBuf::from(cache_home)
3134 .join("rcal")
3135 .join("holidays")
3136 .join("nager");
3137 }
3138
3139 if let Some(home) = env::var_os("HOME") {
3140 return PathBuf::from(home)
3141 .join(".cache")
3142 .join("rcal")
3143 .join("holidays")
3144 .join("nager");
3145 }
3146
3147 env::temp_dir().join("rcal").join("holidays").join("nager")
3148 }
3149
3150 fn slugify(value: &str) -> String {
3151 let mut slug = String::new();
3152 let mut last_dash = false;
3153
3154 for value in value.chars().flat_map(char::to_lowercase) {
3155 if value.is_ascii_alphanumeric() {
3156 slug.push(value);
3157 last_dash = false;
3158 } else if !last_dash && !slug.is_empty() {
3159 slug.push('-');
3160 last_dash = true;
3161 }
3162 }
3163
3164 if slug.ends_with('-') {
3165 slug.pop();
3166 }
3167
3168 slug
3169 }
3170
3171 #[derive(Debug, Deserialize)]
3172 #[serde(rename_all = "camelCase")]
3173 struct NagerHolidayRecord {
3174 date: String,
3175 name: String,
3176 }
3177
3178 #[cfg(test)]
3179 mod tests {
3180 use super::*;
3181 use time::Month;
3182
3183 fn date(day: u8) -> CalendarDate {
3184 date_ymd(2026, Month::April, day)
3185 }
3186
3187 fn date_ymd(year: i32, month: Month, day: u8) -> CalendarDate {
3188 CalendarDate::from_ymd(year, month, day).expect("valid test date")
3189 }
3190
3191 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
3192 EventDateTime::new(date, time(hour, minute))
3193 }
3194
3195 fn source() -> SourceMetadata {
3196 SourceMetadata::fixture()
3197 }
3198
3199 fn temp_events_path(name: &str) -> PathBuf {
3200 std::env::temp_dir()
3201 .join(format!("rcal-local-events-test-{}", std::process::id()))
3202 .join(name)
3203 .join("events.json")
3204 }
3205
3206 fn timed(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
3207 Event::timed(id, title, start, end, source()).expect("valid timed event")
3208 }
3209
3210 #[test]
3211 fn event_write_target_ids_support_provider_calendar_identity() {
3212 let target = EventWriteTargetId::provider("google", "personal", "primary");
3213
3214 assert!(!target.is_local());
3215 assert!(target.is_provider("google"));
3216 assert!(!target.is_microsoft());
3217 assert_eq!(target.provider_id(), Some("google"));
3218 assert_eq!(target.account_id(), Some("personal"));
3219 assert_eq!(target.calendar_id(), Some("primary"));
3220 assert_eq!(
3221 target.source_id().as_deref(),
3222 Some("google:personal:primary")
3223 );
3224 }
3225
3226 #[test]
3227 fn event_write_target_ids_are_recovered_from_provider_source_metadata() {
3228 let day = date(23);
3229 let event = Event::timed(
3230 "google:personal:primary:event-1",
3231 "Planning",
3232 at(day, 9, 0),
3233 at(day, 10, 0),
3234 SourceMetadata::new("google:personal:primary", "Google personal: Primary"),
3235 )
3236 .expect("valid provider event");
3237
3238 assert_eq!(
3239 EventWriteTargetId::from_event(&event),
3240 Some(EventWriteTargetId::provider(
3241 "google", "personal", "primary"
3242 ))
3243 );
3244 assert!(event.is_provider_backed());
3245 assert!(event.is_editable());
3246 }
3247
3248 #[test]
3249 fn configured_source_reports_unknown_provider_targets_clearly() {
3250 let day = date(23);
3251 let mut source =
3252 ConfiguredAgendaSource::new(InMemoryAgendaSource::new(), HolidayProvider::off());
3253 let draft = CreateEventDraft {
3254 title: "Planning".to_string(),
3255 timing: CreateEventTiming::Timed {
3256 start: at(day, 9, 0),
3257 end: at(day, 10, 0),
3258 },
3259 location: None,
3260 notes: None,
3261 reminders: Vec::new(),
3262 recurrence: None,
3263 };
3264
3265 let err = source
3266 .create_event_with_target(
3267 draft,
3268 &EventWriteTargetId::provider("google", "personal", "primary"),
3269 )
3270 .expect_err("unknown provider target fails");
3271
3272 assert!(
3273 err.to_string()
3274 .contains("provider 'google' is not configured")
3275 );
3276 }
3277
3278 #[test]
3279 fn agenda_construction_handles_empty_days() {
3280 let day = date(23);
3281 let source = InMemoryAgendaSource::new();
3282
3283 let agenda = DayAgenda::from_source(day, &source);
3284
3285 assert_eq!(agenda, DayAgenda::empty(day));
3286 assert!(agenda.is_empty());
3287 }
3288
3289 #[test]
3290 fn agenda_construction_handles_holiday_only_days() {
3291 let day = date(23);
3292 let mut agenda_source = InMemoryAgendaSource::new();
3293 agenda_source.push_holiday(Holiday::new("earth-day", "Earth Day", day, source()));
3294 agenda_source.push_holiday(Holiday::new("tomorrow", "Tomorrow", date(24), source()));
3295
3296 let agenda = DayAgenda::from_source(day, &agenda_source);
3297
3298 assert!(!agenda.is_empty());
3299 assert_eq!(agenda.holidays.len(), 1);
3300 assert_eq!(agenda.holidays[0].name, "Earth Day");
3301 assert!(agenda.all_day_events.is_empty());
3302 assert!(agenda.timed_events.is_empty());
3303 }
3304
3305 #[test]
3306 fn agenda_keeps_all_day_events_separate_and_sorted() {
3307 let day = date(23);
3308 let events = vec![
3309 Event::all_day("b", "Release", day, source()),
3310 Event::all_day("a", "Birthday", day, source()),
3311 Event::all_day("other", "Other Day", date(24), source()),
3312 ];
3313
3314 let agenda = DayAgenda::build(day, events, []);
3315
3316 let titles = agenda
3317 .all_day_events
3318 .iter()
3319 .map(|event| event.title.as_str())
3320 .collect::<Vec<_>>();
3321 assert_eq!(titles, ["Birthday", "Release"]);
3322 assert!(agenda.timed_events.is_empty());
3323 }
3324
3325 #[test]
3326 fn timed_events_sort_by_visible_time_then_title() {
3327 let day = date(23);
3328 let events = vec![
3329 timed("late", "Late", at(day, 13, 0), at(day, 14, 0)),
3330 timed("alpha", "Alpha", at(day, 9, 0), at(day, 10, 0)),
3331 timed("beta", "Beta", at(day, 9, 0), at(day, 9, 30)),
3332 ];
3333
3334 let agenda = DayAgenda::build(day, events, []);
3335
3336 let ids = agenda
3337 .timed_events
3338 .iter()
3339 .map(|event| event.event.id.as_str())
3340 .collect::<Vec<_>>();
3341 assert_eq!(ids, ["beta", "alpha", "late"]);
3342 }
3343
3344 #[test]
3345 fn overlapping_timed_events_share_group_until_the_cluster_ends() {
3346 let day = date(23);
3347 let events = vec![
3348 timed("first", "First", at(day, 9, 0), at(day, 10, 0)),
3349 timed("overlap", "Overlap", at(day, 9, 30), at(day, 10, 30)),
3350 timed("touching", "Touching", at(day, 10, 30), at(day, 11, 0)),
3351 ];
3352
3353 let agenda = DayAgenda::build(day, events, []);
3354 let groups = agenda
3355 .timed_events
3356 .iter()
3357 .map(|event| event.overlap_group)
3358 .collect::<Vec<_>>();
3359
3360 assert_eq!(groups, [0, 0, 1]);
3361 assert!(agenda.timed_events[0].overlaps(&agenda.timed_events[1]));
3362 assert!(!agenda.timed_events[1].overlaps(&agenda.timed_events[2]));
3363 }
3364
3365 #[test]
3366 fn cross_midnight_events_clip_to_selected_day() {
3367 let day = date(23);
3368 let previous_day = day.add_days(-1);
3369 let next_day = day.add_days(1);
3370 let events = vec![
3371 timed(
3372 "from-yesterday",
3373 "From yesterday",
3374 at(previous_day, 23, 0),
3375 at(day, 1, 30),
3376 ),
3377 timed(
3378 "into-tomorrow",
3379 "Into tomorrow",
3380 at(day, 23, 0),
3381 at(next_day, 1, 0),
3382 ),
3383 timed(
3384 "ends-at-start",
3385 "Ends at start",
3386 at(previous_day, 22, 0),
3387 at(day, 0, 0),
3388 ),
3389 ];
3390
3391 let agenda = DayAgenda::build(day, events, []);
3392 let first = &agenda.timed_events[0];
3393 let second = &agenda.timed_events[1];
3394
3395 assert_eq!(agenda.timed_events.len(), 2);
3396 assert_eq!(first.event.id, "from-yesterday");
3397 assert_eq!(first.visible_start, DayMinute::START);
3398 assert_eq!(first.visible_end.as_minutes(), 90);
3399 assert!(first.starts_before_day);
3400 assert!(!first.ends_after_day);
3401
3402 assert_eq!(second.event.id, "into-tomorrow");
3403 assert_eq!(second.visible_start.as_minutes(), 23 * 60);
3404 assert_eq!(second.visible_end, DayMinute::END);
3405 assert!(!second.starts_before_day);
3406 assert!(second.ends_after_day);
3407 }
3408
3409 #[test]
3410 fn in_memory_fixture_provides_development_agenda_data() {
3411 let day = date(23);
3412 let source = InMemoryAgendaSource::development_fixture();
3413
3414 let agenda = DayAgenda::from_source(day, &source);
3415
3416 assert!(agenda.holidays.is_empty());
3417 assert_eq!(agenda.all_day_events[0].title, "Release day");
3418 assert_eq!(agenda.timed_events.len(), 3);
3419 assert_eq!(agenda.timed_events[0].overlap_group, 0);
3420 assert_eq!(agenda.timed_events[1].overlap_group, 0);
3421 assert_eq!(agenda.timed_events[2].visible_end, DayMinute::END);
3422 }
3423
3424 #[test]
3425 fn date_ranges_are_half_open_for_sources() {
3426 let day = date(23);
3427 let range = DateRange::new(day, day.add_days(1)).expect("valid range");
3428 let source = InMemoryAgendaSource::with_events_and_holidays(
3429 vec![
3430 timed("inside", "Inside", at(day, 12, 0), at(day, 13, 0)),
3431 timed(
3432 "outside",
3433 "Outside",
3434 at(day.add_days(1), 12, 0),
3435 at(day.add_days(1), 13, 0),
3436 ),
3437 ],
3438 vec![
3439 Holiday::new("inside-holiday", "Inside Holiday", day, source()),
3440 Holiday::new(
3441 "outside-holiday",
3442 "Outside Holiday",
3443 day.add_days(1),
3444 source(),
3445 ),
3446 ],
3447 );
3448
3449 assert_eq!(source.events_intersecting(range).len(), 1);
3450 assert_eq!(source.holidays_in(range).len(), 1);
3451 assert!(!range.contains_date(day.add_days(1)));
3452 }
3453
3454 #[test]
3455 fn daily_recurrence_expands_with_interval_and_count() {
3456 let start = date_ymd(2026, Month::April, 1);
3457 let event = Event::all_day("daily", "Every other day", start, source()).with_recurrence(
3458 RecurrenceRule {
3459 frequency: RecurrenceFrequency::Daily,
3460 interval: 2,
3461 end: RecurrenceEnd::Count(3),
3462 weekdays: Vec::new(),
3463 monthly: None,
3464 yearly: None,
3465 },
3466 );
3467 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3468 let range = DateRange::new(start, date_ymd(2026, Month::April, 10)).expect("valid range");
3469
3470 let dates = source
3471 .events_intersecting(range)
3472 .into_iter()
3473 .filter_map(|event| event.timing.date())
3474 .collect::<Vec<_>>();
3475
3476 assert_eq!(dates, [date_ymd(2026, Month::April, 1), date(3), date(5)]);
3477 }
3478
3479 #[test]
3480 fn weekly_recurrence_supports_multiple_days_and_interval() {
3481 let start = date_ymd(2026, Month::April, 5);
3482 let event =
3483 Event::all_day("weekly", "Workout", start, source()).with_recurrence(RecurrenceRule {
3484 frequency: RecurrenceFrequency::Weekly,
3485 interval: 2,
3486 end: RecurrenceEnd::Never,
3487 weekdays: vec![Weekday::Sunday, Weekday::Tuesday],
3488 monthly: None,
3489 yearly: None,
3490 });
3491 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3492 let range = DateRange::new(start, date_ymd(2026, Month::April, 23)).expect("valid range");
3493
3494 let dates = source
3495 .events_intersecting(range)
3496 .into_iter()
3497 .filter_map(|event| event.timing.date())
3498 .collect::<Vec<_>>();
3499
3500 assert_eq!(dates, [date(5), date(7), date(19), date(21)]);
3501 }
3502
3503 #[test]
3504 fn weekly_interval_uses_calendar_weeks_not_rolling_start_windows() {
3505 let start = date_ymd(2026, Month::April, 15);
3506 let event =
3507 Event::all_day("class", "CS412", start, source()).with_recurrence(RecurrenceRule {
3508 frequency: RecurrenceFrequency::Weekly,
3509 interval: 2,
3510 end: RecurrenceEnd::Until(date_ymd(2026, Month::May, 15)),
3511 weekdays: vec![Weekday::Monday, Weekday::Wednesday, Weekday::Friday],
3512 monthly: None,
3513 yearly: None,
3514 });
3515 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3516 let range = DateRange::new(
3517 date_ymd(2026, Month::April, 1),
3518 date_ymd(2026, Month::May, 1),
3519 )
3520 .expect("valid range");
3521
3522 let dates = source
3523 .events_intersecting(range)
3524 .into_iter()
3525 .filter_map(|event| event.timing.date())
3526 .collect::<Vec<_>>();
3527
3528 assert_eq!(
3529 dates,
3530 [
3531 date_ymd(2026, Month::April, 15),
3532 date_ymd(2026, Month::April, 17),
3533 date_ymd(2026, Month::April, 27),
3534 date_ymd(2026, Month::April, 29),
3535 ]
3536 );
3537 assert!(!dates.contains(&date_ymd(2026, Month::April, 20)));
3538 }
3539
3540 #[test]
3541 fn monthly_recurrence_skips_invalid_day_of_month_dates() {
3542 let start = date_ymd(2026, Month::January, 31);
3543 let event = Event::all_day("month-day", "Month end", start, source()).with_recurrence(
3544 RecurrenceRule {
3545 frequency: RecurrenceFrequency::Monthly,
3546 interval: 1,
3547 end: RecurrenceEnd::Never,
3548 weekdays: Vec::new(),
3549 monthly: Some(RecurrenceMonthlyRule::DayOfMonth(31)),
3550 yearly: None,
3551 },
3552 );
3553 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3554 let range = DateRange::new(start, date_ymd(2026, Month::April, 1)).expect("valid range");
3555
3556 let dates = source
3557 .events_intersecting(range)
3558 .into_iter()
3559 .filter_map(|event| event.timing.date())
3560 .collect::<Vec<_>>();
3561
3562 assert_eq!(dates, [start, date_ymd(2026, Month::March, 31)]);
3563 }
3564
3565 #[test]
3566 fn monthly_recurrence_supports_last_weekday_rules() {
3567 let start = date_ymd(2026, Month::April, 30);
3568 let event = Event::all_day("last-thursday", "Review", start, source()).with_recurrence(
3569 RecurrenceRule {
3570 frequency: RecurrenceFrequency::Monthly,
3571 interval: 1,
3572 end: RecurrenceEnd::Count(3),
3573 weekdays: Vec::new(),
3574 monthly: Some(RecurrenceMonthlyRule::WeekdayOrdinal {
3575 ordinal: RecurrenceOrdinal::Last,
3576 weekday: Weekday::Thursday,
3577 }),
3578 yearly: None,
3579 },
3580 );
3581 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3582 let range = DateRange::new(start, date_ymd(2026, Month::July, 1)).expect("valid range");
3583
3584 let dates = source
3585 .events_intersecting(range)
3586 .into_iter()
3587 .filter_map(|event| event.timing.date())
3588 .collect::<Vec<_>>();
3589
3590 assert_eq!(
3591 dates,
3592 [
3593 date_ymd(2026, Month::April, 30),
3594 date_ymd(2026, Month::May, 28),
3595 date_ymd(2026, Month::June, 25)
3596 ]
3597 );
3598 }
3599
3600 #[test]
3601 fn yearly_recurrence_skips_invalid_dates_and_supports_weekday_ordinal() {
3602 let leap_day = date_ymd(2024, Month::February, 29);
3603 let leap =
3604 Event::all_day("leap", "Leap", leap_day, source()).with_recurrence(RecurrenceRule {
3605 frequency: RecurrenceFrequency::Yearly,
3606 interval: 1,
3607 end: RecurrenceEnd::Never,
3608 weekdays: Vec::new(),
3609 monthly: None,
3610 yearly: Some(RecurrenceYearlyRule::Date {
3611 month: Month::February,
3612 day: 29,
3613 }),
3614 });
3615 let thanksgiving = Event::all_day(
3616 "thanksgiving",
3617 "Thanksgiving",
3618 date_ymd(2026, Month::November, 26),
3619 source(),
3620 )
3621 .with_recurrence(RecurrenceRule {
3622 frequency: RecurrenceFrequency::Yearly,
3623 interval: 1,
3624 end: RecurrenceEnd::Count(2),
3625 weekdays: Vec::new(),
3626 monthly: None,
3627 yearly: Some(RecurrenceYearlyRule::WeekdayOrdinal {
3628 month: Month::November,
3629 ordinal: RecurrenceOrdinal::Number(4),
3630 weekday: Weekday::Thursday,
3631 }),
3632 });
3633 let source =
3634 InMemoryAgendaSource::with_events_and_holidays(vec![leap, thanksgiving], Vec::new());
3635 let range = DateRange::new(
3636 date_ymd(2024, Month::January, 1),
3637 date_ymd(2029, Month::January, 1),
3638 )
3639 .expect("valid range");
3640
3641 let ids_and_dates = source
3642 .events_intersecting(range)
3643 .into_iter()
3644 .map(|event| (event.id, event.timing.date().expect("all-day date")))
3645 .collect::<Vec<_>>();
3646
3647 assert!(ids_and_dates.contains(&("leap#2024-02-29".to_string(), leap_day)));
3648 assert!(ids_and_dates.contains(&(
3649 "leap#2028-02-29".to_string(),
3650 date_ymd(2028, Month::February, 29)
3651 )));
3652 assert!(
3653 !ids_and_dates
3654 .iter()
3655 .any(|(_, date)| *date == date_ymd(2025, Month::February, 28))
3656 );
3657 assert!(ids_and_dates.contains(&(
3658 "thanksgiving#2026-11-26".to_string(),
3659 date_ymd(2026, Month::November, 26)
3660 )));
3661 assert!(ids_and_dates.contains(&(
3662 "thanksgiving#2027-11-25".to_string(),
3663 date_ymd(2027, Month::November, 25)
3664 )));
3665 }
3666
3667 #[test]
3668 fn recurring_cross_midnight_events_intersect_each_visible_day() {
3669 let start = date(23);
3670 let event = Event::timed(
3671 "late",
3672 "Late shift",
3673 at(start, 23, 0),
3674 at(start.add_days(1), 1, 0),
3675 source(),
3676 )
3677 .expect("valid recurring event")
3678 .with_recurrence(RecurrenceRule {
3679 frequency: RecurrenceFrequency::Daily,
3680 interval: 1,
3681 end: RecurrenceEnd::Count(2),
3682 weekdays: Vec::new(),
3683 monthly: None,
3684 yearly: None,
3685 });
3686 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3687
3688 let agenda = DayAgenda::from_source(start.add_days(1), &source);
3689
3690 assert_eq!(agenda.timed_events.len(), 2);
3691 assert!(agenda.timed_events[0].starts_before_day);
3692 assert_eq!(agenda.timed_events[0].visible_end.as_minutes(), 60);
3693 assert_eq!(agenda.timed_events[1].visible_start.as_minutes(), 23 * 60);
3694 assert!(agenda.timed_events[1].ends_after_day);
3695 }
3696
3697 #[test]
3698 fn invalid_ranges_are_rejected() {
3699 let day = date(23);
3700
3701 assert!(DateRange::new(day, day).is_err());
3702 assert!(Event::timed("bad", "Bad", at(day, 9, 0), at(day, 9, 0), source()).is_err());
3703 }
3704
3705 #[test]
3706 fn local_event_store_loads_missing_file_as_empty() {
3707 let path = temp_events_path("missing");
3708 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3709
3710 let source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3711 .expect("missing event file is empty");
3712
3713 assert!(
3714 source
3715 .events_intersecting(DateRange::day(date(23)))
3716 .is_empty()
3717 );
3718 }
3719
3720 #[test]
3721 fn local_event_store_saves_and_loads_timed_and_all_day_events() {
3722 let path = temp_events_path("save-load");
3723 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3724 let day = date(23);
3725 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3726 .expect("missing event file is empty");
3727
3728 source
3729 .create_event(CreateEventDraft {
3730 title: "Planning".to_string(),
3731 timing: CreateEventTiming::Timed {
3732 start: at(day, 9, 0),
3733 end: at(day, 10, 0),
3734 },
3735 location: Some("War room".to_string()),
3736 notes: Some("Bring notes".to_string()),
3737 reminders: vec![Reminder::minutes_before(10), Reminder::minutes_before(60)],
3738 recurrence: None,
3739 })
3740 .expect("timed event saves");
3741 source
3742 .create_event(CreateEventDraft {
3743 title: "Release day".to_string(),
3744 timing: CreateEventTiming::AllDay { date: day },
3745 location: None,
3746 notes: None,
3747 reminders: vec![Reminder::minutes_before(24 * 60)],
3748 recurrence: None,
3749 })
3750 .expect("all-day event saves");
3751
3752 let body = std::fs::read_to_string(&path).expect("event file exists");
3753 assert!(body.contains(r#""version": 2"#));
3754 assert!(body.contains(r#""reminders_minutes_before""#));
3755
3756 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3757 .expect("saved file reloads");
3758 let agenda = DayAgenda::from_source(day, &reloaded);
3759
3760 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3761
3762 assert_eq!(agenda.all_day_events.len(), 1);
3763 assert_eq!(agenda.all_day_events[0].title, "Release day");
3764 assert_eq!(agenda.timed_events.len(), 1);
3765 assert_eq!(agenda.timed_events[0].event.title, "Planning");
3766 assert_eq!(
3767 agenda.timed_events[0].event.location.as_deref(),
3768 Some("War room")
3769 );
3770 assert_eq!(
3771 agenda.timed_events[0]
3772 .event
3773 .reminders
3774 .iter()
3775 .map(|reminder| reminder.minutes_before)
3776 .collect::<Vec<_>>(),
3777 [10, 60]
3778 );
3779 }
3780
3781 #[test]
3782 fn local_event_store_updates_existing_event_id_and_persists() {
3783 let path = temp_events_path("update");
3784 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3785 let day = date(23);
3786 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3787 .expect("missing event file is empty");
3788 let event = source
3789 .create_event(CreateEventDraft {
3790 title: "Planning".to_string(),
3791 timing: CreateEventTiming::Timed {
3792 start: at(day, 9, 0),
3793 end: at(day, 10, 0),
3794 },
3795 location: None,
3796 notes: None,
3797 reminders: Vec::new(),
3798 recurrence: None,
3799 })
3800 .expect("event saves");
3801
3802 let updated = source
3803 .update_event(
3804 &event.id,
3805 CreateEventDraft {
3806 title: "Updated planning".to_string(),
3807 timing: CreateEventTiming::AllDay { date: day },
3808 location: Some("Room 2".to_string()),
3809 notes: Some("Moved".to_string()),
3810 reminders: vec![Reminder::minutes_before(5)],
3811 recurrence: None,
3812 },
3813 )
3814 .expect("event updates");
3815
3816 assert_eq!(updated.id, event.id);
3817 assert!(updated.is_local());
3818
3819 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3820 .expect("saved file reloads");
3821 let agenda = DayAgenda::from_source(day, &reloaded);
3822
3823 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3824
3825 assert!(agenda.timed_events.is_empty());
3826 assert_eq!(agenda.all_day_events.len(), 1);
3827 assert_eq!(agenda.all_day_events[0].id, event.id);
3828 assert_eq!(agenda.all_day_events[0].title, "Updated planning");
3829 assert_eq!(agenda.all_day_events[0].location.as_deref(), Some("Room 2"));
3830 }
3831
3832 #[test]
3833 fn local_event_store_duplicates_single_event_and_persists() {
3834 let path = temp_events_path("duplicate-event");
3835 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3836 let day = date(23);
3837 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3838 .expect("missing event file is empty");
3839 let event = source
3840 .create_event(CreateEventDraft {
3841 title: "Planning".to_string(),
3842 timing: CreateEventTiming::Timed {
3843 start: at(day, 9, 0),
3844 end: at(day, 10, 0),
3845 },
3846 location: Some("Room 1".to_string()),
3847 notes: Some("Bring notes".to_string()),
3848 reminders: vec![Reminder::minutes_before(15)],
3849 recurrence: None,
3850 })
3851 .expect("event saves");
3852
3853 let copied = source.duplicate_event(&event.id).expect("event copies");
3854 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3855 .expect("saved file reloads");
3856 let agenda = DayAgenda::from_source(day, &reloaded);
3857
3858 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3859
3860 assert_ne!(copied.id, event.id);
3861 assert!(copied.is_local());
3862 assert_eq!(copied.title, "Planning");
3863 assert_eq!(copied.location.as_deref(), Some("Room 1"));
3864 assert_eq!(agenda.timed_events.len(), 2);
3865 }
3866
3867 #[test]
3868 fn local_event_store_duplicates_occurrence_as_standalone_event() {
3869 let path = temp_events_path("duplicate-occurrence");
3870 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3871 let day = date(23);
3872 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3873 .expect("missing event file is empty");
3874 let event = source
3875 .create_event(CreateEventDraft {
3876 title: "Standup".to_string(),
3877 timing: CreateEventTiming::Timed {
3878 start: at(day, 9, 0),
3879 end: at(day, 9, 30),
3880 },
3881 location: None,
3882 notes: None,
3883 reminders: Vec::new(),
3884 recurrence: Some(RecurrenceRule {
3885 frequency: RecurrenceFrequency::Daily,
3886 interval: 1,
3887 end: RecurrenceEnd::Count(2),
3888 weekdays: Vec::new(),
3889 monthly: None,
3890 yearly: None,
3891 }),
3892 })
3893 .expect("recurring event saves");
3894 let anchor = OccurrenceAnchor::Timed {
3895 start: at(day.add_days(1), 9, 0),
3896 };
3897
3898 let copied = source
3899 .duplicate_occurrence(&event.id, anchor)
3900 .expect("occurrence copies");
3901 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3902 .expect("saved file reloads");
3903 let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3904
3905 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3906
3907 assert_ne!(copied.id, event.id);
3908 assert!(copied.occurrence().is_none());
3909 assert!(copied.recurrence.is_none());
3910 assert_eq!(agenda.timed_events.len(), 2);
3911 assert!(
3912 agenda
3913 .timed_events
3914 .iter()
3915 .any(|agenda_event| agenda_event.event.id == copied.id)
3916 );
3917 }
3918
3919 #[test]
3920 fn local_event_store_duplicates_recurring_series() {
3921 let path = temp_events_path("duplicate-series");
3922 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3923 let day = date(23);
3924 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3925 .expect("missing event file is empty");
3926 let event = source
3927 .create_event(CreateEventDraft {
3928 title: "Standup".to_string(),
3929 timing: CreateEventTiming::Timed {
3930 start: at(day, 9, 0),
3931 end: at(day, 9, 30),
3932 },
3933 location: None,
3934 notes: None,
3935 reminders: Vec::new(),
3936 recurrence: Some(RecurrenceRule {
3937 frequency: RecurrenceFrequency::Daily,
3938 interval: 1,
3939 end: RecurrenceEnd::Count(2),
3940 weekdays: Vec::new(),
3941 monthly: None,
3942 yearly: None,
3943 }),
3944 })
3945 .expect("recurring event saves");
3946
3947 let copied = source
3948 .duplicate_event(&event.id)
3949 .expect("recurring series copies");
3950 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3951 .expect("saved file reloads");
3952 let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3953
3954 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3955
3956 assert_ne!(copied.id, event.id);
3957 assert!(copied.recurrence.is_some());
3958 assert_eq!(agenda.timed_events.len(), 2);
3959 assert!(
3960 agenda
3961 .timed_events
3962 .iter()
3963 .any(|agenda_event| agenda_event.event.id.starts_with(&copied.id))
3964 );
3965 }
3966
3967 #[test]
3968 fn local_event_store_loads_version_one_and_rewrites_version_two_on_save() {
3969 let path = temp_events_path("version-one");
3970 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3971 std::fs::create_dir_all(path.parent().expect("path has parent"))
3972 .expect("parent can be created");
3973 std::fs::write(
3974 &path,
3975 r#"{
3976 "version": 1,
3977 "events": [
3978 {
3979 "id": "old",
3980 "title": "Old file",
3981 "date": "2026-04-23"
3982 }
3983 ]
3984 }"#,
3985 )
3986 .expect("file can be written");
3987 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3988 .expect("version one file loads");
3989
3990 source
3991 .update_event(
3992 "old",
3993 CreateEventDraft {
3994 title: "Rewritten".to_string(),
3995 timing: CreateEventTiming::AllDay { date: date(23) },
3996 location: None,
3997 notes: None,
3998 reminders: Vec::new(),
3999 recurrence: None,
4000 },
4001 )
4002 .expect("event update saves");
4003
4004 let body = std::fs::read_to_string(&path).expect("event file exists");
4005 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4006
4007 assert!(body.contains(r#""version": 2"#));
4008 assert!(body.contains("Rewritten"));
4009 }
4010
4011 #[test]
4012 fn local_event_store_saves_recurring_series_and_occurrence_overrides() {
4013 let path = temp_events_path("recurring-overrides");
4014 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4015 let day = date(23);
4016 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4017 .expect("missing event file is empty");
4018 let event = source
4019 .create_event(CreateEventDraft {
4020 title: "Standup".to_string(),
4021 timing: CreateEventTiming::Timed {
4022 start: at(day, 9, 0),
4023 end: at(day, 9, 30),
4024 },
4025 location: None,
4026 notes: None,
4027 reminders: Vec::new(),
4028 recurrence: Some(RecurrenceRule {
4029 frequency: RecurrenceFrequency::Daily,
4030 interval: 1,
4031 end: RecurrenceEnd::Count(3),
4032 weekdays: Vec::new(),
4033 monthly: None,
4034 yearly: None,
4035 }),
4036 })
4037 .expect("recurring event saves");
4038 let anchor = OccurrenceAnchor::Timed {
4039 start: at(day.add_days(1), 9, 0),
4040 };
4041
4042 source
4043 .update_occurrence(
4044 &event.id,
4045 anchor,
4046 CreateEventDraft {
4047 title: "Moved standup".to_string(),
4048 timing: CreateEventTiming::Timed {
4049 start: at(day.add_days(1), 10, 0),
4050 end: at(day.add_days(1), 10, 30),
4051 },
4052 location: Some("Room 2".to_string()),
4053 notes: None,
4054 reminders: vec![Reminder::minutes_before(5)],
4055 recurrence: None,
4056 },
4057 )
4058 .expect("occurrence override saves");
4059
4060 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4061 .expect("saved file reloads");
4062 let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
4063
4064 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4065
4066 assert_eq!(agenda.timed_events.len(), 1);
4067 let overridden = &agenda.timed_events[0].event;
4068 assert_eq!(overridden.title, "Moved standup");
4069 assert_eq!(overridden.location.as_deref(), Some("Room 2"));
4070 assert_eq!(
4071 overridden.occurrence().map(|occurrence| occurrence.anchor),
4072 Some(anchor)
4073 );
4074 }
4075
4076 #[test]
4077 fn local_event_store_deletes_single_event_and_persists() {
4078 let path = temp_events_path("delete-event");
4079 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4080 let day = date(23);
4081 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4082 .expect("missing event file is empty");
4083 let event = source
4084 .create_event(CreateEventDraft {
4085 title: "Planning".to_string(),
4086 timing: CreateEventTiming::Timed {
4087 start: at(day, 9, 0),
4088 end: at(day, 10, 0),
4089 },
4090 location: None,
4091 notes: None,
4092 reminders: Vec::new(),
4093 recurrence: None,
4094 })
4095 .expect("event saves");
4096
4097 let deleted = source.delete_event(&event.id).expect("event deletes");
4098 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4099 .expect("saved file reloads");
4100 let agenda = DayAgenda::from_source(day, &reloaded);
4101
4102 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4103
4104 assert_eq!(deleted.id, event.id);
4105 assert!(agenda.is_empty());
4106 }
4107
4108 #[test]
4109 fn local_event_store_deletes_one_recurring_occurrence_and_persists() {
4110 let path = temp_events_path("delete-occurrence");
4111 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4112 let day = date(23);
4113 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4114 .expect("missing event file is empty");
4115 let event = source
4116 .create_event(CreateEventDraft {
4117 title: "Standup".to_string(),
4118 timing: CreateEventTiming::Timed {
4119 start: at(day, 9, 0),
4120 end: at(day, 9, 30),
4121 },
4122 location: None,
4123 notes: None,
4124 reminders: Vec::new(),
4125 recurrence: Some(RecurrenceRule {
4126 frequency: RecurrenceFrequency::Daily,
4127 interval: 1,
4128 end: RecurrenceEnd::Count(3),
4129 weekdays: Vec::new(),
4130 monthly: None,
4131 yearly: None,
4132 }),
4133 })
4134 .expect("recurring event saves");
4135 let deleted_anchor = OccurrenceAnchor::Timed {
4136 start: at(day.add_days(1), 9, 0),
4137 };
4138
4139 source
4140 .update_occurrence(
4141 &event.id,
4142 deleted_anchor,
4143 CreateEventDraft {
4144 title: "Moved".to_string(),
4145 timing: CreateEventTiming::Timed {
4146 start: at(day.add_days(1), 10, 0),
4147 end: at(day.add_days(1), 10, 30),
4148 },
4149 location: None,
4150 notes: None,
4151 reminders: Vec::new(),
4152 recurrence: None,
4153 },
4154 )
4155 .expect("override saves before delete");
4156 source
4157 .delete_occurrence(&event.id, deleted_anchor)
4158 .expect("occurrence deletes");
4159 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4160 .expect("saved file reloads");
4161
4162 let first = DayAgenda::from_source(day, &reloaded);
4163 let second = DayAgenda::from_source(day.add_days(1), &reloaded);
4164 let third = DayAgenda::from_source(day.add_days(2), &reloaded);
4165
4166 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4167
4168 assert_eq!(first.timed_events.len(), 1);
4169 assert!(second.timed_events.is_empty());
4170 assert_eq!(third.timed_events.len(), 1);
4171 let stored = reloaded
4172 .local_event_by_id(&event.id)
4173 .expect("series exists");
4174 assert_eq!(stored.deleted_occurrences, vec![deleted_anchor]);
4175 assert!(stored.occurrence_overrides.is_empty());
4176 }
4177
4178 #[test]
4179 fn local_event_store_delete_series_removes_all_occurrences() {
4180 let path = temp_events_path("delete-series");
4181 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4182 let day = date(23);
4183 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4184 .expect("missing event file is empty");
4185 let event = source
4186 .create_event(CreateEventDraft {
4187 title: "Standup".to_string(),
4188 timing: CreateEventTiming::Timed {
4189 start: at(day, 9, 0),
4190 end: at(day, 9, 30),
4191 },
4192 location: None,
4193 notes: None,
4194 reminders: Vec::new(),
4195 recurrence: Some(RecurrenceRule {
4196 frequency: RecurrenceFrequency::Daily,
4197 interval: 1,
4198 end: RecurrenceEnd::Count(3),
4199 weekdays: Vec::new(),
4200 monthly: None,
4201 yearly: None,
4202 }),
4203 })
4204 .expect("recurring event saves");
4205
4206 source.delete_event(&event.id).expect("series deletes");
4207 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4208 .expect("saved file reloads");
4209 let range = DateRange::new(day, day.add_days(4)).expect("valid range");
4210
4211 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4212
4213 assert!(reloaded.events_intersecting(range).is_empty());
4214 }
4215
4216 #[test]
4217 fn series_edits_drop_overrides_whose_anchor_no_longer_generates() {
4218 let path = temp_events_path("series-edit-overrides");
4219 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4220 let day = date(23);
4221 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4222 .expect("missing event file is empty");
4223 let event = source
4224 .create_event(CreateEventDraft {
4225 title: "Standup".to_string(),
4226 timing: CreateEventTiming::Timed {
4227 start: at(day, 9, 0),
4228 end: at(day, 9, 30),
4229 },
4230 location: None,
4231 notes: None,
4232 reminders: Vec::new(),
4233 recurrence: Some(RecurrenceRule {
4234 frequency: RecurrenceFrequency::Daily,
4235 interval: 1,
4236 end: RecurrenceEnd::Count(3),
4237 weekdays: Vec::new(),
4238 monthly: None,
4239 yearly: None,
4240 }),
4241 })
4242 .expect("recurring event saves");
4243 let anchor = OccurrenceAnchor::Timed {
4244 start: at(day.add_days(1), 9, 0),
4245 };
4246 source
4247 .update_occurrence(
4248 &event.id,
4249 anchor,
4250 CreateEventDraft {
4251 title: "Override".to_string(),
4252 timing: CreateEventTiming::Timed {
4253 start: at(day.add_days(1), 10, 0),
4254 end: at(day.add_days(1), 10, 30),
4255 },
4256 location: None,
4257 notes: None,
4258 reminders: Vec::new(),
4259 recurrence: None,
4260 },
4261 )
4262 .expect("override saves");
4263
4264 let updated = source
4265 .update_event(
4266 &event.id,
4267 CreateEventDraft {
4268 title: "Standup".to_string(),
4269 timing: CreateEventTiming::Timed {
4270 start: at(day, 9, 0),
4271 end: at(day, 9, 30),
4272 },
4273 location: None,
4274 notes: None,
4275 reminders: Vec::new(),
4276 recurrence: Some(RecurrenceRule {
4277 frequency: RecurrenceFrequency::Weekly,
4278 interval: 1,
4279 end: RecurrenceEnd::Never,
4280 weekdays: vec![day.weekday()],
4281 monthly: None,
4282 yearly: None,
4283 }),
4284 },
4285 )
4286 .expect("series update saves");
4287
4288 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4289
4290 assert!(updated.occurrence_overrides.is_empty());
4291 }
4292
4293 #[test]
4294 fn local_event_store_rejects_missing_and_non_local_updates() {
4295 let day = date(23);
4296 let mut source = ConfiguredAgendaSource::new(
4297 InMemoryAgendaSource::with_events_and_holidays(
4298 vec![timed("fixture", "Fixture", at(day, 8, 0), at(day, 9, 0))],
4299 Vec::new(),
4300 ),
4301 HolidayProvider::off(),
4302 );
4303 let draft = CreateEventDraft {
4304 title: "Updated".to_string(),
4305 timing: CreateEventTiming::Timed {
4306 start: at(day, 10, 0),
4307 end: at(day, 11, 0),
4308 },
4309 location: None,
4310 notes: None,
4311 reminders: Vec::new(),
4312 recurrence: None,
4313 };
4314
4315 assert!(matches!(
4316 source
4317 .update_event("missing", draft.clone())
4318 .expect_err("missing event fails"),
4319 LocalEventStoreError::EventNotFound { .. }
4320 ));
4321 assert!(matches!(
4322 source
4323 .update_event("fixture", draft)
4324 .expect_err("fixture event fails"),
4325 LocalEventStoreError::EventNotEditable { .. }
4326 ));
4327 }
4328
4329 #[test]
4330 fn local_event_store_rejects_malformed_json() {
4331 let path = temp_events_path("malformed");
4332 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
4333 std::fs::create_dir_all(path.parent().expect("path has parent"))
4334 .expect("parent can be created");
4335 std::fs::write(&path, "{not json").expect("file can be written");
4336
4337 let err = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
4338 .expect_err("malformed file fails");
4339
4340 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
4341
4342 assert!(matches!(err, LocalEventStoreError::Parse { .. }));
4343 assert!(err.to_string().contains("failed to parse"));
4344 }
4345
4346 #[test]
4347 fn us_federal_source_returns_2026_observed_holidays() {
4348 let source = UsFederalHolidaySource;
4349 let range = DateRange::new(
4350 date_ymd(2026, Month::January, 1),
4351 date_ymd(2027, Month::January, 1),
4352 )
4353 .expect("valid range");
4354
4355 let holidays = source.holidays_in(range);
4356 let observed = holidays
4357 .iter()
4358 .map(|holiday| (holiday.name.as_str(), holiday.date))
4359 .collect::<Vec<_>>();
4360
4361 assert_eq!(holidays.len(), 11);
4362 assert!(observed.contains(&("New Year's Day", date_ymd(2026, Month::January, 1))));
4363 assert!(observed.contains(&(
4364 "Birthday of Martin Luther King, Jr.",
4365 date_ymd(2026, Month::January, 19),
4366 )));
4367 assert!(
4368 observed.contains(&("Washington's Birthday", date_ymd(2026, Month::February, 16),))
4369 );
4370 assert!(observed.contains(&("Memorial Day", date_ymd(2026, Month::May, 25))));
4371 assert!(observed.contains(&(
4372 "Juneteenth National Independence Day",
4373 date_ymd(2026, Month::June, 19),
4374 )));
4375 assert!(observed.contains(&("Independence Day", date_ymd(2026, Month::July, 3))));
4376 assert!(observed.contains(&("Labor Day", date_ymd(2026, Month::September, 7))));
4377 assert!(observed.contains(&("Columbus Day", date_ymd(2026, Month::October, 12))));
4378 assert!(observed.contains(&("Veterans Day", date_ymd(2026, Month::November, 11))));
4379 assert!(observed.contains(&("Thanksgiving Day", date_ymd(2026, Month::November, 26))));
4380 assert!(observed.contains(&("Christmas Day", date_ymd(2026, Month::December, 25))));
4381 }
4382
4383 #[test]
4384 fn us_federal_source_includes_previous_year_observed_new_year() {
4385 let source = UsFederalHolidaySource;
4386 let day = date_ymd(2021, Month::December, 31);
4387
4388 let holidays = source.holidays_in(DateRange::day(day));
4389
4390 assert_eq!(holidays.len(), 1);
4391 assert_eq!(holidays[0].name, "New Year's Day");
4392 assert_eq!(holidays[0].date, day);
4393 }
4394
4395 #[test]
4396 fn nager_source_reads_cached_holidays_without_network() {
4397 let cache_dir = std::env::temp_dir()
4398 .join(format!("rcal-nager-test-{}", std::process::id()))
4399 .join("cached-holidays-basic");
4400 let _ = std::fs::remove_dir_all(&cache_dir);
4401 let country_dir = cache_dir.join("GB");
4402 std::fs::create_dir_all(&country_dir).expect("cache dir can be created");
4403 std::fs::write(
4404 country_dir.join("2026.json"),
4405 r#"[{"date":"2026-12-25","localName":"Christmas Day","name":"Christmas Day","countryCode":"GB","fixed":false,"global":true,"counties":null,"launchYear":null,"types":["Public"]}]"#,
4406 )
4407 .expect("cache file can be written");
4408
4409 let source = NagerHolidaySource::with_cache_dir("gb", &cache_dir, Duration::from_millis(1));
4410 let holidays = source.holidays_in(DateRange::day(date_ymd(2026, Month::December, 25)));
4411
4412 let _ = std::fs::remove_dir_all(cache_dir);
4413
4414 assert_eq!(holidays.len(), 1);
4415 assert_eq!(holidays[0].id, "nager-GB-2026-12-25-christmas-day");
4416 assert_eq!(holidays[0].name, "Christmas Day");
4417 assert_eq!(holidays[0].source.source_id, "nager-GB");
4418 }
4419
4420 #[test]
4421 fn nager_source_respects_half_open_year_boundaries() {
4422 let cache_dir = std::env::temp_dir()
4423 .join(format!("rcal-nager-test-{}", std::process::id()))
4424 .join("half-open-range");
4425 let _ = std::fs::remove_dir_all(&cache_dir);
4426 let country_dir = cache_dir.join("GB");
4427 std::fs::create_dir_all(&country_dir).expect("cache dir can be created");
4428 std::fs::write(country_dir.join("2026.json"), "[]").expect("cache file can be written");
4429 std::fs::write(country_dir.join("2027.json"), "[]").expect("cache file can be written");
4430
4431 let source = NagerHolidaySource::with_cache_dir("gb", &cache_dir, Duration::from_millis(1));
4432 let range = DateRange::new(
4433 date_ymd(2026, Month::December, 31),
4434 date_ymd(2027, Month::January, 1),
4435 )
4436 .expect("valid range");
4437
4438 let _ = source.holidays_in(range);
4439 let attempted_years = source.state.borrow().attempted_years.clone();
4440 let _ = std::fs::remove_dir_all(cache_dir);
4441
4442 assert!(attempted_years.contains(&2026));
4443 assert!(!attempted_years.contains(&2027));
4444 }
4445 }
4446