Skip to main content

aimcal_core/
event.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use 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
13/// Trait representing a calendar event.
14pub trait Event {
15    /// The short identifier for the event.
16    /// It will be `None` if the event does not have a short ID.
17    /// It is used for display purposes and may not be unique.
18    fn short_id(&self) -> Option<NonZeroU32> {
19        None
20    }
21
22    /// The unique identifier for the event.
23    fn uid(&self) -> Cow<'_, str>;
24
25    /// The description of the event, if available.
26    fn description(&self) -> Option<Cow<'_, str>>;
27
28    /// The location of the event, if available.
29    fn start(&self) -> Option<LooseDateTime>;
30
31    /// The start date and time of the event, if available.
32    fn end(&self) -> Option<LooseDateTime>;
33
34    /// The status of the event, if available.
35    fn status(&self) -> Option<EventStatus>;
36
37    /// The summary of the event.
38    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() // PERF: avoid allocation
44    }
45
46    fn description(&self) -> Option<Cow<'_, str>> {
47        self.description
48            .as_ref()
49            .map(|a| a.content.to_string().into()) // PERF: avoid allocation
50    }
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()) // PERF: avoid allocation
68    }
69}
70
71/// Darft for an event, used for creating new events.
72#[derive(Debug, Clone)]
73pub struct EventDraft {
74    /// The calendar ID to create the event in. Uses default calendar if None.
75    pub calendar_id: Option<String>,
76    /// The description of the event, if available.
77    pub description: Option<String>,
78    /// The start date and time of the event, if available.
79    pub start: Option<LooseDateTime>,
80    /// The end date and time of the event, if available.
81    pub end: Option<LooseDateTime>,
82    /// The status of the event.
83    pub status: EventStatus,
84    /// The summary of the event.
85    pub summary: String,
86}
87
88impl EventDraft {
89    /// Creates a new empty patch.
90    pub(crate) fn default(now: &Zoned) -> Self {
91        // next 00 or 30 minute
92        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                // If start is not specified, but end is, set start to end - duration
125                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                // If end is not specified, but start is, set it to start + duration
139                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    /// Converts the draft into an aimcal-ical `VEvent` component.
181    pub(crate) fn into_ics(self, uid: &str) -> VEvent<String> {
182        // Convert to UTC for DTSTAMP (required by RFC 5545)
183        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/// Patch for an event, allowing partial updates.
217#[derive(Debug, Default, Clone)]
218pub struct EventPatch {
219    /// The description of the event, if available.
220    pub description: Option<Option<String>>,
221    /// The start date and time of the event, if available.
222    pub start: Option<Option<LooseDateTime>>,
223    /// The end date and time of the event, if available.
224    pub end: Option<Option<LooseDateTime>>,
225    /// The status of the event, if available.
226    pub status: Option<EventStatus>,
227    /// The summary of the event, if available.
228    pub summary: Option<String>,
229}
230
231impl EventPatch {
232    /// Is this patch empty, meaning no fields are set
233    #[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/// Patch for an event, allowing partial updates.
268#[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    /// Applies the patch to a mutable event, modifying it in place.
282    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        // Set the creation time to now if it is not already set
308        if e.dt_stamp.date.year == 1970 {
309            // TODO: better check for unset
310            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/// The status of an event, which can be tentative, confirmed, or cancelled.
319#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
320#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
321pub enum EventStatus {
322    /// The event is tentative.
323    Tentative,
324    /// The event is confirmed.
325    #[default]
326    Confirmed,
327    /// The event is cancelled.
328    Cancelled,
329}
330
331// TODO: should be removed
332const 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/// Conditions for filtering events in a calendar.
386#[derive(Debug, Default, Clone)]
387pub struct EventConditions {
388    /// Whether to include only startable events.
389    pub startable: Option<DateTimeAnchor>,
390    /// The cutoff date and time, events ending after this will be excluded.
391    pub cutoff: Option<DateTimeAnchor>,
392    /// The calendar ID to filter events by
393    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    /// The date and time after which the event must start
417    pub start_before: Option<Zoned>,
418    /// The date and time after which the event must end
419    pub end_after: Option<Zoned>,
420    /// The calendar ID to filter events by
421    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    /// Helper function to create a test `EventDraft` with minimal fields
431    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        // Create a DateTimeUtc for June 15, 2024 at 10:30:00 UTC
444        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    // EventDraft tests
510
511    #[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        // Time should be rounded to next 00 or 30 minute
518        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        // Should round to 10:30
540        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        // End should be start + 1 hour
547        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        // Should round to 11:00
564        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        // End should be start + 1 hour
571        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        // End should be start + 1 hour
629        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        // Start should be end - 1 hour
654        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        // Should use now for start and now + 1 hour for end
673        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        // DateOnly should be preserved, end should be start + 1 day (via hour math that gets truncated)
692        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    // EventPatch tests
806
807    #[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), // Some(None) means clear the field
880            ..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        // Check that the start was updated to June 1, 2025 at 14:00 UTC
914        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), // Some(None) means clear the field
973            ..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        // The resolved patch should have now for setting dt_stamp
1016        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        // Create a VEvent with a normal dt_stamp
1024        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        // dt_stamp should still be 2024 (from vevent_dt_stamp)
1035        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        // Create a VEvent with a recent dt_stamp
1043        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        // dt_stamp should not be updated
1098        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            // Don't change description, start, end, status
1116            ..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        // Check start and end were updated
1176        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        // The time should be 14:00:00 for start and 15:00:00 for end
1184        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}