jmap_jscalendar_types/lib.rs
1//! JSCalendar (RFC 8984) typed sub-types for the jmap-* crate family.
2//!
3//! Normative reference: RFC 8984 (JSCalendar).
4//!
5//! These are sub-object types that have no JMAP identity of their own.
6//! They are embedded within `CalendarEvent` (from `jmap-calendars-types`),
7//! `Task` (from `jmap-tasks-types`), and other JMAP objects.
8//!
9//! ## Crate family position
10//!
11//! ```text
12//! jmap-types (RFC 8620 wire primitives)
13//! └── jmap-jscalendar-types ← this crate (RFC 8984 typed sub-types)
14//! ├── jmap-calendars-types (consumes via path-dep + re-export)
15//! └── jmap-tasks-types (consumes via path-dep + re-export)
16//! ```
17//!
18//! ## Design: newtype wrappers for scalar temporal values
19//!
20//! RFC 8984 §1.4.5 defines `LocalDateTime` as a string without a timezone
21//! offset (e.g. `"2024-06-15T09:00:00"`). RFC 8984 §1.4.6 defines `Duration`
22//! as an ISO 8601-subset string (e.g. `"PT1H"`). RFC 8984 §1.4.7 defines
23//! `SignedDuration` as an optional-sign prefix on Duration.
24//!
25//! These are modelled as newtype wrappers around `String` to document intent
26//! at the type level without pulling in a heavy parser dependency. Validation
27//! of internal format is left to the backend.
28//!
29//! ## Spec-driven divergences (deliberate, do not "fix")
30//!
31//! Three design choices in this crate look like inconsistency at a glance but
32//! are deliberate spec-compliance decisions; preserve them against future
33//! "consistency" or "simplification" PRs.
34//!
35//! 1. **Bare `String` `at_type` (not `Option<String>`).** Diverges from the
36//! sibling `jmap-jscontact-types` which uses `Option<String>`. Spec
37//! authority: RFC 8984 marks every `@type` discriminator as
38//! `(mandatory)` with zero `defaultType` annotations; RFC 9553 §1.3.4
39//! introduces `defaultType` and permits omitting `@type` in
40//! implied-type positions. Workspace canonical-templates rule
41//! explicitly permits "differences mandated by the relevant RFC or
42//! draft". See `PLAN.md` and `bd:JMAP-sgrr.3`.
43//!
44//! 2. **`AlertTrigger::Unknown(serde_json::Value)`.** Holds an opaque
45//! `Value` rather than a typed struct or a `String`. Spec authority:
46//! RFC 8984 §4.5.2 "Implementations MUST NOT trigger for trigger types
47//! they do not understand but MUST preserve them." A typed
48//! `Unknown(String)` would discard the inner fields; a typed
49//! `Unknown { at_type, fields }` would force a schema on what is
50//! explicitly unschema'd. The manual `Deserialize` impl is required
51//! because serde does not support `#[serde(tag = "@type", other)]`
52//! with non-unit tuple variants. See [`AlertTrigger`] rustdoc.
53//!
54//! 3. **`serde_json::{Map, Value}` in the public API surface.** The
55//! workspace extras-preservation policy mandates a
56//! `pub extra: serde_json::Map<String, serde_json::Value>` field on
57//! every wire-format struct, and `AlertTrigger::Unknown` carries a
58//! raw `Value`. This locks the crate's major version to
59//! `serde_json`'s, which is the explicit trade-off: round-trip
60//! fidelity for vendor / site / private fields outweighs the
61//! coupling cost. See `PLAN.md` "Extras-preservation policy" and
62//! workspace `AGENTS.md`.
63//!
64//! ## Test-oracle discipline
65//!
66//! Test fixtures are constructed from `serde_json::json!({...})` literals
67//! whose shape comes directly from RFC 8984 example text. Per workspace
68//! test-integrity rules, the oracle MUST be the spec example, NOT the code
69//! under test. Do NOT replace fixture construction with "build a typed
70//! struct, serialize, deserialize, compare" — that pattern uses the code
71//! under test as its own oracle.
72
73#![forbid(unsafe_code)]
74
75use std::collections::HashMap;
76
77use serde::{Deserialize, Serialize};
78
79// Re-export the `jmap-types` symbols that appear in this crate's public
80// API surface so consumers can name them without taking a separate
81// `jmap-types` dependency. `Id` appears on `Link.blob_id`, `UTCDate` on
82// `Participant.schedule_updated` / `progress_updated` and
83// `AbsoluteTrigger.when` / `Alert.acknowledged` / `TimeZone.updated` /
84// `TimeZone.valid_until`, and `PatchObject` on
85// `TimeZoneRule.recurrence_overrides`.
86pub use jmap_types::{Id, PatchObject, UTCDate};
87
88// ── Type-tag discriminator ────────────────────────────────────────────────────
89
90/// Mismatch between an object's `at_type` wire string and the
91/// RFC 8984-mandated discriminator literal for its Rust type.
92///
93/// Returned by [`TypeDiscriminator::validate_at_type`]. RFC 8984 marks
94/// every `@type` discriminator as `(mandatory)` and assigns a specific
95/// string literal per type (e.g. `"NDay"`, `"Participant"`,
96/// `"OffsetTrigger"`). Deserialize itself does NOT enforce the match
97/// so that round-trip preservation of unfamiliar payloads still works;
98/// consumers that need strict input validation call
99/// `validate_at_type()` after deserializing.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct TypeTagMismatch {
102 /// The literal the Rust type expects (e.g. `"NDay"`).
103 pub expected: &'static str,
104 /// The literal carried in the deserialized value's `at_type` field.
105 pub actual: String,
106}
107
108impl std::fmt::Display for TypeTagMismatch {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(
111 f,
112 "JSCalendar @type mismatch: expected {:?}, got {:?}",
113 self.expected, self.actual
114 )
115 }
116}
117
118impl std::error::Error for TypeTagMismatch {}
119
120/// Wire-format type-tag discriminator for JSCalendar sub-objects
121/// (RFC 8984).
122///
123/// Each implementing struct names its mandatory `@type` wire literal in
124/// the [`Self::TYPE_TAG`] associated const. The default
125/// [`Self::validate_at_type`] method compares the carried `at_type`
126/// field against that literal.
127///
128/// Deserialize is deliberately permissive — it does NOT enforce the
129/// match, so an object carrying a vendor-extended or future-spec
130/// `@type` value can still be deserialized for round-trip preservation
131/// per RFC 8984's preserve-mandate. Consumers needing strict input
132/// validation MUST call `validate_at_type()` explicitly after
133/// deserializing.
134pub trait TypeDiscriminator {
135 /// The mandatory `@type` wire literal for this Rust type per
136 /// RFC 8984. Example: `"NDay"` for [`NDay`], `"Participant"` for
137 /// [`Participant`].
138 const TYPE_TAG: &'static str;
139
140 /// The `at_type` field value carried by this instance. Implementors
141 /// just return `&self.at_type`.
142 fn at_type(&self) -> &str;
143
144 /// Validate that the carried `at_type` matches the RFC 8984
145 /// mandatory discriminator literal for this Rust type.
146 ///
147 /// Default implementation compares against [`Self::TYPE_TAG`].
148 /// Returns `Err(TypeTagMismatch)` on mismatch; `Ok(())` otherwise.
149 fn validate_at_type(&self) -> Result<(), TypeTagMismatch> {
150 if self.at_type() == Self::TYPE_TAG {
151 Ok(())
152 } else {
153 Err(TypeTagMismatch {
154 expected: Self::TYPE_TAG,
155 actual: self.at_type().to_owned(),
156 })
157 }
158 }
159}
160
161// ── Scalar wrappers ───────────────────────────────────────────────────────────
162
163/// A date-time string without a timezone offset (RFC 8984 §1.4.5).
164///
165/// Format: `YYYY-MM-DDTHH:MM:SS` (no `Z`, no `±offset`).
166///
167/// # Validation
168///
169/// The `From<String>` and `From<&str>` constructors accept **any** string
170/// without validating against the RFC 8984 §1.4.5 ABNF. This is
171/// deliberate: parsing the format is left to the backend (per `PLAN.md`)
172/// to avoid pulling in a heavy date-time parser dependency. Callers
173/// MUST treat the inner string as opaque-but-presumed-well-formed and
174/// validate at the system boundary.
175#[non_exhaustive]
176#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
177pub struct LocalDateTime(String);
178
179impl From<String> for LocalDateTime {
180 fn from(s: String) -> Self {
181 Self(s)
182 }
183}
184
185impl From<&str> for LocalDateTime {
186 fn from(s: &str) -> Self {
187 Self(s.to_owned())
188 }
189}
190
191impl AsRef<str> for LocalDateTime {
192 fn as_ref(&self) -> &str {
193 &self.0
194 }
195}
196
197impl std::fmt::Display for LocalDateTime {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 f.write_str(&self.0)
200 }
201}
202
203/// An ISO 8601 duration string (RFC 8984 §1.4.6).
204///
205/// Example: `"PT1H"`, `"P1DT2H"`.
206///
207/// # Validation
208///
209/// The `From<String>` and `From<&str>` constructors accept **any** string
210/// without validating against the RFC 8984 §1.4.6 ABNF. This is
211/// deliberate: parsing the format is left to the backend (per `PLAN.md`)
212/// to avoid pulling in a heavy duration parser dependency. Callers MUST
213/// treat the inner string as opaque-but-presumed-well-formed and
214/// validate at the system boundary.
215#[non_exhaustive]
216#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
217pub struct Duration(String);
218
219impl From<String> for Duration {
220 fn from(s: String) -> Self {
221 Self(s)
222 }
223}
224
225impl From<&str> for Duration {
226 fn from(s: &str) -> Self {
227 Self(s.to_owned())
228 }
229}
230
231impl AsRef<str> for Duration {
232 fn as_ref(&self) -> &str {
233 &self.0
234 }
235}
236
237impl std::fmt::Display for Duration {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.write_str(&self.0)
240 }
241}
242
243/// A signed ISO 8601 duration string (RFC 8984 §1.4.7).
244///
245/// Like `Duration` but may be prefixed with `+` or `-`.
246/// Example: `"-PT15M"`, `"+PT30M"`.
247///
248/// # Validation
249///
250/// The `From<String>` and `From<&str>` constructors accept **any** string
251/// without validating against the RFC 8984 §1.4.7 ABNF. This is
252/// deliberate: parsing the format is left to the backend (per `PLAN.md`)
253/// to avoid pulling in a heavy duration parser dependency. Callers MUST
254/// treat the inner string as opaque-but-presumed-well-formed and
255/// validate at the system boundary.
256#[non_exhaustive]
257#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
258pub struct SignedDuration(String);
259
260impl From<String> for SignedDuration {
261 fn from(s: String) -> Self {
262 Self(s)
263 }
264}
265
266impl From<&str> for SignedDuration {
267 fn from(s: &str) -> Self {
268 Self(s.to_owned())
269 }
270}
271
272impl AsRef<str> for SignedDuration {
273 fn as_ref(&self) -> &str {
274 &self.0
275 }
276}
277
278impl std::fmt::Display for SignedDuration {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.write_str(&self.0)
281 }
282}
283
284/// A UTC offset string (RFC 5545 / RFC 8984 §4.7.2 — the TZOFFSETFROM /
285/// TZOFFSETTO format).
286///
287/// Format: `±HHMM` or `±HHMMSS`. Examples: `"+0100"`, `"-0500"`,
288/// `"+053000"`. Used by `TimeZoneRule.offset_from` / `offset_to`.
289///
290/// # Validation
291///
292/// The `From<String>` and `From<&str>` constructors accept **any** string
293/// without validating against the format. This is deliberate: parsing
294/// is left to the backend (per `PLAN.md`). Callers MUST treat the inner
295/// string as opaque-but-presumed-well-formed and validate at the system
296/// boundary.
297#[non_exhaustive]
298#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
299pub struct UTCOffset(String);
300
301impl From<String> for UTCOffset {
302 fn from(s: String) -> Self {
303 Self(s)
304 }
305}
306
307impl From<&str> for UTCOffset {
308 fn from(s: &str) -> Self {
309 Self(s.to_owned())
310 }
311}
312
313impl AsRef<str> for UTCOffset {
314 fn as_ref(&self) -> &str {
315 &self.0
316 }
317}
318
319impl std::fmt::Display for UTCOffset {
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 f.write_str(&self.0)
322 }
323}
324
325// ── @type serde-default functions ─────────────────────────────────────────────
326//
327// RFC 8984 marks every `@type` discriminator as `(mandatory)`, but a
328// spec-violating vendor server, partial fixture, or sub-object built via
329// `serde_json::to_value` may omit the field. Without a serde default,
330// such input fails the whole parent object's deserialize (e.g. a Task
331// carrying a CheckItem whose @type was dropped).
332//
333// Each per-type function supplies the RFC 8984-mandated literal so
334// deserialize is liberal in what it accepts while serialize still always
335// emits the field. The shape stays bare `String` per this crate's
336// documented design decision (see crate-level rustdoc item 1 and
337// AGENTS.md). Mirrors the same pattern in the sibling
338// `jmap-tasks-types` (bd:JMAP-ky8g.1) for Person / CheckItem /
339// Checklist / Comment. See bd:JMAP-ky8g.10.
340
341fn n_day_at_type_default() -> String {
342 "NDay".to_owned()
343}
344
345fn recurrence_rule_at_type_default() -> String {
346 "RecurrenceRule".to_owned()
347}
348
349fn location_at_type_default() -> String {
350 "Location".to_owned()
351}
352
353fn virtual_location_at_type_default() -> String {
354 "VirtualLocation".to_owned()
355}
356
357fn link_at_type_default() -> String {
358 "Link".to_owned()
359}
360
361fn relation_at_type_default() -> String {
362 "Relation".to_owned()
363}
364
365fn participant_at_type_default() -> String {
366 "Participant".to_owned()
367}
368
369fn offset_trigger_at_type_default() -> String {
370 "OffsetTrigger".to_owned()
371}
372
373fn absolute_trigger_at_type_default() -> String {
374 "AbsoluteTrigger".to_owned()
375}
376
377fn alert_at_type_default() -> String {
378 "Alert".to_owned()
379}
380
381fn time_zone_rule_at_type_default() -> String {
382 "TimeZoneRule".to_owned()
383}
384
385fn time_zone_at_type_default() -> String {
386 "TimeZone".to_owned()
387}
388
389// ── RecurrenceRule ────────────────────────────────────────────────────────────
390
391/// The `nthOfPeriod` field of an [`NDay`] entry (RFC 8984 §4.3.3).
392#[non_exhaustive]
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct NDay {
396 /// Object type discriminator; always `"NDay"` on the wire.
397 ///
398 /// Deserialize is liberal: if `@type` is absent (spec-violating
399 /// vendor input), this field defaults to `"NDay"` rather than
400 /// failing the whole parent object's deserialize. Serialize always
401 /// emits the field. See bd:JMAP-ky8g.10.
402 #[serde(rename = "@type", default = "n_day_at_type_default")]
403 pub at_type: String,
404
405 /// Day of the week: `"mo"`, `"tu"`, `"we"`, `"th"`, `"fr"`, `"sa"`, `"su"`.
406 pub day: String,
407
408 /// Which occurrence within the period (non-zero integer), or `null`.
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub nth_of_period: Option<i32>,
411
412 /// Catch-all for vendor / site / private extension fields not covered
413 /// by the typed fields above. Preserves unknown fields across
414 /// deserialize/serialize round-trip per workspace extras-preservation
415 /// policy (see workspace AGENTS.md).
416 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
417 pub extra: serde_json::Map<String, serde_json::Value>,
418}
419
420impl NDay {
421 /// Construct a new `NDay` with the mandatory `day` value
422 /// (RFC 8984 §4.3.3). `at_type` is set to `"NDay"`; all optional
423 /// fields default to `None` / empty.
424 pub fn new(day: impl Into<String>) -> Self {
425 Self {
426 at_type: "NDay".to_owned(),
427 day: day.into(),
428 nth_of_period: None,
429 extra: serde_json::Map::new(),
430 }
431 }
432}
433
434impl TypeDiscriminator for NDay {
435 const TYPE_TAG: &'static str = "NDay";
436 fn at_type(&self) -> &str {
437 &self.at_type
438 }
439}
440
441/// A recurrence rule as defined in RFC 8984 §4.3.3.
442///
443/// Used in `recurrenceRules` and `excludedRecurrenceRules` of a
444/// `CalendarEvent` (from `jmap-calendars-types`).
445#[non_exhaustive]
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct RecurrenceRule {
449 /// Object type discriminator; always `"RecurrenceRule"` on the wire.
450 ///
451 /// Deserialize is liberal: if `@type` is absent (spec-violating
452 /// vendor input), this field defaults to `"RecurrenceRule"` rather
453 /// than failing the whole parent object's deserialize. Serialize
454 /// always emits the field. See bd:JMAP-ky8g.10.
455 #[serde(rename = "@type", default = "recurrence_rule_at_type_default")]
456 pub at_type: String,
457
458 /// Recurrence frequency: `"yearly"`, `"monthly"`, `"weekly"`, `"daily"`,
459 /// `"hourly"`, `"minutely"`, or `"secondly"`.
460 pub frequency: String,
461
462 /// Interval between recurrences (≥ 1; default 1).
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub interval: Option<u64>,
465
466 /// Calendar system (default `"gregorian"`).
467 #[serde(skip_serializing_if = "Option::is_none")]
468 pub rscale: Option<String>,
469
470 /// How to handle skipped dates: `"omit"`, `"backward"`, `"forward"`.
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub skip: Option<String>,
473
474 /// First day of week (default `"mo"`): `"mo"`–`"su"`.
475 #[serde(skip_serializing_if = "Option::is_none")]
476 pub first_day_of_week: Option<String>,
477
478 /// Specific days within the frequency period.
479 #[serde(skip_serializing_if = "Option::is_none")]
480 pub by_day: Option<Vec<NDay>>,
481
482 /// Specific days of the month (±1–±31).
483 #[serde(skip_serializing_if = "Option::is_none")]
484 pub by_month_day: Option<Vec<i32>>,
485
486 /// Specific months (e.g. `"1"`–`"12"`, optionally suffixed with `"L"`).
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub by_month: Option<Vec<String>>,
489
490 /// Specific days of the year (±1–±366).
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub by_year_day: Option<Vec<i32>>,
493
494 /// Specific weeks of the year (±1–±53).
495 #[serde(skip_serializing_if = "Option::is_none")]
496 pub by_week_no: Option<Vec<i32>>,
497
498 /// Specific hours (0–23).
499 #[serde(skip_serializing_if = "Option::is_none")]
500 pub by_hour: Option<Vec<u8>>,
501
502 /// Specific minutes (0–59).
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub by_minute: Option<Vec<u8>>,
505
506 /// Specific seconds (0–60).
507 #[serde(skip_serializing_if = "Option::is_none")]
508 pub by_second: Option<Vec<u8>>,
509
510 /// Filter by position within the set (positive = from start, negative = from end).
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub by_set_position: Option<Vec<i32>>,
513
514 /// Maximum number of occurrences.
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub count: Option<u64>,
517
518 /// The recurrence ends on or before this `LocalDateTime`
519 /// (RFC 8984 §4.3.3 — `until` is a LocalDateTime, NOT a UTC date-time).
520 #[serde(skip_serializing_if = "Option::is_none")]
521 pub until: Option<LocalDateTime>,
522
523 /// Catch-all for vendor / site / private extension fields not covered
524 /// by the typed fields above. Preserves unknown fields across
525 /// deserialize/serialize round-trip per workspace extras-preservation
526 /// policy (see workspace AGENTS.md).
527 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
528 pub extra: serde_json::Map<String, serde_json::Value>,
529}
530
531impl RecurrenceRule {
532 /// Construct a new `RecurrenceRule` with the mandatory `frequency`
533 /// value (RFC 8984 §4.3.3). `at_type` is set to `"RecurrenceRule"`;
534 /// all optional fields default to `None`.
535 ///
536 /// `frequency` MUST be one of `"yearly"`, `"monthly"`, `"weekly"`,
537 /// `"daily"`, `"hourly"`, `"minutely"`, `"secondly"` per the spec —
538 /// not enforced at construction time.
539 pub fn new(frequency: impl Into<String>) -> Self {
540 Self {
541 at_type: "RecurrenceRule".to_owned(),
542 frequency: frequency.into(),
543 interval: None,
544 rscale: None,
545 skip: None,
546 first_day_of_week: None,
547 by_day: None,
548 by_month_day: None,
549 by_month: None,
550 by_year_day: None,
551 by_week_no: None,
552 by_hour: None,
553 by_minute: None,
554 by_second: None,
555 by_set_position: None,
556 count: None,
557 until: None,
558 extra: serde_json::Map::new(),
559 }
560 }
561}
562
563impl TypeDiscriminator for RecurrenceRule {
564 const TYPE_TAG: &'static str = "RecurrenceRule";
565 fn at_type(&self) -> &str {
566 &self.at_type
567 }
568}
569
570// ── Location and VirtualLocation ─────────────────────────────────────────────
571
572/// A physical or virtual location associated with an event (RFC 8984 §4.2.5).
573#[non_exhaustive]
574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
575#[serde(rename_all = "camelCase")]
576pub struct Location {
577 /// Object type discriminator; always `"Location"` on the wire.
578 ///
579 /// Deserialize is liberal: if `@type` is absent (spec-violating
580 /// vendor input), this field defaults to `"Location"` rather than
581 /// failing the whole parent object's deserialize. Serialize always
582 /// emits the field. See bd:JMAP-ky8g.10.
583 #[serde(rename = "@type", default = "location_at_type_default")]
584 pub at_type: String,
585
586 /// Human-readable name for this location.
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub name: Option<String>,
589
590 /// Additional description of the location.
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub description: Option<String>,
593
594 /// Map of location type URIs → `true`.
595 #[serde(skip_serializing_if = "Option::is_none")]
596 pub location_types: Option<HashMap<String, bool>>,
597
598 /// Relation of this location to the event: `"start"` or `"end"`.
599 #[serde(skip_serializing_if = "Option::is_none")]
600 pub relative_to: Option<String>,
601
602 /// IANA time zone id for this location.
603 #[serde(skip_serializing_if = "Option::is_none")]
604 pub time_zone: Option<String>,
605
606 /// Geographic coordinates as a `geo:` URI.
607 #[serde(skip_serializing_if = "Option::is_none")]
608 pub coordinates: Option<String>,
609
610 /// Attachments and images associated with this location.
611 #[serde(skip_serializing_if = "Option::is_none")]
612 pub links: Option<HashMap<String, Link>>,
613
614 /// Catch-all for vendor / site / private extension fields not covered
615 /// by the typed fields above. Preserves unknown fields across
616 /// deserialize/serialize round-trip per workspace extras-preservation
617 /// policy (see workspace AGENTS.md).
618 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
619 pub extra: serde_json::Map<String, serde_json::Value>,
620}
621
622impl Location {
623 /// Construct a new `Location` (RFC 8984 §4.2.5) with `at_type` set to
624 /// `"Location"` and all optional fields defaulted to `None`.
625 pub fn new() -> Self {
626 Self {
627 at_type: "Location".to_owned(),
628 name: None,
629 description: None,
630 location_types: None,
631 relative_to: None,
632 time_zone: None,
633 coordinates: None,
634 links: None,
635 extra: serde_json::Map::new(),
636 }
637 }
638}
639
640impl Default for Location {
641 fn default() -> Self {
642 Self::new()
643 }
644}
645
646impl TypeDiscriminator for Location {
647 const TYPE_TAG: &'static str = "Location";
648 fn at_type(&self) -> &str {
649 &self.at_type
650 }
651}
652
653/// An online meeting or virtual location (RFC 8984 §4.2.6).
654#[non_exhaustive]
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
656#[serde(rename_all = "camelCase")]
657pub struct VirtualLocation {
658 /// Object type discriminator; always `"VirtualLocation"` on the wire.
659 ///
660 /// Deserialize is liberal: if `@type` is absent (spec-violating
661 /// vendor input), this field defaults to `"VirtualLocation"` rather
662 /// than failing the whole parent object's deserialize. Serialize
663 /// always emits the field. See bd:JMAP-ky8g.10.
664 #[serde(rename = "@type", default = "virtual_location_at_type_default")]
665 pub at_type: String,
666
667 /// Human-readable name for this virtual location.
668 #[serde(skip_serializing_if = "Option::is_none")]
669 pub name: Option<String>,
670
671 /// Additional description.
672 #[serde(skip_serializing_if = "Option::is_none")]
673 pub description: Option<String>,
674
675 /// URI to join the virtual location (e.g. a conference call or meeting URL).
676 ///
677 /// Mandatory per RFC 8984 §4.2.6 — a `VirtualLocation` without a `uri` is
678 /// malformed. Unlike top-level JMAP object fields, sub-object fields are NOT
679 /// subject to RFC 8620 §5.1 partial-response suppression, so this cannot be
680 /// absent in a well-formed server response.
681 pub uri: String,
682
683 /// Map of feature type URIs → `true`.
684 #[serde(skip_serializing_if = "Option::is_none")]
685 pub features: Option<HashMap<String, bool>>,
686
687 /// Catch-all for vendor / site / private extension fields not covered
688 /// by the typed fields above. Preserves unknown fields across
689 /// deserialize/serialize round-trip per workspace extras-preservation
690 /// policy (see workspace AGENTS.md).
691 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
692 pub extra: serde_json::Map<String, serde_json::Value>,
693}
694
695impl VirtualLocation {
696 /// Construct a new `VirtualLocation` with the mandatory `uri` value
697 /// (RFC 8984 §4.2.6). `at_type` is set to `"VirtualLocation"`; all
698 /// optional fields default to `None`.
699 pub fn new(uri: impl Into<String>) -> Self {
700 Self {
701 at_type: "VirtualLocation".to_owned(),
702 name: None,
703 description: None,
704 uri: uri.into(),
705 features: None,
706 extra: serde_json::Map::new(),
707 }
708 }
709}
710
711impl TypeDiscriminator for VirtualLocation {
712 const TYPE_TAG: &'static str = "VirtualLocation";
713 fn at_type(&self) -> &str {
714 &self.at_type
715 }
716}
717
718// ── Link ─────────────────────────────────────────────────────────────────────
719
720/// An attachment, image, or URL associated with an event (RFC 8984 §1.4.11).
721///
722/// # Source invariant: at least one of `href` or `blob_id` MUST be set
723///
724/// RFC 8984 §1.4.11 marks `href` as `"String" (mandatory)`. The JMAP
725/// Calendars draft (draft-ietf-jmap-calendars-26 §5.3) relaxes that
726/// mandate: "Instead of mandating an 'href' property, clients may set a
727/// 'blobId' property instead to reference a blob of binary data in the
728/// account". The combined contract is therefore **exactly one of
729/// `href` or `blob_id` MUST be present**, and both MAY be set
730/// simultaneously (a server-stored blob with a public-fetch URL).
731///
732/// This invariant is **not** encoded in the Rust type — both fields are
733/// `Option` so that partial deserialization (e.g. of an in-flight update
734/// where only one half has been populated) succeeds. Encoding the
735/// invariant via `enum LinkSource { Href, BlobId, Both }` would be a
736/// breaking API change blocking partial constructors and is deliberately
737/// deferred until consumer evidence warrants it.
738///
739/// Consumers that need to validate the invariant on inbound data SHOULD
740/// call [`Link::validate_source`].
741#[non_exhaustive]
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct Link {
745 /// Object type discriminator; always `"Link"` on the wire.
746 ///
747 /// Deserialize is liberal: if `@type` is absent (spec-violating
748 /// vendor input), this field defaults to `"Link"` rather than
749 /// failing the whole parent object's deserialize. Serialize always
750 /// emits the field. See bd:JMAP-ky8g.10.
751 #[serde(rename = "@type", default = "link_at_type_default")]
752 pub at_type: String,
753
754 /// URI from which the linked resource may be fetched (RFC 8984
755 /// §1.4.11 — `"String" (mandatory)`).
756 ///
757 /// The pure RFC 8984 mandate is relaxed by the JMAP Calendars draft
758 /// (draft-ietf-jmap-calendars-26 §5.3) when `blob_id` is set: the
759 /// client may omit `href` and reference the resource by JMAP blob
760 /// id instead. At least one of `href` or `blob_id` MUST be
761 /// present on a well-formed Link; see the struct-level docs.
762 #[serde(skip_serializing_if = "Option::is_none")]
763 pub href: Option<String>,
764
765 /// Content type (MIME type) of the linked resource.
766 #[serde(skip_serializing_if = "Option::is_none")]
767 pub content_type: Option<String>,
768
769 /// Size of the linked resource in bytes.
770 #[serde(skip_serializing_if = "Option::is_none")]
771 pub size: Option<u64>,
772
773 /// Relationship of this link to the event (e.g. `"enclosure"`, `"describedby"`).
774 #[serde(skip_serializing_if = "Option::is_none")]
775 pub rel: Option<String>,
776
777 /// Display/file name for the link.
778 #[serde(skip_serializing_if = "Option::is_none")]
779 pub display: Option<String>,
780
781 /// Content-id value for inline images embedded in a `text/html` description
782 /// via `cid:` URLs (RFC 8984 §1.4.11).
783 ///
784 /// Only meaningful when `CalendarEvent.descriptionContentType` is `text/html`
785 /// and the HTML body references this link as `<img src="cid:…">`.
786 #[serde(skip_serializing_if = "Option::is_none")]
787 pub cid: Option<String>,
788
789 /// Human-readable, plain-text description of the linked resource
790 /// (RFC 8984 §1.4.11).
791 ///
792 /// Distinct from `display` (which is a file name); `title` is a longer
793 /// description suitable for accessibility text or tooltips.
794 #[serde(skip_serializing_if = "Option::is_none")]
795 pub title: Option<String>,
796
797 /// JMAP blob id (draft-ietf-jmap-calendars-26 §5.3 / §10.9.14).
798 ///
799 /// When present, `href` may be absent — the JMAP Calendars draft
800 /// §5.3 explicitly permits substituting a `blob_id` for the
801 /// otherwise-mandatory `href`. At least one of `href` or
802 /// `blob_id` MUST be present on a well-formed Link; see the
803 /// struct-level docs.
804 ///
805 /// Per draft §5.3: the server MUST translate this to an embedded
806 /// `data:` URL when sending to systems that cannot access blobs.
807 #[serde(skip_serializing_if = "Option::is_none")]
808 pub blob_id: Option<Id>,
809
810 /// Catch-all for vendor / site / private extension fields not covered
811 /// by the typed fields above. Preserves unknown fields across
812 /// deserialize/serialize round-trip per workspace extras-preservation
813 /// policy (see workspace AGENTS.md).
814 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
815 pub extra: serde_json::Map<String, serde_json::Value>,
816}
817
818/// Error returned by [`Link::validate_source`] when the source invariant
819/// (RFC 8984 §1.4.11 + JMAP Calendars draft §5.3) is violated.
820///
821/// Currently the only failure mode is "neither `href` nor `blob_id` is
822/// set"; the type is `#[non_exhaustive]` so additional variants can be
823/// added without breaking matches.
824#[non_exhaustive]
825#[derive(Debug, Clone, PartialEq, Eq)]
826pub enum LinkSourceError {
827 /// Neither `href` nor `blob_id` is set. Per RFC 8984 §1.4.11
828 /// `href` is mandatory; per JMAP Calendars draft §5.3 a
829 /// `blob_id` may substitute. At least one of the two MUST be
830 /// present.
831 Missing,
832}
833
834impl std::fmt::Display for LinkSourceError {
835 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836 match self {
837 LinkSourceError::Missing => f.write_str(
838 "Link has neither href nor blobId set; RFC 8984 §1.4.11 and \
839 JMAP Calendars draft §5.3 require at least one",
840 ),
841 }
842 }
843}
844
845impl std::error::Error for LinkSourceError {}
846
847impl Link {
848 /// Construct an empty `Link` (RFC 8984 §1.4.11) with `at_type` set
849 /// to `"Link"` and all optional fields defaulted to `None`.
850 ///
851 /// Note: the returned `Link` does NOT satisfy the source invariant
852 /// (at least one of `href`/`blob_id` MUST be set); callers are
853 /// expected to set one of them before serializing. Use
854 /// [`Link::validate_source`] to check.
855 pub fn new() -> Self {
856 Self {
857 at_type: "Link".to_owned(),
858 href: None,
859 content_type: None,
860 size: None,
861 rel: None,
862 display: None,
863 cid: None,
864 title: None,
865 blob_id: None,
866 extra: serde_json::Map::new(),
867 }
868 }
869
870 /// Construct a `Link` from an `href` URI string (the RFC 8984 §1.4.11
871 /// happy path).
872 pub fn with_href(href: impl Into<String>) -> Self {
873 Self {
874 href: Some(href.into()),
875 ..Self::new()
876 }
877 }
878
879 /// Construct a `Link` referencing a JMAP blob by id (JMAP Calendars
880 /// draft §5.3).
881 pub fn with_blob_id(blob_id: Id) -> Self {
882 Self {
883 blob_id: Some(blob_id),
884 ..Self::new()
885 }
886 }
887
888 /// Validate the source invariant: at least one of `href` or
889 /// `blob_id` MUST be present.
890 ///
891 /// Combined contract from RFC 8984 §1.4.11 (`href` mandatory) and
892 /// the JMAP Calendars draft (draft-ietf-jmap-calendars-26 §5.3,
893 /// `blob_id` may substitute for `href`). Both fields MAY be set
894 /// simultaneously — that is permitted, only the "neither set"
895 /// case fails.
896 ///
897 /// This is an opt-in check. Deserialization itself does NOT enforce
898 /// the invariant so that partial Links (e.g. in-flight updates)
899 /// round-trip cleanly; consumers that need a wire-validity check on
900 /// inbound data call this method.
901 pub fn validate_source(&self) -> Result<(), LinkSourceError> {
902 if self.href.is_none() && self.blob_id.is_none() {
903 Err(LinkSourceError::Missing)
904 } else {
905 Ok(())
906 }
907 }
908}
909
910impl Default for Link {
911 fn default() -> Self {
912 Self::new()
913 }
914}
915
916impl TypeDiscriminator for Link {
917 const TYPE_TAG: &'static str = "Link";
918 fn at_type(&self) -> &str {
919 &self.at_type
920 }
921}
922
923// ── Relation ─────────────────────────────────────────────────────────────────
924
925/// A relationship between this object and another, identified by UID
926/// (RFC 8984 §1.4.10).
927#[non_exhaustive]
928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
929#[serde(rename_all = "camelCase")]
930pub struct Relation {
931 /// Object type discriminator; always `"Relation"` on the wire.
932 ///
933 /// Deserialize is liberal: if `@type` is absent (spec-violating
934 /// vendor input), this field defaults to `"Relation"` rather than
935 /// failing the whole parent object's deserialize. Serialize always
936 /// emits the field. See bd:JMAP-ky8g.10.
937 #[serde(rename = "@type", default = "relation_at_type_default")]
938 pub at_type: String,
939
940 /// Map of relationship type URIs → `true`.
941 #[serde(skip_serializing_if = "Option::is_none")]
942 pub relation: Option<HashMap<String, bool>>,
943
944 /// Catch-all for vendor / site / private extension fields not covered
945 /// by the typed fields above. Preserves unknown fields across
946 /// deserialize/serialize round-trip per workspace extras-preservation
947 /// policy (see workspace AGENTS.md).
948 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
949 pub extra: serde_json::Map<String, serde_json::Value>,
950}
951
952impl Relation {
953 /// Construct an empty `Relation` (RFC 8984 §1.4.10) with `at_type`
954 /// set to `"Relation"` and all optional fields defaulted to `None`.
955 pub fn new() -> Self {
956 Self {
957 at_type: "Relation".to_owned(),
958 relation: None,
959 extra: serde_json::Map::new(),
960 }
961 }
962}
963
964impl Default for Relation {
965 fn default() -> Self {
966 Self::new()
967 }
968}
969
970impl TypeDiscriminator for Relation {
971 const TYPE_TAG: &'static str = "Relation";
972 fn at_type(&self) -> &str {
973 &self.at_type
974 }
975}
976
977// ── Participant ───────────────────────────────────────────────────────────────
978
979/// A participant in an event (RFC 8984 §4.4.6).
980#[non_exhaustive]
981#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
982#[serde(rename_all = "camelCase")]
983pub struct Participant {
984 /// Object type discriminator; always `"Participant"` on the wire.
985 ///
986 /// Deserialize is liberal: if `@type` is absent (spec-violating
987 /// vendor input), this field defaults to `"Participant"` rather
988 /// than failing the whole parent object's deserialize. Serialize
989 /// always emits the field. See bd:JMAP-ky8g.10.
990 #[serde(rename = "@type", default = "participant_at_type_default")]
991 pub at_type: String,
992
993 /// Display name of the participant.
994 #[serde(skip_serializing_if = "Option::is_none")]
995 pub name: Option<String>,
996
997 /// Email address (addr-spec) of the participant.
998 #[serde(skip_serializing_if = "Option::is_none")]
999 pub email: Option<String>,
1000
1001 /// Additional description.
1002 #[serde(skip_serializing_if = "Option::is_none")]
1003 pub description: Option<String>,
1004
1005 /// Map of scheduling method → URI for sending scheduling messages.
1006 #[serde(skip_serializing_if = "Option::is_none")]
1007 pub send_to: Option<HashMap<String, String>>,
1008
1009 /// Kind of participant: `"individual"`, `"group"`, `"location"`, `"resource"`.
1010 #[serde(skip_serializing_if = "Option::is_none")]
1011 pub kind: Option<String>,
1012
1013 /// Map of role URIs → `true` (e.g. `"owner"`, `"attendee"`, `"chair"`).
1014 ///
1015 /// RFC 8984 §4.4.6: "At least one role MUST be specified for the
1016 /// participant". The non-empty mandate is NOT enforced by the type
1017 /// system or by deserialize; use [`Participant::validate_roles`]
1018 /// for an opt-in check.
1019 pub roles: HashMap<String, bool>,
1020
1021 /// Id of the location this participant is associated with.
1022 #[serde(skip_serializing_if = "Option::is_none")]
1023 pub location_id: Option<String>,
1024
1025 /// BCP 47 language tag for this participant.
1026 #[serde(skip_serializing_if = "Option::is_none")]
1027 pub language: Option<String>,
1028
1029 /// Participation status (default `"needs-action"`).
1030 #[serde(skip_serializing_if = "Option::is_none")]
1031 pub participation_status: Option<String>,
1032
1033 /// Free-form comment on participation.
1034 #[serde(skip_serializing_if = "Option::is_none")]
1035 pub participation_comment: Option<String>,
1036
1037 /// Whether the participant is expected to send a reply (default `false`).
1038 #[serde(skip_serializing_if = "Option::is_none")]
1039 pub expect_reply: Option<bool>,
1040
1041 /// Scheduling agent: `"server"`, `"client"`, or `"none"` (default `"server"`).
1042 #[serde(skip_serializing_if = "Option::is_none")]
1043 pub schedule_agent: Option<String>,
1044
1045 /// iTIP scheduling address URI for this participant.
1046 #[serde(skip_serializing_if = "Option::is_none")]
1047 pub calendar_address: Option<String>,
1048
1049 /// Id of the participant who invited this participant, if any.
1050 #[serde(skip_serializing_if = "Option::is_none")]
1051 pub invited_by: Option<String>,
1052
1053 /// Map of participant ids → `true` for participants this one delegated to.
1054 #[serde(skip_serializing_if = "Option::is_none")]
1055 pub delegated_to: Option<HashMap<String, bool>>,
1056
1057 /// Map of participant ids → `true` for participants who delegated to this one.
1058 #[serde(skip_serializing_if = "Option::is_none")]
1059 pub delegated_from: Option<HashMap<String, bool>>,
1060
1061 /// Map of group participant ids → `true` that this participant is a member of.
1062 #[serde(skip_serializing_if = "Option::is_none")]
1063 pub member_of: Option<HashMap<String, bool>>,
1064
1065 /// Links associated with this participant.
1066 #[serde(skip_serializing_if = "Option::is_none")]
1067 pub links: Option<HashMap<String, Link>>,
1068
1069 /// iTIP scheduling sequence number for this participant (RFC 8984 §5.2.1).
1070 ///
1071 /// Context: Participant — this is a per-participant iTIP tracking field,
1072 /// not an event-level field. The server updates it when an iTIP message
1073 /// is processed.
1074 #[serde(skip_serializing_if = "Option::is_none")]
1075 pub schedule_sequence: Option<u64>,
1076
1077 /// UTC date-time of the last iTIP scheduling message processed for this
1078 /// participant (RFC 8984 §5.2.2).
1079 ///
1080 /// Context: Participant — per-participant iTIP tracking, not event-level.
1081 #[serde(skip_serializing_if = "Option::is_none")]
1082 pub schedule_updated: Option<UTCDate>,
1083
1084 /// iTIP status codes from the most recent scheduling message sent to this
1085 /// participant (RFC 8984 §4.4.6).
1086 ///
1087 /// An array of iTIP status code strings (e.g. `"1.0"`, `"2.0"`, `"5.0"`).
1088 /// Server-set and persisted; absent when no scheduling has occurred.
1089 #[serde(skip_serializing_if = "Option::is_none")]
1090 pub schedule_status: Option<Vec<String>>,
1091
1092 /// Client request to force a scheduling message (RFC 8984 §4.4.6,
1093 /// default `false`).
1094 ///
1095 /// A client sets this to `true` to ask the server to send a scheduling
1096 /// message to the participant even when it would not normally do so
1097 /// (e.g. no significant change was made, or `scheduleAgent` is
1098 /// `"client"`). Per the spec this property MUST NOT be stored on the
1099 /// server or appear in a scheduling message — it is request-only.
1100 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub schedule_force_send: Option<bool>,
1102
1103 /// Email address of the iMIP sender, if different from the
1104 /// participant's `imip` send-to URI (RFC 8984 §4.4.6).
1105 ///
1106 /// SHOULD only be set when the From-header address of the email that
1107 /// last updated this participant differs from the `mailto:` URI in
1108 /// `sendTo["imip"]`. If set, MUST be a valid `addr-spec` per
1109 /// RFC 5322 §3.4.1.
1110 #[serde(skip_serializing_if = "Option::is_none")]
1111 pub sent_by: Option<String>,
1112
1113 /// Task-only: progress of the participant for this task
1114 /// (RFC 8984 §4.4.6; allowed values in §5.2.5).
1115 ///
1116 /// MUST NOT be set when `participationStatus` is anything other than
1117 /// `"accepted"`. Only meaningful on a `Task`; ignored on an `Event`.
1118 /// Type-level forward-compatibility: this field is kept as
1119 /// `Option<String>` rather than a typed enum because RFC 8984 §5.2.5
1120 /// defines an open value set extensible via the IANA "JSCalendar
1121 /// Enum Values" registry.
1122 #[serde(skip_serializing_if = "Option::is_none")]
1123 pub progress: Option<String>,
1124
1125 /// Task-only: timestamp the `progress` property was last set
1126 /// (RFC 8984 §4.4.6; semantics in §5.2.6).
1127 ///
1128 /// Only meaningful on a `Task`; ignored on an `Event`.
1129 #[serde(skip_serializing_if = "Option::is_none")]
1130 pub progress_updated: Option<UTCDate>,
1131
1132 /// Task-only: percent completion of the participant for this task
1133 /// (RFC 8984 §4.4.6).
1134 ///
1135 /// MUST be a value in the range `0..=100` per the spec. Only
1136 /// meaningful on a `Task`; ignored on an `Event`. The type permits
1137 /// the full `u8` range; values outside `0..=100` are wire-invalid
1138 /// and the consumer is responsible for range-checking on input.
1139 #[serde(skip_serializing_if = "Option::is_none")]
1140 pub percent_complete: Option<u8>,
1141
1142 /// Catch-all for vendor / site / private extension fields not covered
1143 /// by the typed fields above. Preserves unknown fields across
1144 /// deserialize/serialize round-trip per workspace extras-preservation
1145 /// policy (see workspace AGENTS.md).
1146 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1147 pub extra: serde_json::Map<String, serde_json::Value>,
1148}
1149
1150/// Error returned by [`Participant::validate_roles`] when the
1151/// RFC 8984 §4.4.6 non-empty-roles invariant is violated.
1152#[non_exhaustive]
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub enum ParticipantRolesError {
1155 /// The `roles` map is empty. RFC 8984 §4.4.6 says
1156 /// "At least one role MUST be specified for the participant".
1157 Empty,
1158}
1159
1160impl std::fmt::Display for ParticipantRolesError {
1161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1162 match self {
1163 ParticipantRolesError::Empty => f.write_str(
1164 "Participant.roles is empty; RFC 8984 §4.4.6 requires at \
1165 least one role",
1166 ),
1167 }
1168 }
1169}
1170
1171impl std::error::Error for ParticipantRolesError {}
1172
1173impl Participant {
1174 /// Construct a new `Participant` with the mandatory `roles` map
1175 /// (RFC 8984 §4.4.6). `at_type` is set to `"Participant"`; all
1176 /// optional fields default to `None`.
1177 ///
1178 /// Per RFC 8984 §4.4.6 the `roles` map MUST be non-empty: "At least
1179 /// one role MUST be specified for the participant". This
1180 /// constructor accepts any `HashMap`, including an empty one — the
1181 /// non-empty mandate is not enforced at construction time so that
1182 /// partial in-flight values round-trip cleanly. Callers SHOULD
1183 /// populate at least one role entry before serializing. Use
1184 /// [`Participant::validate_roles`] to check.
1185 pub fn new(roles: HashMap<String, bool>) -> Self {
1186 Self {
1187 at_type: "Participant".to_owned(),
1188 name: None,
1189 email: None,
1190 description: None,
1191 send_to: None,
1192 kind: None,
1193 roles,
1194 location_id: None,
1195 language: None,
1196 participation_status: None,
1197 participation_comment: None,
1198 expect_reply: None,
1199 schedule_agent: None,
1200 calendar_address: None,
1201 invited_by: None,
1202 delegated_to: None,
1203 delegated_from: None,
1204 member_of: None,
1205 links: None,
1206 schedule_sequence: None,
1207 schedule_updated: None,
1208 schedule_status: None,
1209 schedule_force_send: None,
1210 sent_by: None,
1211 progress: None,
1212 progress_updated: None,
1213 percent_complete: None,
1214 extra: serde_json::Map::new(),
1215 }
1216 }
1217}
1218
1219impl Participant {
1220 /// Validate the RFC 8984 §4.4.6 non-empty-roles invariant.
1221 ///
1222 /// Returns `Err(ParticipantRolesError::Empty)` when `roles` is
1223 /// empty; `Ok(())` otherwise. Opt-in check — deserialize does NOT
1224 /// enforce the mandate so that partial in-flight Participant values
1225 /// round-trip cleanly.
1226 pub fn validate_roles(&self) -> Result<(), ParticipantRolesError> {
1227 if self.roles.is_empty() {
1228 Err(ParticipantRolesError::Empty)
1229 } else {
1230 Ok(())
1231 }
1232 }
1233}
1234
1235impl TypeDiscriminator for Participant {
1236 const TYPE_TAG: &'static str = "Participant";
1237 fn at_type(&self) -> &str {
1238 &self.at_type
1239 }
1240}
1241
1242// ── Alert ─────────────────────────────────────────────────────────────────────
1243
1244/// A trigger time given as an offset from the event start or end
1245/// (RFC 8984 §4.5.2).
1246#[non_exhaustive]
1247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1248#[serde(rename_all = "camelCase")]
1249pub struct OffsetTrigger {
1250 /// Object type discriminator; always `"OffsetTrigger"` on the wire.
1251 ///
1252 /// Deserialize is liberal: if `@type` is absent (spec-violating
1253 /// vendor input), this field defaults to `"OffsetTrigger"` rather
1254 /// than failing the whole parent object's deserialize. Serialize
1255 /// always emits the field. See bd:JMAP-ky8g.10.
1256 #[serde(rename = "@type", default = "offset_trigger_at_type_default")]
1257 pub at_type: String,
1258
1259 /// Duration offset from `relative_to`.
1260 pub offset: SignedDuration,
1261
1262 /// Whether to measure from `"start"` or `"end"` of the event.
1263 /// Default is `"start"`.
1264 #[serde(skip_serializing_if = "Option::is_none")]
1265 pub relative_to: Option<String>,
1266
1267 /// Catch-all for vendor / site / private extension fields not covered
1268 /// by the typed fields above. Preserves unknown fields across
1269 /// deserialize/serialize round-trip per workspace extras-preservation
1270 /// policy (see workspace AGENTS.md).
1271 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1272 pub extra: serde_json::Map<String, serde_json::Value>,
1273}
1274
1275impl OffsetTrigger {
1276 /// Construct a new `OffsetTrigger` with the mandatory `offset` value
1277 /// (RFC 8984 §4.5.2). `at_type` is set to `"OffsetTrigger"`;
1278 /// `relative_to` defaults to `None` (the spec default is `"start"`).
1279 pub fn new(offset: SignedDuration) -> Self {
1280 Self {
1281 at_type: "OffsetTrigger".to_owned(),
1282 offset,
1283 relative_to: None,
1284 extra: serde_json::Map::new(),
1285 }
1286 }
1287}
1288
1289impl TypeDiscriminator for OffsetTrigger {
1290 const TYPE_TAG: &'static str = "OffsetTrigger";
1291 fn at_type(&self) -> &str {
1292 &self.at_type
1293 }
1294}
1295
1296/// A trigger time given as an absolute UTC date-time (RFC 8984 §4.5.2).
1297#[non_exhaustive]
1298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1299#[serde(rename_all = "camelCase")]
1300pub struct AbsoluteTrigger {
1301 /// Object type discriminator; always `"AbsoluteTrigger"` on the wire.
1302 ///
1303 /// Deserialize is liberal: if `@type` is absent (spec-violating
1304 /// vendor input), this field defaults to `"AbsoluteTrigger"` rather
1305 /// than failing the whole parent object's deserialize. Serialize
1306 /// always emits the field. See bd:JMAP-ky8g.10.
1307 #[serde(rename = "@type", default = "absolute_trigger_at_type_default")]
1308 pub at_type: String,
1309
1310 /// The absolute UTC date-time at which to trigger the alert.
1311 pub when: UTCDate,
1312
1313 /// Catch-all for vendor / site / private extension fields not covered
1314 /// by the typed fields above. Preserves unknown fields across
1315 /// deserialize/serialize round-trip per workspace extras-preservation
1316 /// policy (see workspace AGENTS.md).
1317 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1318 pub extra: serde_json::Map<String, serde_json::Value>,
1319}
1320
1321impl AbsoluteTrigger {
1322 /// Construct a new `AbsoluteTrigger` with the mandatory `when` value
1323 /// (RFC 8984 §4.5.2). `at_type` is set to `"AbsoluteTrigger"`.
1324 pub fn new(when: UTCDate) -> Self {
1325 Self {
1326 at_type: "AbsoluteTrigger".to_owned(),
1327 when,
1328 extra: serde_json::Map::new(),
1329 }
1330 }
1331}
1332
1333impl TypeDiscriminator for AbsoluteTrigger {
1334 const TYPE_TAG: &'static str = "AbsoluteTrigger";
1335 fn at_type(&self) -> &str {
1336 &self.at_type
1337 }
1338}
1339
1340/// Alert trigger — either offset-based, absolute, or an unknown future type
1341/// (RFC 8984 §4.5.2).
1342///
1343/// The `@type` field on the wire selects the variant. The `Unknown` variant
1344/// preserves any unrecognised trigger type for round-trip fidelity, as
1345/// required by the spec: "Implementations MUST NOT trigger for trigger types
1346/// they do not understand but MUST preserve them."
1347///
1348/// Serde is implemented manually because `#[serde(tag = "@type", other)]`
1349/// with a tuple variant is not supported by serde's derive macros; `other`
1350/// only works with unit variants in internally-tagged enums.
1351///
1352/// # Deserialization behaviour on malformed input
1353///
1354/// The custom `Deserialize` impl is deliberately permissive about the JSON
1355/// *shape* of the input — only the `@type` tag drives the dispatch. Any
1356/// input whose `@type` is missing, is not a string, or names a tag other
1357/// than `"OffsetTrigger"` / `"AbsoluteTrigger"` is captured as
1358/// [`AlertTrigger::Unknown`] carrying the original `serde_json::Value`
1359/// unchanged. This includes:
1360///
1361/// - top-level non-objects: `null`, arrays, numbers, strings, booleans
1362/// - objects without an `@type` key
1363/// - objects whose `@type` is a non-string value (`null`, `42`, `[]`)
1364/// - objects whose `@type` is a string outside the known set
1365///
1366/// The rationale is round-trip fidelity per the spec's preserve-mandate:
1367/// rejecting at deserialize time would force consumers to drop data they
1368/// are supposed to preserve. Consumers that need a stricter check should
1369/// pattern-match on the variant and inspect the carried `Value`. A future
1370/// well-typed `AlertTrigger` variant only ever displaces input that
1371/// previously matched on `@type` exactly, so this permissive behaviour is
1372/// forward-compatible with the spec's evolution.
1373///
1374/// # Maintainer note: do NOT "clean up" this enum
1375///
1376/// The `Unknown(serde_json::Value)` variant exists because RFC 8984 §4.5.2
1377/// requires preservation of unrecognised trigger types. The following
1378/// three "cleanups" all violate that MUST and break the regression tests
1379/// `alert_trigger_unknown_dispatch_on_hostile_input` and
1380/// `alert_trigger_unknown_round_trips_through_serialize` (both in this
1381/// file), as well as `alert_unknown_trigger_roundtrip` in
1382/// `crate-jmap-calendars-types/tests/types_test.rs`:
1383///
1384/// 1. **Remove the `Unknown` variant.** An exhaustive enum forces
1385/// deserialize to either fail on an unknown `@type` (data loss + error)
1386/// or silently drop the input (data loss). Both violate
1387/// `MUST preserve them`.
1388///
1389/// 2. **Replace `Unknown(Value)` with `#[serde(other)]` on a unit variant.**
1390/// A unit variant discards the carried JSON payload. The spec preserve-
1391/// mandate requires the *bytes* round-trip, not just the variant
1392/// discriminator. `Unknown(String)` and `Unknown { at_type: String }`
1393/// have the same defect — they drop the inner fields.
1394///
1395/// 3. **Add `#[serde(deny_unknown_fields)]` to `AlertTrigger` or to
1396/// `OffsetTrigger` / `AbsoluteTrigger`.** A peer that emits an extension
1397/// field on a known trigger type (e.g. `OffsetTrigger` with a vendor
1398/// field) would fail to deserialize entirely. The catch-all `extra`
1399/// map on the inner structs is the workspace's
1400/// extras-preservation mechanism for that case; `deny_unknown_fields`
1401/// fights it.
1402///
1403/// If you are reading this comment because you have a fourth "cleanup"
1404/// in mind: confirm against the regression test and the spec passage
1405/// (RFC 8984 §4.5.2) before proposing it. The design defense is
1406/// bd:JMAP-1rwf.8.
1407#[non_exhaustive]
1408#[derive(Debug, Clone, PartialEq)]
1409pub enum AlertTrigger {
1410 /// Offset-based trigger: fires at `offset` relative to event start/end.
1411 OffsetTrigger(OffsetTrigger),
1412 /// Absolute trigger: fires at a specific UTC date-time.
1413 AbsoluteTrigger(AbsoluteTrigger),
1414 /// Any other trigger type; preserved opaquely as raw JSON.
1415 Unknown(serde_json::Value),
1416}
1417
1418impl Serialize for AlertTrigger {
1419 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1420 match self {
1421 AlertTrigger::OffsetTrigger(t) => t.serialize(s),
1422 AlertTrigger::AbsoluteTrigger(t) => t.serialize(s),
1423 AlertTrigger::Unknown(v) => v.serialize(s),
1424 }
1425 }
1426}
1427
1428impl<'de> Deserialize<'de> for AlertTrigger {
1429 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1430 // Deserialize into an intermediate Value, then dispatch on @type.
1431 let v = serde_json::Value::deserialize(d)?;
1432 let tag = v
1433 .get("@type")
1434 .and_then(|t| t.as_str())
1435 .unwrap_or("")
1436 .to_owned();
1437 match tag.as_str() {
1438 "OffsetTrigger" => {
1439 let t: OffsetTrigger =
1440 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1441 Ok(AlertTrigger::OffsetTrigger(t))
1442 }
1443 "AbsoluteTrigger" => {
1444 let t: AbsoluteTrigger =
1445 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1446 Ok(AlertTrigger::AbsoluteTrigger(t))
1447 }
1448 _ => Ok(AlertTrigger::Unknown(v)),
1449 }
1450 }
1451}
1452
1453/// An alert to be shown or emailed before or after an event (RFC 8984 §4.5.2).
1454#[non_exhaustive]
1455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1456#[serde(rename_all = "camelCase")]
1457pub struct Alert {
1458 /// Object type discriminator; always `"Alert"` on the wire.
1459 ///
1460 /// Deserialize is liberal: if `@type` is absent (spec-violating
1461 /// vendor input), this field defaults to `"Alert"` rather than
1462 /// failing the whole parent object's deserialize. Serialize always
1463 /// emits the field. See bd:JMAP-ky8g.10.
1464 #[serde(rename = "@type", default = "alert_at_type_default")]
1465 pub at_type: String,
1466
1467 /// When to trigger the alert.
1468 pub trigger: AlertTrigger,
1469
1470 /// UTC date-time when the user acknowledged this alert, or `null`.
1471 #[serde(skip_serializing_if = "Option::is_none")]
1472 pub acknowledged: Option<UTCDate>,
1473
1474 /// Related alerts (e.g. for snooze chains); keys are alert ids.
1475 #[serde(skip_serializing_if = "Option::is_none")]
1476 pub related_to: Option<HashMap<String, Relation>>,
1477
1478 /// How to present the alert: `"display"` or `"email"` (default `"display"`).
1479 #[serde(skip_serializing_if = "Option::is_none")]
1480 pub action: Option<String>,
1481
1482 /// Catch-all for vendor / site / private extension fields not covered
1483 /// by the typed fields above. Preserves unknown fields across
1484 /// deserialize/serialize round-trip per workspace extras-preservation
1485 /// policy (see workspace AGENTS.md).
1486 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1487 pub extra: serde_json::Map<String, serde_json::Value>,
1488}
1489
1490impl Alert {
1491 /// Construct a new `Alert` with the mandatory `trigger` value
1492 /// (RFC 8984 §4.5.2). `at_type` is set to `"Alert"`; all optional
1493 /// fields default to `None`.
1494 pub fn new(trigger: AlertTrigger) -> Self {
1495 Self {
1496 at_type: "Alert".to_owned(),
1497 trigger,
1498 acknowledged: None,
1499 related_to: None,
1500 action: None,
1501 extra: serde_json::Map::new(),
1502 }
1503 }
1504}
1505
1506impl TypeDiscriminator for Alert {
1507 const TYPE_TAG: &'static str = "Alert";
1508 fn at_type(&self) -> &str {
1509 &self.at_type
1510 }
1511}
1512
1513// ── TimeZone / TimeZoneRule ───────────────────────────────────────────────────
1514
1515/// A STANDARD or DAYLIGHT sub-component of a [`TimeZone`] (RFC 8984 §4.7.2).
1516///
1517/// Maps to a VTIMEZONE STANDARD or DAYLIGHT sub-component from iCalendar.
1518/// At most one recurrence rule is allowed per rule.
1519#[non_exhaustive]
1520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1521#[serde(rename_all = "camelCase")]
1522pub struct TimeZoneRule {
1523 /// Object type discriminator; always `"TimeZoneRule"` on the wire.
1524 ///
1525 /// Deserialize is liberal: if `@type` is absent (spec-violating
1526 /// vendor input), this field defaults to `"TimeZoneRule"` rather
1527 /// than failing the whole parent object's deserialize. Serialize
1528 /// always emits the field. See bd:JMAP-ky8g.10.
1529 #[serde(rename = "@type", default = "time_zone_rule_at_type_default")]
1530 pub at_type: String,
1531
1532 /// DTSTART from iCalendar — the local date-time the rule first applies.
1533 pub start: LocalDateTime,
1534
1535 /// TZOFFSETFROM from iCalendar — the UTC offset in effect before the
1536 /// transition (format `±HHMM` or `±HHMMSS`). Typed as
1537 /// [`UTCOffset`] for consistency with the other temporal newtypes;
1538 /// the inner string is opaque to this crate (validation deferred to
1539 /// the backend per `PLAN.md`).
1540 pub offset_from: UTCOffset,
1541
1542 /// TZOFFSETTO from iCalendar — the UTC offset in effect after the
1543 /// transition (format `±HHMM` or `±HHMMSS`). Typed as
1544 /// [`UTCOffset`] for consistency with the other temporal newtypes.
1545 pub offset_to: UTCOffset,
1546
1547 /// RRULE from iCalendar — recurrence rules for the transition.
1548 /// Per RFC 8984 §4.7.2 the `until` value MUST be interpreted as a
1549 /// local time in the UTC time zone during evaluation.
1550 #[serde(skip_serializing_if = "Option::is_none")]
1551 pub recurrence_rules: Option<Vec<RecurrenceRule>>,
1552
1553 /// RDATE properties from iCalendar — additional explicit transition
1554 /// dates. Keys are LocalDateTime strings; the PatchObject value MUST
1555 /// be the empty JSON object (`{}`) per RFC 8984 §4.7.2.
1556 ///
1557 /// The type permits non-empty PatchObject values that the wire spec
1558 /// forbids — this is deliberate for round-trip preservation of
1559 /// in-flight data. Use
1560 /// [`TimeZoneRule::validate_recurrence_overrides_empty`] for an
1561 /// opt-in check that every value is the empty patch.
1562 #[serde(skip_serializing_if = "Option::is_none")]
1563 pub recurrence_overrides: Option<HashMap<LocalDateTime, PatchObject>>,
1564
1565 /// TZNAME properties from iCalendar — set of human-readable names
1566 /// for this rule. The map value MUST be `true` for each key.
1567 #[serde(skip_serializing_if = "Option::is_none")]
1568 pub names: Option<HashMap<String, bool>>,
1569
1570 /// COMMENT properties from iCalendar — order MUST be preserved.
1571 #[serde(skip_serializing_if = "Option::is_none")]
1572 pub comments: Option<Vec<String>>,
1573
1574 /// Catch-all for vendor / site / private extension fields not covered
1575 /// by the typed fields above. Preserves unknown fields across
1576 /// deserialize/serialize round-trip per workspace extras-preservation
1577 /// policy (see workspace AGENTS.md).
1578 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1579 pub extra: serde_json::Map<String, serde_json::Value>,
1580}
1581
1582impl TimeZoneRule {
1583 /// Construct a new `TimeZoneRule` with the three mandatory fields
1584 /// (RFC 8984 §4.7.2). `at_type` is set to `"TimeZoneRule"`; all
1585 /// optional fields default to `None`.
1586 ///
1587 /// `offset_from` / `offset_to` MUST be a valid signed offset string
1588 /// (`±HHMM` or `±HHMMSS`) per the spec — not enforced at
1589 /// construction time. Accepts anything `Into<UTCOffset>`, which
1590 /// includes `&str` and `String` via the newtype's `From` impls.
1591 pub fn new(
1592 start: LocalDateTime,
1593 offset_from: impl Into<UTCOffset>,
1594 offset_to: impl Into<UTCOffset>,
1595 ) -> Self {
1596 Self {
1597 at_type: "TimeZoneRule".to_owned(),
1598 start,
1599 offset_from: offset_from.into(),
1600 offset_to: offset_to.into(),
1601 recurrence_rules: None,
1602 recurrence_overrides: None,
1603 names: None,
1604 comments: None,
1605 extra: serde_json::Map::new(),
1606 }
1607 }
1608}
1609
1610/// Error returned by [`TimeZoneRule::validate_recurrence_overrides_empty`]
1611/// when the RFC 8984 §4.7.2 "PatchObject value MUST be the empty patch"
1612/// constraint is violated.
1613#[non_exhaustive]
1614#[derive(Debug, Clone, PartialEq, Eq)]
1615pub enum RecurrenceOverridesError {
1616 /// At least one entry in `recurrence_overrides` carries a
1617 /// non-empty PatchObject value. RFC 8984 §4.7.2 requires every
1618 /// value to be the empty patch (`{}`).
1619 NonEmptyPatch {
1620 /// The wire-format key (LocalDateTime string) of the offending
1621 /// entry.
1622 key: String,
1623 },
1624}
1625
1626impl std::fmt::Display for RecurrenceOverridesError {
1627 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1628 match self {
1629 RecurrenceOverridesError::NonEmptyPatch { key } => write!(
1630 f,
1631 "TimeZoneRule.recurrenceOverrides[{key:?}] carries a non-empty \
1632 PatchObject; RFC 8984 §4.7.2 requires the empty patch"
1633 ),
1634 }
1635 }
1636}
1637
1638impl std::error::Error for RecurrenceOverridesError {}
1639
1640impl TimeZoneRule {
1641 /// Validate the RFC 8984 §4.7.2 constraint on
1642 /// `recurrence_overrides`: every PatchObject value MUST be the
1643 /// empty patch (`{}`).
1644 ///
1645 /// Returns `Ok(())` if `recurrence_overrides` is `None`, an empty
1646 /// map, or contains only empty-patch values. Returns
1647 /// `Err(RecurrenceOverridesError::NonEmptyPatch { key })` naming
1648 /// the first offending entry's wire key. Opt-in check —
1649 /// deserialize itself does NOT enforce the constraint.
1650 pub fn validate_recurrence_overrides_empty(&self) -> Result<(), RecurrenceOverridesError> {
1651 if let Some(map) = &self.recurrence_overrides {
1652 for (k, v) in map {
1653 if !v.as_map().is_empty() {
1654 return Err(RecurrenceOverridesError::NonEmptyPatch {
1655 key: k.as_ref().to_owned(),
1656 });
1657 }
1658 }
1659 }
1660 Ok(())
1661 }
1662}
1663
1664impl TypeDiscriminator for TimeZoneRule {
1665 const TYPE_TAG: &'static str = "TimeZoneRule";
1666 fn at_type(&self) -> &str {
1667 &self.at_type
1668 }
1669}
1670
1671/// A time-zone definition embedded in `CalendarEvent.timeZones` or
1672/// `Task.timeZones` (RFC 8984 §4.7.2).
1673///
1674/// Maps to a VTIMEZONE component from iCalendar. A valid TimeZone MUST
1675/// define at least one transition rule in `standard` or `daylight`.
1676#[non_exhaustive]
1677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1678#[serde(rename_all = "camelCase")]
1679pub struct TimeZone {
1680 /// Object type discriminator; always `"TimeZone"` on the wire.
1681 ///
1682 /// Deserialize is liberal: if `@type` is absent (spec-violating
1683 /// vendor input), this field defaults to `"TimeZone"` rather than
1684 /// failing the whole parent object's deserialize. Serialize always
1685 /// emits the field. See bd:JMAP-ky8g.10.
1686 #[serde(rename = "@type", default = "time_zone_at_type_default")]
1687 pub at_type: String,
1688
1689 /// TZID from iCalendar — the time-zone identifier.
1690 ///
1691 /// MUST be a valid `paramtext` value per RFC 5545 §3.1.
1692 pub tz_id: String,
1693
1694 /// LAST-MODIFIED from iCalendar.
1695 #[serde(skip_serializing_if = "Option::is_none")]
1696 pub updated: Option<UTCDate>,
1697
1698 /// TZURL from iCalendar.
1699 #[serde(skip_serializing_if = "Option::is_none")]
1700 pub url: Option<String>,
1701
1702 /// TZUNTIL from iCalendar (RFC 7808).
1703 #[serde(skip_serializing_if = "Option::is_none")]
1704 pub valid_until: Option<UTCDate>,
1705
1706 /// TZID-ALIAS-OF properties from iCalendar (RFC 7808). Map keys are
1707 /// the alias identifiers; the value MUST be `true` for each key.
1708 #[serde(skip_serializing_if = "Option::is_none")]
1709 pub aliases: Option<HashMap<String, bool>>,
1710
1711 /// STANDARD sub-components from iCalendar. Order MUST be preserved.
1712 #[serde(skip_serializing_if = "Option::is_none")]
1713 pub standard: Option<Vec<TimeZoneRule>>,
1714
1715 /// DAYLIGHT sub-components from iCalendar. Order MUST be preserved.
1716 #[serde(skip_serializing_if = "Option::is_none")]
1717 pub daylight: Option<Vec<TimeZoneRule>>,
1718
1719 /// Catch-all for vendor / site / private extension fields not covered
1720 /// by the typed fields above. Preserves unknown fields across
1721 /// deserialize/serialize round-trip per workspace extras-preservation
1722 /// policy (see workspace AGENTS.md).
1723 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1724 pub extra: serde_json::Map<String, serde_json::Value>,
1725}
1726
1727impl TimeZone {
1728 /// Construct a new `TimeZone` with the mandatory `tz_id` value
1729 /// (RFC 8984 §4.7.2). `at_type` is set to `"TimeZone"`; all
1730 /// optional fields default to `None`.
1731 ///
1732 /// Per RFC 8984 §4.7.2 a valid TimeZone MUST define at least one
1733 /// transition rule in `standard` or `daylight`; this constructor
1734 /// does not enforce that — callers are expected to populate at
1735 /// least one of them before serializing.
1736 pub fn new(tz_id: impl Into<String>) -> Self {
1737 Self {
1738 at_type: "TimeZone".to_owned(),
1739 tz_id: tz_id.into(),
1740 updated: None,
1741 url: None,
1742 valid_until: None,
1743 aliases: None,
1744 standard: None,
1745 daylight: None,
1746 extra: serde_json::Map::new(),
1747 }
1748 }
1749}
1750
1751impl TypeDiscriminator for TimeZone {
1752 const TYPE_TAG: &'static str = "TimeZone";
1753 fn at_type(&self) -> &str {
1754 &self.at_type
1755 }
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760 //! Wire-format regression tests for the newtype-typed temporal fields
1761 //! introduced by bd:JMAP-sc1b.74.
1762 //!
1763 //! These tests deserialize hand-built JSON whose shape matches
1764 //! RFC 8984 examples, then re-serialize and compare. The oracle is the
1765 //! input JSON — never the code under test. They exist to catch a
1766 //! regression where the newtype loses its transparent serde behaviour
1767 //! (e.g. by adding a second field) and wraps the value in `[…]` or
1768 //! `{"0": …}` on the wire.
1769 use super::*;
1770 use serde_json::json;
1771
1772 /// Oracle: `TypeDiscriminator::validate_at_type` enforces the
1773 /// RFC 8984-mandated `@type` literal. Hostile input carrying a
1774 /// wrong `@type` (e.g. `{"@type": "NotNDay", "day": "mo"}`)
1775 /// deserializes successfully for round-trip preservation but
1776 /// `validate_at_type` rejects with `TypeTagMismatch`.
1777 /// (bd:JMAP-mno4.15)
1778 #[test]
1779 fn validate_at_type_rejects_wrong_discriminator() {
1780 // NDay with hostile @type — deserialize succeeds, validate
1781 // rejects.
1782 let raw = json!({"@type": "NotNDay", "day": "mo"});
1783 let bad: NDay = serde_json::from_value(raw).unwrap();
1784 assert_eq!(bad.day, "mo"); // payload survived for round-trip
1785 let err = bad.validate_at_type().expect_err("expected mismatch");
1786 assert_eq!(err.expected, "NDay");
1787 assert_eq!(err.actual, "NotNDay");
1788
1789 // Constructor-built value passes validate_at_type.
1790 let good = NDay::new("mo");
1791 assert!(good.validate_at_type().is_ok());
1792
1793 // Spot-check a few other types: constructor-built passes,
1794 // wrong-tag input fails.
1795 let bad_loc: Location =
1796 serde_json::from_value(json!({"@type": "Place", "name": "HQ"})).unwrap();
1797 assert_eq!(bad_loc.validate_at_type().unwrap_err().expected, "Location");
1798 assert!(Location::new().validate_at_type().is_ok());
1799
1800 let bad_alert: Alert = serde_json::from_value(json!({
1801 "@type": "Notification",
1802 "trigger": {"@type": "OffsetTrigger", "offset": "-PT5M"}
1803 }))
1804 .unwrap();
1805 assert_eq!(bad_alert.validate_at_type().unwrap_err().expected, "Alert");
1806 }
1807
1808 /// Oracle: every `new` constructor sets `at_type` to the wire
1809 /// discriminator literal mandated by RFC 8984. Round-trips through
1810 /// serde_json::to_value reproduces the discriminator on the wire as
1811 /// `"@type": "<TypeName>"`, matching the spec text verbatim.
1812 /// (bd:JMAP-mno4.10)
1813 #[test]
1814 fn constructors_set_at_type_to_wire_discriminator() {
1815 // Cases (constructed_value, expected_wire_discriminator).
1816 let nday = NDay::new("mo");
1817 let rule = RecurrenceRule::new("weekly");
1818 let loc = Location::new();
1819 let vloc = VirtualLocation::new("https://example.com/m");
1820 let link = Link::with_href("https://example.com/x");
1821 let rel = Relation::new();
1822 let mut roles = HashMap::new();
1823 roles.insert("attendee".to_owned(), true);
1824 let part = Participant::new(roles);
1825 let off = OffsetTrigger::new(SignedDuration::from("-PT15M"));
1826 let abs = AbsoluteTrigger::new(UTCDate::from("2024-06-15T08:45:00Z"));
1827 let alert = Alert::new(AlertTrigger::OffsetTrigger(off.clone()));
1828 let rule_in_tz =
1829 TimeZoneRule::new(LocalDateTime::from("1970-01-01T00:00:00"), "+0000", "+0000");
1830 let tz = TimeZone::new("Etc/UTC");
1831
1832 for (val, expected_tag) in [
1833 (serde_json::to_value(&nday).unwrap(), "NDay"),
1834 (serde_json::to_value(&rule).unwrap(), "RecurrenceRule"),
1835 (serde_json::to_value(&loc).unwrap(), "Location"),
1836 (serde_json::to_value(&vloc).unwrap(), "VirtualLocation"),
1837 (serde_json::to_value(&link).unwrap(), "Link"),
1838 (serde_json::to_value(&rel).unwrap(), "Relation"),
1839 (serde_json::to_value(&part).unwrap(), "Participant"),
1840 (serde_json::to_value(&off).unwrap(), "OffsetTrigger"),
1841 (serde_json::to_value(&abs).unwrap(), "AbsoluteTrigger"),
1842 (serde_json::to_value(&alert).unwrap(), "Alert"),
1843 (serde_json::to_value(&rule_in_tz).unwrap(), "TimeZoneRule"),
1844 (serde_json::to_value(&tz).unwrap(), "TimeZone"),
1845 ] {
1846 assert_eq!(
1847 val["@type"], expected_tag,
1848 "constructor must set @type to {expected_tag}; got {val:?}"
1849 );
1850 }
1851 }
1852
1853 /// Oracle: `Link::with_blob_id` and `Link::with_href` both satisfy
1854 /// the source invariant.
1855 #[test]
1856 fn link_constructors_satisfy_source_invariant() {
1857 let with_href = Link::with_href("https://example.com");
1858 let with_blob = Link::with_blob_id(Id::from("Ge682d5d7aad50b3a4f"));
1859 let empty = Link::new();
1860 assert!(with_href.validate_source().is_ok());
1861 assert!(with_blob.validate_source().is_ok());
1862 assert_eq!(empty.validate_source(), Err(LinkSourceError::Missing));
1863 }
1864
1865 /// Oracle: the three scalar newtypes (`LocalDateTime`, `Duration`,
1866 /// `SignedDuration`) format via `std::fmt::Display` as the bare wire
1867 /// string, matching the wire format defined in RFC 8984 §1.4.5/.6/.7.
1868 /// Without `Display`, `format!("at {dt}")` would not compile.
1869 #[test]
1870 fn scalar_newtypes_display_as_wire_string() {
1871 let dt = LocalDateTime::from("2024-06-15T09:00:00");
1872 let dur = Duration::from("PT1H");
1873 let sdur = SignedDuration::from("-PT15M");
1874 assert_eq!(format!("{dt}"), "2024-06-15T09:00:00");
1875 assert_eq!(format!("{dur}"), "PT1H");
1876 assert_eq!(format!("{sdur}"), "-PT15M");
1877 }
1878
1879 /// Oracle: `RecurrenceRule.until` serializes as a bare LocalDateTime
1880 /// string (RFC 8984 §4.3.3 example shape), not a wrapped array or
1881 /// object.
1882 #[test]
1883 fn recurrence_rule_until_is_bare_string_on_the_wire() {
1884 let raw = json!({
1885 "@type": "RecurrenceRule",
1886 "frequency": "monthly",
1887 "until": "2024-12-31T23:59:59"
1888 });
1889 let rule: RecurrenceRule =
1890 serde_json::from_value(raw.clone()).expect("RecurrenceRule must deserialize");
1891 // Sanity-check that the canary value really did land in the field.
1892 assert_eq!(
1893 rule.until.as_ref().map(AsRef::as_ref),
1894 Some("2024-12-31T23:59:59"),
1895 "until must deserialize into a LocalDateTime carrying the wire string"
1896 );
1897
1898 let round_tripped = serde_json::to_value(&rule).expect("serialize must succeed");
1899 assert_eq!(
1900 round_tripped["until"],
1901 json!("2024-12-31T23:59:59"),
1902 "until must serialize as a bare string; got {round_tripped:?}"
1903 );
1904 }
1905
1906 /// Oracle: `OffsetTrigger.offset` serializes as a bare SignedDuration
1907 /// string (RFC 8984 §4.5.2 example: `"-PT15M"`).
1908 #[test]
1909 fn offset_trigger_offset_is_bare_string_on_the_wire() {
1910 let raw = json!({
1911 "@type": "OffsetTrigger",
1912 "offset": "-PT15M"
1913 });
1914 let trigger: OffsetTrigger =
1915 serde_json::from_value(raw).expect("OffsetTrigger must deserialize");
1916 assert_eq!(
1917 trigger.offset.as_ref(),
1918 "-PT15M",
1919 "offset must deserialize into a SignedDuration"
1920 );
1921
1922 let round_tripped = serde_json::to_value(&trigger).expect("serialize must succeed");
1923 assert_eq!(
1924 round_tripped["offset"],
1925 json!("-PT15M"),
1926 "offset must serialize as a bare string; got {round_tripped:?}"
1927 );
1928 }
1929
1930 /// Oracle: `AlertTrigger::deserialize` accepts all malformed/hostile
1931 /// JSON shapes and routes them to `Unknown(Value)` per the documented
1932 /// permissive policy (RFC 8984 §4.5.2 preserve-mandate). Verifies
1933 /// the bd:JMAP-mno4.19 docstring claim with a probe — none of these
1934 /// inputs panic, error, or get reshaped; all land in `Unknown` with
1935 /// the input `Value` intact.
1936 #[test]
1937 fn alert_trigger_unknown_dispatch_on_hostile_input() {
1938 let hostile_values = [
1939 ("null", json!(null)),
1940 ("empty_array", json!([])),
1941 ("integer", json!(42)),
1942 ("bare_string", json!("hello")),
1943 ("boolean", json!(true)),
1944 ("object_without_at_type", json!({"offset": "-PT15M"})),
1945 ("object_with_int_at_type", json!({"@type": 42})),
1946 ("object_with_null_at_type", json!({"@type": null})),
1947 ("object_with_array_at_type", json!({"@type": []})),
1948 (
1949 "object_with_unknown_tag",
1950 json!({"@type": "FuturisticTrigger", "futuristicArg": 1}),
1951 ),
1952 ];
1953 for (label, v) in hostile_values {
1954 let parsed: AlertTrigger = serde_json::from_value(v.clone())
1955 .unwrap_or_else(|e| panic!("{label}: must not error, got {e}"));
1956 match parsed {
1957 AlertTrigger::Unknown(round) => assert_eq!(
1958 round, v,
1959 "{label}: Unknown must preserve the input Value bit-exactly"
1960 ),
1961 other => panic!("{label}: expected Unknown variant, got {other:?}"),
1962 }
1963 }
1964 }
1965
1966 /// Oracle: `AlertTrigger::Unknown(Value)` round-trips through serialize
1967 /// → deserialize unchanged, including a non-object payload. The
1968 /// Serialize impl just delegates to the inner Value, so a non-object
1969 /// stored in `Unknown` round-trips verbatim.
1970 #[test]
1971 fn alert_trigger_unknown_round_trips_through_serialize() {
1972 let original = AlertTrigger::Unknown(json!({"@type": "X", "k": 1}));
1973 let wire = serde_json::to_value(&original).unwrap();
1974 let back: AlertTrigger = serde_json::from_value(wire).unwrap();
1975 assert_eq!(original, back);
1976 }
1977
1978 /// Oracle: `AbsoluteTrigger.when` serializes as a bare UTC date-time
1979 /// string (RFC 8984 §4.5.2 example: `"2024-06-15T08:45:00Z"`).
1980 #[test]
1981 fn absolute_trigger_when_is_bare_string_on_the_wire() {
1982 let raw = json!({
1983 "@type": "AbsoluteTrigger",
1984 "when": "2024-06-15T08:45:00Z"
1985 });
1986 let trigger: AbsoluteTrigger =
1987 serde_json::from_value(raw).expect("AbsoluteTrigger must deserialize");
1988 assert_eq!(
1989 trigger.when.as_ref(),
1990 "2024-06-15T08:45:00Z",
1991 "when must deserialize into a UTCDate"
1992 );
1993
1994 let round_tripped = serde_json::to_value(&trigger).expect("serialize must succeed");
1995 assert_eq!(
1996 round_tripped["when"],
1997 json!("2024-06-15T08:45:00Z"),
1998 "when must serialize as a bare string; got {round_tripped:?}"
1999 );
2000 }
2001
2002 // ── Extras-preservation policy tests (JMAP-lbdy.4) ───────────────────
2003 //
2004 // One round-trip preservation test per migrated type. Each asserts
2005 // that an unknown vendor / site / private-extension field survives
2006 // deserialize/serialize unchanged. Per workspace AGENTS.md
2007 // "Extras-preservation policy for vendor/site fields".
2008
2009 /// `NDay.extra` captures vendor fields and preserves them.
2010 #[test]
2011 fn nday_preserves_vendor_extras() {
2012 let raw = json!({
2013 "@type": "NDay",
2014 "day": "mo",
2015 "acmeCorpDayLabel": "first-mon"
2016 });
2017 let n: NDay = serde_json::from_value(raw).unwrap();
2018 assert_eq!(
2019 n.extra.get("acmeCorpDayLabel").and_then(|v| v.as_str()),
2020 Some("first-mon")
2021 );
2022 let back = serde_json::to_value(&n).unwrap();
2023 assert_eq!(back["acmeCorpDayLabel"], "first-mon");
2024 }
2025
2026 /// `RecurrenceRule.extra` captures vendor fields and preserves them.
2027 #[test]
2028 fn recurrence_rule_preserves_vendor_extras() {
2029 let raw = json!({
2030 "@type": "RecurrenceRule",
2031 "frequency": "monthly",
2032 "acmeCorpRuleNote": "billing-cycle"
2033 });
2034 let r: RecurrenceRule = serde_json::from_value(raw).unwrap();
2035 assert_eq!(
2036 r.extra.get("acmeCorpRuleNote").and_then(|v| v.as_str()),
2037 Some("billing-cycle")
2038 );
2039 let back = serde_json::to_value(&r).unwrap();
2040 assert_eq!(back["acmeCorpRuleNote"], "billing-cycle");
2041 }
2042
2043 /// `Location.extra` captures vendor fields and preserves them.
2044 #[test]
2045 fn location_preserves_vendor_extras() {
2046 let raw = json!({
2047 "@type": "Location",
2048 "name": "HQ",
2049 "acmeCorpInternalCode": "bldg-7"
2050 });
2051 let l: Location = serde_json::from_value(raw).unwrap();
2052 assert_eq!(
2053 l.extra.get("acmeCorpInternalCode").and_then(|v| v.as_str()),
2054 Some("bldg-7")
2055 );
2056 let back = serde_json::to_value(&l).unwrap();
2057 assert_eq!(back["acmeCorpInternalCode"], "bldg-7");
2058 }
2059
2060 /// `VirtualLocation.extra` captures vendor fields and preserves them.
2061 #[test]
2062 fn virtual_location_preserves_vendor_extras() {
2063 let raw = json!({
2064 "@type": "VirtualLocation",
2065 "uri": "https://example.com/meet/42",
2066 "acmeCorpMeetingId": "meet-42"
2067 });
2068 let v: VirtualLocation = serde_json::from_value(raw).unwrap();
2069 assert_eq!(
2070 v.extra.get("acmeCorpMeetingId").and_then(|x| x.as_str()),
2071 Some("meet-42")
2072 );
2073 let back = serde_json::to_value(&v).unwrap();
2074 assert_eq!(back["acmeCorpMeetingId"], "meet-42");
2075 }
2076
2077 /// Oracle: `Link::validate_source` accepts a Link with `href` only,
2078 /// `blob_id` only, or both; rejects a Link with neither. Combined
2079 /// RFC 8984 §1.4.11 + JMAP Calendars draft §5.3 contract.
2080 #[test]
2081 fn link_validate_source_enforces_invariant() {
2082 // href only — accepted (pure RFC 8984 case).
2083 let href_only: Link = serde_json::from_value(json!({
2084 "@type": "Link",
2085 "href": "https://example.com/x"
2086 }))
2087 .unwrap();
2088 assert!(href_only.validate_source().is_ok());
2089
2090 // blob_id only — accepted (JMAP Calendars §5.3 case).
2091 let blob_only: Link = serde_json::from_value(json!({
2092 "@type": "Link",
2093 "blobId": "Ge682d5d7aad50b3a4f7180a7ed9276476485ea52"
2094 }))
2095 .unwrap();
2096 assert!(blob_only.validate_source().is_ok());
2097
2098 // Both — accepted (server-stored blob with public-fetch URL).
2099 let both: Link = serde_json::from_value(json!({
2100 "@type": "Link",
2101 "href": "https://example.com/x",
2102 "blobId": "Ge682d5d7aad50b3a4f7180a7ed9276476485ea52"
2103 }))
2104 .unwrap();
2105 assert!(both.validate_source().is_ok());
2106
2107 // Neither — rejected.
2108 let neither: Link = serde_json::from_value(json!({"@type": "Link"})).unwrap();
2109 assert_eq!(neither.validate_source(), Err(LinkSourceError::Missing));
2110 }
2111
2112 /// `Link.extra` captures vendor fields and preserves them.
2113 #[test]
2114 fn link_preserves_vendor_extras() {
2115 let raw = json!({
2116 "@type": "Link",
2117 "href": "https://example.com/x",
2118 "acmeCorpClassification": "internal"
2119 });
2120 let l: Link = serde_json::from_value(raw).unwrap();
2121 assert_eq!(
2122 l.extra
2123 .get("acmeCorpClassification")
2124 .and_then(|v| v.as_str()),
2125 Some("internal")
2126 );
2127 let back = serde_json::to_value(&l).unwrap();
2128 assert_eq!(back["acmeCorpClassification"], "internal");
2129 }
2130
2131 /// `Relation.extra` captures vendor fields and preserves them.
2132 #[test]
2133 fn relation_preserves_vendor_extras() {
2134 let raw = json!({
2135 "@type": "Relation",
2136 "acmeCorpDirection": "outbound"
2137 });
2138 let r: Relation = serde_json::from_value(raw).unwrap();
2139 assert_eq!(
2140 r.extra.get("acmeCorpDirection").and_then(|v| v.as_str()),
2141 Some("outbound")
2142 );
2143 let back = serde_json::to_value(&r).unwrap();
2144 assert_eq!(back["acmeCorpDirection"], "outbound");
2145 }
2146
2147 /// Oracle: `Participant::validate_roles` enforces the RFC 8984
2148 /// §4.4.6 non-empty-roles mandate. Empty `roles` deserializes
2149 /// cleanly (for round-trip preservation) but `validate_roles`
2150 /// rejects. (bd:JMAP-mno4.16)
2151 #[test]
2152 fn participant_validate_roles_rejects_empty() {
2153 // Empty roles — deserialize succeeds, validate rejects.
2154 let raw_empty = json!({"@type": "Participant", "roles": {}});
2155 let p_empty: Participant = serde_json::from_value(raw_empty).unwrap();
2156 assert_eq!(p_empty.validate_roles(), Err(ParticipantRolesError::Empty));
2157
2158 // Non-empty roles — validate accepts.
2159 let mut roles = HashMap::new();
2160 roles.insert("attendee".to_owned(), true);
2161 let p_good = Participant::new(roles);
2162 assert!(p_good.validate_roles().is_ok());
2163 }
2164
2165 /// Oracle: the five RFC 8984 §4.4.6 fields that bd:JMAP-mno4.1 added
2166 /// (`scheduleForceSend`, `sentBy`, `progress`, `progressUpdated`,
2167 /// `percentComplete`) deserialize into their typed fields and round-trip
2168 /// to identical wire JSON. Each field name and shape comes verbatim
2169 /// from the RFC 8984 §4.4.6 spec text — not from the code under test.
2170 #[test]
2171 fn participant_new_rfc8984_fields_round_trip() {
2172 let raw = json!({
2173 "@type": "Participant",
2174 "roles": {"attendee": true},
2175 "scheduleForceSend": true,
2176 "sentBy": "delegate@example.com",
2177 "progress": "in-process",
2178 "progressUpdated": "2024-06-15T08:45:00Z",
2179 "percentComplete": 42
2180 });
2181 let p: Participant =
2182 serde_json::from_value(raw.clone()).expect("Participant must deserialize");
2183 assert_eq!(p.schedule_force_send, Some(true));
2184 assert_eq!(p.sent_by.as_deref(), Some("delegate@example.com"));
2185 assert_eq!(p.progress.as_deref(), Some("in-process"));
2186 assert_eq!(
2187 p.progress_updated.as_ref().map(AsRef::as_ref),
2188 Some("2024-06-15T08:45:00Z")
2189 );
2190 assert_eq!(p.percent_complete, Some(42));
2191
2192 let back = serde_json::to_value(&p).expect("serialize must succeed");
2193 assert_eq!(back, raw, "round-trip must preserve wire shape");
2194 }
2195
2196 /// `Participant.extra` captures vendor fields and preserves them.
2197 #[test]
2198 fn participant_preserves_vendor_extras() {
2199 let raw = json!({
2200 "@type": "Participant",
2201 "roles": {"attendee": true},
2202 "acmeCorpEmployeeId": "emp-42"
2203 });
2204 let p: Participant = serde_json::from_value(raw).unwrap();
2205 assert_eq!(
2206 p.extra.get("acmeCorpEmployeeId").and_then(|v| v.as_str()),
2207 Some("emp-42")
2208 );
2209 let back = serde_json::to_value(&p).unwrap();
2210 assert_eq!(back["acmeCorpEmployeeId"], "emp-42");
2211 }
2212
2213 /// `OffsetTrigger.extra` captures vendor fields and preserves them.
2214 #[test]
2215 fn offset_trigger_preserves_vendor_extras() {
2216 let raw = json!({
2217 "@type": "OffsetTrigger",
2218 "offset": "-PT15M",
2219 "acmeCorpClientTag": "mobile"
2220 });
2221 let t: OffsetTrigger = serde_json::from_value(raw).unwrap();
2222 assert_eq!(
2223 t.extra.get("acmeCorpClientTag").and_then(|v| v.as_str()),
2224 Some("mobile")
2225 );
2226 let back = serde_json::to_value(&t).unwrap();
2227 assert_eq!(back["acmeCorpClientTag"], "mobile");
2228 }
2229
2230 /// `AbsoluteTrigger.extra` captures vendor fields and preserves them.
2231 #[test]
2232 fn absolute_trigger_preserves_vendor_extras() {
2233 let raw = json!({
2234 "@type": "AbsoluteTrigger",
2235 "when": "2024-06-15T08:45:00Z",
2236 "acmeCorpTriggerSource": "iCal"
2237 });
2238 let t: AbsoluteTrigger = serde_json::from_value(raw).unwrap();
2239 assert_eq!(
2240 t.extra
2241 .get("acmeCorpTriggerSource")
2242 .and_then(|v| v.as_str()),
2243 Some("iCal")
2244 );
2245 let back = serde_json::to_value(&t).unwrap();
2246 assert_eq!(back["acmeCorpTriggerSource"], "iCal");
2247 }
2248
2249 /// `Alert.extra` captures vendor fields and preserves them.
2250 #[test]
2251 fn alert_preserves_vendor_extras() {
2252 let raw = json!({
2253 "@type": "Alert",
2254 "trigger": {
2255 "@type": "OffsetTrigger",
2256 "offset": "-PT15M"
2257 },
2258 "acmeCorpAlertChannel": "mobile-push"
2259 });
2260 let a: Alert = serde_json::from_value(raw).unwrap();
2261 assert_eq!(
2262 a.extra.get("acmeCorpAlertChannel").and_then(|v| v.as_str()),
2263 Some("mobile-push")
2264 );
2265 let back = serde_json::to_value(&a).unwrap();
2266 assert_eq!(back["acmeCorpAlertChannel"], "mobile-push");
2267 }
2268
2269 /// `TimeZoneRule.extra` captures vendor fields and preserves them.
2270 #[test]
2271 fn time_zone_rule_preserves_vendor_extras() {
2272 let raw = json!({
2273 "@type": "TimeZoneRule",
2274 "start": "1970-01-01T00:00:00",
2275 "offsetFrom": "+0000",
2276 "offsetTo": "+0000",
2277 "acmeCorpRuleOrigin": "iana-tzdata-2024a"
2278 });
2279 let r: TimeZoneRule = serde_json::from_value(raw).unwrap();
2280 assert_eq!(
2281 r.extra.get("acmeCorpRuleOrigin").and_then(|v| v.as_str()),
2282 Some("iana-tzdata-2024a")
2283 );
2284 let back = serde_json::to_value(&r).unwrap();
2285 assert_eq!(back["acmeCorpRuleOrigin"], "iana-tzdata-2024a");
2286 }
2287
2288 /// `TimeZone.extra` captures vendor fields and preserves them.
2289 #[test]
2290 fn time_zone_preserves_vendor_extras() {
2291 let raw = json!({
2292 "@type": "TimeZone",
2293 "tzId": "Etc/UTC",
2294 "acmeCorpDataSource": "iana"
2295 });
2296 let t: TimeZone = serde_json::from_value(raw).unwrap();
2297 assert_eq!(
2298 t.extra.get("acmeCorpDataSource").and_then(|v| v.as_str()),
2299 Some("iana")
2300 );
2301 let back = serde_json::to_value(&t).unwrap();
2302 assert_eq!(back["acmeCorpDataSource"], "iana");
2303 }
2304
2305 /// Oracle: a minimal `TimeZone` with a STANDARD rule round-trips per
2306 /// RFC 8984 §4.7.2. The wire shape — `tzId`, `@type` discriminators on
2307 /// both TimeZone and TimeZoneRule, and `offsetFrom` / `offsetTo` as
2308 /// signed offset strings — comes directly from the spec text.
2309 #[test]
2310 fn time_zone_with_standard_rule_round_trips() {
2311 let raw = json!({
2312 "@type": "TimeZone",
2313 "tzId": "Europe/Berlin",
2314 "standard": [{
2315 "@type": "TimeZoneRule",
2316 "start": "1996-10-27T03:00:00",
2317 "offsetFrom": "+0200",
2318 "offsetTo": "+0100",
2319 "recurrenceRules": [{
2320 "@type": "RecurrenceRule",
2321 "frequency": "yearly",
2322 "byMonth": ["10"],
2323 "byDay": [{
2324 "@type": "NDay",
2325 "day": "su",
2326 "nthOfPeriod": -1
2327 }]
2328 }],
2329 "names": {"CET": true}
2330 }],
2331 "daylight": [{
2332 "@type": "TimeZoneRule",
2333 "start": "1996-03-31T02:00:00",
2334 "offsetFrom": "+0100",
2335 "offsetTo": "+0200",
2336 "recurrenceRules": [{
2337 "@type": "RecurrenceRule",
2338 "frequency": "yearly",
2339 "byMonth": ["3"],
2340 "byDay": [{
2341 "@type": "NDay",
2342 "day": "su",
2343 "nthOfPeriod": -1
2344 }]
2345 }],
2346 "names": {"CEST": true}
2347 }]
2348 });
2349 let tz: TimeZone = serde_json::from_value(raw.clone()).expect("TimeZone must deserialize");
2350 assert_eq!(tz.tz_id, "Europe/Berlin");
2351 assert_eq!(tz.standard.as_ref().map(Vec::len), Some(1));
2352 assert_eq!(tz.daylight.as_ref().map(Vec::len), Some(1));
2353 let standard = &tz.standard.as_ref().unwrap()[0];
2354 assert_eq!(standard.offset_from.as_ref(), "+0200");
2355 assert_eq!(standard.offset_to.as_ref(), "+0100");
2356 assert_eq!(
2357 standard.recurrence_rules.as_ref().map(Vec::len),
2358 Some(1),
2359 "STANDARD rule must carry exactly one RRULE per RFC 8984 §4.7.2"
2360 );
2361
2362 let back = serde_json::to_value(&tz).expect("serialize must succeed");
2363 assert_eq!(back, raw, "round-trip must preserve wire shape");
2364 }
2365
2366 /// Oracle: `TimeZoneRule::validate_recurrence_overrides_empty`
2367 /// enforces the RFC 8984 §4.7.2 empty-patch constraint. A
2368 /// non-empty PatchObject value deserializes cleanly (for
2369 /// round-trip preservation) but the validator names the offending
2370 /// key. (bd:JMAP-mno4.18)
2371 #[test]
2372 fn time_zone_rule_validate_recurrence_overrides_rejects_non_empty() {
2373 // None: validate passes trivially.
2374 let none_rule =
2375 TimeZoneRule::new(LocalDateTime::from("1970-01-01T00:00:00"), "+0000", "+0000");
2376 assert!(none_rule.validate_recurrence_overrides_empty().is_ok());
2377
2378 // All empty: validate passes.
2379 let raw_ok = json!({
2380 "@type": "TimeZoneRule",
2381 "start": "1970-01-01T00:00:00",
2382 "offsetFrom": "+0000",
2383 "offsetTo": "+0000",
2384 "recurrenceOverrides": {
2385 "1990-04-01T02:00:00": {},
2386 "1991-04-07T02:00:00": {}
2387 }
2388 });
2389 let ok_rule: TimeZoneRule = serde_json::from_value(raw_ok).unwrap();
2390 assert!(ok_rule.validate_recurrence_overrides_empty().is_ok());
2391
2392 // One non-empty: validate names the offender.
2393 let raw_bad = json!({
2394 "@type": "TimeZoneRule",
2395 "start": "1970-01-01T00:00:00",
2396 "offsetFrom": "+0000",
2397 "offsetTo": "+0000",
2398 "recurrenceOverrides": {
2399 "1990-04-01T02:00:00": {},
2400 "1991-04-07T02:00:00": {"acmeCorp": "shouldnt-be-here"}
2401 }
2402 });
2403 let bad_rule: TimeZoneRule = serde_json::from_value(raw_bad).unwrap();
2404 let err = bad_rule.validate_recurrence_overrides_empty().unwrap_err();
2405 match err {
2406 RecurrenceOverridesError::NonEmptyPatch { key } => {
2407 assert_eq!(key, "1991-04-07T02:00:00");
2408 }
2409 }
2410 }
2411
2412 /// Oracle: `TimeZoneRule.recurrenceOverrides` is a `LocalDateTime[PatchObject]`
2413 /// map; per RFC 8984 §4.7.2 the patch object MUST be the empty `{}`.
2414 /// This test verifies the typed map deserializes and the empty-patch
2415 /// constraint survives round-trip.
2416 #[test]
2417 fn time_zone_rule_recurrence_overrides_round_trips() {
2418 let raw = json!({
2419 "@type": "TimeZoneRule",
2420 "start": "1970-01-01T00:00:00",
2421 "offsetFrom": "+0000",
2422 "offsetTo": "+0000",
2423 "recurrenceOverrides": {
2424 "1990-04-01T02:00:00": {},
2425 "1991-04-07T02:00:00": {}
2426 }
2427 });
2428 let r: TimeZoneRule = serde_json::from_value(raw).expect("TimeZoneRule must deserialize");
2429 let overrides = r
2430 .recurrence_overrides
2431 .as_ref()
2432 .expect("recurrenceOverrides must deserialize as Some");
2433 assert_eq!(overrides.len(), 2);
2434 for v in overrides.values() {
2435 assert!(
2436 v.as_map().is_empty(),
2437 "PatchObject value MUST be empty per RFC 8984 §4.7.2"
2438 );
2439 }
2440 }
2441
2442 // ── @type-default regression tests (bd:JMAP-ky8g.10) ──────────────────
2443 //
2444 // Every JSCalendar sub-type declares `@type` as a bare `String` with a
2445 // serde-default function returning the RFC 8984-mandated literal.
2446 // Deserialize MUST succeed when `@type` is absent (spec-violating
2447 // producer input or partial fixture), populating the field with the
2448 // literal. Serialize MUST always emit the field.
2449 //
2450 // Independent oracle: hand-written JSON shaped against RFC 8984 §4.x
2451 // example text with `@type` omitted, plus the produced serialize-back
2452 // JSON checked against the same RFC's mandated literal.
2453 //
2454 // The bead's acceptance criterion is "one regression test per type
2455 // asserting (a) deserialize succeeds when `@type` is absent and the
2456 // field equals the literal; (b) explicit non-default values round-trip
2457 // verbatim". Per-type (a) tests follow. A representative (b) test on
2458 // Participant covers the contract uniformly (the serde-default mechanism
2459 // is the same across all 12 types; per-type duplication would not add
2460 // signal). A nested-parent regression test covers the concrete failure
2461 // mode the bead identifies.
2462
2463 /// `NDay` deserialize succeeds when `@type` is absent and defaults to
2464 /// `"NDay"`. Re-serialize emits the field with the default value.
2465 #[test]
2466 fn n_day_at_type_defaults_when_absent() {
2467 let raw = json!({ "day": "mo" });
2468 let n: NDay = serde_json::from_value(raw).unwrap();
2469 assert_eq!(n.at_type, "NDay");
2470 let back = serde_json::to_value(&n).unwrap();
2471 assert_eq!(back["@type"], "NDay");
2472 }
2473
2474 /// `RecurrenceRule` deserialize succeeds when `@type` is absent and
2475 /// defaults to `"RecurrenceRule"`. Re-serialize emits the field.
2476 #[test]
2477 fn recurrence_rule_at_type_defaults_when_absent() {
2478 let raw = json!({ "frequency": "weekly" });
2479 let r: RecurrenceRule = serde_json::from_value(raw).unwrap();
2480 assert_eq!(r.at_type, "RecurrenceRule");
2481 let back = serde_json::to_value(&r).unwrap();
2482 assert_eq!(back["@type"], "RecurrenceRule");
2483 }
2484
2485 /// `Location` deserialize succeeds when `@type` is absent and defaults
2486 /// to `"Location"`. Re-serialize emits the field with the default.
2487 #[test]
2488 fn location_at_type_defaults_when_absent() {
2489 let raw = json!({ "name": "HQ" });
2490 let l: Location = serde_json::from_value(raw).unwrap();
2491 assert_eq!(l.at_type, "Location");
2492 let back = serde_json::to_value(&l).unwrap();
2493 assert_eq!(back["@type"], "Location");
2494 }
2495
2496 /// `VirtualLocation` deserialize succeeds when `@type` is absent and
2497 /// defaults to `"VirtualLocation"`. Re-serialize emits the field.
2498 #[test]
2499 fn virtual_location_at_type_defaults_when_absent() {
2500 let raw = json!({ "uri": "https://example.com/meet/abc" });
2501 let v: VirtualLocation = serde_json::from_value(raw).unwrap();
2502 assert_eq!(v.at_type, "VirtualLocation");
2503 let back = serde_json::to_value(&v).unwrap();
2504 assert_eq!(back["@type"], "VirtualLocation");
2505 }
2506
2507 /// `Link` deserialize succeeds when `@type` is absent and defaults to
2508 /// `"Link"`. Re-serialize emits the field with the default value.
2509 #[test]
2510 fn link_at_type_defaults_when_absent() {
2511 let raw = json!({ "href": "https://example.com/attach.pdf" });
2512 let l: Link = serde_json::from_value(raw).unwrap();
2513 assert_eq!(l.at_type, "Link");
2514 let back = serde_json::to_value(&l).unwrap();
2515 assert_eq!(back["@type"], "Link");
2516 }
2517
2518 /// `Relation` deserialize succeeds when `@type` is absent and defaults
2519 /// to `"Relation"`. Re-serialize emits the field with the default.
2520 #[test]
2521 fn relation_at_type_defaults_when_absent() {
2522 let raw = json!({ "relation": { "parent": true } });
2523 let r: Relation = serde_json::from_value(raw).unwrap();
2524 assert_eq!(r.at_type, "Relation");
2525 let back = serde_json::to_value(&r).unwrap();
2526 assert_eq!(back["@type"], "Relation");
2527 }
2528
2529 /// `Participant` deserialize succeeds when `@type` is absent and
2530 /// defaults to `"Participant"`. Re-serialize emits the field.
2531 #[test]
2532 fn participant_at_type_defaults_when_absent() {
2533 let raw = json!({ "name": "Alice", "roles": { "attendee": true } });
2534 let p: Participant = serde_json::from_value(raw).unwrap();
2535 assert_eq!(p.at_type, "Participant");
2536 let back = serde_json::to_value(&p).unwrap();
2537 assert_eq!(back["@type"], "Participant");
2538 }
2539
2540 /// `OffsetTrigger` deserialize succeeds when `@type` is absent and
2541 /// defaults to `"OffsetTrigger"`. Re-serialize emits the field.
2542 #[test]
2543 fn offset_trigger_at_type_defaults_when_absent() {
2544 let raw = json!({ "offset": "-PT5M" });
2545 let t: OffsetTrigger = serde_json::from_value(raw).unwrap();
2546 assert_eq!(t.at_type, "OffsetTrigger");
2547 let back = serde_json::to_value(&t).unwrap();
2548 assert_eq!(back["@type"], "OffsetTrigger");
2549 }
2550
2551 /// `AbsoluteTrigger` deserialize succeeds when `@type` is absent and
2552 /// defaults to `"AbsoluteTrigger"`. Re-serialize emits the field.
2553 #[test]
2554 fn absolute_trigger_at_type_defaults_when_absent() {
2555 let raw = json!({ "when": "2024-01-19T18:00:00Z" });
2556 let t: AbsoluteTrigger = serde_json::from_value(raw).unwrap();
2557 assert_eq!(t.at_type, "AbsoluteTrigger");
2558 let back = serde_json::to_value(&t).unwrap();
2559 assert_eq!(back["@type"], "AbsoluteTrigger");
2560 }
2561
2562 /// `Alert` deserialize succeeds when `@type` is absent and defaults to
2563 /// `"Alert"`. Re-serialize emits the field with the default value.
2564 ///
2565 /// The nested `trigger` keeps its explicit `@type` here so the
2566 /// `AlertTrigger` manual deserializer dispatches; the bead's hazard
2567 /// is the wrapping object's `@type` being missing, not the nested
2568 /// trigger's tag (which is required by the dispatch logic).
2569 #[test]
2570 fn alert_at_type_defaults_when_absent() {
2571 let raw = json!({
2572 "trigger": { "@type": "OffsetTrigger", "offset": "-PT5M" }
2573 });
2574 let a: Alert = serde_json::from_value(raw).unwrap();
2575 assert_eq!(a.at_type, "Alert");
2576 let back = serde_json::to_value(&a).unwrap();
2577 assert_eq!(back["@type"], "Alert");
2578 }
2579
2580 /// `TimeZoneRule` deserialize succeeds when `@type` is absent and
2581 /// defaults to `"TimeZoneRule"`. Re-serialize emits the field.
2582 #[test]
2583 fn time_zone_rule_at_type_defaults_when_absent() {
2584 let raw = json!({
2585 "start": "1970-01-01T00:00:00",
2586 "offsetFrom": "+0000",
2587 "offsetTo": "+0000"
2588 });
2589 let r: TimeZoneRule = serde_json::from_value(raw).unwrap();
2590 assert_eq!(r.at_type, "TimeZoneRule");
2591 let back = serde_json::to_value(&r).unwrap();
2592 assert_eq!(back["@type"], "TimeZoneRule");
2593 }
2594
2595 /// `TimeZone` deserialize succeeds when `@type` is absent and defaults
2596 /// to `"TimeZone"`. Re-serialize emits the field with the default.
2597 #[test]
2598 fn time_zone_at_type_defaults_when_absent() {
2599 let raw = json!({ "tzId": "Etc/UTC" });
2600 let z: TimeZone = serde_json::from_value(raw).unwrap();
2601 assert_eq!(z.at_type, "TimeZone");
2602 let back = serde_json::to_value(&z).unwrap();
2603 assert_eq!(back["@type"], "TimeZone");
2604 }
2605
2606 /// Explicit non-default `@type` values round-trip verbatim — the
2607 /// serde-default does NOT overwrite an explicit wire value. Locks in
2608 /// the contract that a vendor shipping a non-conformant string is
2609 /// preserved end-to-end rather than silently normalised. The serde-
2610 /// default mechanism is the same across all 12 sub-types; a single
2611 /// representative test (Participant) covers the contract uniformly.
2612 /// `validate_at_type()` is the strict-input path callers opt into.
2613 #[test]
2614 fn participant_at_type_explicit_value_round_trips_verbatim() {
2615 let raw = json!({
2616 "@type": "AcmeCorpParticipant",
2617 "name": "Alice",
2618 "roles": { "attendee": true }
2619 });
2620 let p: Participant = serde_json::from_value(raw).unwrap();
2621 assert_eq!(p.at_type, "AcmeCorpParticipant");
2622 let back = serde_json::to_value(&p).unwrap();
2623 assert_eq!(back["@type"], "AcmeCorpParticipant");
2624 // The strict-input path surfaces the mismatch when callers opt in.
2625 assert!(p.validate_at_type().is_err());
2626 }
2627
2628 /// A parent object (Alert wrapping OffsetTrigger; representative of
2629 /// every JSCalendar parent-with-sub-objects case) deserializes
2630 /// successfully when nested sub-objects omit their `@type`. This is
2631 /// the concrete failure mode the bead identifies: a server response
2632 /// missing `@type` on a sub-object would previously fail the whole
2633 /// parent's deserialize.
2634 ///
2635 /// NOTE: `AlertTrigger` is the one exception — its manual
2636 /// `Deserialize` dispatches on `@type` and so the discriminator MUST
2637 /// be present on the trigger to select the variant. The bead's
2638 /// fix-scope explicitly does not touch the dispatch logic (a
2639 /// missing-`@type` trigger falls into `AlertTrigger::Unknown` by
2640 /// design per RFC 8984 §4.5.2 preserve-mandate). The outer `Alert`
2641 /// container's `@type` is what gains the default.
2642 #[test]
2643 fn alert_with_missing_outer_at_type_deserializes() {
2644 let raw = json!({
2645 "trigger": {
2646 "@type": "OffsetTrigger",
2647 "offset": "-PT15M"
2648 },
2649 "action": "display"
2650 });
2651 let a: Alert = serde_json::from_value(raw).unwrap();
2652 assert_eq!(a.at_type, "Alert");
2653 match a.trigger {
2654 AlertTrigger::OffsetTrigger(ref t) => {
2655 assert_eq!(t.at_type, "OffsetTrigger");
2656 assert_eq!(t.offset.as_ref(), "-PT15M");
2657 }
2658 _ => panic!("trigger MUST deserialize as OffsetTrigger variant"),
2659 }
2660 }
2661}