Rust · 108303 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, Serialize};
12 use time::{Month, Time, Weekday};
13
14 use crate::calendar::CalendarDate;
15
16 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 pub struct DateRange {
18 pub start: CalendarDate,
19 pub end: CalendarDate,
20 }
21
22 impl DateRange {
23 pub fn day(date: CalendarDate) -> Self {
24 Self {
25 start: date,
26 end: date.add_days(1),
27 }
28 }
29
30 pub fn new(start: CalendarDate, end: CalendarDate) -> Result<Self, AgendaError> {
31 if start < end {
32 Ok(Self { start, end })
33 } else {
34 Err(AgendaError::InvalidDateRange { start, end })
35 }
36 }
37
38 pub fn contains_date(self, date: CalendarDate) -> bool {
39 self.start <= date && date < self.end
40 }
41 }
42
43 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44 pub struct EventDateTime {
45 pub date: CalendarDate,
46 pub time: Time,
47 }
48
49 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
50 pub struct DayMinute(u16);
51
52 impl DayMinute {
53 pub const START: Self = Self(0);
54 pub const END: Self = Self(24 * 60);
55
56 pub const fn from_minutes(minutes: u16) -> Self {
57 Self(minutes)
58 }
59
60 pub fn from_time(time: Time) -> Self {
61 Self(u16::from(time.hour()) * 60 + u16::from(time.minute()))
62 }
63
64 pub const fn as_minutes(self) -> u16 {
65 self.0
66 }
67 }
68
69 impl EventDateTime {
70 pub const fn new(date: CalendarDate, time: Time) -> Self {
71 Self { date, time }
72 }
73 }
74
75 #[derive(Debug, Clone, PartialEq, Eq)]
76 pub struct SourceMetadata {
77 pub source_id: String,
78 pub source_name: String,
79 pub external_id: Option<String>,
80 }
81
82 impl SourceMetadata {
83 pub fn new(source_id: impl Into<String>, source_name: impl Into<String>) -> Self {
84 Self {
85 source_id: source_id.into(),
86 source_name: source_name.into(),
87 external_id: None,
88 }
89 }
90
91 pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
92 self.external_id = Some(external_id.into());
93 self
94 }
95
96 pub fn fixture() -> Self {
97 Self::new("fixture", "In-memory fixture")
98 }
99
100 pub fn local() -> Self {
101 Self::new("local", "Local events")
102 }
103 }
104
105 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
106 pub struct Reminder {
107 pub minutes_before: u16,
108 }
109
110 impl Reminder {
111 pub const fn minutes_before(minutes_before: u16) -> Self {
112 Self { minutes_before }
113 }
114 }
115
116 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
117 pub enum EventTiming {
118 AllDay {
119 date: CalendarDate,
120 },
121 Timed {
122 start: EventDateTime,
123 end: EventDateTime,
124 },
125 }
126
127 impl EventTiming {
128 pub const fn date(self) -> Option<CalendarDate> {
129 match self {
130 Self::AllDay { date } => Some(date),
131 Self::Timed { .. } => None,
132 }
133 }
134
135 pub const fn is_all_day(self) -> bool {
136 matches!(self, Self::AllDay { .. })
137 }
138
139 pub const fn is_timed(self) -> bool {
140 matches!(self, Self::Timed { .. })
141 }
142 }
143
144 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
145 pub enum RecurrenceFrequency {
146 Daily,
147 Weekly,
148 Monthly,
149 Yearly,
150 }
151
152 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
153 pub enum RecurrenceEnd {
154 Never,
155 Until(CalendarDate),
156 Count(u32),
157 }
158
159 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
160 pub enum RecurrenceOrdinal {
161 Number(u8),
162 Last,
163 }
164
165 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
166 pub enum RecurrenceMonthlyRule {
167 DayOfMonth(u8),
168 WeekdayOrdinal {
169 ordinal: RecurrenceOrdinal,
170 weekday: Weekday,
171 },
172 }
173
174 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
175 pub enum RecurrenceYearlyRule {
176 Date {
177 month: Month,
178 day: u8,
179 },
180 WeekdayOrdinal {
181 month: Month,
182 ordinal: RecurrenceOrdinal,
183 weekday: Weekday,
184 },
185 }
186
187 #[derive(Debug, Clone, PartialEq, Eq)]
188 pub struct RecurrenceRule {
189 pub frequency: RecurrenceFrequency,
190 pub interval: u16,
191 pub end: RecurrenceEnd,
192 pub weekdays: Vec<Weekday>,
193 pub monthly: Option<RecurrenceMonthlyRule>,
194 pub yearly: Option<RecurrenceYearlyRule>,
195 }
196
197 impl RecurrenceRule {
198 pub fn new(frequency: RecurrenceFrequency) -> Self {
199 Self {
200 frequency,
201 interval: 1,
202 end: RecurrenceEnd::Never,
203 weekdays: Vec::new(),
204 monthly: None,
205 yearly: None,
206 }
207 }
208
209 pub fn with_interval(mut self, interval: u16) -> Self {
210 self.interval = interval.max(1);
211 self
212 }
213
214 pub fn interval(&self) -> u16 {
215 self.interval.max(1)
216 }
217 }
218
219 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
220 pub enum OccurrenceAnchor {
221 AllDay { date: CalendarDate },
222 Timed { start: EventDateTime },
223 }
224
225 impl OccurrenceAnchor {
226 pub const fn date(self) -> CalendarDate {
227 match self {
228 Self::AllDay { date } => date,
229 Self::Timed { start } => start.date,
230 }
231 }
232
233 fn storage_key(self) -> String {
234 match self {
235 Self::AllDay { date } => format!("{date}"),
236 Self::Timed { start } => {
237 format!("{}T{}", start.date, format_time(start.time))
238 }
239 }
240 }
241 }
242
243 #[derive(Debug, Clone, PartialEq, Eq)]
244 pub struct OccurrenceMetadata {
245 pub series_id: String,
246 pub anchor: OccurrenceAnchor,
247 }
248
249 #[derive(Debug, Clone, PartialEq, Eq)]
250 pub struct OccurrenceOverride {
251 pub anchor: OccurrenceAnchor,
252 pub draft: CreateEventDraft,
253 }
254
255 #[derive(Debug, Clone, PartialEq, Eq)]
256 pub struct Event {
257 pub id: String,
258 pub title: String,
259 pub location: Option<String>,
260 pub notes: Option<String>,
261 pub reminders: Vec<Reminder>,
262 pub source: SourceMetadata,
263 pub timing: EventTiming,
264 pub recurrence: Option<RecurrenceRule>,
265 pub occurrence: Option<OccurrenceMetadata>,
266 pub occurrence_overrides: Vec<OccurrenceOverride>,
267 }
268
269 impl Event {
270 pub fn all_day(
271 id: impl Into<String>,
272 title: impl Into<String>,
273 date: CalendarDate,
274 source: SourceMetadata,
275 ) -> Self {
276 Self {
277 id: id.into(),
278 title: title.into(),
279 location: None,
280 notes: None,
281 reminders: Vec::new(),
282 source,
283 timing: EventTiming::AllDay { date },
284 recurrence: None,
285 occurrence: None,
286 occurrence_overrides: Vec::new(),
287 }
288 }
289
290 pub fn timed(
291 id: impl Into<String>,
292 title: impl Into<String>,
293 start: EventDateTime,
294 end: EventDateTime,
295 source: SourceMetadata,
296 ) -> Result<Self, AgendaError> {
297 if start >= end {
298 return Err(AgendaError::InvalidEventRange { start, end });
299 }
300
301 Ok(Self {
302 id: id.into(),
303 title: title.into(),
304 location: None,
305 notes: None,
306 reminders: Vec::new(),
307 source,
308 timing: EventTiming::Timed { start, end },
309 recurrence: None,
310 occurrence: None,
311 occurrence_overrides: Vec::new(),
312 })
313 }
314
315 pub fn with_location(mut self, location: impl Into<String>) -> Self {
316 self.location = Some(location.into());
317 self
318 }
319
320 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
321 self.notes = Some(notes.into());
322 self
323 }
324
325 pub fn with_reminders(mut self, reminders: Vec<Reminder>) -> Self {
326 self.reminders = reminders;
327 self
328 }
329
330 pub fn with_recurrence(mut self, recurrence: RecurrenceRule) -> Self {
331 self.recurrence = Some(recurrence);
332 self
333 }
334
335 pub const fn is_all_day(&self) -> bool {
336 self.timing.is_all_day()
337 }
338
339 pub const fn is_timed(&self) -> bool {
340 self.timing.is_timed()
341 }
342
343 pub fn is_local(&self) -> bool {
344 self.source.source_id == "local"
345 }
346
347 pub const fn is_recurring_series(&self) -> bool {
348 self.recurrence.is_some()
349 }
350
351 pub const fn occurrence(&self) -> Option<&OccurrenceMetadata> {
352 self.occurrence.as_ref()
353 }
354
355 pub fn intersects_range(&self, range: DateRange) -> bool {
356 match self.timing {
357 EventTiming::AllDay { date } => range.contains_date(date),
358 EventTiming::Timed { start, end } => {
359 let range_start = EventDateTime::new(range.start, Time::MIDNIGHT);
360 let range_end = EventDateTime::new(range.end, Time::MIDNIGHT);
361
362 start < range_end && end > range_start
363 }
364 }
365 }
366 }
367
368 #[derive(Debug, Clone, PartialEq, Eq)]
369 pub struct CreateEventDraft {
370 pub title: String,
371 pub timing: CreateEventTiming,
372 pub location: Option<String>,
373 pub notes: Option<String>,
374 pub reminders: Vec<Reminder>,
375 pub recurrence: Option<RecurrenceRule>,
376 }
377
378 impl CreateEventDraft {
379 pub fn into_event(self, id: String) -> Result<Event, AgendaError> {
380 let source = SourceMetadata::local().with_external_id(id.clone());
381 let mut event = match self.timing {
382 CreateEventTiming::AllDay { date } => Event::all_day(id, self.title, date, source),
383 CreateEventTiming::Timed { start, end } => {
384 Event::timed(id, self.title, start, end, source)?
385 }
386 };
387
388 event.location = self.location;
389 event.notes = self.notes;
390 event.reminders = self.reminders;
391 event.recurrence = self.recurrence;
392 Ok(event)
393 }
394
395 fn without_recurrence(mut self) -> Self {
396 self.recurrence = None;
397 self
398 }
399 }
400
401 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
402 pub enum CreateEventTiming {
403 AllDay {
404 date: CalendarDate,
405 },
406 Timed {
407 start: EventDateTime,
408 end: EventDateTime,
409 },
410 }
411
412 #[derive(Debug, Clone, PartialEq, Eq)]
413 pub struct Holiday {
414 pub id: String,
415 pub name: String,
416 pub date: CalendarDate,
417 pub source: SourceMetadata,
418 }
419
420 impl Holiday {
421 pub fn new(
422 id: impl Into<String>,
423 name: impl Into<String>,
424 date: CalendarDate,
425 source: SourceMetadata,
426 ) -> Self {
427 Self {
428 id: id.into(),
429 name: name.into(),
430 date,
431 source,
432 }
433 }
434 }
435
436 #[derive(Debug, Clone, PartialEq, Eq)]
437 pub struct TimedAgendaEvent {
438 pub event: Event,
439 pub visible_start: DayMinute,
440 pub visible_end: DayMinute,
441 pub starts_before_day: bool,
442 pub ends_after_day: bool,
443 pub overlap_group: usize,
444 }
445
446 impl TimedAgendaEvent {
447 pub fn overlaps(&self, other: &Self) -> bool {
448 self.visible_start < other.visible_end && other.visible_start < self.visible_end
449 }
450 }
451
452 #[derive(Debug, Clone, PartialEq, Eq)]
453 pub struct DayAgenda {
454 pub date: CalendarDate,
455 pub holidays: Vec<Holiday>,
456 pub all_day_events: Vec<Event>,
457 pub timed_events: Vec<TimedAgendaEvent>,
458 }
459
460 impl DayAgenda {
461 pub fn empty(date: CalendarDate) -> Self {
462 Self {
463 date,
464 holidays: Vec::new(),
465 all_day_events: Vec::new(),
466 timed_events: Vec::new(),
467 }
468 }
469
470 pub fn build(
471 date: CalendarDate,
472 events: impl IntoIterator<Item = Event>,
473 holidays: impl IntoIterator<Item = Holiday>,
474 ) -> Self {
475 let range = DateRange::day(date);
476 let events = events
477 .into_iter()
478 .filter(|event| event.intersects_range(range))
479 .collect::<Vec<_>>();
480
481 let mut agenda = Self {
482 date,
483 holidays: holidays
484 .into_iter()
485 .filter(|holiday| holiday.date == date)
486 .collect(),
487 all_day_events: events
488 .iter()
489 .cloned()
490 .filter_map(|event| match event.timing {
491 EventTiming::AllDay { date: event_date } if event_date == date => Some(event),
492 _ => None,
493 })
494 .collect(),
495 timed_events: events_to_timed_agenda_events(date, events),
496 };
497
498 agenda.sort();
499 agenda
500 }
501
502 pub fn from_source<S>(date: CalendarDate, source: &S) -> Self
503 where
504 S: AgendaSource + ?Sized,
505 {
506 let range = DateRange::day(date);
507 Self::build(
508 date,
509 source.events_intersecting(range),
510 source.holidays_in(range),
511 )
512 }
513
514 pub fn is_empty(&self) -> bool {
515 self.holidays.is_empty() && self.all_day_events.is_empty() && self.timed_events.is_empty()
516 }
517
518 fn sort(&mut self) {
519 self.holidays
520 .sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
521 self.all_day_events
522 .sort_by(|left, right| left.title.cmp(&right.title).then(left.id.cmp(&right.id)));
523 self.timed_events.sort_by(|left, right| {
524 left.visible_start
525 .cmp(&right.visible_start)
526 .then(left.visible_end.cmp(&right.visible_end))
527 .then(left.event.title.cmp(&right.event.title))
528 .then(left.event.id.cmp(&right.event.id))
529 });
530 assign_overlap_groups(&mut self.timed_events);
531 }
532 }
533
534 pub trait AgendaSource {
535 fn events_intersecting(&self, range: DateRange) -> Vec<Event>;
536
537 fn holidays_in(&self, range: DateRange) -> Vec<Holiday>;
538
539 fn local_event_by_id(&self, _id: &str) -> Option<Event> {
540 None
541 }
542 }
543
544 #[derive(Debug)]
545 pub struct ConfiguredAgendaSource {
546 events: InMemoryAgendaSource,
547 holidays: HolidayProvider,
548 events_file: Option<PathBuf>,
549 }
550
551 impl ConfiguredAgendaSource {
552 pub fn development(holidays: HolidayProvider) -> Self {
553 Self::new(InMemoryAgendaSource::new(), holidays)
554 }
555
556 pub fn new(events: InMemoryAgendaSource, holidays: HolidayProvider) -> Self {
557 Self {
558 events,
559 holidays,
560 events_file: None,
561 }
562 }
563
564 pub fn from_events_file(
565 events_file: impl Into<PathBuf>,
566 holidays: HolidayProvider,
567 ) -> Result<Self, LocalEventStoreError> {
568 let events_file = events_file.into();
569 let events = load_events_file(&events_file)?;
570 Ok(Self {
571 events,
572 holidays,
573 events_file: Some(events_file),
574 })
575 }
576
577 pub fn create_event(&mut self, draft: CreateEventDraft) -> Result<Event, LocalEventStoreError> {
578 let id = self.next_local_event_id(&draft.title);
579 let event = draft
580 .into_event(id)
581 .map_err(|err| LocalEventStoreError::Encode {
582 path: self.events_file.clone(),
583 reason: err.to_string(),
584 })?;
585 if let Some(path) = &self.events_file {
586 let mut events = self.events.events().to_vec();
587 events.push(event.clone());
588 write_events_file(path, &events)?;
589 }
590 self.events.push_event(event.clone());
591 Ok(event)
592 }
593
594 pub fn update_event(
595 &mut self,
596 id: &str,
597 draft: CreateEventDraft,
598 ) -> Result<Event, LocalEventStoreError> {
599 let mut events = self.events.events().to_vec();
600 let Some(index) = events.iter().position(|event| event.id == id) else {
601 return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
602 };
603 if !events[index].is_local() {
604 return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() });
605 }
606
607 let mut event =
608 draft
609 .into_event(id.to_string())
610 .map_err(|err| LocalEventStoreError::Encode {
611 path: self.events_file.clone(),
612 reason: err.to_string(),
613 })?;
614 let existing_overrides = std::mem::take(&mut events[index].occurrence_overrides);
615 event.occurrence_overrides = existing_overrides
616 .into_iter()
617 .filter(|override_record| event_generates_anchor(&event, override_record.anchor))
618 .collect();
619 events[index] = event.clone();
620
621 if let Some(path) = &self.events_file {
622 write_events_file(path, &events)?;
623 }
624 self.events.events = events;
625 Ok(event)
626 }
627
628 pub fn update_occurrence(
629 &mut self,
630 series_id: &str,
631 anchor: OccurrenceAnchor,
632 draft: CreateEventDraft,
633 ) -> Result<Event, LocalEventStoreError> {
634 let mut events = self.events.events().to_vec();
635 let Some(index) = events.iter().position(|event| event.id == series_id) else {
636 return Err(LocalEventStoreError::EventNotFound {
637 id: series_id.to_string(),
638 });
639 };
640 if !events[index].is_local() || !events[index].is_recurring_series() {
641 return Err(LocalEventStoreError::EventNotEditable {
642 id: series_id.to_string(),
643 });
644 }
645 if !event_generates_anchor(&events[index], anchor) {
646 return Err(LocalEventStoreError::OccurrenceNotFound {
647 id: series_id.to_string(),
648 anchor: anchor.storage_key(),
649 });
650 }
651
652 let override_record = OccurrenceOverride {
653 anchor,
654 draft: draft.without_recurrence(),
655 };
656 if let Some(existing) = events[index]
657 .occurrence_overrides
658 .iter_mut()
659 .find(|existing| existing.anchor == anchor)
660 {
661 *existing = override_record;
662 } else {
663 events[index].occurrence_overrides.push(override_record);
664 }
665
666 let event = occurrence_override_event(&events[index], anchor).ok_or_else(|| {
667 LocalEventStoreError::OccurrenceNotFound {
668 id: series_id.to_string(),
669 anchor: anchor.storage_key(),
670 }
671 })?;
672
673 if let Some(path) = &self.events_file {
674 write_events_file(path, &events)?;
675 }
676 self.events.events = events;
677 Ok(event)
678 }
679
680 fn next_local_event_id(&self, title: &str) -> String {
681 let now = SystemTime::now()
682 .duration_since(UNIX_EPOCH)
683 .map(|duration| duration.as_millis())
684 .unwrap_or_default();
685 let counter = self.events.events().len() + 1;
686 let slug = slugify(title);
687 if slug.is_empty() {
688 format!("local-{now}-{counter}")
689 } else {
690 format!("local-{now}-{counter}-{slug}")
691 }
692 }
693 }
694
695 impl AgendaSource for ConfiguredAgendaSource {
696 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
697 self.events.events_intersecting(range)
698 }
699
700 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
701 self.holidays.holidays_in(range)
702 }
703
704 fn local_event_by_id(&self, id: &str) -> Option<Event> {
705 self.events.local_event_by_id(id)
706 }
707 }
708
709 #[derive(Debug)]
710 pub enum HolidayProvider {
711 Off(EmptyAgendaSource),
712 UsFederal(UsFederalHolidaySource),
713 Nager(NagerHolidaySource),
714 }
715
716 impl HolidayProvider {
717 pub const fn off() -> Self {
718 Self::Off(EmptyAgendaSource)
719 }
720
721 pub const fn us_federal() -> Self {
722 Self::UsFederal(UsFederalHolidaySource)
723 }
724
725 pub fn nager(country_code: impl Into<String>) -> Self {
726 Self::Nager(NagerHolidaySource::new(country_code))
727 }
728 }
729
730 impl AgendaSource for HolidayProvider {
731 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
732 Vec::new()
733 }
734
735 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
736 match self {
737 Self::Off(source) => source.holidays_in(range),
738 Self::UsFederal(source) => source.holidays_in(range),
739 Self::Nager(source) => source.holidays_in(range),
740 }
741 }
742 }
743
744 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
745 pub struct EmptyAgendaSource;
746
747 impl AgendaSource for EmptyAgendaSource {
748 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
749 Vec::new()
750 }
751
752 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
753 Vec::new()
754 }
755 }
756
757 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
758 pub struct UsFederalHolidaySource;
759
760 impl AgendaSource for UsFederalHolidaySource {
761 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
762 Vec::new()
763 }
764
765 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
766 let mut holidays = Vec::new();
767
768 for year in range.start.year() - 1..=range.end.year() + 1 {
769 holidays.extend(us_federal_holidays_for_year(year));
770 }
771
772 holidays.retain(|holiday| range.contains_date(holiday.date));
773 holidays.sort_by(|left, right| left.date.cmp(&right.date).then(left.name.cmp(&right.name)));
774 holidays.dedup_by(|left, right| left.id == right.id);
775 holidays
776 }
777 }
778
779 #[derive(Debug)]
780 pub struct NagerHolidaySource {
781 country_code: String,
782 cache_dir: PathBuf,
783 timeout: Duration,
784 state: RefCell<NagerHolidayState>,
785 }
786
787 impl NagerHolidaySource {
788 pub fn new(country_code: impl Into<String>) -> Self {
789 Self::with_cache_dir(
790 country_code,
791 default_nager_cache_dir(),
792 Duration::from_millis(1500),
793 )
794 }
795
796 pub fn with_cache_dir(
797 country_code: impl Into<String>,
798 cache_dir: impl Into<PathBuf>,
799 timeout: Duration,
800 ) -> Self {
801 Self {
802 country_code: country_code.into().to_ascii_uppercase(),
803 cache_dir: cache_dir.into(),
804 timeout,
805 state: RefCell::new(NagerHolidayState::default()),
806 }
807 }
808
809 fn ensure_year_loaded(&self, year: i32) {
810 if self.state.borrow().attempted_years.contains(&year) {
811 return;
812 }
813
814 let holidays = self.load_year(year).unwrap_or_default();
815 let mut state = self.state.borrow_mut();
816 state.attempted_years.insert(year);
817 state.holidays_by_year.insert(year, holidays);
818 }
819
820 fn load_year(&self, year: i32) -> Option<Vec<Holiday>> {
821 if let Some(cached) = self.load_year_from_cache(year) {
822 return Some(cached);
823 }
824
825 let body = self.fetch_year(year)?;
826 let holidays = parse_nager_holidays(&self.country_code, &body)?;
827 self.write_year_cache(year, &body);
828 Some(holidays)
829 }
830
831 fn load_year_from_cache(&self, year: i32) -> Option<Vec<Holiday>> {
832 let body = fs::read_to_string(self.cache_file(year)).ok()?;
833 parse_nager_holidays(&self.country_code, &body)
834 }
835
836 fn fetch_year(&self, year: i32) -> Option<String> {
837 let client = reqwest::blocking::Client::builder()
838 .timeout(self.timeout)
839 .user_agent("rcal/0.1")
840 .build()
841 .ok()?;
842 let url = format!(
843 "https://date.nager.at/api/v3/PublicHolidays/{year}/{}",
844 self.country_code
845 );
846 let response = client.get(url).send().ok()?;
847
848 if !response.status().is_success() {
849 return None;
850 }
851
852 response.text().ok()
853 }
854
855 fn write_year_cache(&self, year: i32, body: &str) {
856 let file = self.cache_file(year);
857 if let Some(parent) = file.parent() {
858 let _ = fs::create_dir_all(parent);
859 }
860 let _ = fs::write(file, body);
861 }
862
863 fn cache_file(&self, year: i32) -> PathBuf {
864 self.cache_dir
865 .join(&self.country_code)
866 .join(format!("{year}.json"))
867 }
868 }
869
870 impl AgendaSource for NagerHolidaySource {
871 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
872 Vec::new()
873 }
874
875 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
876 let final_date = range.end.add_days(-1);
877 for year in range.start.year()..=final_date.year() {
878 self.ensure_year_loaded(year);
879 }
880
881 let state = self.state.borrow();
882 let mut holidays = state
883 .holidays_by_year
884 .values()
885 .flatten()
886 .filter(|holiday| range.contains_date(holiday.date))
887 .cloned()
888 .collect::<Vec<_>>();
889 holidays.sort_by(|left, right| left.date.cmp(&right.date).then(left.name.cmp(&right.name)));
890 holidays
891 }
892 }
893
894 #[derive(Debug, Default)]
895 struct NagerHolidayState {
896 holidays_by_year: HashMap<i32, Vec<Holiday>>,
897 attempted_years: HashSet<i32>,
898 }
899
900 #[derive(Debug, Default, Clone, PartialEq, Eq)]
901 pub struct InMemoryAgendaSource {
902 events: Vec<Event>,
903 holidays: Vec<Holiday>,
904 }
905
906 impl InMemoryAgendaSource {
907 pub fn new() -> Self {
908 Self {
909 events: Vec::new(),
910 holidays: Vec::new(),
911 }
912 }
913
914 pub fn with_events_and_holidays(events: Vec<Event>, holidays: Vec<Holiday>) -> Self {
915 Self { events, holidays }
916 }
917
918 pub fn push_event(&mut self, event: Event) {
919 self.events.push(event);
920 }
921
922 pub fn events(&self) -> &[Event] {
923 &self.events
924 }
925
926 pub fn push_holiday(&mut self, holiday: Holiday) {
927 self.holidays.push(holiday);
928 }
929
930 pub fn development_fixture() -> Self {
931 let date = CalendarDate::from_ymd(2026, Month::April, 23).expect("fixture date is valid");
932 let source = SourceMetadata::fixture();
933
934 Self {
935 events: vec![
936 Event::all_day("release-day", "Release day", date, source.clone()),
937 Event::timed(
938 "standup",
939 "Standup",
940 EventDateTime::new(date, time(9, 0)),
941 EventDateTime::new(date, time(9, 30)),
942 source.clone(),
943 )
944 .expect("fixture event range is valid"),
945 Event::timed(
946 "review",
947 "Review",
948 EventDateTime::new(date, time(9, 15)),
949 EventDateTime::new(date, time(10, 0)),
950 source.clone(),
951 )
952 .expect("fixture event range is valid"),
953 Event::timed(
954 "deploy",
955 "Late deploy",
956 EventDateTime::new(date, time(23, 0)),
957 EventDateTime::new(date.add_days(1), time(1, 0)),
958 source.clone(),
959 )
960 .expect("fixture event range is valid"),
961 ],
962 holidays: Vec::new(),
963 }
964 }
965 }
966
967 impl AgendaSource for InMemoryAgendaSource {
968 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
969 let mut events = self
970 .events
971 .iter()
972 .flat_map(|event| events_intersecting_range(event, range))
973 .collect::<Vec<_>>();
974 events.sort_by(|left, right| {
975 event_sort_key(left)
976 .cmp(&event_sort_key(right))
977 .then(left.id.cmp(&right.id))
978 });
979 events
980 }
981
982 fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
983 self.holidays
984 .iter()
985 .filter(|holiday| range.contains_date(holiday.date))
986 .cloned()
987 .collect()
988 }
989
990 fn local_event_by_id(&self, id: &str) -> Option<Event> {
991 self.events
992 .iter()
993 .find(|event| event.id == id && event.is_local())
994 .cloned()
995 }
996 }
997
998 fn events_intersecting_range(event: &Event, range: DateRange) -> Vec<Event> {
999 if event.recurrence.is_none() {
1000 return event
1001 .intersects_range(range)
1002 .then(|| event.clone())
1003 .into_iter()
1004 .collect();
1005 }
1006
1007 expand_recurring_event(event, range)
1008 .into_iter()
1009 .filter(|event| event.intersects_range(range))
1010 .collect()
1011 }
1012
1013 fn expand_recurring_event(event: &Event, range: DateRange) -> Vec<Event> {
1014 let Some(recurrence) = &event.recurrence else {
1015 return Vec::new();
1016 };
1017 let Some(start_date) = event_start_date(event) else {
1018 return Vec::new();
1019 };
1020
1021 let mut events = Vec::new();
1022 let final_date = range.end.add_days(-1);
1023 let mut date = start_date;
1024 let mut generated_count = 0_u32;
1025
1026 while date <= final_date {
1027 if let RecurrenceEnd::Until(until) = recurrence.end
1028 && date > until
1029 {
1030 break;
1031 }
1032
1033 if recurs_on_date(date, start_date, recurrence) {
1034 generated_count = generated_count.saturating_add(1);
1035 if let RecurrenceEnd::Count(max_count) = recurrence.end
1036 && generated_count > max_count
1037 {
1038 break;
1039 }
1040
1041 let anchor = occurrence_anchor_for_date(event, date);
1042 let instance = occurrence_override_event(event, anchor)
1043 .unwrap_or_else(|| generated_occurrence_event(event, anchor));
1044 events.push(instance);
1045 }
1046
1047 date = date.add_days(1);
1048 }
1049
1050 events
1051 }
1052
1053 fn event_generates_anchor(event: &Event, anchor: OccurrenceAnchor) -> bool {
1054 let Some(recurrence) = &event.recurrence else {
1055 return false;
1056 };
1057 let Some(start_date) = event_start_date(event) else {
1058 return false;
1059 };
1060 if anchor.date() < start_date || !recurs_on_date(anchor.date(), start_date, recurrence) {
1061 return false;
1062 }
1063 if !anchor_is_within_recurrence_end(anchor.date(), start_date, recurrence) {
1064 return false;
1065 }
1066 occurrence_anchor_for_date(event, anchor.date()) == anchor
1067 }
1068
1069 fn anchor_is_within_recurrence_end(
1070 anchor_date: CalendarDate,
1071 start_date: CalendarDate,
1072 recurrence: &RecurrenceRule,
1073 ) -> bool {
1074 if let RecurrenceEnd::Until(until) = recurrence.end
1075 && anchor_date > until
1076 {
1077 return false;
1078 }
1079
1080 if let RecurrenceEnd::Count(max_count) = recurrence.end {
1081 let mut count = 0_u32;
1082 let mut date = start_date;
1083 while date <= anchor_date {
1084 if recurs_on_date(date, start_date, recurrence) {
1085 count = count.saturating_add(1);
1086 }
1087 date = date.add_days(1);
1088 }
1089 return count <= max_count;
1090 }
1091
1092 true
1093 }
1094
1095 fn occurrence_override_event(series: &Event, anchor: OccurrenceAnchor) -> Option<Event> {
1096 let override_record = series
1097 .occurrence_overrides
1098 .iter()
1099 .find(|override_record| override_record.anchor == anchor)?;
1100 occurrence_event_from_draft(series, anchor, override_record.draft.clone()).ok()
1101 }
1102
1103 fn generated_occurrence_event(series: &Event, anchor: OccurrenceAnchor) -> Event {
1104 let mut event = series.clone();
1105 event.id = occurrence_event_id(series, anchor);
1106 event.timing = occurrence_timing(series, anchor);
1107 event.occurrence = Some(OccurrenceMetadata {
1108 series_id: series.id.clone(),
1109 anchor,
1110 });
1111 event.recurrence = None;
1112 event.occurrence_overrides = Vec::new();
1113 event
1114 }
1115
1116 fn occurrence_event_from_draft(
1117 series: &Event,
1118 anchor: OccurrenceAnchor,
1119 draft: CreateEventDraft,
1120 ) -> Result<Event, AgendaError> {
1121 let mut event = draft
1122 .without_recurrence()
1123 .into_event(occurrence_event_id(series, anchor))?;
1124 event.source = series.source.clone();
1125 event.occurrence = Some(OccurrenceMetadata {
1126 series_id: series.id.clone(),
1127 anchor,
1128 });
1129 Ok(event)
1130 }
1131
1132 fn occurrence_event_id(series: &Event, anchor: OccurrenceAnchor) -> String {
1133 format!("{}#{}", series.id, anchor.storage_key())
1134 }
1135
1136 fn occurrence_anchor_for_date(event: &Event, date: CalendarDate) -> OccurrenceAnchor {
1137 match event.timing {
1138 EventTiming::AllDay { .. } => OccurrenceAnchor::AllDay { date },
1139 EventTiming::Timed { start, .. } => OccurrenceAnchor::Timed {
1140 start: EventDateTime::new(date, start.time),
1141 },
1142 }
1143 }
1144
1145 fn occurrence_timing(event: &Event, anchor: OccurrenceAnchor) -> EventTiming {
1146 match (event.timing, anchor) {
1147 (EventTiming::AllDay { .. }, OccurrenceAnchor::AllDay { date }) => {
1148 EventTiming::AllDay { date }
1149 }
1150 (
1151 EventTiming::Timed { start, end },
1152 OccurrenceAnchor::Timed {
1153 start: anchor_start,
1154 },
1155 ) => {
1156 let duration_minutes = datetime_distance_minutes(start, end);
1157 EventTiming::Timed {
1158 start: anchor_start,
1159 end: add_minutes(anchor_start, duration_minutes),
1160 }
1161 }
1162 _ => event.timing,
1163 }
1164 }
1165
1166 fn event_start_date(event: &Event) -> Option<CalendarDate> {
1167 match event.timing {
1168 EventTiming::AllDay { date } => Some(date),
1169 EventTiming::Timed { start, .. } => Some(start.date),
1170 }
1171 }
1172
1173 fn recurs_on_date(date: CalendarDate, start_date: CalendarDate, rule: &RecurrenceRule) -> bool {
1174 if date < start_date {
1175 return false;
1176 }
1177
1178 match rule.frequency {
1179 RecurrenceFrequency::Daily => {
1180 days_between(start_date, date) % i32::from(rule.interval()) == 0
1181 }
1182 RecurrenceFrequency::Weekly => {
1183 let days = days_between(start_date, date);
1184 let week_index = days / 7;
1185 let weekdays = recurrence_weekdays(rule, start_date);
1186 week_index % i32::from(rule.interval()) == 0 && weekdays.contains(&date.weekday())
1187 }
1188 RecurrenceFrequency::Monthly => {
1189 let months = months_between(start_date, date);
1190 if months < 0 || months % i32::from(rule.interval()) != 0 {
1191 return false;
1192 }
1193 let monthly = rule
1194 .monthly
1195 .unwrap_or(RecurrenceMonthlyRule::DayOfMonth(start_date.day()));
1196 match monthly {
1197 RecurrenceMonthlyRule::DayOfMonth(day) => {
1198 CalendarDate::from_ymd(date.year(), date.month(), day).ok() == Some(date)
1199 }
1200 RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => {
1201 weekday_ordinal_date(date.year(), date.month(), ordinal, weekday) == Some(date)
1202 }
1203 }
1204 }
1205 RecurrenceFrequency::Yearly => {
1206 let years = date.year() - start_date.year();
1207 if years < 0 || years % i32::from(rule.interval()) != 0 {
1208 return false;
1209 }
1210 let yearly = rule.yearly.unwrap_or(RecurrenceYearlyRule::Date {
1211 month: start_date.month(),
1212 day: start_date.day(),
1213 });
1214 match yearly {
1215 RecurrenceYearlyRule::Date { month, day } => {
1216 CalendarDate::from_ymd(date.year(), month, day).ok() == Some(date)
1217 }
1218 RecurrenceYearlyRule::WeekdayOrdinal {
1219 month,
1220 ordinal,
1221 weekday,
1222 } => {
1223 date.month() == month
1224 && weekday_ordinal_date(date.year(), month, ordinal, weekday) == Some(date)
1225 }
1226 }
1227 }
1228 }
1229 }
1230
1231 fn recurrence_weekdays(rule: &RecurrenceRule, start_date: CalendarDate) -> Vec<Weekday> {
1232 if rule.weekdays.is_empty() {
1233 vec![start_date.weekday()]
1234 } else {
1235 rule.weekdays.clone()
1236 }
1237 }
1238
1239 fn weekday_ordinal_date(
1240 year: i32,
1241 month: Month,
1242 ordinal: RecurrenceOrdinal,
1243 weekday: Weekday,
1244 ) -> Option<CalendarDate> {
1245 match ordinal {
1246 RecurrenceOrdinal::Number(number) if (1..=4).contains(&number) => {
1247 let first = CalendarDate::from_ymd(year, month, 1).ok()?;
1248 let first_weekday = first.weekday().number_days_from_sunday();
1249 let target_weekday = weekday.number_days_from_sunday();
1250 let offset = (target_weekday + 7 - first_weekday) % 7;
1251 let day = 1 + offset + (number - 1) * 7;
1252 CalendarDate::from_ymd(year, month, day).ok()
1253 }
1254 RecurrenceOrdinal::Last => {
1255 let mut date = CalendarDate::from_ymd(year, month, month.length(year)).ok()?;
1256 while date.weekday() != weekday {
1257 date = date.add_days(-1);
1258 }
1259 Some(date)
1260 }
1261 _ => None,
1262 }
1263 }
1264
1265 pub fn recurrence_ordinal_for_date(date: CalendarDate) -> RecurrenceOrdinal {
1266 if date.day().saturating_add(7) > date.month().length(date.year()) {
1267 RecurrenceOrdinal::Last
1268 } else {
1269 RecurrenceOrdinal::Number(((date.day() - 1) / 7) + 1)
1270 }
1271 }
1272
1273 fn days_between(start: CalendarDate, end: CalendarDate) -> i32 {
1274 end.inner().to_julian_day() - start.inner().to_julian_day()
1275 }
1276
1277 fn months_between(start: CalendarDate, end: CalendarDate) -> i32 {
1278 (end.year() - start.year()) * 12 + i32::from(u8::from(end.month()))
1279 - i32::from(u8::from(start.month()))
1280 }
1281
1282 fn datetime_distance_minutes(start: EventDateTime, end: EventDateTime) -> i32 {
1283 days_between(start.date, end.date) * 24 * 60 + time_minutes(end.time) - time_minutes(start.time)
1284 }
1285
1286 fn add_minutes(start: EventDateTime, duration_minutes: i32) -> EventDateTime {
1287 let absolute_minutes = time_minutes(start.time) + duration_minutes;
1288 let day_offset = absolute_minutes.div_euclid(24 * 60);
1289 let minute_of_day = absolute_minutes.rem_euclid(24 * 60);
1290 EventDateTime::new(
1291 start.date.add_days(day_offset),
1292 Time::from_hms(
1293 u8::try_from(minute_of_day / 60).expect("hour stays in range"),
1294 u8::try_from(minute_of_day % 60).expect("minute stays in range"),
1295 0,
1296 )
1297 .expect("computed time is valid"),
1298 )
1299 }
1300
1301 fn time_minutes(time: Time) -> i32 {
1302 i32::from(time.hour()) * 60 + i32::from(time.minute())
1303 }
1304
1305 fn event_sort_key(event: &Event) -> (CalendarDate, DayMinute, String) {
1306 match event.timing {
1307 EventTiming::AllDay { date } => (date, DayMinute::START, event.title.clone()),
1308 EventTiming::Timed { start, .. } => (
1309 start.date,
1310 DayMinute::from_time(start.time),
1311 event.title.clone(),
1312 ),
1313 }
1314 }
1315
1316 pub fn default_events_file() -> PathBuf {
1317 if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
1318 return PathBuf::from(data_home).join("rcal").join("events.json");
1319 }
1320
1321 if let Some(home) = env::var_os("HOME") {
1322 return PathBuf::from(home)
1323 .join(".local")
1324 .join("share")
1325 .join("rcal")
1326 .join("events.json");
1327 }
1328
1329 env::temp_dir().join("rcal").join("events.json")
1330 }
1331
1332 #[derive(Debug, Clone, PartialEq, Eq)]
1333 pub enum LocalEventStoreError {
1334 Read {
1335 path: PathBuf,
1336 reason: String,
1337 },
1338 Parse {
1339 path: PathBuf,
1340 reason: String,
1341 },
1342 UnsupportedVersion {
1343 path: PathBuf,
1344 version: u8,
1345 },
1346 EventNotFound {
1347 id: String,
1348 },
1349 OccurrenceNotFound {
1350 id: String,
1351 anchor: String,
1352 },
1353 EventNotEditable {
1354 id: String,
1355 },
1356 Encode {
1357 path: Option<PathBuf>,
1358 reason: String,
1359 },
1360 Write {
1361 path: PathBuf,
1362 reason: String,
1363 },
1364 }
1365
1366 impl fmt::Display for LocalEventStoreError {
1367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1368 match self {
1369 Self::Read { path, reason } => {
1370 write!(f, "failed to read {}: {reason}", path.display())
1371 }
1372 Self::Parse { path, reason } => {
1373 write!(f, "failed to parse {}: {reason}", path.display())
1374 }
1375 Self::UnsupportedVersion { path, version } => write!(
1376 f,
1377 "unsupported local events file version {version} in {}",
1378 path.display()
1379 ),
1380 Self::EventNotFound { id } => write!(f, "local event '{id}' was not found"),
1381 Self::OccurrenceNotFound { id, anchor } => {
1382 write!(
1383 f,
1384 "recurring occurrence '{anchor}' was not found for local event '{id}'"
1385 )
1386 }
1387 Self::EventNotEditable { id } => write!(f, "event '{id}' is not editable locally"),
1388 Self::Encode { path, reason } => {
1389 if let Some(path) = path {
1390 write!(f, "failed to encode {}: {reason}", path.display())
1391 } else {
1392 write!(f, "failed to encode local event: {reason}")
1393 }
1394 }
1395 Self::Write { path, reason } => {
1396 write!(f, "failed to write {}: {reason}", path.display())
1397 }
1398 }
1399 }
1400 }
1401
1402 impl Error for LocalEventStoreError {}
1403
1404 fn load_events_file(path: &Path) -> Result<InMemoryAgendaSource, LocalEventStoreError> {
1405 let body = match fs::read_to_string(path) {
1406 Ok(body) => body,
1407 Err(err) if err.kind() == io::ErrorKind::NotFound => {
1408 return Ok(InMemoryAgendaSource::new());
1409 }
1410 Err(err) => {
1411 return Err(LocalEventStoreError::Read {
1412 path: path.to_path_buf(),
1413 reason: err.to_string(),
1414 });
1415 }
1416 };
1417
1418 let file = serde_json::from_str::<LocalEventsFile>(&body).map_err(|err| {
1419 LocalEventStoreError::Parse {
1420 path: path.to_path_buf(),
1421 reason: err.to_string(),
1422 }
1423 })?;
1424
1425 if !matches!(file.version, 1 | LOCAL_EVENTS_VERSION) {
1426 return Err(LocalEventStoreError::UnsupportedVersion {
1427 path: path.to_path_buf(),
1428 version: file.version,
1429 });
1430 }
1431
1432 let events = file
1433 .events
1434 .into_iter()
1435 .map(|record| record.into_event(path))
1436 .collect::<Result<Vec<_>, _>>()?;
1437 Ok(InMemoryAgendaSource::with_events_and_holidays(
1438 events,
1439 Vec::new(),
1440 ))
1441 }
1442
1443 fn write_events_file(path: &Path, events: &[Event]) -> Result<(), LocalEventStoreError> {
1444 if let Some(parent) = path.parent() {
1445 fs::create_dir_all(parent).map_err(|err| LocalEventStoreError::Write {
1446 path: parent.to_path_buf(),
1447 reason: err.to_string(),
1448 })?;
1449 }
1450
1451 let file = LocalEventsFile {
1452 version: LOCAL_EVENTS_VERSION,
1453 events: events.iter().map(LocalEventRecord::from_event).collect(),
1454 };
1455 let body = serde_json::to_string_pretty(&file).map_err(|err| LocalEventStoreError::Encode {
1456 path: Some(path.to_path_buf()),
1457 reason: err.to_string(),
1458 })?;
1459 let temp_path = path.with_extension(format!(
1460 "{}.tmp",
1461 path.extension()
1462 .and_then(|extension| extension.to_str())
1463 .unwrap_or("json")
1464 ));
1465
1466 fs::write(&temp_path, body).map_err(|err| LocalEventStoreError::Write {
1467 path: temp_path.clone(),
1468 reason: err.to_string(),
1469 })?;
1470 fs::rename(&temp_path, path).map_err(|err| LocalEventStoreError::Write {
1471 path: path.to_path_buf(),
1472 reason: err.to_string(),
1473 })
1474 }
1475
1476 const LOCAL_EVENTS_VERSION: u8 = 2;
1477
1478 #[derive(Debug, Serialize, Deserialize)]
1479 struct LocalEventsFile {
1480 version: u8,
1481 #[serde(default)]
1482 events: Vec<LocalEventRecord>,
1483 }
1484
1485 #[derive(Debug, Serialize, Deserialize)]
1486 #[serde(untagged)]
1487 enum LocalEventRecord {
1488 Timed {
1489 id: String,
1490 title: String,
1491 start_date: String,
1492 start_time: String,
1493 end_date: String,
1494 end_time: String,
1495 #[serde(default, skip_serializing_if = "Option::is_none")]
1496 location: Option<String>,
1497 #[serde(default, skip_serializing_if = "Option::is_none")]
1498 notes: Option<String>,
1499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1500 reminders_minutes_before: Vec<u16>,
1501 #[serde(default, skip_serializing_if = "Option::is_none")]
1502 recurrence: Option<LocalRecurrenceRecord>,
1503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1504 overrides: Vec<LocalOccurrenceOverrideRecord>,
1505 },
1506 AllDay {
1507 id: String,
1508 title: String,
1509 date: String,
1510 #[serde(default, skip_serializing_if = "Option::is_none")]
1511 location: Option<String>,
1512 #[serde(default, skip_serializing_if = "Option::is_none")]
1513 notes: Option<String>,
1514 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1515 reminders_minutes_before: Vec<u16>,
1516 #[serde(default, skip_serializing_if = "Option::is_none")]
1517 recurrence: Option<LocalRecurrenceRecord>,
1518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1519 overrides: Vec<LocalOccurrenceOverrideRecord>,
1520 },
1521 }
1522
1523 impl LocalEventRecord {
1524 fn from_event(event: &Event) -> Self {
1525 let reminders_minutes_before = event
1526 .reminders
1527 .iter()
1528 .map(|reminder| reminder.minutes_before)
1529 .collect::<Vec<_>>();
1530 let recurrence = event
1531 .recurrence
1532 .as_ref()
1533 .map(LocalRecurrenceRecord::from_rule);
1534 let overrides = event
1535 .occurrence_overrides
1536 .iter()
1537 .map(LocalOccurrenceOverrideRecord::from_override)
1538 .collect::<Vec<_>>();
1539
1540 match event.timing {
1541 EventTiming::AllDay { date } => Self::AllDay {
1542 id: event.id.clone(),
1543 title: event.title.clone(),
1544 date: date.to_string(),
1545 location: event.location.clone(),
1546 notes: event.notes.clone(),
1547 reminders_minutes_before,
1548 recurrence,
1549 overrides,
1550 },
1551 EventTiming::Timed { start, end } => Self::Timed {
1552 id: event.id.clone(),
1553 title: event.title.clone(),
1554 start_date: start.date.to_string(),
1555 start_time: format_time(start.time),
1556 end_date: end.date.to_string(),
1557 end_time: format_time(end.time),
1558 location: event.location.clone(),
1559 notes: event.notes.clone(),
1560 reminders_minutes_before,
1561 recurrence,
1562 overrides,
1563 },
1564 }
1565 }
1566
1567 fn into_event(self, path: &Path) -> Result<Event, LocalEventStoreError> {
1568 match self {
1569 Self::Timed {
1570 id,
1571 title,
1572 start_date,
1573 start_time,
1574 end_date,
1575 end_time,
1576 location,
1577 notes,
1578 reminders_minutes_before,
1579 recurrence,
1580 overrides,
1581 } => {
1582 let start = EventDateTime::new(
1583 parse_local_date(&start_date, path)?,
1584 parse_local_time(&start_time, path)?,
1585 );
1586 let end = EventDateTime::new(
1587 parse_local_date(&end_date, path)?,
1588 parse_local_time(&end_time, path)?,
1589 );
1590 let mut event = Event::timed(
1591 id.clone(),
1592 title,
1593 start,
1594 end,
1595 SourceMetadata::local().with_external_id(id),
1596 )
1597 .map_err(|err| LocalEventStoreError::Parse {
1598 path: path.to_path_buf(),
1599 reason: err.to_string(),
1600 })?;
1601 event.location = empty_to_none(location);
1602 event.notes = empty_to_none(notes);
1603 event.reminders = reminders_from_minutes(reminders_minutes_before);
1604 event.recurrence = recurrence
1605 .map(|recurrence| recurrence.into_rule(path))
1606 .transpose()?;
1607 event.occurrence_overrides = overrides
1608 .into_iter()
1609 .map(|override_record| override_record.into_override(path))
1610 .collect::<Result<Vec<_>, _>>()?;
1611 Ok(event)
1612 }
1613 Self::AllDay {
1614 id,
1615 title,
1616 date,
1617 location,
1618 notes,
1619 reminders_minutes_before,
1620 recurrence,
1621 overrides,
1622 } => {
1623 let mut event = Event::all_day(
1624 id.clone(),
1625 title,
1626 parse_local_date(&date, path)?,
1627 SourceMetadata::local().with_external_id(id),
1628 );
1629 event.location = empty_to_none(location);
1630 event.notes = empty_to_none(notes);
1631 event.reminders = reminders_from_minutes(reminders_minutes_before);
1632 event.recurrence = recurrence
1633 .map(|recurrence| recurrence.into_rule(path))
1634 .transpose()?;
1635 event.occurrence_overrides = overrides
1636 .into_iter()
1637 .map(|override_record| override_record.into_override(path))
1638 .collect::<Result<Vec<_>, _>>()?;
1639 Ok(event)
1640 }
1641 }
1642 }
1643 }
1644
1645 #[derive(Debug, Clone, Serialize, Deserialize)]
1646 struct LocalRecurrenceRecord {
1647 frequency: String,
1648 interval: u16,
1649 #[serde(default)]
1650 weekdays: Vec<String>,
1651 #[serde(default, skip_serializing_if = "Option::is_none")]
1652 monthly: Option<LocalRecurrenceMonthlyRecord>,
1653 #[serde(default, skip_serializing_if = "Option::is_none")]
1654 yearly: Option<LocalRecurrenceYearlyRecord>,
1655 end: LocalRecurrenceEndRecord,
1656 }
1657
1658 impl LocalRecurrenceRecord {
1659 fn from_rule(rule: &RecurrenceRule) -> Self {
1660 Self {
1661 frequency: match rule.frequency {
1662 RecurrenceFrequency::Daily => "daily",
1663 RecurrenceFrequency::Weekly => "weekly",
1664 RecurrenceFrequency::Monthly => "monthly",
1665 RecurrenceFrequency::Yearly => "yearly",
1666 }
1667 .to_string(),
1668 interval: rule.interval(),
1669 weekdays: rule
1670 .weekdays
1671 .iter()
1672 .map(|weekday| weekday_name(*weekday))
1673 .collect(),
1674 monthly: rule.monthly.map(LocalRecurrenceMonthlyRecord::from_rule),
1675 yearly: rule.yearly.map(LocalRecurrenceYearlyRecord::from_rule),
1676 end: LocalRecurrenceEndRecord::from_rule(rule.end),
1677 }
1678 }
1679
1680 fn into_rule(self, path: &Path) -> Result<RecurrenceRule, LocalEventStoreError> {
1681 let frequency = match self.frequency.as_str() {
1682 "daily" => RecurrenceFrequency::Daily,
1683 "weekly" => RecurrenceFrequency::Weekly,
1684 "monthly" => RecurrenceFrequency::Monthly,
1685 "yearly" => RecurrenceFrequency::Yearly,
1686 value => {
1687 return Err(LocalEventStoreError::Parse {
1688 path: path.to_path_buf(),
1689 reason: format!("invalid recurrence frequency '{value}'"),
1690 });
1691 }
1692 };
1693 let weekdays = self
1694 .weekdays
1695 .into_iter()
1696 .map(|weekday| parse_weekday_record(&weekday, path))
1697 .collect::<Result<Vec<_>, _>>()?;
1698
1699 Ok(RecurrenceRule {
1700 frequency,
1701 interval: self.interval.max(1),
1702 end: self.end.into_rule(path)?,
1703 weekdays,
1704 monthly: self
1705 .monthly
1706 .map(|monthly| monthly.into_rule(path))
1707 .transpose()?,
1708 yearly: self
1709 .yearly
1710 .map(|yearly| yearly.into_rule(path))
1711 .transpose()?,
1712 })
1713 }
1714 }
1715
1716 #[derive(Debug, Clone, Serialize, Deserialize)]
1717 #[serde(tag = "mode", rename_all = "snake_case")]
1718 enum LocalRecurrenceEndRecord {
1719 Never,
1720 Until { date: String },
1721 Count { count: u32 },
1722 }
1723
1724 impl LocalRecurrenceEndRecord {
1725 fn from_rule(end: RecurrenceEnd) -> Self {
1726 match end {
1727 RecurrenceEnd::Never => Self::Never,
1728 RecurrenceEnd::Until(date) => Self::Until {
1729 date: date.to_string(),
1730 },
1731 RecurrenceEnd::Count(count) => Self::Count { count },
1732 }
1733 }
1734
1735 fn into_rule(self, path: &Path) -> Result<RecurrenceEnd, LocalEventStoreError> {
1736 match self {
1737 Self::Never => Ok(RecurrenceEnd::Never),
1738 Self::Until { date } => Ok(RecurrenceEnd::Until(parse_local_date(&date, path)?)),
1739 Self::Count { count } => Ok(RecurrenceEnd::Count(count.max(1))),
1740 }
1741 }
1742 }
1743
1744 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1745 #[serde(tag = "mode", rename_all = "snake_case")]
1746 enum LocalRecurrenceMonthlyRecord {
1747 DayOfMonth {
1748 day: u8,
1749 },
1750 WeekdayOrdinal {
1751 ordinal: LocalRecurrenceOrdinalRecord,
1752 weekday: LocalWeekdayRecord,
1753 },
1754 }
1755
1756 impl LocalRecurrenceMonthlyRecord {
1757 fn from_rule(rule: RecurrenceMonthlyRule) -> Self {
1758 match rule {
1759 RecurrenceMonthlyRule::DayOfMonth(day) => Self::DayOfMonth { day },
1760 RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => Self::WeekdayOrdinal {
1761 ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
1762 weekday: LocalWeekdayRecord::from_weekday(weekday),
1763 },
1764 }
1765 }
1766
1767 fn into_rule(self, _path: &Path) -> Result<RecurrenceMonthlyRule, LocalEventStoreError> {
1768 Ok(match self {
1769 Self::DayOfMonth { day } => RecurrenceMonthlyRule::DayOfMonth(day),
1770 Self::WeekdayOrdinal { ordinal, weekday } => RecurrenceMonthlyRule::WeekdayOrdinal {
1771 ordinal: ordinal.into_rule(),
1772 weekday: weekday.into_weekday(),
1773 },
1774 })
1775 }
1776 }
1777
1778 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1779 #[serde(tag = "mode", rename_all = "snake_case")]
1780 enum LocalRecurrenceYearlyRecord {
1781 Date {
1782 month: u8,
1783 day: u8,
1784 },
1785 WeekdayOrdinal {
1786 month: u8,
1787 ordinal: LocalRecurrenceOrdinalRecord,
1788 weekday: LocalWeekdayRecord,
1789 },
1790 }
1791
1792 impl LocalRecurrenceYearlyRecord {
1793 fn from_rule(rule: RecurrenceYearlyRule) -> Self {
1794 match rule {
1795 RecurrenceYearlyRule::Date { month, day } => Self::Date {
1796 month: u8::from(month),
1797 day,
1798 },
1799 RecurrenceYearlyRule::WeekdayOrdinal {
1800 month,
1801 ordinal,
1802 weekday,
1803 } => Self::WeekdayOrdinal {
1804 month: u8::from(month),
1805 ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
1806 weekday: LocalWeekdayRecord::from_weekday(weekday),
1807 },
1808 }
1809 }
1810
1811 fn into_rule(self, path: &Path) -> Result<RecurrenceYearlyRule, LocalEventStoreError> {
1812 Ok(match self {
1813 Self::Date { month, day } => RecurrenceYearlyRule::Date {
1814 month: parse_month_record(month, path)?,
1815 day,
1816 },
1817 Self::WeekdayOrdinal {
1818 month,
1819 ordinal,
1820 weekday,
1821 } => RecurrenceYearlyRule::WeekdayOrdinal {
1822 month: parse_month_record(month, path)?,
1823 ordinal: ordinal.into_rule(),
1824 weekday: weekday.into_weekday(),
1825 },
1826 })
1827 }
1828 }
1829
1830 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1831 #[serde(rename_all = "snake_case")]
1832 enum LocalRecurrenceOrdinalRecord {
1833 First,
1834 Second,
1835 Third,
1836 Fourth,
1837 Last,
1838 }
1839
1840 impl LocalRecurrenceOrdinalRecord {
1841 fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
1842 match ordinal {
1843 RecurrenceOrdinal::Number(1) => Self::First,
1844 RecurrenceOrdinal::Number(2) => Self::Second,
1845 RecurrenceOrdinal::Number(3) => Self::Third,
1846 RecurrenceOrdinal::Number(4) => Self::Fourth,
1847 RecurrenceOrdinal::Last | RecurrenceOrdinal::Number(_) => Self::Last,
1848 }
1849 }
1850
1851 const fn into_rule(self) -> RecurrenceOrdinal {
1852 match self {
1853 Self::First => RecurrenceOrdinal::Number(1),
1854 Self::Second => RecurrenceOrdinal::Number(2),
1855 Self::Third => RecurrenceOrdinal::Number(3),
1856 Self::Fourth => RecurrenceOrdinal::Number(4),
1857 Self::Last => RecurrenceOrdinal::Last,
1858 }
1859 }
1860 }
1861
1862 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1863 #[serde(rename_all = "snake_case")]
1864 enum LocalWeekdayRecord {
1865 Sunday,
1866 Monday,
1867 Tuesday,
1868 Wednesday,
1869 Thursday,
1870 Friday,
1871 Saturday,
1872 }
1873
1874 impl LocalWeekdayRecord {
1875 const fn from_weekday(weekday: Weekday) -> Self {
1876 match weekday {
1877 Weekday::Sunday => Self::Sunday,
1878 Weekday::Monday => Self::Monday,
1879 Weekday::Tuesday => Self::Tuesday,
1880 Weekday::Wednesday => Self::Wednesday,
1881 Weekday::Thursday => Self::Thursday,
1882 Weekday::Friday => Self::Friday,
1883 Weekday::Saturday => Self::Saturday,
1884 }
1885 }
1886
1887 const fn into_weekday(self) -> Weekday {
1888 match self {
1889 Self::Sunday => Weekday::Sunday,
1890 Self::Monday => Weekday::Monday,
1891 Self::Tuesday => Weekday::Tuesday,
1892 Self::Wednesday => Weekday::Wednesday,
1893 Self::Thursday => Weekday::Thursday,
1894 Self::Friday => Weekday::Friday,
1895 Self::Saturday => Weekday::Saturday,
1896 }
1897 }
1898 }
1899
1900 #[derive(Debug, Clone, Serialize, Deserialize)]
1901 struct LocalOccurrenceOverrideRecord {
1902 anchor: LocalOccurrenceAnchorRecord,
1903 event: LocalEventDraftRecord,
1904 }
1905
1906 impl LocalOccurrenceOverrideRecord {
1907 fn from_override(override_record: &OccurrenceOverride) -> Self {
1908 Self {
1909 anchor: LocalOccurrenceAnchorRecord::from_anchor(override_record.anchor),
1910 event: LocalEventDraftRecord::from_draft(&override_record.draft),
1911 }
1912 }
1913
1914 fn into_override(self, path: &Path) -> Result<OccurrenceOverride, LocalEventStoreError> {
1915 Ok(OccurrenceOverride {
1916 anchor: self.anchor.into_anchor(path)?,
1917 draft: self.event.into_draft(path)?,
1918 })
1919 }
1920 }
1921
1922 #[derive(Debug, Clone, Serialize, Deserialize)]
1923 #[serde(tag = "kind", rename_all = "snake_case")]
1924 enum LocalOccurrenceAnchorRecord {
1925 AllDay { date: String },
1926 Timed { date: String, time: String },
1927 }
1928
1929 impl LocalOccurrenceAnchorRecord {
1930 fn from_anchor(anchor: OccurrenceAnchor) -> Self {
1931 match anchor {
1932 OccurrenceAnchor::AllDay { date } => Self::AllDay {
1933 date: date.to_string(),
1934 },
1935 OccurrenceAnchor::Timed { start } => Self::Timed {
1936 date: start.date.to_string(),
1937 time: format_time(start.time),
1938 },
1939 }
1940 }
1941
1942 fn into_anchor(self, path: &Path) -> Result<OccurrenceAnchor, LocalEventStoreError> {
1943 Ok(match self {
1944 Self::AllDay { date } => OccurrenceAnchor::AllDay {
1945 date: parse_local_date(&date, path)?,
1946 },
1947 Self::Timed { date, time } => OccurrenceAnchor::Timed {
1948 start: EventDateTime::new(
1949 parse_local_date(&date, path)?,
1950 parse_local_time(&time, path)?,
1951 ),
1952 },
1953 })
1954 }
1955 }
1956
1957 #[derive(Debug, Clone, Serialize, Deserialize)]
1958 #[serde(tag = "timing", rename_all = "snake_case")]
1959 enum LocalEventDraftRecord {
1960 Timed {
1961 title: String,
1962 start_date: String,
1963 start_time: String,
1964 end_date: String,
1965 end_time: String,
1966 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 location: Option<String>,
1968 #[serde(default, skip_serializing_if = "Option::is_none")]
1969 notes: Option<String>,
1970 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1971 reminders_minutes_before: Vec<u16>,
1972 },
1973 AllDay {
1974 title: String,
1975 date: String,
1976 #[serde(default, skip_serializing_if = "Option::is_none")]
1977 location: Option<String>,
1978 #[serde(default, skip_serializing_if = "Option::is_none")]
1979 notes: Option<String>,
1980 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1981 reminders_minutes_before: Vec<u16>,
1982 },
1983 }
1984
1985 impl LocalEventDraftRecord {
1986 fn from_draft(draft: &CreateEventDraft) -> Self {
1987 let reminders_minutes_before = draft
1988 .reminders
1989 .iter()
1990 .map(|reminder| reminder.minutes_before)
1991 .collect::<Vec<_>>();
1992 match draft.timing {
1993 CreateEventTiming::Timed { start, end } => Self::Timed {
1994 title: draft.title.clone(),
1995 start_date: start.date.to_string(),
1996 start_time: format_time(start.time),
1997 end_date: end.date.to_string(),
1998 end_time: format_time(end.time),
1999 location: draft.location.clone(),
2000 notes: draft.notes.clone(),
2001 reminders_minutes_before,
2002 },
2003 CreateEventTiming::AllDay { date } => Self::AllDay {
2004 title: draft.title.clone(),
2005 date: date.to_string(),
2006 location: draft.location.clone(),
2007 notes: draft.notes.clone(),
2008 reminders_minutes_before,
2009 },
2010 }
2011 }
2012
2013 fn into_draft(self, path: &Path) -> Result<CreateEventDraft, LocalEventStoreError> {
2014 Ok(match self {
2015 Self::Timed {
2016 title,
2017 start_date,
2018 start_time,
2019 end_date,
2020 end_time,
2021 location,
2022 notes,
2023 reminders_minutes_before,
2024 } => {
2025 let start = EventDateTime::new(
2026 parse_local_date(&start_date, path)?,
2027 parse_local_time(&start_time, path)?,
2028 );
2029 let end = EventDateTime::new(
2030 parse_local_date(&end_date, path)?,
2031 parse_local_time(&end_time, path)?,
2032 );
2033 if start >= end {
2034 return Err(LocalEventStoreError::Parse {
2035 path: path.to_path_buf(),
2036 reason: format!(
2037 "invalid override range: start {start:?} must be before end {end:?}"
2038 ),
2039 });
2040 }
2041
2042 CreateEventDraft {
2043 title,
2044 timing: CreateEventTiming::Timed { start, end },
2045 location: empty_to_none(location),
2046 notes: empty_to_none(notes),
2047 reminders: reminders_from_minutes(reminders_minutes_before),
2048 recurrence: None,
2049 }
2050 }
2051 Self::AllDay {
2052 title,
2053 date,
2054 location,
2055 notes,
2056 reminders_minutes_before,
2057 } => CreateEventDraft {
2058 title,
2059 timing: CreateEventTiming::AllDay {
2060 date: parse_local_date(&date, path)?,
2061 },
2062 location: empty_to_none(location),
2063 notes: empty_to_none(notes),
2064 reminders: reminders_from_minutes(reminders_minutes_before),
2065 recurrence: None,
2066 },
2067 })
2068 }
2069 }
2070
2071 fn reminders_from_minutes(minutes: Vec<u16>) -> Vec<Reminder> {
2072 let mut reminders = minutes
2073 .into_iter()
2074 .map(Reminder::minutes_before)
2075 .collect::<Vec<_>>();
2076 reminders.sort();
2077 reminders.dedup();
2078 reminders
2079 }
2080
2081 fn empty_to_none(value: Option<String>) -> Option<String> {
2082 value.and_then(|value| {
2083 let trimmed = value.trim();
2084 if trimmed.is_empty() {
2085 None
2086 } else {
2087 Some(trimmed.to_string())
2088 }
2089 })
2090 }
2091
2092 fn weekday_name(weekday: Weekday) -> String {
2093 match weekday {
2094 Weekday::Sunday => "sunday",
2095 Weekday::Monday => "monday",
2096 Weekday::Tuesday => "tuesday",
2097 Weekday::Wednesday => "wednesday",
2098 Weekday::Thursday => "thursday",
2099 Weekday::Friday => "friday",
2100 Weekday::Saturday => "saturday",
2101 }
2102 .to_string()
2103 }
2104
2105 fn parse_weekday_record(value: &str, path: &Path) -> Result<Weekday, LocalEventStoreError> {
2106 match value {
2107 "sunday" => Ok(Weekday::Sunday),
2108 "monday" => Ok(Weekday::Monday),
2109 "tuesday" => Ok(Weekday::Tuesday),
2110 "wednesday" => Ok(Weekday::Wednesday),
2111 "thursday" => Ok(Weekday::Thursday),
2112 "friday" => Ok(Weekday::Friday),
2113 "saturday" => Ok(Weekday::Saturday),
2114 _ => Err(LocalEventStoreError::Parse {
2115 path: path.to_path_buf(),
2116 reason: format!("invalid weekday '{value}'"),
2117 }),
2118 }
2119 }
2120
2121 fn parse_month_record(value: u8, path: &Path) -> Result<Month, LocalEventStoreError> {
2122 Month::try_from(value).map_err(|_| LocalEventStoreError::Parse {
2123 path: path.to_path_buf(),
2124 reason: format!("invalid month '{value}'"),
2125 })
2126 }
2127
2128 fn parse_local_date(value: &str, path: &Path) -> Result<CalendarDate, LocalEventStoreError> {
2129 parse_iso_date(value).ok_or_else(|| LocalEventStoreError::Parse {
2130 path: path.to_path_buf(),
2131 reason: format!("invalid date '{value}'"),
2132 })
2133 }
2134
2135 fn parse_local_time(value: &str, path: &Path) -> Result<Time, LocalEventStoreError> {
2136 parse_hhmm_time(value).ok_or_else(|| LocalEventStoreError::Parse {
2137 path: path.to_path_buf(),
2138 reason: format!("invalid time '{value}'"),
2139 })
2140 }
2141
2142 fn parse_hhmm_time(value: &str) -> Option<Time> {
2143 let mut parts = value.split(':');
2144 let hour = parts.next()?.parse::<u8>().ok()?;
2145 let minute = parts.next()?.parse::<u8>().ok()?;
2146 if parts.next().is_some() {
2147 return None;
2148 }
2149
2150 Time::from_hms(hour, minute, 0).ok()
2151 }
2152
2153 fn format_time(time: Time) -> String {
2154 format!("{:02}:{:02}", time.hour(), time.minute())
2155 }
2156
2157 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2158 pub enum AgendaError {
2159 InvalidDateRange {
2160 start: CalendarDate,
2161 end: CalendarDate,
2162 },
2163 InvalidEventRange {
2164 start: EventDateTime,
2165 end: EventDateTime,
2166 },
2167 }
2168
2169 impl fmt::Display for AgendaError {
2170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2171 match self {
2172 Self::InvalidDateRange { start, end } => {
2173 write!(
2174 f,
2175 "invalid date range: start {start} must be before end {end}"
2176 )
2177 }
2178 Self::InvalidEventRange { start, end } => write!(
2179 f,
2180 "invalid event range: start {start:?} must be before end {end:?}"
2181 ),
2182 }
2183 }
2184 }
2185
2186 impl Error for AgendaError {}
2187
2188 fn events_to_timed_agenda_events(
2189 date: CalendarDate,
2190 events: impl IntoIterator<Item = Event>,
2191 ) -> Vec<TimedAgendaEvent> {
2192 let day_start = EventDateTime::new(date, Time::MIDNIGHT);
2193 let day_end = EventDateTime::new(date.add_days(1), Time::MIDNIGHT);
2194
2195 events
2196 .into_iter()
2197 .filter_map(|event| match event.timing {
2198 EventTiming::Timed { start, end } if start < day_end && end > day_start => {
2199 let starts_before_day = start < day_start;
2200 let ends_after_day = end > day_end;
2201 let visible_start = if starts_before_day {
2202 DayMinute::START
2203 } else {
2204 DayMinute::from_time(start.time)
2205 };
2206 let visible_end = if end >= day_end {
2207 DayMinute::END
2208 } else {
2209 DayMinute::from_time(end.time)
2210 };
2211
2212 Some(TimedAgendaEvent {
2213 event,
2214 visible_start,
2215 visible_end,
2216 starts_before_day,
2217 ends_after_day,
2218 overlap_group: 0,
2219 })
2220 }
2221 _ => None,
2222 })
2223 .collect()
2224 }
2225
2226 fn assign_overlap_groups(events: &mut [TimedAgendaEvent]) {
2227 let mut group_end: Option<DayMinute> = None;
2228 let mut group_index = 0;
2229
2230 for event in events {
2231 if let Some(end) = group_end {
2232 if event.visible_start >= end {
2233 group_index += 1;
2234 group_end = Some(event.visible_end);
2235 } else if event.visible_end > end {
2236 group_end = Some(event.visible_end);
2237 }
2238 } else {
2239 group_end = Some(event.visible_end);
2240 }
2241
2242 event.overlap_group = group_index;
2243 }
2244 }
2245
2246 fn time(hour: u8, minute: u8) -> Time {
2247 Time::from_hms(hour, minute, 0).expect("fixture time is valid")
2248 }
2249
2250 fn us_federal_holidays_for_year(year: i32) -> Vec<Holiday> {
2251 [
2252 fixed_us_federal_holiday(year, "new-years-day", "New Year's Day", Month::January, 1),
2253 weekday_us_federal_holiday(
2254 year,
2255 "martin-luther-king-jr-day",
2256 "Birthday of Martin Luther King, Jr.",
2257 Month::January,
2258 Weekday::Monday,
2259 3,
2260 ),
2261 weekday_us_federal_holiday(
2262 year,
2263 "washingtons-birthday",
2264 "Washington's Birthday",
2265 Month::February,
2266 Weekday::Monday,
2267 3,
2268 ),
2269 last_weekday_us_federal_holiday(
2270 year,
2271 "memorial-day",
2272 "Memorial Day",
2273 Month::May,
2274 Weekday::Monday,
2275 ),
2276 fixed_us_federal_holiday(
2277 year,
2278 "juneteenth",
2279 "Juneteenth National Independence Day",
2280 Month::June,
2281 19,
2282 ),
2283 fixed_us_federal_holiday(year, "independence-day", "Independence Day", Month::July, 4),
2284 weekday_us_federal_holiday(
2285 year,
2286 "labor-day",
2287 "Labor Day",
2288 Month::September,
2289 Weekday::Monday,
2290 1,
2291 ),
2292 weekday_us_federal_holiday(
2293 year,
2294 "columbus-day",
2295 "Columbus Day",
2296 Month::October,
2297 Weekday::Monday,
2298 2,
2299 ),
2300 fixed_us_federal_holiday(year, "veterans-day", "Veterans Day", Month::November, 11),
2301 weekday_us_federal_holiday(
2302 year,
2303 "thanksgiving-day",
2304 "Thanksgiving Day",
2305 Month::November,
2306 Weekday::Thursday,
2307 4,
2308 ),
2309 fixed_us_federal_holiday(year, "christmas-day", "Christmas Day", Month::December, 25),
2310 ]
2311 .into()
2312 }
2313
2314 fn fixed_us_federal_holiday(year: i32, slug: &str, name: &str, month: Month, day: u8) -> Holiday {
2315 let actual = CalendarDate::from_ymd(year, month, day).expect("fixed holiday date is valid");
2316 holiday_with_source(
2317 format!("us-federal-{year}-{slug}"),
2318 name,
2319 observed_date(actual),
2320 "us-federal",
2321 "U.S. federal holidays",
2322 )
2323 }
2324
2325 fn weekday_us_federal_holiday(
2326 year: i32,
2327 slug: &str,
2328 name: &str,
2329 month: Month,
2330 weekday: Weekday,
2331 nth: u8,
2332 ) -> Holiday {
2333 let date = nth_weekday_of_month(year, month, weekday, nth);
2334 holiday_with_source(
2335 format!("us-federal-{year}-{slug}"),
2336 name,
2337 date,
2338 "us-federal",
2339 "U.S. federal holidays",
2340 )
2341 }
2342
2343 fn last_weekday_us_federal_holiday(
2344 year: i32,
2345 slug: &str,
2346 name: &str,
2347 month: Month,
2348 weekday: Weekday,
2349 ) -> Holiday {
2350 let date = last_weekday_of_month(year, month, weekday);
2351 holiday_with_source(
2352 format!("us-federal-{year}-{slug}"),
2353 name,
2354 date,
2355 "us-federal",
2356 "U.S. federal holidays",
2357 )
2358 }
2359
2360 fn observed_date(actual: CalendarDate) -> CalendarDate {
2361 match actual.weekday() {
2362 Weekday::Saturday => actual.add_days(-1),
2363 Weekday::Sunday => actual.add_days(1),
2364 _ => actual,
2365 }
2366 }
2367
2368 fn nth_weekday_of_month(year: i32, month: Month, weekday: Weekday, nth: u8) -> CalendarDate {
2369 let first = CalendarDate::from_ymd(year, month, 1).expect("month start date is valid");
2370 let first_weekday = first.weekday().number_days_from_sunday();
2371 let target_weekday = weekday.number_days_from_sunday();
2372 let offset = (target_weekday + 7 - first_weekday) % 7;
2373 let day = 1 + offset + (nth - 1) * 7;
2374
2375 CalendarDate::from_ymd(year, month, day).expect("nth weekday date is valid")
2376 }
2377
2378 fn last_weekday_of_month(year: i32, month: Month, weekday: Weekday) -> CalendarDate {
2379 let mut date =
2380 CalendarDate::from_ymd(year, month, month.length(year)).expect("month end date is valid");
2381
2382 while date.weekday() != weekday {
2383 date = date.add_days(-1);
2384 }
2385
2386 date
2387 }
2388
2389 fn holiday_with_source(
2390 id: impl Into<String>,
2391 name: impl Into<String>,
2392 date: CalendarDate,
2393 source_id: impl Into<String>,
2394 source_name: impl Into<String>,
2395 ) -> Holiday {
2396 Holiday::new(id, name, date, SourceMetadata::new(source_id, source_name))
2397 }
2398
2399 fn parse_nager_holidays(country_code: &str, body: &str) -> Option<Vec<Holiday>> {
2400 let records = serde_json::from_str::<Vec<NagerHolidayRecord>>(body).ok()?;
2401 let mut holidays = Vec::with_capacity(records.len());
2402
2403 for record in records {
2404 let date = parse_iso_date(&record.date)?;
2405 holidays.push(Holiday::new(
2406 format!(
2407 "nager-{country_code}-{}-{}",
2408 record.date,
2409 slugify(&record.name)
2410 ),
2411 record.name,
2412 date,
2413 SourceMetadata::new(format!("nager-{country_code}"), "Nager.Date")
2414 .with_external_id(format!("{country_code}:{}", record.date)),
2415 ));
2416 }
2417
2418 Some(holidays)
2419 }
2420
2421 fn parse_iso_date(value: &str) -> Option<CalendarDate> {
2422 let mut parts = value.split('-');
2423 let year = parts.next()?.parse::<i32>().ok()?;
2424 let month = parts.next()?.parse::<u8>().ok()?;
2425 let day = parts.next()?.parse::<u8>().ok()?;
2426
2427 if parts.next().is_some() {
2428 return None;
2429 }
2430
2431 CalendarDate::from_ymd(year, Month::try_from(month).ok()?, day).ok()
2432 }
2433
2434 fn default_nager_cache_dir() -> PathBuf {
2435 if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
2436 return PathBuf::from(cache_home)
2437 .join("rcal")
2438 .join("holidays")
2439 .join("nager");
2440 }
2441
2442 if let Some(home) = env::var_os("HOME") {
2443 return PathBuf::from(home)
2444 .join(".cache")
2445 .join("rcal")
2446 .join("holidays")
2447 .join("nager");
2448 }
2449
2450 env::temp_dir().join("rcal").join("holidays").join("nager")
2451 }
2452
2453 fn slugify(value: &str) -> String {
2454 let mut slug = String::new();
2455 let mut last_dash = false;
2456
2457 for value in value.chars().flat_map(char::to_lowercase) {
2458 if value.is_ascii_alphanumeric() {
2459 slug.push(value);
2460 last_dash = false;
2461 } else if !last_dash && !slug.is_empty() {
2462 slug.push('-');
2463 last_dash = true;
2464 }
2465 }
2466
2467 if slug.ends_with('-') {
2468 slug.pop();
2469 }
2470
2471 slug
2472 }
2473
2474 #[derive(Debug, Deserialize)]
2475 #[serde(rename_all = "camelCase")]
2476 struct NagerHolidayRecord {
2477 date: String,
2478 name: String,
2479 }
2480
2481 #[cfg(test)]
2482 mod tests {
2483 use super::*;
2484 use time::Month;
2485
2486 fn date(day: u8) -> CalendarDate {
2487 date_ymd(2026, Month::April, day)
2488 }
2489
2490 fn date_ymd(year: i32, month: Month, day: u8) -> CalendarDate {
2491 CalendarDate::from_ymd(year, month, day).expect("valid test date")
2492 }
2493
2494 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
2495 EventDateTime::new(date, time(hour, minute))
2496 }
2497
2498 fn source() -> SourceMetadata {
2499 SourceMetadata::fixture()
2500 }
2501
2502 fn temp_events_path(name: &str) -> PathBuf {
2503 std::env::temp_dir()
2504 .join(format!("rcal-local-events-test-{}", std::process::id()))
2505 .join(name)
2506 .join("events.json")
2507 }
2508
2509 fn timed(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
2510 Event::timed(id, title, start, end, source()).expect("valid timed event")
2511 }
2512
2513 #[test]
2514 fn agenda_construction_handles_empty_days() {
2515 let day = date(23);
2516 let source = InMemoryAgendaSource::new();
2517
2518 let agenda = DayAgenda::from_source(day, &source);
2519
2520 assert_eq!(agenda, DayAgenda::empty(day));
2521 assert!(agenda.is_empty());
2522 }
2523
2524 #[test]
2525 fn agenda_construction_handles_holiday_only_days() {
2526 let day = date(23);
2527 let mut agenda_source = InMemoryAgendaSource::new();
2528 agenda_source.push_holiday(Holiday::new("earth-day", "Earth Day", day, source()));
2529 agenda_source.push_holiday(Holiday::new("tomorrow", "Tomorrow", date(24), source()));
2530
2531 let agenda = DayAgenda::from_source(day, &agenda_source);
2532
2533 assert!(!agenda.is_empty());
2534 assert_eq!(agenda.holidays.len(), 1);
2535 assert_eq!(agenda.holidays[0].name, "Earth Day");
2536 assert!(agenda.all_day_events.is_empty());
2537 assert!(agenda.timed_events.is_empty());
2538 }
2539
2540 #[test]
2541 fn agenda_keeps_all_day_events_separate_and_sorted() {
2542 let day = date(23);
2543 let events = vec![
2544 Event::all_day("b", "Release", day, source()),
2545 Event::all_day("a", "Birthday", day, source()),
2546 Event::all_day("other", "Other Day", date(24), source()),
2547 ];
2548
2549 let agenda = DayAgenda::build(day, events, []);
2550
2551 let titles = agenda
2552 .all_day_events
2553 .iter()
2554 .map(|event| event.title.as_str())
2555 .collect::<Vec<_>>();
2556 assert_eq!(titles, ["Birthday", "Release"]);
2557 assert!(agenda.timed_events.is_empty());
2558 }
2559
2560 #[test]
2561 fn timed_events_sort_by_visible_time_then_title() {
2562 let day = date(23);
2563 let events = vec![
2564 timed("late", "Late", at(day, 13, 0), at(day, 14, 0)),
2565 timed("alpha", "Alpha", at(day, 9, 0), at(day, 10, 0)),
2566 timed("beta", "Beta", at(day, 9, 0), at(day, 9, 30)),
2567 ];
2568
2569 let agenda = DayAgenda::build(day, events, []);
2570
2571 let ids = agenda
2572 .timed_events
2573 .iter()
2574 .map(|event| event.event.id.as_str())
2575 .collect::<Vec<_>>();
2576 assert_eq!(ids, ["beta", "alpha", "late"]);
2577 }
2578
2579 #[test]
2580 fn overlapping_timed_events_share_group_until_the_cluster_ends() {
2581 let day = date(23);
2582 let events = vec![
2583 timed("first", "First", at(day, 9, 0), at(day, 10, 0)),
2584 timed("overlap", "Overlap", at(day, 9, 30), at(day, 10, 30)),
2585 timed("touching", "Touching", at(day, 10, 30), at(day, 11, 0)),
2586 ];
2587
2588 let agenda = DayAgenda::build(day, events, []);
2589 let groups = agenda
2590 .timed_events
2591 .iter()
2592 .map(|event| event.overlap_group)
2593 .collect::<Vec<_>>();
2594
2595 assert_eq!(groups, [0, 0, 1]);
2596 assert!(agenda.timed_events[0].overlaps(&agenda.timed_events[1]));
2597 assert!(!agenda.timed_events[1].overlaps(&agenda.timed_events[2]));
2598 }
2599
2600 #[test]
2601 fn cross_midnight_events_clip_to_selected_day() {
2602 let day = date(23);
2603 let previous_day = day.add_days(-1);
2604 let next_day = day.add_days(1);
2605 let events = vec![
2606 timed(
2607 "from-yesterday",
2608 "From yesterday",
2609 at(previous_day, 23, 0),
2610 at(day, 1, 30),
2611 ),
2612 timed(
2613 "into-tomorrow",
2614 "Into tomorrow",
2615 at(day, 23, 0),
2616 at(next_day, 1, 0),
2617 ),
2618 timed(
2619 "ends-at-start",
2620 "Ends at start",
2621 at(previous_day, 22, 0),
2622 at(day, 0, 0),
2623 ),
2624 ];
2625
2626 let agenda = DayAgenda::build(day, events, []);
2627 let first = &agenda.timed_events[0];
2628 let second = &agenda.timed_events[1];
2629
2630 assert_eq!(agenda.timed_events.len(), 2);
2631 assert_eq!(first.event.id, "from-yesterday");
2632 assert_eq!(first.visible_start, DayMinute::START);
2633 assert_eq!(first.visible_end.as_minutes(), 90);
2634 assert!(first.starts_before_day);
2635 assert!(!first.ends_after_day);
2636
2637 assert_eq!(second.event.id, "into-tomorrow");
2638 assert_eq!(second.visible_start.as_minutes(), 23 * 60);
2639 assert_eq!(second.visible_end, DayMinute::END);
2640 assert!(!second.starts_before_day);
2641 assert!(second.ends_after_day);
2642 }
2643
2644 #[test]
2645 fn in_memory_fixture_provides_development_agenda_data() {
2646 let day = date(23);
2647 let source = InMemoryAgendaSource::development_fixture();
2648
2649 let agenda = DayAgenda::from_source(day, &source);
2650
2651 assert!(agenda.holidays.is_empty());
2652 assert_eq!(agenda.all_day_events[0].title, "Release day");
2653 assert_eq!(agenda.timed_events.len(), 3);
2654 assert_eq!(agenda.timed_events[0].overlap_group, 0);
2655 assert_eq!(agenda.timed_events[1].overlap_group, 0);
2656 assert_eq!(agenda.timed_events[2].visible_end, DayMinute::END);
2657 }
2658
2659 #[test]
2660 fn date_ranges_are_half_open_for_sources() {
2661 let day = date(23);
2662 let range = DateRange::new(day, day.add_days(1)).expect("valid range");
2663 let source = InMemoryAgendaSource::with_events_and_holidays(
2664 vec![
2665 timed("inside", "Inside", at(day, 12, 0), at(day, 13, 0)),
2666 timed(
2667 "outside",
2668 "Outside",
2669 at(day.add_days(1), 12, 0),
2670 at(day.add_days(1), 13, 0),
2671 ),
2672 ],
2673 vec![
2674 Holiday::new("inside-holiday", "Inside Holiday", day, source()),
2675 Holiday::new(
2676 "outside-holiday",
2677 "Outside Holiday",
2678 day.add_days(1),
2679 source(),
2680 ),
2681 ],
2682 );
2683
2684 assert_eq!(source.events_intersecting(range).len(), 1);
2685 assert_eq!(source.holidays_in(range).len(), 1);
2686 assert!(!range.contains_date(day.add_days(1)));
2687 }
2688
2689 #[test]
2690 fn daily_recurrence_expands_with_interval_and_count() {
2691 let start = date_ymd(2026, Month::April, 1);
2692 let event = Event::all_day("daily", "Every other day", start, source()).with_recurrence(
2693 RecurrenceRule {
2694 frequency: RecurrenceFrequency::Daily,
2695 interval: 2,
2696 end: RecurrenceEnd::Count(3),
2697 weekdays: Vec::new(),
2698 monthly: None,
2699 yearly: None,
2700 },
2701 );
2702 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2703 let range = DateRange::new(start, date_ymd(2026, Month::April, 10)).expect("valid range");
2704
2705 let dates = source
2706 .events_intersecting(range)
2707 .into_iter()
2708 .filter_map(|event| event.timing.date())
2709 .collect::<Vec<_>>();
2710
2711 assert_eq!(dates, [date_ymd(2026, Month::April, 1), date(3), date(5)]);
2712 }
2713
2714 #[test]
2715 fn weekly_recurrence_supports_multiple_days_and_interval() {
2716 let start = date_ymd(2026, Month::April, 5);
2717 let event =
2718 Event::all_day("weekly", "Workout", start, source()).with_recurrence(RecurrenceRule {
2719 frequency: RecurrenceFrequency::Weekly,
2720 interval: 2,
2721 end: RecurrenceEnd::Never,
2722 weekdays: vec![Weekday::Sunday, Weekday::Tuesday],
2723 monthly: None,
2724 yearly: None,
2725 });
2726 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2727 let range = DateRange::new(start, date_ymd(2026, Month::April, 23)).expect("valid range");
2728
2729 let dates = source
2730 .events_intersecting(range)
2731 .into_iter()
2732 .filter_map(|event| event.timing.date())
2733 .collect::<Vec<_>>();
2734
2735 assert_eq!(dates, [date(5), date(7), date(19), date(21)]);
2736 }
2737
2738 #[test]
2739 fn monthly_recurrence_skips_invalid_day_of_month_dates() {
2740 let start = date_ymd(2026, Month::January, 31);
2741 let event = Event::all_day("month-day", "Month end", start, source()).with_recurrence(
2742 RecurrenceRule {
2743 frequency: RecurrenceFrequency::Monthly,
2744 interval: 1,
2745 end: RecurrenceEnd::Never,
2746 weekdays: Vec::new(),
2747 monthly: Some(RecurrenceMonthlyRule::DayOfMonth(31)),
2748 yearly: None,
2749 },
2750 );
2751 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2752 let range = DateRange::new(start, date_ymd(2026, Month::April, 1)).expect("valid range");
2753
2754 let dates = source
2755 .events_intersecting(range)
2756 .into_iter()
2757 .filter_map(|event| event.timing.date())
2758 .collect::<Vec<_>>();
2759
2760 assert_eq!(dates, [start, date_ymd(2026, Month::March, 31)]);
2761 }
2762
2763 #[test]
2764 fn monthly_recurrence_supports_last_weekday_rules() {
2765 let start = date_ymd(2026, Month::April, 30);
2766 let event = Event::all_day("last-thursday", "Review", start, source()).with_recurrence(
2767 RecurrenceRule {
2768 frequency: RecurrenceFrequency::Monthly,
2769 interval: 1,
2770 end: RecurrenceEnd::Count(3),
2771 weekdays: Vec::new(),
2772 monthly: Some(RecurrenceMonthlyRule::WeekdayOrdinal {
2773 ordinal: RecurrenceOrdinal::Last,
2774 weekday: Weekday::Thursday,
2775 }),
2776 yearly: None,
2777 },
2778 );
2779 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2780 let range = DateRange::new(start, date_ymd(2026, Month::July, 1)).expect("valid range");
2781
2782 let dates = source
2783 .events_intersecting(range)
2784 .into_iter()
2785 .filter_map(|event| event.timing.date())
2786 .collect::<Vec<_>>();
2787
2788 assert_eq!(
2789 dates,
2790 [
2791 date_ymd(2026, Month::April, 30),
2792 date_ymd(2026, Month::May, 28),
2793 date_ymd(2026, Month::June, 25)
2794 ]
2795 );
2796 }
2797
2798 #[test]
2799 fn yearly_recurrence_skips_invalid_dates_and_supports_weekday_ordinal() {
2800 let leap_day = date_ymd(2024, Month::February, 29);
2801 let leap =
2802 Event::all_day("leap", "Leap", leap_day, source()).with_recurrence(RecurrenceRule {
2803 frequency: RecurrenceFrequency::Yearly,
2804 interval: 1,
2805 end: RecurrenceEnd::Never,
2806 weekdays: Vec::new(),
2807 monthly: None,
2808 yearly: Some(RecurrenceYearlyRule::Date {
2809 month: Month::February,
2810 day: 29,
2811 }),
2812 });
2813 let thanksgiving = Event::all_day(
2814 "thanksgiving",
2815 "Thanksgiving",
2816 date_ymd(2026, Month::November, 26),
2817 source(),
2818 )
2819 .with_recurrence(RecurrenceRule {
2820 frequency: RecurrenceFrequency::Yearly,
2821 interval: 1,
2822 end: RecurrenceEnd::Count(2),
2823 weekdays: Vec::new(),
2824 monthly: None,
2825 yearly: Some(RecurrenceYearlyRule::WeekdayOrdinal {
2826 month: Month::November,
2827 ordinal: RecurrenceOrdinal::Number(4),
2828 weekday: Weekday::Thursday,
2829 }),
2830 });
2831 let source =
2832 InMemoryAgendaSource::with_events_and_holidays(vec![leap, thanksgiving], Vec::new());
2833 let range = DateRange::new(
2834 date_ymd(2024, Month::January, 1),
2835 date_ymd(2029, Month::January, 1),
2836 )
2837 .expect("valid range");
2838
2839 let ids_and_dates = source
2840 .events_intersecting(range)
2841 .into_iter()
2842 .map(|event| (event.id, event.timing.date().expect("all-day date")))
2843 .collect::<Vec<_>>();
2844
2845 assert!(ids_and_dates.contains(&("leap#2024-02-29".to_string(), leap_day)));
2846 assert!(ids_and_dates.contains(&(
2847 "leap#2028-02-29".to_string(),
2848 date_ymd(2028, Month::February, 29)
2849 )));
2850 assert!(
2851 !ids_and_dates
2852 .iter()
2853 .any(|(_, date)| *date == date_ymd(2025, Month::February, 28))
2854 );
2855 assert!(ids_and_dates.contains(&(
2856 "thanksgiving#2026-11-26".to_string(),
2857 date_ymd(2026, Month::November, 26)
2858 )));
2859 assert!(ids_and_dates.contains(&(
2860 "thanksgiving#2027-11-25".to_string(),
2861 date_ymd(2027, Month::November, 25)
2862 )));
2863 }
2864
2865 #[test]
2866 fn recurring_cross_midnight_events_intersect_each_visible_day() {
2867 let start = date(23);
2868 let event = Event::timed(
2869 "late",
2870 "Late shift",
2871 at(start, 23, 0),
2872 at(start.add_days(1), 1, 0),
2873 source(),
2874 )
2875 .expect("valid recurring event")
2876 .with_recurrence(RecurrenceRule {
2877 frequency: RecurrenceFrequency::Daily,
2878 interval: 1,
2879 end: RecurrenceEnd::Count(2),
2880 weekdays: Vec::new(),
2881 monthly: None,
2882 yearly: None,
2883 });
2884 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2885
2886 let agenda = DayAgenda::from_source(start.add_days(1), &source);
2887
2888 assert_eq!(agenda.timed_events.len(), 2);
2889 assert!(agenda.timed_events[0].starts_before_day);
2890 assert_eq!(agenda.timed_events[0].visible_end.as_minutes(), 60);
2891 assert_eq!(agenda.timed_events[1].visible_start.as_minutes(), 23 * 60);
2892 assert!(agenda.timed_events[1].ends_after_day);
2893 }
2894
2895 #[test]
2896 fn invalid_ranges_are_rejected() {
2897 let day = date(23);
2898
2899 assert!(DateRange::new(day, day).is_err());
2900 assert!(Event::timed("bad", "Bad", at(day, 9, 0), at(day, 9, 0), source()).is_err());
2901 }
2902
2903 #[test]
2904 fn local_event_store_loads_missing_file_as_empty() {
2905 let path = temp_events_path("missing");
2906 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
2907
2908 let source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
2909 .expect("missing event file is empty");
2910
2911 assert!(
2912 source
2913 .events_intersecting(DateRange::day(date(23)))
2914 .is_empty()
2915 );
2916 }
2917
2918 #[test]
2919 fn local_event_store_saves_and_loads_timed_and_all_day_events() {
2920 let path = temp_events_path("save-load");
2921 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
2922 let day = date(23);
2923 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
2924 .expect("missing event file is empty");
2925
2926 source
2927 .create_event(CreateEventDraft {
2928 title: "Planning".to_string(),
2929 timing: CreateEventTiming::Timed {
2930 start: at(day, 9, 0),
2931 end: at(day, 10, 0),
2932 },
2933 location: Some("War room".to_string()),
2934 notes: Some("Bring notes".to_string()),
2935 reminders: vec![Reminder::minutes_before(10), Reminder::minutes_before(60)],
2936 recurrence: None,
2937 })
2938 .expect("timed event saves");
2939 source
2940 .create_event(CreateEventDraft {
2941 title: "Release day".to_string(),
2942 timing: CreateEventTiming::AllDay { date: day },
2943 location: None,
2944 notes: None,
2945 reminders: vec![Reminder::minutes_before(24 * 60)],
2946 recurrence: None,
2947 })
2948 .expect("all-day event saves");
2949
2950 let body = std::fs::read_to_string(&path).expect("event file exists");
2951 assert!(body.contains(r#""version": 2"#));
2952 assert!(body.contains(r#""reminders_minutes_before""#));
2953
2954 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
2955 .expect("saved file reloads");
2956 let agenda = DayAgenda::from_source(day, &reloaded);
2957
2958 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
2959
2960 assert_eq!(agenda.all_day_events.len(), 1);
2961 assert_eq!(agenda.all_day_events[0].title, "Release day");
2962 assert_eq!(agenda.timed_events.len(), 1);
2963 assert_eq!(agenda.timed_events[0].event.title, "Planning");
2964 assert_eq!(
2965 agenda.timed_events[0].event.location.as_deref(),
2966 Some("War room")
2967 );
2968 assert_eq!(
2969 agenda.timed_events[0]
2970 .event
2971 .reminders
2972 .iter()
2973 .map(|reminder| reminder.minutes_before)
2974 .collect::<Vec<_>>(),
2975 [10, 60]
2976 );
2977 }
2978
2979 #[test]
2980 fn local_event_store_updates_existing_event_id_and_persists() {
2981 let path = temp_events_path("update");
2982 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
2983 let day = date(23);
2984 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
2985 .expect("missing event file is empty");
2986 let event = source
2987 .create_event(CreateEventDraft {
2988 title: "Planning".to_string(),
2989 timing: CreateEventTiming::Timed {
2990 start: at(day, 9, 0),
2991 end: at(day, 10, 0),
2992 },
2993 location: None,
2994 notes: None,
2995 reminders: Vec::new(),
2996 recurrence: None,
2997 })
2998 .expect("event saves");
2999
3000 let updated = source
3001 .update_event(
3002 &event.id,
3003 CreateEventDraft {
3004 title: "Updated planning".to_string(),
3005 timing: CreateEventTiming::AllDay { date: day },
3006 location: Some("Room 2".to_string()),
3007 notes: Some("Moved".to_string()),
3008 reminders: vec![Reminder::minutes_before(5)],
3009 recurrence: None,
3010 },
3011 )
3012 .expect("event updates");
3013
3014 assert_eq!(updated.id, event.id);
3015 assert!(updated.is_local());
3016
3017 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3018 .expect("saved file reloads");
3019 let agenda = DayAgenda::from_source(day, &reloaded);
3020
3021 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3022
3023 assert!(agenda.timed_events.is_empty());
3024 assert_eq!(agenda.all_day_events.len(), 1);
3025 assert_eq!(agenda.all_day_events[0].id, event.id);
3026 assert_eq!(agenda.all_day_events[0].title, "Updated planning");
3027 assert_eq!(agenda.all_day_events[0].location.as_deref(), Some("Room 2"));
3028 }
3029
3030 #[test]
3031 fn local_event_store_loads_version_one_and_rewrites_version_two_on_save() {
3032 let path = temp_events_path("version-one");
3033 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3034 std::fs::create_dir_all(path.parent().expect("path has parent"))
3035 .expect("parent can be created");
3036 std::fs::write(
3037 &path,
3038 r#"{
3039 "version": 1,
3040 "events": [
3041 {
3042 "id": "old",
3043 "title": "Old file",
3044 "date": "2026-04-23"
3045 }
3046 ]
3047 }"#,
3048 )
3049 .expect("file can be written");
3050 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3051 .expect("version one file loads");
3052
3053 source
3054 .update_event(
3055 "old",
3056 CreateEventDraft {
3057 title: "Rewritten".to_string(),
3058 timing: CreateEventTiming::AllDay { date: date(23) },
3059 location: None,
3060 notes: None,
3061 reminders: Vec::new(),
3062 recurrence: None,
3063 },
3064 )
3065 .expect("event update saves");
3066
3067 let body = std::fs::read_to_string(&path).expect("event file exists");
3068 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3069
3070 assert!(body.contains(r#""version": 2"#));
3071 assert!(body.contains("Rewritten"));
3072 }
3073
3074 #[test]
3075 fn local_event_store_saves_recurring_series_and_occurrence_overrides() {
3076 let path = temp_events_path("recurring-overrides");
3077 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3078 let day = date(23);
3079 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3080 .expect("missing event file is empty");
3081 let event = source
3082 .create_event(CreateEventDraft {
3083 title: "Standup".to_string(),
3084 timing: CreateEventTiming::Timed {
3085 start: at(day, 9, 0),
3086 end: at(day, 9, 30),
3087 },
3088 location: None,
3089 notes: None,
3090 reminders: Vec::new(),
3091 recurrence: Some(RecurrenceRule {
3092 frequency: RecurrenceFrequency::Daily,
3093 interval: 1,
3094 end: RecurrenceEnd::Count(3),
3095 weekdays: Vec::new(),
3096 monthly: None,
3097 yearly: None,
3098 }),
3099 })
3100 .expect("recurring event saves");
3101 let anchor = OccurrenceAnchor::Timed {
3102 start: at(day.add_days(1), 9, 0),
3103 };
3104
3105 source
3106 .update_occurrence(
3107 &event.id,
3108 anchor,
3109 CreateEventDraft {
3110 title: "Moved standup".to_string(),
3111 timing: CreateEventTiming::Timed {
3112 start: at(day.add_days(1), 10, 0),
3113 end: at(day.add_days(1), 10, 30),
3114 },
3115 location: Some("Room 2".to_string()),
3116 notes: None,
3117 reminders: vec![Reminder::minutes_before(5)],
3118 recurrence: None,
3119 },
3120 )
3121 .expect("occurrence override saves");
3122
3123 let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3124 .expect("saved file reloads");
3125 let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3126
3127 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3128
3129 assert_eq!(agenda.timed_events.len(), 1);
3130 let overridden = &agenda.timed_events[0].event;
3131 assert_eq!(overridden.title, "Moved standup");
3132 assert_eq!(overridden.location.as_deref(), Some("Room 2"));
3133 assert_eq!(
3134 overridden.occurrence().map(|occurrence| occurrence.anchor),
3135 Some(anchor)
3136 );
3137 }
3138
3139 #[test]
3140 fn series_edits_drop_overrides_whose_anchor_no_longer_generates() {
3141 let path = temp_events_path("series-edit-overrides");
3142 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3143 let day = date(23);
3144 let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3145 .expect("missing event file is empty");
3146 let event = source
3147 .create_event(CreateEventDraft {
3148 title: "Standup".to_string(),
3149 timing: CreateEventTiming::Timed {
3150 start: at(day, 9, 0),
3151 end: at(day, 9, 30),
3152 },
3153 location: None,
3154 notes: None,
3155 reminders: Vec::new(),
3156 recurrence: Some(RecurrenceRule {
3157 frequency: RecurrenceFrequency::Daily,
3158 interval: 1,
3159 end: RecurrenceEnd::Count(3),
3160 weekdays: Vec::new(),
3161 monthly: None,
3162 yearly: None,
3163 }),
3164 })
3165 .expect("recurring event saves");
3166 let anchor = OccurrenceAnchor::Timed {
3167 start: at(day.add_days(1), 9, 0),
3168 };
3169 source
3170 .update_occurrence(
3171 &event.id,
3172 anchor,
3173 CreateEventDraft {
3174 title: "Override".to_string(),
3175 timing: CreateEventTiming::Timed {
3176 start: at(day.add_days(1), 10, 0),
3177 end: at(day.add_days(1), 10, 30),
3178 },
3179 location: None,
3180 notes: None,
3181 reminders: Vec::new(),
3182 recurrence: None,
3183 },
3184 )
3185 .expect("override saves");
3186
3187 let updated = source
3188 .update_event(
3189 &event.id,
3190 CreateEventDraft {
3191 title: "Standup".to_string(),
3192 timing: CreateEventTiming::Timed {
3193 start: at(day, 9, 0),
3194 end: at(day, 9, 30),
3195 },
3196 location: None,
3197 notes: None,
3198 reminders: Vec::new(),
3199 recurrence: Some(RecurrenceRule {
3200 frequency: RecurrenceFrequency::Weekly,
3201 interval: 1,
3202 end: RecurrenceEnd::Never,
3203 weekdays: vec![day.weekday()],
3204 monthly: None,
3205 yearly: None,
3206 }),
3207 },
3208 )
3209 .expect("series update saves");
3210
3211 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3212
3213 assert!(updated.occurrence_overrides.is_empty());
3214 }
3215
3216 #[test]
3217 fn local_event_store_rejects_missing_and_non_local_updates() {
3218 let day = date(23);
3219 let mut source = ConfiguredAgendaSource::new(
3220 InMemoryAgendaSource::with_events_and_holidays(
3221 vec![timed("fixture", "Fixture", at(day, 8, 0), at(day, 9, 0))],
3222 Vec::new(),
3223 ),
3224 HolidayProvider::off(),
3225 );
3226 let draft = CreateEventDraft {
3227 title: "Updated".to_string(),
3228 timing: CreateEventTiming::Timed {
3229 start: at(day, 10, 0),
3230 end: at(day, 11, 0),
3231 },
3232 location: None,
3233 notes: None,
3234 reminders: Vec::new(),
3235 recurrence: None,
3236 };
3237
3238 assert!(matches!(
3239 source
3240 .update_event("missing", draft.clone())
3241 .expect_err("missing event fails"),
3242 LocalEventStoreError::EventNotFound { .. }
3243 ));
3244 assert!(matches!(
3245 source
3246 .update_event("fixture", draft)
3247 .expect_err("fixture event fails"),
3248 LocalEventStoreError::EventNotEditable { .. }
3249 ));
3250 }
3251
3252 #[test]
3253 fn local_event_store_rejects_malformed_json() {
3254 let path = temp_events_path("malformed");
3255 let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3256 std::fs::create_dir_all(path.parent().expect("path has parent"))
3257 .expect("parent can be created");
3258 std::fs::write(&path, "{not json").expect("file can be written");
3259
3260 let err = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3261 .expect_err("malformed file fails");
3262
3263 let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3264
3265 assert!(matches!(err, LocalEventStoreError::Parse { .. }));
3266 assert!(err.to_string().contains("failed to parse"));
3267 }
3268
3269 #[test]
3270 fn us_federal_source_returns_2026_observed_holidays() {
3271 let source = UsFederalHolidaySource;
3272 let range = DateRange::new(
3273 date_ymd(2026, Month::January, 1),
3274 date_ymd(2027, Month::January, 1),
3275 )
3276 .expect("valid range");
3277
3278 let holidays = source.holidays_in(range);
3279 let observed = holidays
3280 .iter()
3281 .map(|holiday| (holiday.name.as_str(), holiday.date))
3282 .collect::<Vec<_>>();
3283
3284 assert_eq!(holidays.len(), 11);
3285 assert!(observed.contains(&("New Year's Day", date_ymd(2026, Month::January, 1))));
3286 assert!(observed.contains(&(
3287 "Birthday of Martin Luther King, Jr.",
3288 date_ymd(2026, Month::January, 19),
3289 )));
3290 assert!(
3291 observed.contains(&("Washington's Birthday", date_ymd(2026, Month::February, 16),))
3292 );
3293 assert!(observed.contains(&("Memorial Day", date_ymd(2026, Month::May, 25))));
3294 assert!(observed.contains(&(
3295 "Juneteenth National Independence Day",
3296 date_ymd(2026, Month::June, 19),
3297 )));
3298 assert!(observed.contains(&("Independence Day", date_ymd(2026, Month::July, 3))));
3299 assert!(observed.contains(&("Labor Day", date_ymd(2026, Month::September, 7))));
3300 assert!(observed.contains(&("Columbus Day", date_ymd(2026, Month::October, 12))));
3301 assert!(observed.contains(&("Veterans Day", date_ymd(2026, Month::November, 11))));
3302 assert!(observed.contains(&("Thanksgiving Day", date_ymd(2026, Month::November, 26))));
3303 assert!(observed.contains(&("Christmas Day", date_ymd(2026, Month::December, 25))));
3304 }
3305
3306 #[test]
3307 fn us_federal_source_includes_previous_year_observed_new_year() {
3308 let source = UsFederalHolidaySource;
3309 let day = date_ymd(2021, Month::December, 31);
3310
3311 let holidays = source.holidays_in(DateRange::day(day));
3312
3313 assert_eq!(holidays.len(), 1);
3314 assert_eq!(holidays[0].name, "New Year's Day");
3315 assert_eq!(holidays[0].date, day);
3316 }
3317
3318 #[test]
3319 fn nager_source_reads_cached_holidays_without_network() {
3320 let cache_dir = std::env::temp_dir()
3321 .join(format!("rcal-nager-test-{}", std::process::id()))
3322 .join("cached-holidays-basic");
3323 let _ = std::fs::remove_dir_all(&cache_dir);
3324 let country_dir = cache_dir.join("GB");
3325 std::fs::create_dir_all(&country_dir).expect("cache dir can be created");
3326 std::fs::write(
3327 country_dir.join("2026.json"),
3328 r#"[{"date":"2026-12-25","localName":"Christmas Day","name":"Christmas Day","countryCode":"GB","fixed":false,"global":true,"counties":null,"launchYear":null,"types":["Public"]}]"#,
3329 )
3330 .expect("cache file can be written");
3331
3332 let source = NagerHolidaySource::with_cache_dir("gb", &cache_dir, Duration::from_millis(1));
3333 let holidays = source.holidays_in(DateRange::day(date_ymd(2026, Month::December, 25)));
3334
3335 let _ = std::fs::remove_dir_all(cache_dir);
3336
3337 assert_eq!(holidays.len(), 1);
3338 assert_eq!(holidays[0].id, "nager-GB-2026-12-25-christmas-day");
3339 assert_eq!(holidays[0].name, "Christmas Day");
3340 assert_eq!(holidays[0].source.source_id, "nager-GB");
3341 }
3342
3343 #[test]
3344 fn nager_source_respects_half_open_year_boundaries() {
3345 let cache_dir = std::env::temp_dir()
3346 .join(format!("rcal-nager-test-{}", std::process::id()))
3347 .join("half-open-range");
3348 let _ = std::fs::remove_dir_all(&cache_dir);
3349 let country_dir = cache_dir.join("GB");
3350 std::fs::create_dir_all(&country_dir).expect("cache dir can be created");
3351 std::fs::write(country_dir.join("2026.json"), "[]").expect("cache file can be written");
3352 std::fs::write(country_dir.join("2027.json"), "[]").expect("cache file can be written");
3353
3354 let source = NagerHolidaySource::with_cache_dir("gb", &cache_dir, Duration::from_millis(1));
3355 let range = DateRange::new(
3356 date_ymd(2026, Month::December, 31),
3357 date_ymd(2027, Month::January, 1),
3358 )
3359 .expect("valid range");
3360
3361 let _ = source.holidays_in(range);
3362 let attempted_years = source.state.borrow().attempted_years.clone();
3363 let _ = std::fs::remove_dir_all(cache_dir);
3364
3365 assert!(attempted_years.contains(&2026));
3366 assert!(!attempted_years.contains(&2027));
3367 }
3368 }
3369