#![forbid(unsafe_code)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub use jmap_types::{Id, PatchObject, UTCDate};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeTagMismatch {
pub expected: &'static str,
pub actual: String,
}
impl std::fmt::Display for TypeTagMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"JSCalendar @type mismatch: expected {:?}, got {:?}",
self.expected, self.actual
)
}
}
impl std::error::Error for TypeTagMismatch {}
pub trait TypeDiscriminator {
const TYPE_TAG: &'static str;
fn at_type(&self) -> &str;
fn validate_at_type(&self) -> Result<(), TypeTagMismatch> {
if self.at_type() == Self::TYPE_TAG {
Ok(())
} else {
Err(TypeTagMismatch {
expected: Self::TYPE_TAG,
actual: self.at_type().to_owned(),
})
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LocalDateTime(String);
impl From<String> for LocalDateTime {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for LocalDateTime {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for LocalDateTime {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LocalDateTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Duration(String);
impl From<String> for Duration {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Duration {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for Duration {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Duration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SignedDuration(String);
impl From<String> for SignedDuration {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SignedDuration {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for SignedDuration {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SignedDuration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UTCOffset(String);
impl From<String> for UTCOffset {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for UTCOffset {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for UTCOffset {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for UTCOffset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn n_day_at_type_default() -> String {
"NDay".to_owned()
}
fn recurrence_rule_at_type_default() -> String {
"RecurrenceRule".to_owned()
}
fn location_at_type_default() -> String {
"Location".to_owned()
}
fn virtual_location_at_type_default() -> String {
"VirtualLocation".to_owned()
}
fn link_at_type_default() -> String {
"Link".to_owned()
}
fn relation_at_type_default() -> String {
"Relation".to_owned()
}
fn participant_at_type_default() -> String {
"Participant".to_owned()
}
fn offset_trigger_at_type_default() -> String {
"OffsetTrigger".to_owned()
}
fn absolute_trigger_at_type_default() -> String {
"AbsoluteTrigger".to_owned()
}
fn alert_at_type_default() -> String {
"Alert".to_owned()
}
fn time_zone_rule_at_type_default() -> String {
"TimeZoneRule".to_owned()
}
fn time_zone_at_type_default() -> String {
"TimeZone".to_owned()
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NDay {
#[serde(rename = "@type", default = "n_day_at_type_default")]
pub at_type: String,
pub day: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nth_of_period: Option<i32>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl NDay {
pub fn new(day: impl Into<String>) -> Self {
Self {
at_type: "NDay".to_owned(),
day: day.into(),
nth_of_period: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for NDay {
const TYPE_TAG: &'static str = "NDay";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecurrenceRule {
#[serde(rename = "@type", default = "recurrence_rule_at_type_default")]
pub at_type: String,
pub frequency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rscale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_day_of_week: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_day: Option<Vec<NDay>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_month_day: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_month: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_year_day: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_week_no: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_hour: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_minute: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_second: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_set_position: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub until: Option<LocalDateTime>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl RecurrenceRule {
pub fn new(frequency: impl Into<String>) -> Self {
Self {
at_type: "RecurrenceRule".to_owned(),
frequency: frequency.into(),
interval: None,
rscale: None,
skip: None,
first_day_of_week: None,
by_day: None,
by_month_day: None,
by_month: None,
by_year_day: None,
by_week_no: None,
by_hour: None,
by_minute: None,
by_second: None,
by_set_position: None,
count: None,
until: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for RecurrenceRule {
const TYPE_TAG: &'static str = "RecurrenceRule";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
#[serde(rename = "@type", default = "location_at_type_default")]
pub at_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_types: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relative_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_zone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coordinates: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub links: Option<HashMap<String, Link>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Location {
pub fn new() -> Self {
Self {
at_type: "Location".to_owned(),
name: None,
description: None,
location_types: None,
relative_to: None,
time_zone: None,
coordinates: None,
links: None,
extra: serde_json::Map::new(),
}
}
}
impl Default for Location {
fn default() -> Self {
Self::new()
}
}
impl TypeDiscriminator for Location {
const TYPE_TAG: &'static str = "Location";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VirtualLocation {
#[serde(rename = "@type", default = "virtual_location_at_type_default")]
pub at_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub features: Option<HashMap<String, bool>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl VirtualLocation {
pub fn new(uri: impl Into<String>) -> Self {
Self {
at_type: "VirtualLocation".to_owned(),
name: None,
description: None,
uri: uri.into(),
features: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for VirtualLocation {
const TYPE_TAG: &'static str = "VirtualLocation";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Link {
#[serde(rename = "@type", default = "link_at_type_default")]
pub at_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob_id: Option<Id>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkSourceError {
Missing,
}
impl std::fmt::Display for LinkSourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LinkSourceError::Missing => f.write_str(
"Link has neither href nor blobId set; RFC 8984 §1.4.11 and \
JMAP Calendars draft §5.3 require at least one",
),
}
}
}
impl std::error::Error for LinkSourceError {}
impl Link {
pub fn new() -> Self {
Self {
at_type: "Link".to_owned(),
href: None,
content_type: None,
size: None,
rel: None,
display: None,
cid: None,
title: None,
blob_id: None,
extra: serde_json::Map::new(),
}
}
pub fn with_href(href: impl Into<String>) -> Self {
Self {
href: Some(href.into()),
..Self::new()
}
}
pub fn with_blob_id(blob_id: Id) -> Self {
Self {
blob_id: Some(blob_id),
..Self::new()
}
}
pub fn validate_source(&self) -> Result<(), LinkSourceError> {
if self.href.is_none() && self.blob_id.is_none() {
Err(LinkSourceError::Missing)
} else {
Ok(())
}
}
}
impl Default for Link {
fn default() -> Self {
Self::new()
}
}
impl TypeDiscriminator for Link {
const TYPE_TAG: &'static str = "Link";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Relation {
#[serde(rename = "@type", default = "relation_at_type_default")]
pub at_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub relation: Option<HashMap<String, bool>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Relation {
pub fn new() -> Self {
Self {
at_type: "Relation".to_owned(),
relation: None,
extra: serde_json::Map::new(),
}
}
}
impl Default for Relation {
fn default() -> Self {
Self::new()
}
}
impl TypeDiscriminator for Relation {
const TYPE_TAG: &'static str = "Relation";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Participant {
#[serde(rename = "@type", default = "participant_at_type_default")]
pub at_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_to: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
pub roles: HashMap<String, bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub participation_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub participation_comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expect_reply: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invited_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegated_to: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegated_from: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_of: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub links: Option<HashMap<String, Link>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_sequence: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_updated: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_status: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_force_send: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sent_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress_updated: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent_complete: Option<u8>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParticipantRolesError {
Empty,
}
impl std::fmt::Display for ParticipantRolesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParticipantRolesError::Empty => f.write_str(
"Participant.roles is empty; RFC 8984 §4.4.6 requires at \
least one role",
),
}
}
}
impl std::error::Error for ParticipantRolesError {}
impl Participant {
pub fn new(roles: HashMap<String, bool>) -> Self {
Self {
at_type: "Participant".to_owned(),
name: None,
email: None,
description: None,
send_to: None,
kind: None,
roles,
location_id: None,
language: None,
participation_status: None,
participation_comment: None,
expect_reply: None,
schedule_agent: None,
calendar_address: None,
invited_by: None,
delegated_to: None,
delegated_from: None,
member_of: None,
links: None,
schedule_sequence: None,
schedule_updated: None,
schedule_status: None,
schedule_force_send: None,
sent_by: None,
progress: None,
progress_updated: None,
percent_complete: None,
extra: serde_json::Map::new(),
}
}
}
impl Participant {
pub fn validate_roles(&self) -> Result<(), ParticipantRolesError> {
if self.roles.is_empty() {
Err(ParticipantRolesError::Empty)
} else {
Ok(())
}
}
}
impl TypeDiscriminator for Participant {
const TYPE_TAG: &'static str = "Participant";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OffsetTrigger {
#[serde(rename = "@type", default = "offset_trigger_at_type_default")]
pub at_type: String,
pub offset: SignedDuration,
#[serde(skip_serializing_if = "Option::is_none")]
pub relative_to: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl OffsetTrigger {
pub fn new(offset: SignedDuration) -> Self {
Self {
at_type: "OffsetTrigger".to_owned(),
offset,
relative_to: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for OffsetTrigger {
const TYPE_TAG: &'static str = "OffsetTrigger";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AbsoluteTrigger {
#[serde(rename = "@type", default = "absolute_trigger_at_type_default")]
pub at_type: String,
pub when: UTCDate,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl AbsoluteTrigger {
pub fn new(when: UTCDate) -> Self {
Self {
at_type: "AbsoluteTrigger".to_owned(),
when,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for AbsoluteTrigger {
const TYPE_TAG: &'static str = "AbsoluteTrigger";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum AlertTrigger {
OffsetTrigger(OffsetTrigger),
AbsoluteTrigger(AbsoluteTrigger),
Unknown(serde_json::Value),
}
impl Serialize for AlertTrigger {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
AlertTrigger::OffsetTrigger(t) => t.serialize(s),
AlertTrigger::AbsoluteTrigger(t) => t.serialize(s),
AlertTrigger::Unknown(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for AlertTrigger {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
let tag = v
.get("@type")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_owned();
match tag.as_str() {
"OffsetTrigger" => {
let t: OffsetTrigger =
serde_json::from_value(v).map_err(serde::de::Error::custom)?;
Ok(AlertTrigger::OffsetTrigger(t))
}
"AbsoluteTrigger" => {
let t: AbsoluteTrigger =
serde_json::from_value(v).map_err(serde::de::Error::custom)?;
Ok(AlertTrigger::AbsoluteTrigger(t))
}
_ => Ok(AlertTrigger::Unknown(v)),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Alert {
#[serde(rename = "@type", default = "alert_at_type_default")]
pub at_type: String,
pub trigger: AlertTrigger,
#[serde(skip_serializing_if = "Option::is_none")]
pub acknowledged: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related_to: Option<HashMap<String, Relation>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Alert {
pub fn new(trigger: AlertTrigger) -> Self {
Self {
at_type: "Alert".to_owned(),
trigger,
acknowledged: None,
related_to: None,
action: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for Alert {
const TYPE_TAG: &'static str = "Alert";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeZoneRule {
#[serde(rename = "@type", default = "time_zone_rule_at_type_default")]
pub at_type: String,
pub start: LocalDateTime,
pub offset_from: UTCOffset,
pub offset_to: UTCOffset,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurrence_rules: Option<Vec<RecurrenceRule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurrence_overrides: Option<HashMap<LocalDateTime, PatchObject>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub names: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comments: Option<Vec<String>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl TimeZoneRule {
pub fn new(
start: LocalDateTime,
offset_from: impl Into<UTCOffset>,
offset_to: impl Into<UTCOffset>,
) -> Self {
Self {
at_type: "TimeZoneRule".to_owned(),
start,
offset_from: offset_from.into(),
offset_to: offset_to.into(),
recurrence_rules: None,
recurrence_overrides: None,
names: None,
comments: None,
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecurrenceOverridesError {
NonEmptyPatch {
key: String,
},
}
impl std::fmt::Display for RecurrenceOverridesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecurrenceOverridesError::NonEmptyPatch { key } => write!(
f,
"TimeZoneRule.recurrenceOverrides[{key:?}] carries a non-empty \
PatchObject; RFC 8984 §4.7.2 requires the empty patch"
),
}
}
}
impl std::error::Error for RecurrenceOverridesError {}
impl TimeZoneRule {
pub fn validate_recurrence_overrides_empty(&self) -> Result<(), RecurrenceOverridesError> {
if let Some(map) = &self.recurrence_overrides {
for (k, v) in map {
if !v.as_map().is_empty() {
return Err(RecurrenceOverridesError::NonEmptyPatch {
key: k.as_ref().to_owned(),
});
}
}
}
Ok(())
}
}
impl TypeDiscriminator for TimeZoneRule {
const TYPE_TAG: &'static str = "TimeZoneRule";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeZone {
#[serde(rename = "@type", default = "time_zone_at_type_default")]
pub at_type: String,
pub tz_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<UTCDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<HashMap<String, bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standard: Option<Vec<TimeZoneRule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daylight: Option<Vec<TimeZoneRule>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl TimeZone {
pub fn new(tz_id: impl Into<String>) -> Self {
Self {
at_type: "TimeZone".to_owned(),
tz_id: tz_id.into(),
updated: None,
url: None,
valid_until: None,
aliases: None,
standard: None,
daylight: None,
extra: serde_json::Map::new(),
}
}
}
impl TypeDiscriminator for TimeZone {
const TYPE_TAG: &'static str = "TimeZone";
fn at_type(&self) -> &str {
&self.at_type
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validate_at_type_rejects_wrong_discriminator() {
let raw = json!({"@type": "NotNDay", "day": "mo"});
let bad: NDay = serde_json::from_value(raw).unwrap();
assert_eq!(bad.day, "mo"); let err = bad.validate_at_type().expect_err("expected mismatch");
assert_eq!(err.expected, "NDay");
assert_eq!(err.actual, "NotNDay");
let good = NDay::new("mo");
assert!(good.validate_at_type().is_ok());
let bad_loc: Location =
serde_json::from_value(json!({"@type": "Place", "name": "HQ"})).unwrap();
assert_eq!(bad_loc.validate_at_type().unwrap_err().expected, "Location");
assert!(Location::new().validate_at_type().is_ok());
let bad_alert: Alert = serde_json::from_value(json!({
"@type": "Notification",
"trigger": {"@type": "OffsetTrigger", "offset": "-PT5M"}
}))
.unwrap();
assert_eq!(bad_alert.validate_at_type().unwrap_err().expected, "Alert");
}
#[test]
fn constructors_set_at_type_to_wire_discriminator() {
let nday = NDay::new("mo");
let rule = RecurrenceRule::new("weekly");
let loc = Location::new();
let vloc = VirtualLocation::new("https://example.com/m");
let link = Link::with_href("https://example.com/x");
let rel = Relation::new();
let mut roles = HashMap::new();
roles.insert("attendee".to_owned(), true);
let part = Participant::new(roles);
let off = OffsetTrigger::new(SignedDuration::from("-PT15M"));
let abs = AbsoluteTrigger::new(UTCDate::from("2024-06-15T08:45:00Z"));
let alert = Alert::new(AlertTrigger::OffsetTrigger(off.clone()));
let rule_in_tz =
TimeZoneRule::new(LocalDateTime::from("1970-01-01T00:00:00"), "+0000", "+0000");
let tz = TimeZone::new("Etc/UTC");
for (val, expected_tag) in [
(serde_json::to_value(&nday).unwrap(), "NDay"),
(serde_json::to_value(&rule).unwrap(), "RecurrenceRule"),
(serde_json::to_value(&loc).unwrap(), "Location"),
(serde_json::to_value(&vloc).unwrap(), "VirtualLocation"),
(serde_json::to_value(&link).unwrap(), "Link"),
(serde_json::to_value(&rel).unwrap(), "Relation"),
(serde_json::to_value(&part).unwrap(), "Participant"),
(serde_json::to_value(&off).unwrap(), "OffsetTrigger"),
(serde_json::to_value(&abs).unwrap(), "AbsoluteTrigger"),
(serde_json::to_value(&alert).unwrap(), "Alert"),
(serde_json::to_value(&rule_in_tz).unwrap(), "TimeZoneRule"),
(serde_json::to_value(&tz).unwrap(), "TimeZone"),
] {
assert_eq!(
val["@type"], expected_tag,
"constructor must set @type to {expected_tag}; got {val:?}"
);
}
}
#[test]
fn link_constructors_satisfy_source_invariant() {
let with_href = Link::with_href("https://example.com");
let with_blob = Link::with_blob_id(Id::from("Ge682d5d7aad50b3a4f"));
let empty = Link::new();
assert!(with_href.validate_source().is_ok());
assert!(with_blob.validate_source().is_ok());
assert_eq!(empty.validate_source(), Err(LinkSourceError::Missing));
}
#[test]
fn scalar_newtypes_display_as_wire_string() {
let dt = LocalDateTime::from("2024-06-15T09:00:00");
let dur = Duration::from("PT1H");
let sdur = SignedDuration::from("-PT15M");
assert_eq!(format!("{dt}"), "2024-06-15T09:00:00");
assert_eq!(format!("{dur}"), "PT1H");
assert_eq!(format!("{sdur}"), "-PT15M");
}
#[test]
fn recurrence_rule_until_is_bare_string_on_the_wire() {
let raw = json!({
"@type": "RecurrenceRule",
"frequency": "monthly",
"until": "2024-12-31T23:59:59"
});
let rule: RecurrenceRule =
serde_json::from_value(raw.clone()).expect("RecurrenceRule must deserialize");
assert_eq!(
rule.until.as_ref().map(AsRef::as_ref),
Some("2024-12-31T23:59:59"),
"until must deserialize into a LocalDateTime carrying the wire string"
);
let round_tripped = serde_json::to_value(&rule).expect("serialize must succeed");
assert_eq!(
round_tripped["until"],
json!("2024-12-31T23:59:59"),
"until must serialize as a bare string; got {round_tripped:?}"
);
}
#[test]
fn offset_trigger_offset_is_bare_string_on_the_wire() {
let raw = json!({
"@type": "OffsetTrigger",
"offset": "-PT15M"
});
let trigger: OffsetTrigger =
serde_json::from_value(raw).expect("OffsetTrigger must deserialize");
assert_eq!(
trigger.offset.as_ref(),
"-PT15M",
"offset must deserialize into a SignedDuration"
);
let round_tripped = serde_json::to_value(&trigger).expect("serialize must succeed");
assert_eq!(
round_tripped["offset"],
json!("-PT15M"),
"offset must serialize as a bare string; got {round_tripped:?}"
);
}
#[test]
fn alert_trigger_unknown_dispatch_on_hostile_input() {
let hostile_values = [
("null", json!(null)),
("empty_array", json!([])),
("integer", json!(42)),
("bare_string", json!("hello")),
("boolean", json!(true)),
("object_without_at_type", json!({"offset": "-PT15M"})),
("object_with_int_at_type", json!({"@type": 42})),
("object_with_null_at_type", json!({"@type": null})),
("object_with_array_at_type", json!({"@type": []})),
(
"object_with_unknown_tag",
json!({"@type": "FuturisticTrigger", "futuristicArg": 1}),
),
];
for (label, v) in hostile_values {
let parsed: AlertTrigger = serde_json::from_value(v.clone())
.unwrap_or_else(|e| panic!("{label}: must not error, got {e}"));
match parsed {
AlertTrigger::Unknown(round) => assert_eq!(
round, v,
"{label}: Unknown must preserve the input Value bit-exactly"
),
other => panic!("{label}: expected Unknown variant, got {other:?}"),
}
}
}
#[test]
fn alert_trigger_unknown_round_trips_through_serialize() {
let original = AlertTrigger::Unknown(json!({"@type": "X", "k": 1}));
let wire = serde_json::to_value(&original).unwrap();
let back: AlertTrigger = serde_json::from_value(wire).unwrap();
assert_eq!(original, back);
}
#[test]
fn absolute_trigger_when_is_bare_string_on_the_wire() {
let raw = json!({
"@type": "AbsoluteTrigger",
"when": "2024-06-15T08:45:00Z"
});
let trigger: AbsoluteTrigger =
serde_json::from_value(raw).expect("AbsoluteTrigger must deserialize");
assert_eq!(
trigger.when.as_ref(),
"2024-06-15T08:45:00Z",
"when must deserialize into a UTCDate"
);
let round_tripped = serde_json::to_value(&trigger).expect("serialize must succeed");
assert_eq!(
round_tripped["when"],
json!("2024-06-15T08:45:00Z"),
"when must serialize as a bare string; got {round_tripped:?}"
);
}
#[test]
fn nday_preserves_vendor_extras() {
let raw = json!({
"@type": "NDay",
"day": "mo",
"acmeCorpDayLabel": "first-mon"
});
let n: NDay = serde_json::from_value(raw).unwrap();
assert_eq!(
n.extra.get("acmeCorpDayLabel").and_then(|v| v.as_str()),
Some("first-mon")
);
let back = serde_json::to_value(&n).unwrap();
assert_eq!(back["acmeCorpDayLabel"], "first-mon");
}
#[test]
fn recurrence_rule_preserves_vendor_extras() {
let raw = json!({
"@type": "RecurrenceRule",
"frequency": "monthly",
"acmeCorpRuleNote": "billing-cycle"
});
let r: RecurrenceRule = serde_json::from_value(raw).unwrap();
assert_eq!(
r.extra.get("acmeCorpRuleNote").and_then(|v| v.as_str()),
Some("billing-cycle")
);
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["acmeCorpRuleNote"], "billing-cycle");
}
#[test]
fn location_preserves_vendor_extras() {
let raw = json!({
"@type": "Location",
"name": "HQ",
"acmeCorpInternalCode": "bldg-7"
});
let l: Location = serde_json::from_value(raw).unwrap();
assert_eq!(
l.extra.get("acmeCorpInternalCode").and_then(|v| v.as_str()),
Some("bldg-7")
);
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["acmeCorpInternalCode"], "bldg-7");
}
#[test]
fn virtual_location_preserves_vendor_extras() {
let raw = json!({
"@type": "VirtualLocation",
"uri": "https://example.com/meet/42",
"acmeCorpMeetingId": "meet-42"
});
let v: VirtualLocation = serde_json::from_value(raw).unwrap();
assert_eq!(
v.extra.get("acmeCorpMeetingId").and_then(|x| x.as_str()),
Some("meet-42")
);
let back = serde_json::to_value(&v).unwrap();
assert_eq!(back["acmeCorpMeetingId"], "meet-42");
}
#[test]
fn link_validate_source_enforces_invariant() {
let href_only: Link = serde_json::from_value(json!({
"@type": "Link",
"href": "https://example.com/x"
}))
.unwrap();
assert!(href_only.validate_source().is_ok());
let blob_only: Link = serde_json::from_value(json!({
"@type": "Link",
"blobId": "Ge682d5d7aad50b3a4f7180a7ed9276476485ea52"
}))
.unwrap();
assert!(blob_only.validate_source().is_ok());
let both: Link = serde_json::from_value(json!({
"@type": "Link",
"href": "https://example.com/x",
"blobId": "Ge682d5d7aad50b3a4f7180a7ed9276476485ea52"
}))
.unwrap();
assert!(both.validate_source().is_ok());
let neither: Link = serde_json::from_value(json!({"@type": "Link"})).unwrap();
assert_eq!(neither.validate_source(), Err(LinkSourceError::Missing));
}
#[test]
fn link_preserves_vendor_extras() {
let raw = json!({
"@type": "Link",
"href": "https://example.com/x",
"acmeCorpClassification": "internal"
});
let l: Link = serde_json::from_value(raw).unwrap();
assert_eq!(
l.extra
.get("acmeCorpClassification")
.and_then(|v| v.as_str()),
Some("internal")
);
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["acmeCorpClassification"], "internal");
}
#[test]
fn relation_preserves_vendor_extras() {
let raw = json!({
"@type": "Relation",
"acmeCorpDirection": "outbound"
});
let r: Relation = serde_json::from_value(raw).unwrap();
assert_eq!(
r.extra.get("acmeCorpDirection").and_then(|v| v.as_str()),
Some("outbound")
);
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["acmeCorpDirection"], "outbound");
}
#[test]
fn participant_validate_roles_rejects_empty() {
let raw_empty = json!({"@type": "Participant", "roles": {}});
let p_empty: Participant = serde_json::from_value(raw_empty).unwrap();
assert_eq!(p_empty.validate_roles(), Err(ParticipantRolesError::Empty));
let mut roles = HashMap::new();
roles.insert("attendee".to_owned(), true);
let p_good = Participant::new(roles);
assert!(p_good.validate_roles().is_ok());
}
#[test]
fn participant_new_rfc8984_fields_round_trip() {
let raw = json!({
"@type": "Participant",
"roles": {"attendee": true},
"scheduleForceSend": true,
"sentBy": "delegate@example.com",
"progress": "in-process",
"progressUpdated": "2024-06-15T08:45:00Z",
"percentComplete": 42
});
let p: Participant =
serde_json::from_value(raw.clone()).expect("Participant must deserialize");
assert_eq!(p.schedule_force_send, Some(true));
assert_eq!(p.sent_by.as_deref(), Some("delegate@example.com"));
assert_eq!(p.progress.as_deref(), Some("in-process"));
assert_eq!(
p.progress_updated.as_ref().map(AsRef::as_ref),
Some("2024-06-15T08:45:00Z")
);
assert_eq!(p.percent_complete, Some(42));
let back = serde_json::to_value(&p).expect("serialize must succeed");
assert_eq!(back, raw, "round-trip must preserve wire shape");
}
#[test]
fn participant_preserves_vendor_extras() {
let raw = json!({
"@type": "Participant",
"roles": {"attendee": true},
"acmeCorpEmployeeId": "emp-42"
});
let p: Participant = serde_json::from_value(raw).unwrap();
assert_eq!(
p.extra.get("acmeCorpEmployeeId").and_then(|v| v.as_str()),
Some("emp-42")
);
let back = serde_json::to_value(&p).unwrap();
assert_eq!(back["acmeCorpEmployeeId"], "emp-42");
}
#[test]
fn offset_trigger_preserves_vendor_extras() {
let raw = json!({
"@type": "OffsetTrigger",
"offset": "-PT15M",
"acmeCorpClientTag": "mobile"
});
let t: OffsetTrigger = serde_json::from_value(raw).unwrap();
assert_eq!(
t.extra.get("acmeCorpClientTag").and_then(|v| v.as_str()),
Some("mobile")
);
let back = serde_json::to_value(&t).unwrap();
assert_eq!(back["acmeCorpClientTag"], "mobile");
}
#[test]
fn absolute_trigger_preserves_vendor_extras() {
let raw = json!({
"@type": "AbsoluteTrigger",
"when": "2024-06-15T08:45:00Z",
"acmeCorpTriggerSource": "iCal"
});
let t: AbsoluteTrigger = serde_json::from_value(raw).unwrap();
assert_eq!(
t.extra
.get("acmeCorpTriggerSource")
.and_then(|v| v.as_str()),
Some("iCal")
);
let back = serde_json::to_value(&t).unwrap();
assert_eq!(back["acmeCorpTriggerSource"], "iCal");
}
#[test]
fn alert_preserves_vendor_extras() {
let raw = json!({
"@type": "Alert",
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT15M"
},
"acmeCorpAlertChannel": "mobile-push"
});
let a: Alert = serde_json::from_value(raw).unwrap();
assert_eq!(
a.extra.get("acmeCorpAlertChannel").and_then(|v| v.as_str()),
Some("mobile-push")
);
let back = serde_json::to_value(&a).unwrap();
assert_eq!(back["acmeCorpAlertChannel"], "mobile-push");
}
#[test]
fn time_zone_rule_preserves_vendor_extras() {
let raw = json!({
"@type": "TimeZoneRule",
"start": "1970-01-01T00:00:00",
"offsetFrom": "+0000",
"offsetTo": "+0000",
"acmeCorpRuleOrigin": "iana-tzdata-2024a"
});
let r: TimeZoneRule = serde_json::from_value(raw).unwrap();
assert_eq!(
r.extra.get("acmeCorpRuleOrigin").and_then(|v| v.as_str()),
Some("iana-tzdata-2024a")
);
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["acmeCorpRuleOrigin"], "iana-tzdata-2024a");
}
#[test]
fn time_zone_preserves_vendor_extras() {
let raw = json!({
"@type": "TimeZone",
"tzId": "Etc/UTC",
"acmeCorpDataSource": "iana"
});
let t: TimeZone = serde_json::from_value(raw).unwrap();
assert_eq!(
t.extra.get("acmeCorpDataSource").and_then(|v| v.as_str()),
Some("iana")
);
let back = serde_json::to_value(&t).unwrap();
assert_eq!(back["acmeCorpDataSource"], "iana");
}
#[test]
fn time_zone_with_standard_rule_round_trips() {
let raw = json!({
"@type": "TimeZone",
"tzId": "Europe/Berlin",
"standard": [{
"@type": "TimeZoneRule",
"start": "1996-10-27T03:00:00",
"offsetFrom": "+0200",
"offsetTo": "+0100",
"recurrenceRules": [{
"@type": "RecurrenceRule",
"frequency": "yearly",
"byMonth": ["10"],
"byDay": [{
"@type": "NDay",
"day": "su",
"nthOfPeriod": -1
}]
}],
"names": {"CET": true}
}],
"daylight": [{
"@type": "TimeZoneRule",
"start": "1996-03-31T02:00:00",
"offsetFrom": "+0100",
"offsetTo": "+0200",
"recurrenceRules": [{
"@type": "RecurrenceRule",
"frequency": "yearly",
"byMonth": ["3"],
"byDay": [{
"@type": "NDay",
"day": "su",
"nthOfPeriod": -1
}]
}],
"names": {"CEST": true}
}]
});
let tz: TimeZone = serde_json::from_value(raw.clone()).expect("TimeZone must deserialize");
assert_eq!(tz.tz_id, "Europe/Berlin");
assert_eq!(tz.standard.as_ref().map(Vec::len), Some(1));
assert_eq!(tz.daylight.as_ref().map(Vec::len), Some(1));
let standard = &tz.standard.as_ref().unwrap()[0];
assert_eq!(standard.offset_from.as_ref(), "+0200");
assert_eq!(standard.offset_to.as_ref(), "+0100");
assert_eq!(
standard.recurrence_rules.as_ref().map(Vec::len),
Some(1),
"STANDARD rule must carry exactly one RRULE per RFC 8984 §4.7.2"
);
let back = serde_json::to_value(&tz).expect("serialize must succeed");
assert_eq!(back, raw, "round-trip must preserve wire shape");
}
#[test]
fn time_zone_rule_validate_recurrence_overrides_rejects_non_empty() {
let none_rule =
TimeZoneRule::new(LocalDateTime::from("1970-01-01T00:00:00"), "+0000", "+0000");
assert!(none_rule.validate_recurrence_overrides_empty().is_ok());
let raw_ok = json!({
"@type": "TimeZoneRule",
"start": "1970-01-01T00:00:00",
"offsetFrom": "+0000",
"offsetTo": "+0000",
"recurrenceOverrides": {
"1990-04-01T02:00:00": {},
"1991-04-07T02:00:00": {}
}
});
let ok_rule: TimeZoneRule = serde_json::from_value(raw_ok).unwrap();
assert!(ok_rule.validate_recurrence_overrides_empty().is_ok());
let raw_bad = json!({
"@type": "TimeZoneRule",
"start": "1970-01-01T00:00:00",
"offsetFrom": "+0000",
"offsetTo": "+0000",
"recurrenceOverrides": {
"1990-04-01T02:00:00": {},
"1991-04-07T02:00:00": {"acmeCorp": "shouldnt-be-here"}
}
});
let bad_rule: TimeZoneRule = serde_json::from_value(raw_bad).unwrap();
let err = bad_rule.validate_recurrence_overrides_empty().unwrap_err();
match err {
RecurrenceOverridesError::NonEmptyPatch { key } => {
assert_eq!(key, "1991-04-07T02:00:00");
}
}
}
#[test]
fn time_zone_rule_recurrence_overrides_round_trips() {
let raw = json!({
"@type": "TimeZoneRule",
"start": "1970-01-01T00:00:00",
"offsetFrom": "+0000",
"offsetTo": "+0000",
"recurrenceOverrides": {
"1990-04-01T02:00:00": {},
"1991-04-07T02:00:00": {}
}
});
let r: TimeZoneRule = serde_json::from_value(raw).expect("TimeZoneRule must deserialize");
let overrides = r
.recurrence_overrides
.as_ref()
.expect("recurrenceOverrides must deserialize as Some");
assert_eq!(overrides.len(), 2);
for v in overrides.values() {
assert!(
v.as_map().is_empty(),
"PatchObject value MUST be empty per RFC 8984 §4.7.2"
);
}
}
#[test]
fn n_day_at_type_defaults_when_absent() {
let raw = json!({ "day": "mo" });
let n: NDay = serde_json::from_value(raw).unwrap();
assert_eq!(n.at_type, "NDay");
let back = serde_json::to_value(&n).unwrap();
assert_eq!(back["@type"], "NDay");
}
#[test]
fn recurrence_rule_at_type_defaults_when_absent() {
let raw = json!({ "frequency": "weekly" });
let r: RecurrenceRule = serde_json::from_value(raw).unwrap();
assert_eq!(r.at_type, "RecurrenceRule");
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["@type"], "RecurrenceRule");
}
#[test]
fn location_at_type_defaults_when_absent() {
let raw = json!({ "name": "HQ" });
let l: Location = serde_json::from_value(raw).unwrap();
assert_eq!(l.at_type, "Location");
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["@type"], "Location");
}
#[test]
fn virtual_location_at_type_defaults_when_absent() {
let raw = json!({ "uri": "https://example.com/meet/abc" });
let v: VirtualLocation = serde_json::from_value(raw).unwrap();
assert_eq!(v.at_type, "VirtualLocation");
let back = serde_json::to_value(&v).unwrap();
assert_eq!(back["@type"], "VirtualLocation");
}
#[test]
fn link_at_type_defaults_when_absent() {
let raw = json!({ "href": "https://example.com/attach.pdf" });
let l: Link = serde_json::from_value(raw).unwrap();
assert_eq!(l.at_type, "Link");
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["@type"], "Link");
}
#[test]
fn relation_at_type_defaults_when_absent() {
let raw = json!({ "relation": { "parent": true } });
let r: Relation = serde_json::from_value(raw).unwrap();
assert_eq!(r.at_type, "Relation");
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["@type"], "Relation");
}
#[test]
fn participant_at_type_defaults_when_absent() {
let raw = json!({ "name": "Alice", "roles": { "attendee": true } });
let p: Participant = serde_json::from_value(raw).unwrap();
assert_eq!(p.at_type, "Participant");
let back = serde_json::to_value(&p).unwrap();
assert_eq!(back["@type"], "Participant");
}
#[test]
fn offset_trigger_at_type_defaults_when_absent() {
let raw = json!({ "offset": "-PT5M" });
let t: OffsetTrigger = serde_json::from_value(raw).unwrap();
assert_eq!(t.at_type, "OffsetTrigger");
let back = serde_json::to_value(&t).unwrap();
assert_eq!(back["@type"], "OffsetTrigger");
}
#[test]
fn absolute_trigger_at_type_defaults_when_absent() {
let raw = json!({ "when": "2024-01-19T18:00:00Z" });
let t: AbsoluteTrigger = serde_json::from_value(raw).unwrap();
assert_eq!(t.at_type, "AbsoluteTrigger");
let back = serde_json::to_value(&t).unwrap();
assert_eq!(back["@type"], "AbsoluteTrigger");
}
#[test]
fn alert_at_type_defaults_when_absent() {
let raw = json!({
"trigger": { "@type": "OffsetTrigger", "offset": "-PT5M" }
});
let a: Alert = serde_json::from_value(raw).unwrap();
assert_eq!(a.at_type, "Alert");
let back = serde_json::to_value(&a).unwrap();
assert_eq!(back["@type"], "Alert");
}
#[test]
fn time_zone_rule_at_type_defaults_when_absent() {
let raw = json!({
"start": "1970-01-01T00:00:00",
"offsetFrom": "+0000",
"offsetTo": "+0000"
});
let r: TimeZoneRule = serde_json::from_value(raw).unwrap();
assert_eq!(r.at_type, "TimeZoneRule");
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["@type"], "TimeZoneRule");
}
#[test]
fn time_zone_at_type_defaults_when_absent() {
let raw = json!({ "tzId": "Etc/UTC" });
let z: TimeZone = serde_json::from_value(raw).unwrap();
assert_eq!(z.at_type, "TimeZone");
let back = serde_json::to_value(&z).unwrap();
assert_eq!(back["@type"], "TimeZone");
}
#[test]
fn participant_at_type_explicit_value_round_trips_verbatim() {
let raw = json!({
"@type": "AcmeCorpParticipant",
"name": "Alice",
"roles": { "attendee": true }
});
let p: Participant = serde_json::from_value(raw).unwrap();
assert_eq!(p.at_type, "AcmeCorpParticipant");
let back = serde_json::to_value(&p).unwrap();
assert_eq!(back["@type"], "AcmeCorpParticipant");
assert!(p.validate_at_type().is_err());
}
#[test]
fn alert_with_missing_outer_at_type_deserializes() {
let raw = json!({
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT15M"
},
"action": "display"
});
let a: Alert = serde_json::from_value(raw).unwrap();
assert_eq!(a.at_type, "Alert");
match a.trigger {
AlertTrigger::OffsetTrigger(ref t) => {
assert_eq!(t.at_type, "OffsetTrigger");
assert_eq!(t.offset.as_ref(), "-PT15M");
}
_ => panic!("trigger MUST deserialize as OffsetTrigger variant"),
}
}
}