Skip to main content

jmap_calendars_types/
backend.rs

1//! Property selector enums and [`jmap_types::JmapObject`] impls for JMAP Calendars types.
2//!
3//! These are defined here so that `jmap-calendars-server` can use them without
4//! violating the orphan rule (`JmapObject` is foreign but the calendars types
5//! are local to this crate).
6
7use jmap_types::{GetObject, JmapObject, PatchObject, QueryObject, SetObject};
8
9// ---------------------------------------------------------------------------
10// Property selector enums (server-side; no serde required)
11// ---------------------------------------------------------------------------
12
13/// Property selector for [`crate::Calendar`] `/get` and `/set`.
14#[non_exhaustive]
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub enum CalendarProperty {
17    /// The `id` property (draft-ietf-jmap-calendars-33 §4).
18    Id,
19    /// The `name` property (draft-ietf-jmap-calendars-33 §4).
20    Name,
21    /// The `description` property (draft-ietf-jmap-calendars-33 §4).
22    Description,
23    /// The `color` property (draft-ietf-jmap-calendars-33 §4).
24    Color,
25    /// The `sortOrder` property (draft-ietf-jmap-calendars-33 §4).
26    SortOrder,
27    /// The `isSubscribed` property (draft-ietf-jmap-calendars-33 §4).
28    IsSubscribed,
29    /// The `isVisible` property (draft-ietf-jmap-calendars-33 §4).
30    IsVisible,
31    /// The `isDefault` property (draft-ietf-jmap-calendars-33 §4).
32    IsDefault,
33    /// The `includeInAvailability` property (draft-ietf-jmap-calendars-33 §4).
34    IncludeInAvailability,
35    /// The `defaultAlertsWithTime` property (draft-ietf-jmap-calendars-33 §4).
36    DefaultAlertsWithTime,
37    /// The `defaultAlertsWithoutTime` property (draft-ietf-jmap-calendars-33 §4).
38    DefaultAlertsWithoutTime,
39    /// The `timeZone` property (draft-ietf-jmap-calendars-33 §4).
40    TimeZone,
41    /// The `shareWith` property (draft-ietf-jmap-calendars-33 §4).
42    ShareWith,
43    /// The `myRights` property (draft-ietf-jmap-calendars-33 §4).
44    MyRights,
45}
46
47/// Property selector for [`crate::CalendarEvent`] `/get` and `/set`.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub enum CalendarEventProperty {
51    /// The `id` property (draft-ietf-jmap-calendars-33 §5).
52    Id,
53    /// The `baseEventId` property (draft-ietf-jmap-calendars-33 §5).
54    BaseEventId,
55    /// The `calendarIds` property (draft-ietf-jmap-calendars-33 §5).
56    CalendarIds,
57    /// The `isDraft` property (draft-ietf-jmap-calendars-33 §5).
58    IsDraft,
59    /// The `isOrigin` property (draft-ietf-jmap-calendars-33 §5).
60    IsOrigin,
61    /// The `utcStart` property (draft-ietf-jmap-calendars-33 §5).
62    UtcStart,
63    /// The `utcEnd` property (draft-ietf-jmap-calendars-33 §5).
64    UtcEnd,
65    /// The `useDefaultAlerts` property (draft-ietf-jmap-calendars-33 §5).
66    UseDefaultAlerts,
67    /// The `mayInviteSelf` property (draft-ietf-jmap-calendars-33 §5.1.1).
68    MayInviteSelf,
69    /// The `mayInviteOthers` property (draft-ietf-jmap-calendars-33 §5.1.2).
70    MayInviteOthers,
71    /// The `hideAttendees` property (draft-ietf-jmap-calendars-33 §5.1.3).
72    HideAttendees,
73    /// The `blobId` property (draft-ietf-jmap-calendars-33 §5.3).
74    BlobId,
75    /// The `uid` property, inherited from the JSCalendar Event object (RFC 8984 §4.1.2).
76    Uid,
77    /// The `title` property, inherited from the JSCalendar Event object (RFC 8984 §4.2.1).
78    Title,
79    /// The `description` property, inherited from the JSCalendar Event object (RFC 8984 §4.2.2).
80    Description,
81    /// The `start` property, inherited from the JSCalendar Event object (RFC 8984 §5.1.1).
82    Start,
83    /// The `duration` property, inherited from the JSCalendar Event object (RFC 8984 §5.1.2).
84    Duration,
85    /// The `status` property, inherited from the JSCalendar Event object (RFC 8984 §5.1.3).
86    Status,
87}
88
89/// Names of [`CalendarEvent`](crate::CalendarEvent) properties that the JMAP
90/// Calendars draft (draft-ietf-jmap-calendars-33 §5.4) classifies as
91/// **per-user**.
92///
93/// Per-user properties belong to the authenticated user's view of the event;
94/// patching them MUST NOT change the shared `updated` timestamp on the
95/// underlying object. Backends serving multiple users SHOULD store these
96/// separately from the shared event body.
97///
98/// This list mirrors the properties with "Is Per-User" set to "Yes" in
99/// the IANA "JSCalendar Properties" registry (draft-ietf-calext-jscalendarbis-22
100/// §7.4.2), which draft-ietf-jmap-calendars-33 §5.4 now refers to.
101///
102/// **Maintainer note (internal layout).** Internally this list is split
103/// into two private halves — `PER_USER_PROPERTIES_IN_ENUM` and
104/// `PER_USER_PROPERTIES_NOT_YET_IN_ENUM` — because
105/// [`CalendarEventProperty`] is deliberately a subset of the spec
106/// property set: `keywords`, `color`, `freeBusyStatus`, and `alerts`
107/// are reserved as future additions but not yet enumerated. When you
108/// add one of those as a `CalendarEventProperty` variant, move its
109/// wire-name from `PER_USER_PROPERTIES_NOT_YET_IN_ENUM` to
110/// `PER_USER_PROPERTIES_IN_ENUM` in the same commit, and update the
111/// `classify` match in the drift-guard test
112/// `per_user_in_enum_matches_enum_variants`. The
113/// `per_user_const_is_union_of_two_halves` drift-guard test enforces
114/// that this public const equals the disjoint union of the two halves.
115pub const PER_USER_CALENDAR_EVENT_PROPERTIES: &[&str] = &[
116    "keywords",
117    "color",
118    "freeBusyStatus",
119    "useDefaultAlerts",
120    "alerts",
121];
122
123/// Wire-names of per-user [`CalendarEventProperty`] variants that already
124/// exist in the enum.
125///
126/// When you add a per-user property as a new `CalendarEventProperty`
127/// variant, move its wire-name string here from
128/// [`PER_USER_PROPERTIES_NOT_YET_IN_ENUM`].
129///
130/// Invariant (enforced by the `per_user_const_is_union_of_two_halves`
131/// drift-guard test):
132///
133/// ```text
134/// PER_USER_CALENDAR_EVENT_PROPERTIES
135///     = PER_USER_PROPERTIES_IN_ENUM ∪ PER_USER_PROPERTIES_NOT_YET_IN_ENUM
136/// ```
137///
138/// and the two halves are disjoint.
139///
140/// `#[allow(dead_code)]`: this const exists as the documented other half
141/// of the split layout; it is read by drift-guard tests but not by
142/// runtime code (the public [`PER_USER_CALENDAR_EVENT_PROPERTIES`] is
143/// the runtime source of truth).
144#[allow(dead_code)]
145const PER_USER_PROPERTIES_IN_ENUM: &[&str] = &["useDefaultAlerts"];
146
147/// Per-user property wire-names from draft-ietf-jmap-calendars-33 §5.4
148/// that do **not** yet have a corresponding [`CalendarEventProperty`]
149/// variant.
150///
151/// When you add one of these as a `CalendarEventProperty` variant, move
152/// its wire-name string out of this list and into
153/// [`PER_USER_PROPERTIES_IN_ENUM`], and add a `Variant => "wireName"`
154/// arm to the `classify` match in the drift-guard test
155/// `per_user_in_enum_matches_enum_variants`.
156///
157/// `#[allow(dead_code)]`: see [`PER_USER_PROPERTIES_IN_ENUM`].
158#[allow(dead_code)]
159const PER_USER_PROPERTIES_NOT_YET_IN_ENUM: &[&str] =
160    &["keywords", "color", "freeBusyStatus", "alerts"];
161
162/// Returns `true` if `name` is a per-user [`CalendarEvent`](crate::CalendarEvent)
163/// property name per draft-ietf-jmap-calendars-33 §5.4.
164///
165/// See [`PER_USER_CALENDAR_EVENT_PROPERTIES`] for the full set. This is a
166/// wire-protocol property classification: the spec list is fixed by IANA
167/// registration and backends MUST NOT redefine it.
168#[must_use]
169pub fn is_per_user_calendar_event_property(name: &str) -> bool {
170    PER_USER_CALENDAR_EVENT_PROPERTIES.contains(&name)
171}
172
173/// Property selector for [`crate::CalendarEventNotification`] `/get`.
174#[non_exhaustive]
175#[derive(Debug, Clone, PartialEq, Eq, Hash)]
176pub enum CalendarEventNotificationProperty {
177    /// The `id` property (draft-ietf-jmap-calendars-33 §7).
178    Id,
179    /// The `created` property (draft-ietf-jmap-calendars-33 §7).
180    Created,
181    /// The `changedBy` property (draft-ietf-jmap-calendars-33 §7).
182    ChangedBy,
183    /// The `comment` property (draft-ietf-jmap-calendars-33 §7).
184    Comment,
185    /// The `type` property (draft-ietf-jmap-calendars-33 §7).
186    Type,
187    /// The `calendarEventId` property (draft-ietf-jmap-calendars-33 §7).
188    CalendarEventId,
189    /// The `isDraft` property (draft-ietf-jmap-calendars-33 §7).
190    IsDraft,
191    /// The `event` property (draft-ietf-jmap-calendars-33 §7).
192    Event,
193    /// The `eventPatch` property (draft-ietf-jmap-calendars-33 §7).
194    EventPatch,
195}
196
197/// Property selector for [`crate::ParticipantIdentity`] `/get` and `/set`.
198#[non_exhaustive]
199#[derive(Debug, Clone, PartialEq, Eq, Hash)]
200pub enum ParticipantIdentityProperty {
201    /// The `id` property (draft-ietf-jmap-calendars-33 §3).
202    Id,
203    /// The `name` property (draft-ietf-jmap-calendars-33 §3).
204    Name,
205    /// The `calendarAddress` property (draft-ietf-jmap-calendars-33 §3).
206    CalendarAddress,
207    /// The `isDefault` property (draft-ietf-jmap-calendars-33 §3).
208    IsDefault,
209}
210
211// ---------------------------------------------------------------------------
212// JmapObject impls
213// ---------------------------------------------------------------------------
214
215impl JmapObject for crate::Calendar {
216    const TYPE_NAME: &'static str = "Calendar";
217    type Property = CalendarProperty;
218}
219
220impl GetObject for crate::Calendar {}
221
222/// draft-ietf-jmap-calendars-33 §4 (lines 747, 816, 907): `id` is
223/// "(immutable; server-set)"; `isDefault` and `myRights` are
224/// "(server-set)".
225///
226/// draft-ietf-jmap-metadata-04 §3.2: clients write `metadata` and
227/// `privateMetadata` through `Foo/set`.
228impl SetObject for crate::Calendar {
229    type Patch = PatchObject;
230    const SERVER_SET: &'static [&'static str] = &["id", "isDefault", "myRights"];
231    const CLIENT_SETTABLE: &'static [&'static str] = &[
232        "name",
233        "description",
234        "color",
235        "sortOrder",
236        "isSubscribed",
237        "isVisible",
238        "includeInAvailability",
239        "defaultAlertsWithTime",
240        "defaultAlertsWithoutTime",
241        "timeZone",
242        "shareWith",
243        "metadata",
244        "privateMetadata",
245    ];
246    const IMMUTABLE: &'static [&'static str] = &[];
247}
248
249impl JmapObject for crate::CalendarEvent {
250    const TYPE_NAME: &'static str = "CalendarEvent";
251    type Property = CalendarEventProperty;
252}
253
254impl GetObject for crate::CalendarEvent {}
255
256/// draft-ietf-jmap-calendars-33 §5 (lines 1133, 1138, 1151): `id` and
257/// `baseEventId` are "(immutable; server-set)", `isOrigin` is
258/// "(server-set)". No other property, JMAP or JSCalendar, carries either
259/// attribute.
260///
261/// draft-ietf-jmap-metadata-04 §3.2: clients write `metadata` and
262/// `privateMetadata` through `Foo/set`.
263impl SetObject for crate::CalendarEvent {
264    type Patch = PatchObject;
265    const SERVER_SET: &'static [&'static str] = &["id", "baseEventId", "isOrigin"];
266    const CLIENT_SETTABLE: &'static [&'static str] = &[
267        "calendarIds",
268        "isDraft",
269        "utcStart",
270        "utcEnd",
271        "useDefaultAlerts",
272        "mayInviteSelf",
273        "mayInviteOthers",
274        "hideAttendees",
275        "blobId",
276        "iCalendar",
277        "@type",
278        "version",
279        "uid",
280        "relatedTo",
281        "prodId",
282        "created",
283        "updated",
284        "sequence",
285        "title",
286        "description",
287        "descriptionContentType",
288        "showWithoutTime",
289        "locations",
290        "virtualLocations",
291        "links",
292        "locale",
293        "keywords",
294        "categories",
295        "color",
296        "recurrenceId",
297        "recurrenceIdTimeZone",
298        "recurrenceRules",
299        "excludedRecurrenceRules",
300        "recurrenceOverrides",
301        "excluded",
302        "priority",
303        "freeBusyStatus",
304        "privacy",
305        "replyTo",
306        "sentBy",
307        "organizerCalendarAddress",
308        "participants",
309        "requestStatus",
310        "alerts",
311        "localizations",
312        "timeZone",
313        "timeZones",
314        "start",
315        "duration",
316        "status",
317        "metadata",
318        "privateMetadata",
319    ];
320    const IMMUTABLE: &'static [&'static str] = &[];
321}
322
323impl QueryObject for crate::CalendarEvent {
324    type Filter = crate::CalendarEventFilter;
325    type Comparator = crate::CalendarEventComparator;
326}
327
328impl JmapObject for crate::CalendarEventNotification {
329    const TYPE_NAME: &'static str = "CalendarEventNotification";
330    type Property = CalendarEventNotificationProperty;
331}
332
333impl GetObject for crate::CalendarEventNotification {}
334
335/// `SetObject` for `CalendarEventNotification` is destroy-only.
336/// The `Patch` type is never used in practice; [`PatchObject`] is a
337/// safe placeholder that satisfies the trait bound while keeping the
338/// type-system contract aligned with sibling types (RFC 8620 §5.3).
339///
340/// draft-ietf-jmap-calendars-33 §7.3: "Only destroy is supported; any
341/// attempt to create/update MUST be rejected", so every property is
342/// server-set; `id` (line 2601) is also "(immutable; server-set)".
343impl SetObject for crate::CalendarEventNotification {
344    type Patch = PatchObject;
345    const SERVER_SET: &'static [&'static str] = &[
346        "id",
347        "created",
348        "changedBy",
349        "comment",
350        "type",
351        "calendarEventId",
352        "isDraft",
353        "event",
354        "eventPatch",
355    ];
356    const CLIENT_SETTABLE: &'static [&'static str] = &[];
357    const IMMUTABLE: &'static [&'static str] = &[];
358}
359
360impl QueryObject for crate::CalendarEventNotification {
361    type Filter = crate::NotificationFilter;
362    type Comparator = crate::CalendarEventNotificationComparator;
363}
364
365impl JmapObject for crate::ParticipantIdentity {
366    const TYPE_NAME: &'static str = "ParticipantIdentity";
367    type Property = ParticipantIdentityProperty;
368}
369
370impl GetObject for crate::ParticipantIdentity {}
371
372/// draft-ietf-jmap-calendars-33 §3 (lines 627, 638): `id` is "(immutable;
373/// server-set)", `isDefault` is "(server-set)".
374impl SetObject for crate::ParticipantIdentity {
375    type Patch = PatchObject;
376    const SERVER_SET: &'static [&'static str] = &["id", "isDefault"];
377    const CLIENT_SETTABLE: &'static [&'static str] = &["name", "calendarAddress"];
378    const IMMUTABLE: &'static [&'static str] = &[];
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use jmap_types::{Id, UTCDate, check_property_classification};
385    use serde_json::{Map, Value, json};
386    use std::collections::BTreeMap;
387
388    fn de<T: serde::de::DeserializeOwned>(v: Value) -> T {
389        serde_json::from_value(v).expect("hand-written fixture deserializes")
390    }
391
392    fn rights() -> crate::CalendarRights {
393        de(json!({
394            "mayReadFreeBusy": true,
395            "mayReadItems": true,
396            "mayWriteAll": true,
397            "mayWriteOwn": true,
398            "mayUpdatePrivate": true,
399            "mayRSVP": true,
400            "mayShare": true,
401            "mayDelete": true,
402        }))
403    }
404
405    fn patch() -> PatchObject {
406        de(json!({"title": "t"}))
407    }
408
409    // Each classification test builds a fully populated object with a
410    // struct literal, so a new field is a compile error until it is set,
411    // then a classification failure until the `SetObject` lists name it.
412
413    #[test]
414    fn calendar_properties_are_classified() {
415        let obj = crate::Calendar {
416            id: Some(Id::from("c1")),
417            name: "n".into(),
418            description: Some("d".into()),
419            color: Some("red".into()),
420            sort_order: 1,
421            is_subscribed: true,
422            is_visible: true,
423            is_default: true,
424            include_in_availability: de(json!("all")),
425            default_alerts_with_time: Some(BTreeMap::new()),
426            default_alerts_without_time: Some(BTreeMap::new()),
427            time_zone: Some("Etc/UTC".into()),
428            share_with: Some(BTreeMap::from([(Id::from("p1"), rights())])),
429            my_rights: rights(),
430            metadata: Some(de(json!({"acme.example.com": {"memo": "m"}}))),
431            private_metadata: Some(de(json!({"acme.example.com": {"memo": "p"}}))),
432            extra: Map::new(),
433        };
434        assert_eq!(check_property_classification(&obj), Ok(()));
435    }
436
437    #[test]
438    fn calendar_event_properties_are_classified() {
439        let v = || Some(json!({}));
440        let obj = crate::CalendarEvent {
441            id: Some(Id::from("e1")),
442            base_event_id: Some(Id::from("e0")),
443            calendar_ids: Some(BTreeMap::from([(Id::from("c1"), true)])),
444            is_draft: Some(true),
445            is_origin: Some(true),
446            utc_start: Some(UTCDate::from("2026-01-01T00:00:00Z")),
447            utc_end: Some(UTCDate::from("2026-01-01T01:00:00Z")),
448            use_default_alerts: Some(true),
449            may_invite_self: Some(true),
450            may_invite_others: Some(true),
451            hide_attendees: Some(true),
452            blob_id: Some(Id::from("b1")),
453            icalendar: Some(json!([])),
454            at_type: Some("Event".into()),
455            version: Some("2.0".into()),
456            uid: Some("u".into()),
457            related_to: v(),
458            prod_id: Some("p".into()),
459            created: Some(UTCDate::from("2026-01-01T00:00:00Z")),
460            updated: Some(UTCDate::from("2026-01-01T00:00:00Z")),
461            sequence: Some(1),
462            title: Some("t".into()),
463            description: Some("d".into()),
464            description_content_type: Some("text/plain".into()),
465            show_without_time: Some(true),
466            locations: v(),
467            virtual_locations: v(),
468            links: v(),
469            locale: Some("en".into()),
470            keywords: Some(BTreeMap::from([("k".into(), true)])),
471            categories: Some(BTreeMap::from([("c".into(), true)])),
472            color: Some("red".into()),
473            recurrence_id: Some("2026-01-01T00:00:00".into()),
474            recurrence_id_time_zone: Some("Etc/UTC".into()),
475            recurrence_rules: Some(json!([])),
476            excluded_recurrence_rules: Some(json!([])),
477            recurrence_overrides: Some(BTreeMap::from([("2026-01-02T00:00:00".into(), patch())])),
478            excluded: Some(false),
479            priority: Some(1),
480            free_busy_status: Some("busy".into()),
481            privacy: Some("public".into()),
482            reply_to: Some(BTreeMap::from([(
483                "imip".into(),
484                "mailto:a@example.com".into(),
485            )])),
486            sent_by: Some("mailto:a@example.com".into()),
487            organizer_calendar_address: Some("mailto:a@example.com".into()),
488            participants: v(),
489            request_status: Some("2.0;Success".into()),
490            alerts: v(),
491            localizations: Some(BTreeMap::from([("de".into(), patch())])),
492            time_zone: Some("Etc/UTC".into()),
493            time_zones: v(),
494            start: Some("2026-01-01T00:00:00".into()),
495            duration: Some("PT1H".into()),
496            status: Some("confirmed".into()),
497            metadata: Some(de(json!({"acme.example.com": {"memo": "m"}}))),
498            private_metadata: Some(de(json!({"acme.example.com": {"memo": "p"}}))),
499            extra: Map::new(),
500        };
501        assert_eq!(check_property_classification(&obj), Ok(()));
502    }
503
504    #[test]
505    fn calendar_event_notification_properties_are_classified() {
506        let obj = crate::CalendarEventNotification {
507            id: Id::from("n1"),
508            created: UTCDate::from("2026-01-01T00:00:00Z"),
509            changed_by: de(json!({"name": "A", "email": "a@example.com"})),
510            comment: Some("c".into()),
511            notification_type: de(json!("updated")),
512            calendar_event_id: Id::from("e1"),
513            is_draft: Some(false),
514            event: json!({}),
515            event_patch: Some(patch()),
516            extra: Map::new(),
517        };
518        assert_eq!(check_property_classification(&obj), Ok(()));
519    }
520
521    #[test]
522    fn participant_identity_properties_are_classified() {
523        let obj = crate::ParticipantIdentity {
524            id: Some(Id::from("p1")),
525            name: "A".into(),
526            calendar_address: "mailto:a@example.com".into(),
527            is_default: true,
528            extra: Map::new(),
529        };
530        assert_eq!(check_property_classification(&obj), Ok(()));
531    }
532
533    /// Pinning test: the per-user property list MUST match the IANA-registered
534    /// set in draft-ietf-jmap-calendars-26 §10.8.2 exactly.
535    ///
536    /// draft-ietf-jmap-calendars-33 dropped §10.8 and refers to the "Is
537    /// Per-User" column of draft-ietf-calext-jscalendarbis-22 §7.4, which
538    /// marks the same five properties "Yes"; the -26 list stays the oracle
539    /// because it fixes an order.
540    ///
541    /// # Independent oracle (verbatim spec quote)
542    ///
543    /// The expected list is the verbatim bullet list from
544    /// draft-ietf-jmap-calendars-26 §10.8.2 "Initial values for existing
545    /// registrations":
546    ///
547    /// > IANA will set "Is per-user: yes" on the following property
548    /// > registrations:
549    /// >
550    /// > *  keywords
551    /// > *  color
552    /// > *  freeBusyStatus
553    /// > *  useDefaultAlerts
554    /// > *  alerts
555    /// >
556    /// > All other existing registrations will have "Is per-user: no".
557    ///
558    /// The test extracts the bullet item names from a multi-line string
559    /// shaped to mirror the spec passage, then asserts equality with the
560    /// production const. Updating this test requires the contributor to
561    /// edit the spec-shaped string — which a code-review reviewer can
562    /// diff against the spec passage above. A contributor who edits the
563    /// const without also editing the spec quote will see this test
564    /// fail; a contributor who edits both must justify the spec citation
565    /// in the same diff.
566    #[test]
567    fn per_user_calendar_event_properties_match_spec() {
568        // Spec-shaped fixture: mirror of draft-ietf-jmap-calendars-26
569        // §10.8.2 bullet list. Each "*  <name>" line names one IANA-
570        // registered per-user property. Modify ONLY by reconciling
571        // against the verbatim spec text in this test's doc comment.
572        const SPEC_REGISTERED_PER_USER_PROPERTIES_VERBATIM: &str = "\
573*  keywords
574*  color
575*  freeBusyStatus
576*  useDefaultAlerts
577*  alerts
578";
579
580        // Parse the bullet list independently of the production const.
581        // This is the independent-oracle step: the parser does not look
582        // at PER_USER_CALENDAR_EVENT_PROPERTIES.
583        let spec_list: Vec<&str> = SPEC_REGISTERED_PER_USER_PROPERTIES_VERBATIM
584            .lines()
585            .filter_map(|line| line.strip_prefix("*  ").map(str::trim))
586            .filter(|name| !name.is_empty())
587            .collect();
588
589        // Sanity-check the parser before relying on it as the oracle.
590        assert_eq!(
591            spec_list.len(),
592            5,
593            "spec-quote parser sanity: §10.8.2 bullet list has exactly 5 entries; \
594             parser found {}: {spec_list:?}",
595            spec_list.len()
596        );
597
598        // The real assertion: production const equals the spec quote.
599        assert_eq!(
600            PER_USER_CALENDAR_EVENT_PROPERTIES,
601            spec_list.as_slice(),
602            "PER_USER_CALENDAR_EVENT_PROPERTIES must match \
603             draft-ietf-jmap-calendars-26 §10.8.2 verbatim (including order)"
604        );
605    }
606
607    #[test]
608    fn is_per_user_classifies_spec_properties_as_true() {
609        for name in PER_USER_CALENDAR_EVENT_PROPERTIES {
610            assert!(
611                is_per_user_calendar_event_property(name),
612                "expected {name} to be classified per-user"
613            );
614        }
615    }
616
617    #[test]
618    fn is_per_user_classifies_shared_properties_as_false() {
619        // Spot-check a few shared (non-per-user) properties from the draft.
620        for shared in &["id", "title", "start", "duration", "calendarIds", "uid"] {
621            assert!(
622                !is_per_user_calendar_event_property(shared),
623                "expected {shared} to be classified shared"
624            );
625        }
626    }
627
628    #[test]
629    fn is_per_user_rejects_unknown_property() {
630        assert!(!is_per_user_calendar_event_property(""));
631        assert!(!is_per_user_calendar_event_property("notARealProperty"));
632        // Property-path forms like "alerts/abc" are NOT classified per-user;
633        // the routing logic must look at the top-level patch key after
634        // expanding any nested path.
635        assert!(!is_per_user_calendar_event_property("alerts/abc"));
636    }
637
638    /// Drift guard: the public [`PER_USER_CALENDAR_EVENT_PROPERTIES`]
639    /// const MUST equal the disjoint union of the two internal halves
640    /// [`PER_USER_PROPERTIES_IN_ENUM`] and
641    /// [`PER_USER_PROPERTIES_NOT_YET_IN_ENUM`].
642    ///
643    /// If this test fails, you have either:
644    /// - added a name to one half without removing it from the other (the
645    ///   disjointness assertion fires), or
646    /// - added a name to the public const without placing it in either
647    ///   half (the union assertion fires).
648    #[test]
649    fn per_user_const_is_union_of_two_halves() {
650        use std::collections::BTreeSet;
651
652        let in_enum: BTreeSet<&str> = PER_USER_PROPERTIES_IN_ENUM.iter().copied().collect();
653        let not_yet: BTreeSet<&str> = PER_USER_PROPERTIES_NOT_YET_IN_ENUM
654            .iter()
655            .copied()
656            .collect();
657        let public: BTreeSet<&str> = PER_USER_CALENDAR_EVENT_PROPERTIES.iter().copied().collect();
658
659        // Disjoint: a name MUST NOT appear in both halves.
660        let overlap: BTreeSet<&&str> = in_enum.intersection(&not_yet).collect();
661        assert!(
662            overlap.is_empty(),
663            "PER_USER_PROPERTIES_IN_ENUM and PER_USER_PROPERTIES_NOT_YET_IN_ENUM \
664             must be disjoint, but both contain: {overlap:?}. When you promote a \
665             property to the enum, move its string from NOT_YET_IN_ENUM to IN_ENUM, \
666             do not duplicate it."
667        );
668
669        // Union equals the public const.
670        let union: BTreeSet<&str> = in_enum.union(&not_yet).copied().collect();
671        assert_eq!(
672            union, public,
673            "PER_USER_CALENDAR_EVENT_PROPERTIES must equal the union of the two halves"
674        );
675    }
676
677    /// Drift guard: [`PER_USER_PROPERTIES_IN_ENUM`] MUST list exactly the
678    /// wire-names of [`CalendarEventProperty`] variants that the draft
679    /// classifies as per-user.
680    ///
681    /// The `match` below is exhaustive over the (intra-crate-visible)
682    /// `CalendarEventProperty` variants. When you add a new variant the
683    /// compiler will force you to add a match arm; classify it as
684    /// per-user (`true`) or shared (`false`) per the spec, then update
685    /// [`PER_USER_PROPERTIES_IN_ENUM`] (and remove from
686    /// [`PER_USER_PROPERTIES_NOT_YET_IN_ENUM`] if it was reserved there)
687    /// to match the new ground truth.
688    #[test]
689    fn per_user_in_enum_matches_enum_variants() {
690        use std::collections::BTreeSet;
691
692        // Wire-name + per-user classification for every CalendarEventProperty
693        // variant. Adding a variant without updating this match is a compile
694        // error in this same crate (#[non_exhaustive] only applies cross-crate).
695        fn classify(p: &CalendarEventProperty) -> (&'static str, bool) {
696            match p {
697                CalendarEventProperty::Id => ("id", false),
698                CalendarEventProperty::BaseEventId => ("baseEventId", false),
699                CalendarEventProperty::CalendarIds => ("calendarIds", false),
700                CalendarEventProperty::IsDraft => ("isDraft", false),
701                CalendarEventProperty::IsOrigin => ("isOrigin", false),
702                CalendarEventProperty::UtcStart => ("utcStart", false),
703                CalendarEventProperty::UtcEnd => ("utcEnd", false),
704                CalendarEventProperty::UseDefaultAlerts => ("useDefaultAlerts", true),
705                CalendarEventProperty::MayInviteSelf => ("mayInviteSelf", false),
706                CalendarEventProperty::MayInviteOthers => ("mayInviteOthers", false),
707                CalendarEventProperty::HideAttendees => ("hideAttendees", false),
708                CalendarEventProperty::BlobId => ("blobId", false),
709                CalendarEventProperty::Uid => ("uid", false),
710                CalendarEventProperty::Title => ("title", false),
711                CalendarEventProperty::Description => ("description", false),
712                CalendarEventProperty::Start => ("start", false),
713                CalendarEventProperty::Duration => ("duration", false),
714                CalendarEventProperty::Status => ("status", false),
715            }
716        }
717
718        // Every known variant in turn.
719        let variants = [
720            CalendarEventProperty::Id,
721            CalendarEventProperty::BaseEventId,
722            CalendarEventProperty::CalendarIds,
723            CalendarEventProperty::IsDraft,
724            CalendarEventProperty::IsOrigin,
725            CalendarEventProperty::UtcStart,
726            CalendarEventProperty::UtcEnd,
727            CalendarEventProperty::UseDefaultAlerts,
728            CalendarEventProperty::MayInviteSelf,
729            CalendarEventProperty::MayInviteOthers,
730            CalendarEventProperty::HideAttendees,
731            CalendarEventProperty::BlobId,
732            CalendarEventProperty::Uid,
733            CalendarEventProperty::Title,
734            CalendarEventProperty::Description,
735            CalendarEventProperty::Start,
736            CalendarEventProperty::Duration,
737            CalendarEventProperty::Status,
738        ];
739
740        let derived_per_user: BTreeSet<&str> = variants
741            .iter()
742            .filter_map(|p| {
743                let (wire, is_per_user) = classify(p);
744                is_per_user.then_some(wire)
745            })
746            .collect();
747        let declared_per_user: BTreeSet<&str> =
748            PER_USER_PROPERTIES_IN_ENUM.iter().copied().collect();
749
750        assert_eq!(
751            derived_per_user, declared_per_user,
752            "PER_USER_PROPERTIES_IN_ENUM ({declared_per_user:?}) must match the per-user \
753             variants derived from the CalendarEventProperty match in this test \
754             ({derived_per_user:?}). Update the const, the match, or both as the spec dictates."
755        );
756
757        // Sanity: every wire-name produced by classify() must be unique
758        // (a typo that maps two variants to the same wire-name would
759        // silently corrupt routing).
760        let mut wire_names = BTreeSet::new();
761        for p in &variants {
762            let (wire, _) = classify(p);
763            assert!(
764                wire_names.insert(wire),
765                "duplicate wire-name {wire:?} in classify(); a CalendarEventProperty \
766                 variant has the wrong wire-string"
767            );
768        }
769    }
770
771    /// Oracle: RFC 8620 §5.5 (line 2368) types the /query `filter` as
772    /// "FilterOperator|FilterCondition|null". draft-ietf-jmap-calendars-33
773    /// §5.11 names the CalendarEvent `inCalendar` condition and §7.4 the
774    /// CalendarEventNotification `calendarEventIds` condition. A
775    /// hand-written NOT tree decodes as each filter.
776    #[test]
777    fn query_filters_are_operator_trees() {
778        /// Decodes `json` as `O`'s filter, which must be the full tree.
779        fn decodes_as_operator<O, C>(json: serde_json::Value) -> bool
780        where
781            O: QueryObject<Filter = jmap_types::Filter<C>>,
782            C: serde::de::DeserializeOwned,
783        {
784            let f: O::Filter = serde_json::from_value(json).expect("operator tree must decode");
785            matches!(f, jmap_types::Filter::Operator(_))
786        }
787        let not = |cond: serde_json::Value| serde_json::json!({ "operator": "NOT", "conditions": [cond] });
788        assert!(decodes_as_operator::<crate::CalendarEvent, _>(not(
789            serde_json::json!({ "inCalendar": "c1" })
790        )));
791        assert!(decodes_as_operator::<crate::CalendarEventNotification, _>(
792            not(serde_json::json!({ "calendarEventIds": ["e1"] }))
793        ));
794    }
795}