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(¬_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(¬_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}