1use std::{borrow::Cow, fmt::Display, num::NonZeroU32, str::FromStr};
6
7use aimcal_ical as ical;
8use aimcal_ical::{Description, DtEnd, DtStamp, DtStart, EventStatusValue, Summary, Uid, VEvent};
9use jiff::{Span, ToSpan, Zoned};
10
11use crate::{DateTimeAnchor, LooseDateTime};
12
13pub trait Event {
15 fn short_id(&self) -> Option<NonZeroU32> {
19 None
20 }
21
22 fn uid(&self) -> Cow<'_, str>;
24
25 fn description(&self) -> Option<Cow<'_, str>>;
27
28 fn start(&self) -> Option<LooseDateTime>;
30
31 fn end(&self) -> Option<LooseDateTime>;
33
34 fn status(&self) -> Option<EventStatus>;
36
37 fn summary(&self) -> Cow<'_, str>;
39}
40
41impl Event for VEvent<String> {
42 fn uid(&self) -> Cow<'_, str> {
43 self.uid.content.to_string().into() }
45
46 fn description(&self) -> Option<Cow<'_, str>> {
47 self.description
48 .as_ref()
49 .map(|a| a.content.to_string().into()) }
51
52 fn start(&self) -> Option<LooseDateTime> {
53 Some(self.dt_start.0.clone().into())
54 }
55
56 fn end(&self) -> Option<LooseDateTime> {
57 self.dt_end.as_ref().map(|dt| dt.0.clone().into())
58 }
59
60 fn status(&self) -> Option<EventStatus> {
61 self.status.as_ref().map(|s| s.value.into())
62 }
63
64 fn summary(&self) -> Cow<'_, str> {
65 self.summary
66 .as_ref()
67 .map_or_else(|| "".into(), |s| s.content.to_string().into()) }
69}
70
71#[derive(Debug, Clone)]
73pub struct EventDraft {
74 pub calendar_id: Option<String>,
76 pub description: Option<String>,
78 pub start: Option<LooseDateTime>,
80 pub end: Option<LooseDateTime>,
82 pub status: EventStatus,
84 pub summary: String,
86}
87
88impl EventDraft {
89 pub(crate) fn default(now: &Zoned) -> Self {
91 let start = if now.time().minute() < 30 {
93 now.with()
94 .minute(30)
95 .second(0)
96 .subsec_nanosecond(0)
97 .build()
98 .unwrap()
99 } else {
100 (now + Span::new().hours(1))
101 .with()
102 .minute(0)
103 .second(0)
104 .subsec_nanosecond(0)
105 .build()
106 .unwrap()
107 };
108
109 Self {
110 calendar_id: None,
111 description: None,
112 start: Some(start.clone().into()),
113 end: Some((start.checked_add(1.hours()).unwrap()).into()),
114 status: EventStatus::default(),
115 summary: String::new(),
116 }
117 }
118
119 pub(crate) fn resolve<'a>(&'a self, now: &'a Zoned) -> ResolvedEventDraft<'a> {
120 let default_duration = 1.hours();
121 let (start, end) = match (self.start.as_ref(), self.end.as_ref()) {
122 (Some(start), Some(end)) => (start.clone(), end.clone()),
123 (None, Some(end)) => {
124 let neg_duration = Span::new().hours(-1);
126 let start = match end {
127 LooseDateTime::DateOnly(d) => (*d).into(),
128 LooseDateTime::Floating(dt) => {
129 LooseDateTime::Floating(dt.checked_add(neg_duration).unwrap())
130 }
131 LooseDateTime::Local(dt) => {
132 LooseDateTime::Local(dt.checked_add(neg_duration).unwrap())
133 }
134 };
135 (start, end.clone())
136 }
137 (Some(start), None) => {
138 let end = match start {
140 LooseDateTime::DateOnly(d) => (*d).into(),
141 LooseDateTime::Floating(dt) => {
142 LooseDateTime::Floating(dt.checked_add(default_duration).unwrap())
143 }
144 LooseDateTime::Local(dt) => {
145 LooseDateTime::Local(dt.checked_add(default_duration).unwrap())
146 }
147 };
148 (start.clone(), end)
149 }
150 (None, None) => {
151 let end = now.checked_add(default_duration).unwrap();
152 (LooseDateTime::Local(now.clone()), LooseDateTime::Local(end))
153 }
154 };
155
156 ResolvedEventDraft {
157 description: self.description.as_deref(),
158 start,
159 end,
160 status: self.status,
161 summary: &self.summary,
162
163 now,
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
169pub struct ResolvedEventDraft<'a> {
170 pub description: Option<&'a str>,
171 pub start: LooseDateTime,
172 pub end: LooseDateTime,
173 pub status: EventStatus,
174 pub summary: &'a str,
175
176 pub now: &'a Zoned,
177}
178
179impl ResolvedEventDraft<'_> {
180 pub(crate) fn into_ics(self, uid: &str) -> VEvent<String> {
182 let utc_now = self.now.with_time_zone(jiff::tz::TimeZone::UTC);
184 let dt_stamp = DtStamp::new(utc_now.datetime());
185 VEvent {
186 uid: Uid::new(uid.to_string()),
187 dt_stamp,
188 dt_start: DtStart::new(self.start),
189 dt_end: Some(DtEnd::new(self.end)),
190 duration: None,
191 summary: Some(Summary::new(self.summary.to_string())),
192 description: self.description.map(|d| Description::new(d.to_string())),
193 status: Some(ical::EventStatus::new(self.status.into())),
194 location: None,
195 geo: None,
196 url: None,
197 organizer: None,
198 attendees: Vec::new(),
199 last_modified: None,
200 transparency: None,
201 sequence: None,
202 priority: None,
203 classification: None,
204 resources: None,
205 categories: None,
206 rrule: None,
207 rdates: Vec::new(),
208 ex_dates: Vec::new(),
209 x_properties: Vec::new(),
210 retained_properties: Vec::new(),
211 alarms: Vec::new(),
212 }
213 }
214}
215
216#[derive(Debug, Default, Clone)]
218pub struct EventPatch {
219 pub description: Option<Option<String>>,
221 pub start: Option<Option<LooseDateTime>>,
223 pub end: Option<Option<LooseDateTime>>,
225 pub status: Option<EventStatus>,
227 pub summary: Option<String>,
229}
230
231impl EventPatch {
232 #[must_use]
234 pub fn is_empty(&self) -> bool {
235 self.description.is_none()
236 && self.start.is_none()
237 && self.end.is_none()
238 && self.status.is_none()
239 && self.summary.is_none()
240 }
241
242 pub(crate) fn resolve(&self, now: Zoned) -> ResolvedEventPatch<'_> {
243 ResolvedEventPatch {
244 description: self.description.as_ref().map(|opt| opt.as_deref()),
245 start: self.start.clone(),
246 end: self.end.clone(),
247 status: self.status,
248 summary: self.summary.as_deref(),
249
250 now,
251 }
252 }
253}
254
255impl From<EventDraft> for EventPatch {
256 fn from(draft: EventDraft) -> EventPatch {
257 EventPatch {
258 description: draft.description.map(Some),
259 start: draft.start.map(Some),
260 end: draft.end.map(Some),
261 status: Some(draft.status),
262 summary: Some(draft.summary),
263 }
264 }
265}
266
267#[derive(Debug, Default, Clone)]
269#[expect(clippy::option_option)]
270pub struct ResolvedEventPatch<'a> {
271 pub description: Option<Option<&'a str>>,
272 pub start: Option<Option<LooseDateTime>>,
273 pub end: Option<Option<LooseDateTime>>,
274 pub status: Option<EventStatus>,
275 pub summary: Option<&'a str>,
276
277 pub now: Zoned,
278}
279
280impl ResolvedEventPatch<'_> {
281 pub fn apply_to<'a>(&self, e: &'a mut VEvent<String>) -> &'a mut VEvent<String> {
283 if let Some(Some(desc)) = self.description {
284 e.description = Some(Description::new(desc.to_string()));
285 } else if self.description.is_some() {
286 e.description = None;
287 }
288
289 if let Some(Some(ref start)) = self.start {
290 e.dt_start = DtStart::new(start.clone());
291 }
292
293 if let Some(Some(ref end)) = self.end {
294 e.dt_end = Some(DtEnd::new(end.clone()));
295 } else if self.end.is_some() {
296 e.dt_end = None;
297 }
298
299 if let Some(status) = self.status {
300 e.status = Some(ical::EventStatus::new(status.into()));
301 }
302
303 if let Some(summary) = self.summary {
304 e.summary = Some(Summary::new(summary.to_string()));
305 }
306
307 if e.dt_stamp.date.year == 1970 {
309 let utc_now = self.now.with_time_zone(jiff::tz::TimeZone::UTC);
311 e.dt_stamp = DtStamp::new(utc_now.datetime());
312 }
313
314 e
315 }
316}
317
318#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
320#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
321pub enum EventStatus {
322 Tentative,
324 #[default]
326 Confirmed,
327 Cancelled,
329}
330
331const STATUS_TENTATIVE: &str = "TENTATIVE";
333const STATUS_CONFIRMED: &str = "CONFIRMED";
334const STATUS_CANCELLED: &str = "CANCELLED";
335
336impl AsRef<str> for EventStatus {
337 fn as_ref(&self) -> &str {
338 match self {
339 EventStatus::Tentative => STATUS_TENTATIVE,
340 EventStatus::Confirmed => STATUS_CONFIRMED,
341 EventStatus::Cancelled => STATUS_CANCELLED,
342 }
343 }
344}
345
346impl Display for EventStatus {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 self.as_ref().fmt(f)
349 }
350}
351
352impl FromStr for EventStatus {
353 type Err = ();
354
355 fn from_str(value: &str) -> Result<Self, Self::Err> {
356 match value {
357 STATUS_TENTATIVE => Ok(EventStatus::Tentative),
358 STATUS_CONFIRMED => Ok(EventStatus::Confirmed),
359 STATUS_CANCELLED => Ok(EventStatus::Cancelled),
360 _ => Err(()),
361 }
362 }
363}
364
365impl From<EventStatusValue> for EventStatus {
366 fn from(value: EventStatusValue) -> Self {
367 match value {
368 EventStatusValue::Tentative => EventStatus::Tentative,
369 EventStatusValue::Confirmed => EventStatus::Confirmed,
370 EventStatusValue::Cancelled => EventStatus::Cancelled,
371 }
372 }
373}
374
375impl From<EventStatus> for EventStatusValue {
376 fn from(value: EventStatus) -> Self {
377 match value {
378 EventStatus::Tentative => EventStatusValue::Tentative,
379 EventStatus::Confirmed => EventStatusValue::Confirmed,
380 EventStatus::Cancelled => EventStatusValue::Cancelled,
381 }
382 }
383}
384
385#[derive(Debug, Default, Clone)]
387pub struct EventConditions {
388 pub startable: Option<DateTimeAnchor>,
390 pub cutoff: Option<DateTimeAnchor>,
392 pub calendar_id: Option<String>,
394}
395
396impl EventConditions {
397 pub(crate) fn resolve(&self, now: &Zoned) -> Result<ResolvedEventConditions, String> {
398 Ok(ResolvedEventConditions {
399 start_before: self
400 .cutoff
401 .as_ref()
402 .map(|w| w.resolve_at_end_of_day(now))
403 .transpose()?,
404 end_after: self
405 .startable
406 .as_ref()
407 .map(|w| w.resolve_at_start_of_day(now))
408 .transpose()?,
409 calendar_id: self.calendar_id.clone(),
410 })
411 }
412}
413
414#[derive(Debug, Clone)]
415pub struct ResolvedEventConditions {
416 pub start_before: Option<Zoned>,
418 pub end_after: Option<Zoned>,
420 pub calendar_id: Option<String>,
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use aimcal_ical::{Description, DtEnd, DtStart, Summary, Uid, VEvent};
428 use jiff::{civil::date, tz::TimeZone};
429
430 fn test_event_draft() -> EventDraft {
432 EventDraft {
433 calendar_id: None,
434 description: None,
435 start: None,
436 end: None,
437 status: EventStatus::Confirmed,
438 summary: String::new(),
439 }
440 }
441
442 fn vevent_dt_stamp() -> DtStamp<String> {
443 let date = ical::Date::new(2024, 6, 15).unwrap();
445 let time = ical::Time::new(10, 30, 0).unwrap();
446 DtStamp::new(ical::DateTimeUtc {
447 date,
448 time,
449 x_parameters: Vec::new(),
450 retained_parameters: Vec::new(),
451 span: (),
452 })
453 }
454
455 fn create_test_vevent(uid: &str, summary: &str) -> VEvent<String> {
456 let dt_start = LooseDateTime::Local(
457 date(2025, 1, 1)
458 .at(10, 0, 0, 0)
459 .to_zoned(TimeZone::UTC)
460 .unwrap(),
461 );
462 let dt_end = LooseDateTime::Local(
463 date(2025, 1, 1)
464 .at(11, 0, 0, 0)
465 .to_zoned(TimeZone::UTC)
466 .unwrap(),
467 );
468
469 VEvent {
470 uid: Uid::new(uid.to_string()),
471 dt_stamp: vevent_dt_stamp(),
472 dt_start: DtStart::new(dt_start),
473 dt_end: Some(DtEnd::new(dt_end)),
474 duration: None,
475 summary: Some(Summary::new(summary.to_string())),
476 description: None,
477 location: None,
478 geo: None,
479 url: None,
480 organizer: None,
481 attendees: Vec::new(),
482 last_modified: None,
483 status: Some(ical::EventStatus::new(EventStatusValue::Confirmed)),
484 transparency: None,
485 sequence: None,
486 priority: None,
487 classification: None,
488 resources: None,
489 categories: None,
490 rdates: Vec::new(),
491 rrule: None,
492 ex_dates: Vec::new(),
493 x_properties: Vec::new(),
494 retained_properties: Vec::new(),
495 alarms: Vec::new(),
496 }
497 }
498
499 fn create_test_vevent_with_description(
500 uid: &str,
501 summary: &str,
502 description: &str,
503 ) -> VEvent<String> {
504 let mut vevent = create_test_vevent(uid, summary);
505 vevent.description = Some(Description::new(description.to_string()));
506 vevent
507 }
508
509 #[test]
512 fn event_draft_default_creates_draft_with_rounded_time() {
513 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
514
515 let draft = EventDraft::default(&now);
516
517 let _minute = now.time().minute();
519
520 assert!(
521 draft.start.is_some(),
522 "Default draft should have start time"
523 );
524 assert!(draft.end.is_some(), "Default draft should have end time");
525 assert_eq!(draft.summary, "");
526 assert_eq!(draft.status, EventStatus::Confirmed);
527 assert!(draft.description.is_none());
528 }
529
530 #[test]
531 fn event_draft_default_with_time_before_30() {
532 let now = date(2025, 1, 15)
533 .at(10, 15, 0, 0)
534 .to_zoned(TimeZone::UTC)
535 .unwrap();
536
537 let draft = EventDraft::default(&now);
538
539 assert!(draft.start.is_some());
541 let start = draft.start.as_ref().unwrap();
542 assert!(
543 matches!(start, LooseDateTime::Local(dt) if dt.time().hour() == 10 && dt.time().minute() == 30)
544 );
545
546 assert!(draft.end.is_some());
548 let end = draft.end.as_ref().unwrap();
549 assert!(
550 matches!(end, LooseDateTime::Local(dt) if dt.time().hour() == 11 && dt.time().minute() == 30)
551 );
552 }
553
554 #[test]
555 fn event_draft_default_with_time_after_30() {
556 let now = date(2025, 1, 15)
557 .at(10, 45, 0, 0)
558 .to_zoned(TimeZone::UTC)
559 .unwrap();
560
561 let draft = EventDraft::default(&now);
562
563 assert!(draft.start.is_some());
565 let start = draft.start.as_ref().unwrap();
566 assert!(
567 matches!(start, LooseDateTime::Local(dt) if dt.time().hour() == 11 && dt.time().minute() == 0)
568 );
569
570 assert!(draft.end.is_some());
572 let end = draft.end.as_ref().unwrap();
573 assert!(
574 matches!(end, LooseDateTime::Local(dt) if dt.time().hour() == 12 && dt.time().minute() == 0)
575 );
576 }
577
578 #[test]
579 fn event_draft_resolve_with_both_start_and_end() {
580 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
581
582 let start = LooseDateTime::Local(
583 date(2025, 1, 15)
584 .at(10, 0, 0, 0)
585 .to_zoned(TimeZone::UTC)
586 .unwrap(),
587 );
588 let end = LooseDateTime::Local(
589 date(2025, 1, 15)
590 .at(11, 0, 0, 0)
591 .to_zoned(TimeZone::UTC)
592 .unwrap(),
593 );
594
595 let draft = EventDraft {
596 start: Some(start.clone()),
597 end: Some(end.clone()),
598 ..test_event_draft()
599 };
600
601 let resolved = draft.resolve(&now);
602
603 assert_eq!(resolved.summary, "");
604 assert_eq!(resolved.start, start);
605 assert_eq!(resolved.end, end);
606 }
607
608 #[test]
609 fn event_draft_resolve_with_start_only_calculates_end() {
610 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
611
612 let start = LooseDateTime::Local(
613 date(2025, 1, 15)
614 .at(10, 0, 0, 0)
615 .to_zoned(TimeZone::UTC)
616 .unwrap(),
617 );
618
619 let draft = EventDraft {
620 start: Some(start.clone()),
621 end: None,
622 ..test_event_draft()
623 };
624
625 let resolved = draft.resolve(&now);
626
627 assert_eq!(resolved.start, start);
628 assert!(
630 matches!(resolved.end, LooseDateTime::Local(dt) if dt.time().hour() == 11 && dt.time().minute() == 0)
631 );
632 }
633
634 #[test]
635 fn event_draft_resolve_with_end_only_calculates_start() {
636 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
637
638 let end = LooseDateTime::Local(
639 date(2025, 1, 15)
640 .at(11, 0, 0, 0)
641 .to_zoned(TimeZone::UTC)
642 .unwrap(),
643 );
644
645 let draft = EventDraft {
646 start: None,
647 end: Some(end.clone()),
648 ..test_event_draft()
649 };
650
651 let resolved = draft.resolve(&now);
652
653 assert!(
655 matches!(resolved.start, LooseDateTime::Local(dt) if dt.time().hour() == 10 && dt.time().minute() == 0)
656 );
657 assert_eq!(resolved.end, end);
658 }
659
660 #[test]
661 fn event_draft_resolve_with_no_times_uses_now() {
662 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
663
664 let draft = EventDraft {
665 start: None,
666 end: None,
667 ..test_event_draft()
668 };
669
670 let resolved = draft.resolve(&now);
671
672 assert!(matches!(resolved.start, LooseDateTime::Local(_)));
674 assert!(matches!(resolved.end, LooseDateTime::Local(_)));
675 }
676
677 #[test]
678 fn event_draft_resolve_with_date_only_start() {
679 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
680
681 let start = LooseDateTime::DateOnly(date(2025, 1, 15));
682
683 let draft = EventDraft {
684 start: Some(start.clone()),
685 end: None,
686 ..test_event_draft()
687 };
688
689 let resolved = draft.resolve(&now);
690
691 assert_eq!(resolved.start, start);
693 }
694
695 #[test]
696 fn event_draft_into_ics_creates_valid_vevent() {
697 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
698
699 let dt_start = LooseDateTime::Local(
700 date(2025, 1, 15)
701 .at(10, 0, 0, 0)
702 .to_zoned(TimeZone::UTC)
703 .unwrap(),
704 );
705 let dt_end = LooseDateTime::Local(
706 date(2025, 1, 15)
707 .at(11, 0, 0, 0)
708 .to_zoned(TimeZone::UTC)
709 .unwrap(),
710 );
711
712 let draft = EventDraft {
713 calendar_id: None,
714 summary: "Test Event".to_string(),
715 description: Some("Test Description".to_string()),
716 start: Some(dt_start),
717 end: Some(dt_end),
718 status: EventStatus::Confirmed,
719 };
720
721 let resolved = draft.resolve(&now);
722 let vevent = resolved.into_ics("test-uid");
723
724 assert_eq!(vevent.uid.content.to_string(), "test-uid");
725 assert_eq!(
726 vevent.summary.as_ref().unwrap().content.to_string(),
727 "Test Event"
728 );
729 assert_eq!(
730 vevent.description.as_ref().unwrap().content.to_string(),
731 "Test Description"
732 );
733 assert_eq!(
734 vevent.status.as_ref().unwrap().value,
735 EventStatusValue::Confirmed
736 );
737 }
738
739 #[test]
740 fn event_draft_resolve_preserves_status() {
741 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
742
743 for status in [
744 EventStatus::Tentative,
745 EventStatus::Confirmed,
746 EventStatus::Cancelled,
747 ] {
748 let draft = EventDraft {
749 status,
750 ..test_event_draft()
751 };
752
753 let resolved = draft.resolve(&now);
754 assert_eq!(resolved.status, status);
755 }
756 }
757
758 #[test]
759 fn event_draft_resolve_preserves_summary() {
760 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
761
762 let draft = EventDraft {
763 summary: "My Event Summary".to_string(),
764 ..test_event_draft()
765 };
766
767 let resolved = draft.resolve(&now);
768 assert_eq!(resolved.summary, "My Event Summary");
769 }
770
771 #[test]
772 fn event_draft_resolve_preserves_description() {
773 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
774
775 let draft = EventDraft {
776 description: Some("Event description".to_string()),
777 ..test_event_draft()
778 };
779
780 let resolved = draft.resolve(&now);
781 assert_eq!(resolved.description, Some("Event description"));
782 }
783
784 #[test]
785 fn event_draft_resolve_with_none_description() {
786 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
787
788 let draft = EventDraft {
789 description: None,
790 ..test_event_draft()
791 };
792
793 let resolved = draft.resolve(&now);
794 assert!(resolved.description.is_none());
795 }
796
797 #[test]
798 fn event_draft_default_status_is_confirmed() {
799 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
800
801 let draft = EventDraft::default(&now);
802 assert_eq!(draft.status, EventStatus::Confirmed);
803 }
804
805 #[test]
808 fn event_patch_default_is_empty() {
809 let patch = EventPatch::default();
810
811 assert!(patch.is_empty());
812 assert!(patch.description.is_none());
813 assert!(patch.start.is_none());
814 assert!(patch.end.is_none());
815 assert!(patch.status.is_none());
816 assert!(patch.summary.is_none());
817 }
818
819 #[test]
820 fn event_patch_is_empty_detects_no_changes() {
821 let patch = EventPatch::default();
822 assert!(patch.is_empty());
823
824 let patch_with_fields = EventPatch {
825 summary: Some("Test".to_string()),
826 ..Default::default()
827 };
828 assert!(!patch_with_fields.is_empty());
829 }
830
831 #[test]
832 fn event_patch_apply_to_sets_summary() {
833 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
834
835 let mut vevent = create_test_vevent("test-uid", "Original Summary");
836
837 let patch = EventPatch {
838 summary: Some("New Summary".to_string()),
839 ..Default::default()
840 };
841 let resolved = patch.resolve(now.clone());
842
843 resolved.apply_to(&mut vevent);
844
845 assert_eq!(
846 vevent.summary.as_ref().unwrap().content.to_string(),
847 "New Summary"
848 );
849 }
850
851 #[test]
852 fn event_patch_apply_to_sets_description() {
853 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
854
855 let mut vevent = create_test_vevent("test-uid", "Test");
856
857 let patch = EventPatch {
858 description: Some(Some("New description".to_string())),
859 ..Default::default()
860 };
861 let resolved = patch.resolve(now.clone());
862
863 resolved.apply_to(&mut vevent);
864
865 assert_eq!(
866 vevent.description.as_ref().unwrap().content.to_string(),
867 "New description"
868 );
869 }
870
871 #[test]
872 fn event_patch_apply_to_clears_description() {
873 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
874
875 let mut vevent =
876 create_test_vevent_with_description("test-uid", "Test", "Original Description");
877
878 let patch = EventPatch {
879 description: Some(None), ..Default::default()
881 };
882 let resolved = patch.resolve(now.clone());
883
884 resolved.apply_to(&mut vevent);
885
886 assert!(
887 vevent.description.is_none(),
888 "Description should be cleared"
889 );
890 }
891
892 #[test]
893 fn event_patch_apply_to_sets_start() {
894 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
895
896 let mut vevent = create_test_vevent("test-uid", "Test");
897
898 let new_start = LooseDateTime::Local(
899 date(2025, 6, 1)
900 .at(14, 0, 0, 0)
901 .to_zoned(TimeZone::UTC)
902 .unwrap(),
903 );
904
905 let patch = EventPatch {
906 start: Some(Some(new_start.clone())),
907 ..Default::default()
908 };
909 let resolved = patch.resolve(now.clone());
910
911 resolved.apply_to(&mut vevent);
912
913 assert!(
915 matches!(vevent.dt_start.0.value, ical::DateTime::Utc { date, time } if
916 date.year == 2025 &&
917 date.month == 6 &&
918 date.day == 1 &&
919 time.hour == 14 &&
920 time.minute == 0
921 )
922 );
923 }
924
925 #[test]
926 fn event_patch_apply_to_clears_end() {
927 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
928
929 let dt_start = LooseDateTime::Local(
930 date(2025, 6, 1)
931 .at(10, 0, 0, 0)
932 .to_zoned(TimeZone::UTC)
933 .unwrap(),
934 );
935 let dt_end = LooseDateTime::Local(
936 date(2025, 6, 1)
937 .at(11, 0, 0, 0)
938 .to_zoned(TimeZone::UTC)
939 .unwrap(),
940 );
941
942 let mut vevent = VEvent {
943 uid: Uid::new("test-uid".to_string()),
944 dt_stamp: vevent_dt_stamp(),
945 dt_start: DtStart::new(dt_start),
946 dt_end: Some(DtEnd::new(dt_end)),
947 duration: None,
948 summary: None,
949 description: None,
950 location: None,
951 geo: None,
952 url: None,
953 organizer: None,
954 attendees: Vec::new(),
955 last_modified: None,
956 status: None,
957 transparency: None,
958 sequence: None,
959 priority: None,
960 classification: None,
961 resources: None,
962 categories: None,
963 rdates: Vec::new(),
964 rrule: None,
965 ex_dates: Vec::new(),
966 x_properties: Vec::new(),
967 retained_properties: Vec::new(),
968 alarms: Vec::new(),
969 };
970
971 let patch = EventPatch {
972 end: Some(None), ..Default::default()
974 };
975 let resolved = patch.resolve(now.clone());
976
977 resolved.apply_to(&mut vevent);
978
979 assert!(vevent.dt_end.is_none(), "End should be cleared");
980 }
981
982 #[test]
983 fn event_patch_apply_to_sets_status() {
984 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
985
986 let mut vevent = create_test_vevent("test-uid", "Test");
987
988 for status in [EventStatus::Tentative, EventStatus::Cancelled] {
989 let patch = EventPatch {
990 status: Some(status),
991 ..Default::default()
992 };
993 let resolved = patch.resolve(now.clone());
994
995 resolved.apply_to(&mut vevent);
996
997 assert_eq!(
998 vevent.status.as_ref().unwrap().value,
999 EventStatusValue::from(status)
1000 );
1001 }
1002 }
1003
1004 #[test]
1005 fn event_patch_resolve_with_now_sets_dt_stamp_if_unset() {
1006 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
1007
1008 let patch = EventPatch {
1009 summary: Some("Test".to_string()),
1010 ..Default::default()
1011 };
1012
1013 let resolved = patch.resolve(now.clone());
1014
1015 assert_eq!(resolved.now, now);
1017 }
1018
1019 #[test]
1020 fn event_patch_apply_to_preserves_recent_dt_stamp() {
1021 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
1022
1023 let mut vevent = create_test_vevent("test-uid", "Test");
1025
1026 let patch = EventPatch {
1027 summary: Some("Updated".to_string()),
1028 ..Default::default()
1029 };
1030 let resolved = patch.resolve(now.clone());
1031
1032 resolved.apply_to(&mut vevent);
1033
1034 assert_eq!(vevent.dt_stamp.date.year, 2024);
1036 }
1037
1038 #[test]
1039 fn event_patch_apply_to_preserves_dt_stamp_when_set() {
1040 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
1041
1042 let dt_start_val = LooseDateTime::Local(
1044 date(2025, 1, 1)
1045 .at(10, 0, 0, 0)
1046 .to_zoned(TimeZone::UTC)
1047 .unwrap(),
1048 );
1049 let dt_end_val = LooseDateTime::Local(
1050 date(2025, 1, 1)
1051 .at(11, 0, 0, 0)
1052 .to_zoned(TimeZone::UTC)
1053 .unwrap(),
1054 );
1055
1056 let mut vevent = VEvent {
1057 uid: Uid::new("test-uid".to_string()),
1058 dt_stamp: vevent_dt_stamp(),
1059 dt_start: DtStart::new(dt_start_val),
1060 dt_end: Some(DtEnd::new(dt_end_val)),
1061 duration: None,
1062 summary: Some(Summary::new("Test".to_string())),
1063 description: None,
1064 location: None,
1065 geo: None,
1066 url: None,
1067 organizer: None,
1068 attendees: Vec::new(),
1069 last_modified: None,
1070 status: None,
1071 transparency: None,
1072 sequence: None,
1073 priority: None,
1074 classification: None,
1075 resources: None,
1076 categories: None,
1077 rdates: Vec::new(),
1078 rrule: None,
1079 ex_dates: Vec::new(),
1080 x_properties: Vec::new(),
1081 retained_properties: Vec::new(),
1082 alarms: Vec::new(),
1083 };
1084
1085 let original_year = vevent.dt_stamp.date.year;
1086 let original_month = vevent.dt_stamp.date.month;
1087 let original_day = vevent.dt_stamp.date.day;
1088
1089 let patch = EventPatch {
1090 summary: Some("Updated".to_string()),
1091 ..Default::default()
1092 };
1093 let resolved = patch.resolve(now.clone());
1094
1095 resolved.apply_to(&mut vevent);
1096
1097 assert_eq!(vevent.dt_stamp.date.year, original_year);
1099 assert_eq!(vevent.dt_stamp.date.month, original_month);
1100 assert_eq!(vevent.dt_stamp.date.day, original_day);
1101 }
1102
1103 #[test]
1104 fn event_patch_partial_update_only_changes_specified_fields() {
1105 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
1106
1107 let mut vevent =
1108 create_test_vevent_with_description("test-uid", "Test Summary", "Original Description");
1109
1110 let original_description = vevent.description.as_ref().unwrap().content.to_string();
1111 let _original_summary = vevent.summary.as_ref().unwrap().content.to_string();
1112
1113 let patch = EventPatch {
1114 summary: Some("Updated Summary".to_string()),
1115 ..Default::default()
1117 };
1118 let resolved = patch.resolve(now.clone());
1119
1120 resolved.apply_to(&mut vevent);
1121
1122 assert_eq!(
1123 vevent.summary.as_ref().unwrap().content.to_string(),
1124 "Updated Summary"
1125 );
1126 assert_eq!(
1127 vevent.description.as_ref().unwrap().content.to_string(),
1128 original_description
1129 );
1130 }
1131
1132 #[test]
1133 fn event_patch_with_all_fields_updates_completely() {
1134 let now = Zoned::new(jiff::Timestamp::now(), TimeZone::UTC);
1135
1136 let mut vevent = create_test_vevent("test-uid", "Original Summary");
1137
1138 let new_start = LooseDateTime::Local(
1139 date(2025, 6, 1)
1140 .at(14, 0, 0, 0)
1141 .to_zoned(TimeZone::UTC)
1142 .unwrap(),
1143 );
1144 let new_end = LooseDateTime::Local(
1145 date(2025, 6, 1)
1146 .at(15, 0, 0, 0)
1147 .to_zoned(TimeZone::UTC)
1148 .unwrap(),
1149 );
1150
1151 let patch = EventPatch {
1152 description: Some(Some("New Description".to_string())),
1153 start: Some(Some(new_start)),
1154 end: Some(Some(new_end)),
1155 status: Some(EventStatus::Cancelled),
1156 summary: Some("New Summary".to_string()),
1157 };
1158
1159 let resolved = patch.resolve(now.clone());
1160 resolved.apply_to(&mut vevent);
1161
1162 assert_eq!(
1163 vevent.summary.as_ref().unwrap().content.to_string(),
1164 "New Summary"
1165 );
1166 assert_eq!(
1167 vevent.description.as_ref().unwrap().content.to_string(),
1168 "New Description"
1169 );
1170 assert_eq!(
1171 vevent.status.as_ref().unwrap().value,
1172 EventStatusValue::Cancelled
1173 );
1174
1175 assert!(
1177 matches!(vevent.dt_start.0.value, ical::DateTime::Utc { date, .. } if
1178 date.year == 2025 &&
1179 date.month == 6 &&
1180 date.day == 1
1181 )
1182 );
1183 if let ical::DateTime::Utc { time, .. } = vevent.dt_start.0.value {
1185 assert_eq!(time.hour, 14);
1186 }
1187 assert!(vevent.dt_end.is_some());
1188 assert!(
1189 matches!(vevent.dt_end.as_ref().unwrap().0.value, ical::DateTime::Utc { date, .. } if
1190 date.year == 2025 &&
1191 date.month == 6 &&
1192 date.day == 1
1193 )
1194 );
1195 if let ical::DateTime::Utc { time, .. } = vevent.dt_end.as_ref().unwrap().0.value {
1196 assert_eq!(time.hour, 15);
1197 }
1198 }
1199}