1use alloc::{
10 string::{String, ToString},
11 vec::Vec,
12};
13use core::fmt;
14
15use io_replica::{
16 collection::ReplicaCollectionId,
17 object::ReplicaHash,
18 placement::{ReplicaFlags, ReplicaHandle, ReplicaLevel, ReplicaLinkId, ReplicaMeta},
19};
20use serde_json::{Map, Value, json};
21
22pub fn flags_to_json(flags: &ReplicaFlags) -> Option<String> {
29 let items: Vec<&String> = flags.known()?.iter().collect();
30 Some(serde_json::to_string(&items).unwrap_or_else(|_| String::from("[]")))
31}
32
33pub fn flags_from_json(json: Option<&str>) -> ReplicaFlags {
42 let Some(json) = json else {
43 return ReplicaFlags::Unknown;
44 };
45 match serde_json::from_str::<Vec<String>>(json) {
46 Ok(items) => ReplicaFlags::Known(items.into_iter().collect()),
47 Err(_) => ReplicaFlags::Unknown,
48 }
49}
50
51pub fn level_to_int(level: ReplicaLevel) -> i64 {
53 match level {
54 ReplicaLevel::Probed => 0,
55 ReplicaLevel::Meta => 1,
56 ReplicaLevel::Full => 2,
57 }
58}
59
60pub fn level_from_int(value: i64) -> ReplicaLevel {
62 match value {
63 1 => ReplicaLevel::Meta,
64 2 => ReplicaLevel::Full,
65 _ => ReplicaLevel::Probed,
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum PimdirAction {
79 Add {
83 link_id: Option<ReplicaLinkId>,
85 flags: ReplicaFlags,
87 object: Option<ReplicaHash>,
90 meta: Option<ReplicaMeta>,
93 handle: Option<ReplicaHandle>,
96 },
97 SetFlags {
100 seq: i64,
102 flags: ReplicaFlags,
104 },
105 Remove {
108 seq: i64,
110 },
111 Move {
114 seq: i64,
116 to: ReplicaCollectionId,
118 },
119 Copy {
121 seq: i64,
123 to: ReplicaCollectionId,
125 },
126 Update {
128 seq: i64,
130 object: ReplicaHash,
133 meta: Option<ReplicaMeta>,
135 },
136 Unknown {
148 kind: String,
150 payload: String,
153 object_hash: Option<ReplicaHash>,
157 },
158}
159
160impl PimdirAction {
161 pub fn kind(&self) -> &str {
163 match self {
164 Self::Add { .. } => "add",
165 Self::SetFlags { .. } => "set-flags",
166 Self::Remove { .. } => "remove",
167 Self::Move { .. } => "move",
168 Self::Copy { .. } => "copy",
169 Self::Update { .. } => "update",
170 Self::Unknown { kind, .. } => kind,
171 }
172 }
173
174 pub fn object_hash(&self) -> Option<&ReplicaHash> {
178 match self {
179 Self::Add { object, .. } => object.as_ref(),
180 Self::Update { object, .. } => Some(object),
181 Self::Unknown { object_hash, .. } => object_hash.as_ref(),
182 Self::SetFlags { .. } | Self::Remove { .. } | Self::Move { .. } | Self::Copy { .. } => {
183 None
184 }
185 }
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
195pub enum PimdirActionError {
196 Json,
198 UnknownVersion(Option<i64>),
200 MissingField(&'static str),
202}
203
204impl fmt::Display for PimdirActionError {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 match self {
207 Self::Json => write!(f, "pimdir action payload is not a JSON object"),
208 Self::UnknownVersion(Some(v)) => write!(f, "unknown pimdir action version: {v}"),
209 Self::UnknownVersion(None) => write!(f, "pimdir action payload misses its version"),
210 Self::MissingField(field) => write!(f, "pimdir action payload misses field: {field}"),
211 }
212 }
213}
214
215impl core::error::Error for PimdirActionError {}
216
217pub fn action_to_payload(action: &PimdirAction) -> String {
221 if let PimdirAction::Unknown { payload, .. } = action {
224 return payload.clone();
225 }
226
227 let mut map = Map::new();
228 map.insert("v".into(), json!(1));
229
230 match action {
231 PimdirAction::Add {
232 link_id,
233 flags,
234 object,
235 meta,
236 handle,
237 } => {
238 if let Some(link) = link_id {
239 map.insert("link_id".into(), json!(link.0));
240 }
241 map.insert("flags".into(), flags_to_value(flags));
242 if let Some(object) = object {
243 map.insert("object".into(), json!(object.0));
244 }
245 if let Some(meta) = meta {
246 map.insert("meta".into(), meta_to_value(meta));
247 }
248 if let Some(handle) = handle {
249 map.insert("handle".into(), json!(handle.0));
250 }
251 }
252 PimdirAction::SetFlags { seq, flags } => {
253 map.insert("seq".into(), json!(seq));
254 map.insert("flags".into(), flags_to_value(flags));
255 }
256 PimdirAction::Remove { seq } => {
257 map.insert("seq".into(), json!(seq));
258 }
259 PimdirAction::Move { seq, to } | PimdirAction::Copy { seq, to } => {
260 map.insert("seq".into(), json!(seq));
261 map.insert("to".into(), json!(to.0));
262 }
263 PimdirAction::Update { seq, object, meta } => {
264 map.insert("seq".into(), json!(seq));
265 map.insert("object".into(), json!(object.0));
266 if let Some(meta) = meta {
267 map.insert("meta".into(), meta_to_value(meta));
268 }
269 }
270 PimdirAction::Unknown { .. } => {}
271 }
272
273 Value::Object(map).to_string()
274}
275
276pub fn action_from_payload(kind: &str, payload: &str) -> Result<PimdirAction, PimdirActionError> {
281 let value: Value = serde_json::from_str(payload).map_err(|_| PimdirActionError::Json)?;
282 let map = value.as_object().ok_or(PimdirActionError::Json)?;
283
284 let version = map.get("v").and_then(Value::as_i64);
285 if version != Some(1) {
286 return Err(PimdirActionError::UnknownVersion(version));
287 }
288
289 match kind {
290 "add" => Ok(PimdirAction::Add {
291 link_id: get_string(map, "link_id")?.map(ReplicaLinkId),
292 flags: flags_from_value(map.get("flags")),
293 object: get_string(map, "object")?.map(ReplicaHash),
294 meta: map.get("meta").map(meta_from_value),
295 handle: get_string(map, "handle")?.map(ReplicaHandle),
296 }),
297 "set-flags" => Ok(PimdirAction::SetFlags {
298 seq: require_seq(map)?,
299 flags: flags_from_value(map.get("flags")),
300 }),
301 "remove" => Ok(PimdirAction::Remove {
302 seq: require_seq(map)?,
303 }),
304 "move" => Ok(PimdirAction::Move {
305 seq: require_seq(map)?,
306 to: ReplicaCollectionId(require_string(map, "to")?),
307 }),
308 "copy" => Ok(PimdirAction::Copy {
309 seq: require_seq(map)?,
310 to: ReplicaCollectionId(require_string(map, "to")?),
311 }),
312 "update" => Ok(PimdirAction::Update {
313 seq: require_seq(map)?,
314 object: ReplicaHash(require_string(map, "object")?),
315 meta: map.get("meta").map(meta_from_value),
316 }),
317 other => Ok(PimdirAction::Unknown {
321 kind: other.to_string(),
322 payload: payload.to_string(),
323 object_hash: get_string(map, "object")?.map(ReplicaHash),
324 }),
325 }
326}
327
328fn flags_to_value(flags: &ReplicaFlags) -> Value {
335 match flags.known() {
336 None => Value::Null,
337 Some(flags) => Value::Array(flags.iter().map(|f| json!(f)).collect()),
338 }
339}
340
341fn flags_from_value(value: Option<&Value>) -> ReplicaFlags {
345 match value {
346 None | Some(Value::Null) => ReplicaFlags::Unknown,
347 Some(value) => ReplicaFlags::Known(
348 value
349 .as_array()
350 .map(|items| {
351 items
352 .iter()
353 .filter_map(Value::as_str)
354 .map(String::from)
355 .collect()
356 })
357 .unwrap_or_default(),
358 ),
359 }
360}
361
362fn meta_to_value(meta: &ReplicaMeta) -> Value {
365 serde_json::from_str(&meta.0).unwrap_or_else(|_| json!(meta.0))
366}
367
368fn meta_from_value(value: &Value) -> ReplicaMeta {
371 match value.as_str() {
372 Some(text) => ReplicaMeta(text.to_string()),
373 None => ReplicaMeta(value.to_string()),
374 }
375}
376
377fn get_string(
379 map: &Map<String, Value>,
380 field: &'static str,
381) -> Result<Option<String>, PimdirActionError> {
382 match map.get(field) {
383 None => Ok(None),
384 Some(value) => match value.as_str() {
385 Some(text) => Ok(Some(text.to_string())),
386 None => Err(PimdirActionError::MissingField(field)),
387 },
388 }
389}
390
391fn require_string(
393 map: &Map<String, Value>,
394 field: &'static str,
395) -> Result<String, PimdirActionError> {
396 get_string(map, field)?.ok_or(PimdirActionError::MissingField(field))
397}
398
399fn require_seq(map: &Map<String, Value>) -> Result<i64, PimdirActionError> {
401 map.get("seq")
402 .and_then(Value::as_i64)
403 .ok_or(PimdirActionError::MissingField("seq"))
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn flags_round_trip_and_escape() {
412 let flags = ReplicaFlags::from_iter(["\\Seen", "$flagged", "a\"b"]);
413 let json = flags_to_json(&flags).expect("a known set encodes");
414 assert_eq!(flags_from_json(Some(&json)), flags);
415 assert!(json.starts_with('['));
417 assert!(json.contains("\\\\Seen"));
418 }
419
420 #[test]
421 fn an_unknown_set_is_null_and_a_known_empty_one_is_a_list() {
422 assert_eq!(flags_to_json(&ReplicaFlags::Unknown), None);
425 assert_eq!(flags_from_json(None), ReplicaFlags::Unknown);
426
427 assert_eq!(
428 flags_to_json(&ReplicaFlags::default()).as_deref(),
429 Some("[]")
430 );
431 assert_eq!(flags_from_json(Some("[]")), ReplicaFlags::default());
432 }
433
434 #[test]
435 fn a_malformed_flag_set_reads_as_unread_not_as_empty() {
436 assert_eq!(flags_from_json(Some("not json")), ReplicaFlags::Unknown);
440 assert_eq!(flags_from_json(Some("{}")), ReplicaFlags::Unknown);
441 }
442
443 #[test]
444 fn level_map_round_trips() {
445 for l in [ReplicaLevel::Probed, ReplicaLevel::Meta, ReplicaLevel::Full] {
446 assert_eq!(level_from_int(level_to_int(l)), l);
447 }
448 }
449
450 #[test]
451 fn every_action_kind_round_trips_through_its_payload() {
452 let actions = [
453 PimdirAction::Add {
454 link_id: Some(ReplicaLinkId("mid:new".into())),
455 flags: ReplicaFlags::from_iter(["\\Draft"]),
456 object: Some(ReplicaHash("cafebabe".into())),
457 meta: Some(ReplicaMeta("{\"subject\":\"hi\",\"v\":1}".into())),
458 handle: Some(ReplicaHandle("draft-1".into())),
459 },
460 PimdirAction::Add {
461 link_id: None,
462 flags: ReplicaFlags::default(),
463 object: None,
464 meta: None,
465 handle: None,
466 },
467 PimdirAction::SetFlags {
468 seq: 4,
469 flags: ReplicaFlags::from_iter(["\\Seen", "$flagged"]),
470 },
471 PimdirAction::Remove { seq: 5 },
472 PimdirAction::Move {
473 seq: 6,
474 to: ReplicaCollectionId("Archive".into()),
475 },
476 PimdirAction::Copy {
477 seq: 7,
478 to: ReplicaCollectionId("Backup".into()),
479 },
480 PimdirAction::Update {
481 seq: 8,
482 object: ReplicaHash("beef0000".into()),
483 meta: None,
484 },
485 ];
486 for action in actions {
487 let payload = action_to_payload(&action);
488 assert!(payload.contains("\"v\":1"), "versioned: {payload}");
490 let decoded = action_from_payload(action.kind(), &payload).unwrap();
491 assert_eq!(decoded, action, "round-trip of {payload}");
492 }
493 }
494
495 #[test]
496 fn a_non_json_meta_survives_the_payload_embedding() {
497 let action = PimdirAction::Update {
498 seq: 1,
499 object: ReplicaHash("cafebabe".into()),
500 meta: Some(ReplicaMeta("not json".into())),
501 };
502 let payload = action_to_payload(&action);
503 assert_eq!(action_from_payload("update", &payload).unwrap(), action);
504 }
505
506 #[test]
507 fn malformed_action_payloads_error_instead_of_decaying() {
508 assert_eq!(
510 action_from_payload("remove", "not json"),
511 Err(PimdirActionError::Json)
512 );
513 assert_eq!(
514 action_from_payload("remove", "{\"seq\":1}"),
515 Err(PimdirActionError::UnknownVersion(None))
516 );
517 assert_eq!(
518 action_from_payload("remove", "{\"v\":2,\"seq\":1}"),
519 Err(PimdirActionError::UnknownVersion(Some(2)))
520 );
521 assert_eq!(
522 action_from_payload("remove", "{\"v\":1}"),
523 Err(PimdirActionError::MissingField("seq"))
524 );
525 }
526
527 #[test]
528 fn an_owner_defined_kind_survives_whole_instead_of_erroring() {
529 let payload = "{\"v\":1,\"object\":\"cafebabe\",\"to\":[\"a@b.c\"]}";
532 let decoded = action_from_payload("submit", payload).unwrap();
533 assert_eq!(
534 decoded,
535 PimdirAction::Unknown {
536 kind: "submit".into(),
537 payload: payload.into(),
538 object_hash: Some(ReplicaHash("cafebabe".into())),
539 }
540 );
541 assert_eq!(decoded.kind(), "submit");
542 assert_eq!(decoded.object_hash(), Some(&ReplicaHash("cafebabe".into())));
543 assert_eq!(action_to_payload(&decoded), payload);
546
547 assert_eq!(
549 action_from_payload("submit", "{\"to\":[]}"),
550 Err(PimdirActionError::UnknownVersion(None))
551 );
552 assert_eq!(
553 action_from_payload("submit", "nope"),
554 Err(PimdirActionError::Json)
555 );
556 }
557
558 #[test]
559 fn the_pinned_hash_follows_the_payload_body() {
560 let add = PimdirAction::Add {
561 link_id: None,
562 flags: ReplicaFlags::default(),
563 object: Some(ReplicaHash("cafebabe".into())),
564 meta: None,
565 handle: None,
566 };
567 assert_eq!(add.object_hash(), Some(&ReplicaHash("cafebabe".into())));
568 assert_eq!(PimdirAction::Remove { seq: 1 }.object_hash(), None);
569 }
570}