Skip to main content

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}