use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{Id, State};
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetResponse<T> {
pub account_id: Id,
pub state: State,
pub list: Vec<T>,
pub not_found: Option<Vec<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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangesResponse {
pub account_id: Id,
pub old_state: State,
pub new_state: State,
pub has_more_changes: bool,
pub created: Vec<Id>,
pub updated: Vec<Id>,
pub destroyed: Vec<Id>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_properties: Option<Vec<String>>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetError {
#[serde(rename = "type")]
pub error_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub existing_id: Option<Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_found: Option<Vec<Id>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_recipients: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invalid_recipients: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_size: Option<u64>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl SetError {
pub fn new(error_type: impl Into<String>) -> Self {
Self {
error_type: error_type.into(),
description: None,
properties: None,
existing_id: None,
not_found: None,
max_recipients: None,
invalid_recipients: None,
max_size: None,
extra: serde_json::Map::new(),
}
}
}
impl std::fmt::Display for SetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.description {
Some(desc) => write!(f, "{}: {}", self.error_type, desc),
None => write!(f, "{}", self.error_type),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(bound(
deserialize = "T: serde::de::DeserializeOwned",
serialize = "T: Serialize"
))]
pub struct SetResponse<T = serde_json::Value> {
pub account_id: Id,
pub old_state: Option<State>,
pub new_state: State,
pub created: Option<HashMap<String, T>>,
pub updated: Option<HashMap<Id, Option<T>>>,
pub destroyed: Option<Vec<Id>>,
pub not_created: Option<HashMap<String, SetError>>,
pub not_updated: Option<HashMap<Id, SetError>>,
pub not_destroyed: Option<HashMap<Id, SetError>>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryResponse {
pub account_id: Id,
pub query_state: State,
pub can_calculate_changes: bool,
pub position: u64,
pub ids: Vec<Id>,
pub total: Option<u64>,
pub limit: Option<u64>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddedItem {
pub id: Id,
pub index: u64,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryChangesResponse {
pub account_id: Id,
pub old_query_state: State,
pub new_query_state: State,
pub total: Option<u64>,
pub removed: Vec<Id>,
pub added: Vec<AddedItem>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn get_response_round_trips() {
let raw = json!({
"accountId": "A1",
"state": "s42",
"list": [{"id": "x", "name": "First"}],
"notFound": ["missing1"]
});
let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
assert_eq!(resp.account_id.as_ref(), "A1");
assert_eq!(resp.state, "s42");
assert_eq!(resp.list.len(), 1);
assert_eq!(resp.list[0]["name"], "First");
let nf = resp.not_found.as_ref().unwrap();
assert_eq!(nf.len(), 1);
assert_eq!(nf[0].as_ref(), "missing1");
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["accountId"], "A1");
assert_eq!(back["notFound"][0], "missing1");
}
#[test]
fn get_response_null_not_found() {
let raw = json!({
"accountId": "A1",
"state": "s1",
"list": [],
"notFound": null
});
let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
assert!(resp.not_found.is_none());
}
#[test]
fn changes_response_round_trips() {
let raw = json!({
"accountId": "A1",
"oldState": "s0",
"newState": "s1",
"hasMoreChanges": false,
"created": ["a"],
"updated": ["b"],
"destroyed": ["c"]
});
let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
assert_eq!(resp.old_state, "s0");
assert_eq!(resp.new_state, "s1");
assert!(!resp.has_more_changes);
assert_eq!(resp.created[0].as_ref(), "a");
assert_eq!(resp.updated[0].as_ref(), "b");
assert_eq!(resp.destroyed[0].as_ref(), "c");
assert!(resp.updated_properties.is_none());
}
#[test]
fn changes_response_deserializes_mailbox_updated_properties() {
let raw = json!({
"accountId": "A1",
"oldState": "78541",
"newState": "78542",
"hasMoreChanges": false,
"updatedProperties": [
"totalEmails", "unreadEmails",
"totalThreads", "unreadThreads"
],
"created": [],
"updated": ["B"],
"destroyed": []
});
let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
let props = resp
.updated_properties
.expect("updatedProperties must be present");
assert_eq!(
props,
vec![
"totalEmails".to_string(),
"unreadEmails".to_string(),
"totalThreads".to_string(),
"unreadThreads".to_string()
]
);
}
#[test]
fn changes_response_deserializes_quota_updated_properties() {
let raw = json!({
"accountId": "A1",
"oldState": "78541",
"newState": "78542",
"hasMoreChanges": false,
"updatedProperties": ["used"],
"created": [],
"updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
"destroyed": []
});
let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
let props = resp
.updated_properties
.expect("updatedProperties must be present");
assert_eq!(props, vec!["used".to_string()]);
}
#[test]
fn changes_response_accepts_explicit_null_updated_properties() {
let raw = json!({
"accountId": "A1",
"oldState": "s0",
"newState": "s1",
"hasMoreChanges": false,
"updatedProperties": null,
"created": [],
"updated": ["B"],
"destroyed": []
});
let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
assert!(resp.updated_properties.is_none());
}
#[test]
fn changes_response_omits_updated_properties_when_none() {
let resp = ChangesResponse {
account_id: Id::from("A1"),
old_state: "s0".into(),
new_state: "s1".into(),
has_more_changes: false,
created: vec![],
updated: vec![],
destroyed: vec![],
updated_properties: None,
extra: serde_json::Map::new(),
};
let serialized = serde_json::to_value(&resp).expect("must serialize");
assert!(
serialized.get("updatedProperties").is_none(),
"updatedProperties must be omitted when None"
);
}
#[test]
fn set_response_updated_accepts_null_value() {
let raw = json!({
"accountId": "A1",
"oldState": "s1",
"newState": "s2",
"updated": { "ev1": null, "ev2": null }
});
let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
let upd = resp.updated.unwrap();
assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
}
#[test]
fn set_response_updated_accepts_object_value() {
let raw = json!({
"accountId": "A1",
"oldState": "s1",
"newState": "s2",
"updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
});
let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
let upd = resp.updated.unwrap();
let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
assert_eq!(ev1["title"], "Meeting");
}
#[test]
fn set_response_not_updated_keys_are_ids() {
let raw = json!({
"accountId": "A1",
"oldState": "s1",
"newState": "s1",
"notUpdated": {
"ev1": { "type": "stateMismatch" }
}
});
let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
let nu = resp.not_updated.unwrap();
assert_eq!(
nu.get(&Id::from("ev1")).unwrap().error_type,
"stateMismatch"
);
}
#[test]
fn set_error_full_8_fields_round_trip() {
let raw = json!({
"type": "alreadyExists",
"description": "conflict",
"properties": ["name"],
"existingId": "obj-7",
"notFound": ["blob-1", "blob-2"],
"maxRecipients": 50,
"invalidRecipients": ["bad@", "no@no"],
"maxSize": 10485760
});
let err = SetError::deserialize(&raw).unwrap();
assert_eq!(err.error_type, "alreadyExists");
assert_eq!(err.description.as_deref(), Some("conflict"));
assert_eq!(err.properties.as_ref().unwrap()[0], "name");
assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
assert_eq!(err.max_recipients, Some(50));
assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
assert_eq!(err.max_size, Some(10_485_760));
let back = serde_json::to_value(&err).unwrap();
assert_eq!(back, raw);
}
#[test]
fn set_error_minimal_omits_optional_fields_on_serialize() {
let err = SetError::new("forbidden");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "forbidden");
assert!(json.get("description").is_none());
assert!(json.get("properties").is_none());
assert!(json.get("existingId").is_none());
assert!(json.get("notFound").is_none());
assert!(json.get("maxRecipients").is_none());
assert!(json.get("invalidRecipients").is_none());
assert!(json.get("maxSize").is_none());
let obj = json.as_object().unwrap();
assert_eq!(
obj.len(),
1,
"minimal SetError must serialize to exactly {{type}}: {json}"
);
}
#[test]
fn set_error_extension_fields_round_trip_via_extra() {
let raw = json!({
"type": "rateLimited",
"description": "slow-mode active",
"serverRetryAfter": "2026-01-01T00:00:00Z"
});
let err = SetError::deserialize(&raw).unwrap();
assert_eq!(err.error_type, "rateLimited");
assert_eq!(err.description.as_deref(), Some("slow-mode active"));
assert_eq!(
err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
Some("2026-01-01T00:00:00Z"),
"extension field must land in extra map: {err:?}"
);
let back = serde_json::to_value(&err).unwrap();
assert_eq!(back, raw, "round-trip must preserve extension field");
}
#[test]
fn set_error_extension_type_round_trips() {
let err = SetError::new("noSupportedScheduleMethods");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "noSupportedScheduleMethods");
let back: SetError = serde_json::from_value(json).unwrap();
assert_eq!(back.error_type, "noSupportedScheduleMethods");
}
#[test]
fn set_error_display_with_description() {
let err = SetError {
error_type: "forbidden".to_owned(),
description: Some("not your calendar".to_owned()),
..SetError::new("forbidden")
};
assert_eq!(err.to_string(), "forbidden: not your calendar");
}
#[test]
fn set_error_display_without_description() {
let err = SetError::new("forbidden");
assert_eq!(err.to_string(), "forbidden");
}
#[test]
fn query_response_round_trips() {
let raw = json!({
"accountId": "A1",
"queryState": "qs1",
"canCalculateChanges": true,
"position": 0,
"ids": ["a", "b", "c"],
"total": 3,
"limit": 100
});
let resp: QueryResponse = serde_json::from_value(raw).unwrap();
assert_eq!(resp.query_state, "qs1");
assert!(resp.can_calculate_changes);
assert_eq!(resp.ids.len(), 3);
assert_eq!(resp.total, Some(3));
assert_eq!(resp.limit, Some(100));
}
#[test]
fn query_response_omits_optional_total_and_limit() {
let raw = json!({
"accountId": "A1",
"queryState": "qs1",
"canCalculateChanges": false,
"position": 0,
"ids": [],
"total": null,
"limit": null
});
let resp: QueryResponse = serde_json::from_value(raw).unwrap();
assert!(resp.total.is_none());
assert!(resp.limit.is_none());
}
#[test]
fn query_changes_response_round_trips() {
let raw = json!({
"accountId": "A1",
"oldQueryState": "qs0",
"newQueryState": "qs1",
"total": 5,
"removed": ["x"],
"added": [
{"id": "y", "index": 2}
]
});
let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
assert_eq!(resp.old_query_state, "qs0");
assert_eq!(resp.new_query_state, "qs1");
assert_eq!(resp.total, Some(5));
assert_eq!(resp.removed[0].as_ref(), "x");
assert_eq!(resp.added.len(), 1);
assert_eq!(resp.added[0].id.as_ref(), "y");
assert_eq!(resp.added[0].index, 2);
}
#[test]
fn added_item_round_trips() {
let raw = json!({"id": "foo", "index": 7});
let item = AddedItem::deserialize(&raw).unwrap();
assert_eq!(item.id.as_ref(), "foo");
assert_eq!(item.index, 7);
assert_eq!(serde_json::to_value(&item).unwrap(), raw);
}
#[test]
fn get_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "A1",
"state": "s1",
"list": [],
"notFound": null,
"acmeCorpAuditTrail": {"sequence": 42}
});
let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpAuditTrail")
.and_then(|v| v["sequence"].as_u64()),
Some(42),
"vendor field must land in extra: {:?}",
resp.extra
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(
back["acmeCorpAuditTrail"]["sequence"], 42,
"vendor field must survive serialize: {back}"
);
}
#[test]
fn changes_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "A1",
"oldState": "s0",
"newState": "s1",
"hasMoreChanges": false,
"created": [],
"updated": [],
"destroyed": [],
"acmeCorpReplayToken": "rt-99"
});
let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpReplayToken")
.and_then(|v| v.as_str()),
Some("rt-99")
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpReplayToken"], "rt-99");
}
#[test]
fn changes_response_null_updated_properties_and_extras_coexist() {
let raw = json!({
"accountId": "A1",
"oldState": "s0",
"newState": "s1",
"hasMoreChanges": false,
"created": [],
"updated": ["B"],
"destroyed": [],
"updatedProperties": null,
"acmeCorpReplayToken": "rt-99",
"acmeCorpMetadata": { "requestId": "r1", "trace": "x" }
});
let resp: ChangesResponse = serde_json::from_value(raw.clone()).expect("must deserialize");
assert!(
resp.updated_properties.is_none(),
"explicit null updatedProperties must deserialize as None"
);
assert!(
!resp.extra.contains_key("updatedProperties"),
"updatedProperties must not appear in extra after the typed \
field consumed it (was: {:?})",
resp.extra
);
assert_eq!(
resp.extra
.get("acmeCorpReplayToken")
.and_then(|v| v.as_str()),
Some("rt-99")
);
let nested = resp
.extra
.get("acmeCorpMetadata")
.and_then(|v| v.as_object())
.expect("acmeCorpMetadata must be a nested object in extra");
assert_eq!(nested.get("requestId").and_then(|v| v.as_str()), Some("r1"));
assert_eq!(nested.get("trace").and_then(|v| v.as_str()), Some("x"));
let back = serde_json::to_value(&resp).expect("must serialize");
assert!(
back.get("updatedProperties").is_none(),
"None updated_properties must not serialize an explicit null \
(skip_serializing_if = Option::is_none): {back}"
);
assert_eq!(back["acmeCorpReplayToken"], "rt-99");
assert_eq!(back["acmeCorpMetadata"]["requestId"], "r1");
assert_eq!(back["acmeCorpMetadata"]["trace"], "x");
let resp2: ChangesResponse = serde_json::from_value(back).expect("reparse must succeed");
assert!(resp2.updated_properties.is_none());
assert_eq!(resp2.extra, resp.extra);
}
#[test]
fn set_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "A1",
"oldState": "s1",
"newState": "s2",
"acmeCorpTransactionId": "txn-abc"
});
let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpTransactionId")
.and_then(|v| v.as_str()),
Some("txn-abc")
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
}
#[test]
fn query_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "A1",
"queryState": "qs1",
"canCalculateChanges": false,
"position": 0,
"ids": [],
"total": null,
"limit": null,
"acmeCorpSearchTimingMs": 17
});
let resp: QueryResponse = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpSearchTimingMs")
.and_then(|v| v.as_u64()),
Some(17)
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpSearchTimingMs"], 17);
}
#[test]
fn query_changes_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "A1",
"oldQueryState": "qs0",
"newQueryState": "qs1",
"total": null,
"removed": [],
"added": [],
"acmeCorpDeltaToken": "dt-2"
});
let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpDeltaToken")
.and_then(|v| v.as_str()),
Some("dt-2")
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
}
#[test]
fn added_item_preserves_vendor_extras() {
let raw = json!({
"id": "x",
"index": 0,
"acmeCorpHighlight": true
});
let item = AddedItem::deserialize(&raw).unwrap();
assert_eq!(
item.extra
.get("acmeCorpHighlight")
.and_then(|v| v.as_bool()),
Some(true)
);
let back = serde_json::to_value(&item).unwrap();
assert_eq!(back["acmeCorpHighlight"], true);
}
#[test]
fn empty_extras_omitted_from_wire() {
let resp = AddedItem {
id: Id::from("z"),
index: 1,
extra: serde_json::Map::new(),
};
let serialized = serde_json::to_value(&resp).expect("must serialize");
let obj = serialized.as_object().expect("must be object");
assert_eq!(
obj.len(),
2,
"empty extras must not add any wire keys; got {serialized}"
);
assert!(obj.contains_key("id"));
assert!(obj.contains_key("index"));
}
}