use alloc::{
string::{String, ToString},
vec::Vec,
};
use core::fmt;
use io_replica::{
collection::ReplicaCollectionId,
object::ReplicaHash,
placement::{ReplicaFlags, ReplicaHandle, ReplicaLevel, ReplicaLinkId, ReplicaMeta},
};
use serde_json::{Map, Value, json};
pub fn flags_to_json(flags: &ReplicaFlags) -> Option<String> {
let items: Vec<&String> = flags.known()?.iter().collect();
Some(serde_json::to_string(&items).unwrap_or_else(|_| String::from("[]")))
}
pub fn flags_from_json(json: Option<&str>) -> ReplicaFlags {
let Some(json) = json else {
return ReplicaFlags::Unknown;
};
match serde_json::from_str::<Vec<String>>(json) {
Ok(items) => ReplicaFlags::Known(items.into_iter().collect()),
Err(_) => ReplicaFlags::Unknown,
}
}
pub fn level_to_int(level: ReplicaLevel) -> i64 {
match level {
ReplicaLevel::Probed => 0,
ReplicaLevel::Meta => 1,
ReplicaLevel::Full => 2,
}
}
pub fn level_from_int(value: i64) -> ReplicaLevel {
match value {
1 => ReplicaLevel::Meta,
2 => ReplicaLevel::Full,
_ => ReplicaLevel::Probed,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PimdirAction {
Add {
link_id: Option<ReplicaLinkId>,
flags: ReplicaFlags,
object: Option<ReplicaHash>,
meta: Option<ReplicaMeta>,
handle: Option<ReplicaHandle>,
},
SetFlags {
seq: i64,
flags: ReplicaFlags,
},
Remove {
seq: i64,
},
Move {
seq: i64,
to: ReplicaCollectionId,
},
Copy {
seq: i64,
to: ReplicaCollectionId,
},
Update {
seq: i64,
object: ReplicaHash,
meta: Option<ReplicaMeta>,
},
Unknown {
kind: String,
payload: String,
object_hash: Option<ReplicaHash>,
},
}
impl PimdirAction {
pub fn kind(&self) -> &str {
match self {
Self::Add { .. } => "add",
Self::SetFlags { .. } => "set-flags",
Self::Remove { .. } => "remove",
Self::Move { .. } => "move",
Self::Copy { .. } => "copy",
Self::Update { .. } => "update",
Self::Unknown { kind, .. } => kind,
}
}
pub fn object_hash(&self) -> Option<&ReplicaHash> {
match self {
Self::Add { object, .. } => object.as_ref(),
Self::Update { object, .. } => Some(object),
Self::Unknown { object_hash, .. } => object_hash.as_ref(),
Self::SetFlags { .. } | Self::Remove { .. } | Self::Move { .. } | Self::Copy { .. } => {
None
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PimdirActionError {
Json,
UnknownVersion(Option<i64>),
MissingField(&'static str),
}
impl fmt::Display for PimdirActionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Json => write!(f, "pimdir action payload is not a JSON object"),
Self::UnknownVersion(Some(v)) => write!(f, "unknown pimdir action version: {v}"),
Self::UnknownVersion(None) => write!(f, "pimdir action payload misses its version"),
Self::MissingField(field) => write!(f, "pimdir action payload misses field: {field}"),
}
}
}
impl core::error::Error for PimdirActionError {}
pub fn action_to_payload(action: &PimdirAction) -> String {
if let PimdirAction::Unknown { payload, .. } = action {
return payload.clone();
}
let mut map = Map::new();
map.insert("v".into(), json!(1));
match action {
PimdirAction::Add {
link_id,
flags,
object,
meta,
handle,
} => {
if let Some(link) = link_id {
map.insert("link_id".into(), json!(link.0));
}
map.insert("flags".into(), flags_to_value(flags));
if let Some(object) = object {
map.insert("object".into(), json!(object.0));
}
if let Some(meta) = meta {
map.insert("meta".into(), meta_to_value(meta));
}
if let Some(handle) = handle {
map.insert("handle".into(), json!(handle.0));
}
}
PimdirAction::SetFlags { seq, flags } => {
map.insert("seq".into(), json!(seq));
map.insert("flags".into(), flags_to_value(flags));
}
PimdirAction::Remove { seq } => {
map.insert("seq".into(), json!(seq));
}
PimdirAction::Move { seq, to } | PimdirAction::Copy { seq, to } => {
map.insert("seq".into(), json!(seq));
map.insert("to".into(), json!(to.0));
}
PimdirAction::Update { seq, object, meta } => {
map.insert("seq".into(), json!(seq));
map.insert("object".into(), json!(object.0));
if let Some(meta) = meta {
map.insert("meta".into(), meta_to_value(meta));
}
}
PimdirAction::Unknown { .. } => {}
}
Value::Object(map).to_string()
}
pub fn action_from_payload(kind: &str, payload: &str) -> Result<PimdirAction, PimdirActionError> {
let value: Value = serde_json::from_str(payload).map_err(|_| PimdirActionError::Json)?;
let map = value.as_object().ok_or(PimdirActionError::Json)?;
let version = map.get("v").and_then(Value::as_i64);
if version != Some(1) {
return Err(PimdirActionError::UnknownVersion(version));
}
match kind {
"add" => Ok(PimdirAction::Add {
link_id: get_string(map, "link_id")?.map(ReplicaLinkId),
flags: flags_from_value(map.get("flags")),
object: get_string(map, "object")?.map(ReplicaHash),
meta: map.get("meta").map(meta_from_value),
handle: get_string(map, "handle")?.map(ReplicaHandle),
}),
"set-flags" => Ok(PimdirAction::SetFlags {
seq: require_seq(map)?,
flags: flags_from_value(map.get("flags")),
}),
"remove" => Ok(PimdirAction::Remove {
seq: require_seq(map)?,
}),
"move" => Ok(PimdirAction::Move {
seq: require_seq(map)?,
to: ReplicaCollectionId(require_string(map, "to")?),
}),
"copy" => Ok(PimdirAction::Copy {
seq: require_seq(map)?,
to: ReplicaCollectionId(require_string(map, "to")?),
}),
"update" => Ok(PimdirAction::Update {
seq: require_seq(map)?,
object: ReplicaHash(require_string(map, "object")?),
meta: map.get("meta").map(meta_from_value),
}),
other => Ok(PimdirAction::Unknown {
kind: other.to_string(),
payload: payload.to_string(),
object_hash: get_string(map, "object")?.map(ReplicaHash),
}),
}
}
fn flags_to_value(flags: &ReplicaFlags) -> Value {
match flags.known() {
None => Value::Null,
Some(flags) => Value::Array(flags.iter().map(|f| json!(f)).collect()),
}
}
fn flags_from_value(value: Option<&Value>) -> ReplicaFlags {
match value {
None | Some(Value::Null) => ReplicaFlags::Unknown,
Some(value) => ReplicaFlags::Known(
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default(),
),
}
}
fn meta_to_value(meta: &ReplicaMeta) -> Value {
serde_json::from_str(&meta.0).unwrap_or_else(|_| json!(meta.0))
}
fn meta_from_value(value: &Value) -> ReplicaMeta {
match value.as_str() {
Some(text) => ReplicaMeta(text.to_string()),
None => ReplicaMeta(value.to_string()),
}
}
fn get_string(
map: &Map<String, Value>,
field: &'static str,
) -> Result<Option<String>, PimdirActionError> {
match map.get(field) {
None => Ok(None),
Some(value) => match value.as_str() {
Some(text) => Ok(Some(text.to_string())),
None => Err(PimdirActionError::MissingField(field)),
},
}
}
fn require_string(
map: &Map<String, Value>,
field: &'static str,
) -> Result<String, PimdirActionError> {
get_string(map, field)?.ok_or(PimdirActionError::MissingField(field))
}
fn require_seq(map: &Map<String, Value>) -> Result<i64, PimdirActionError> {
map.get("seq")
.and_then(Value::as_i64)
.ok_or(PimdirActionError::MissingField("seq"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_round_trip_and_escape() {
let flags = ReplicaFlags::from_iter(["\\Seen", "$flagged", "a\"b"]);
let json = flags_to_json(&flags).expect("a known set encodes");
assert_eq!(flags_from_json(Some(&json)), flags);
assert!(json.starts_with('['));
assert!(json.contains("\\\\Seen"));
}
#[test]
fn an_unknown_set_is_null_and_a_known_empty_one_is_a_list() {
assert_eq!(flags_to_json(&ReplicaFlags::Unknown), None);
assert_eq!(flags_from_json(None), ReplicaFlags::Unknown);
assert_eq!(
flags_to_json(&ReplicaFlags::default()).as_deref(),
Some("[]")
);
assert_eq!(flags_from_json(Some("[]")), ReplicaFlags::default());
}
#[test]
fn a_malformed_flag_set_reads_as_unread_not_as_empty() {
assert_eq!(flags_from_json(Some("not json")), ReplicaFlags::Unknown);
assert_eq!(flags_from_json(Some("{}")), ReplicaFlags::Unknown);
}
#[test]
fn level_map_round_trips() {
for l in [ReplicaLevel::Probed, ReplicaLevel::Meta, ReplicaLevel::Full] {
assert_eq!(level_from_int(level_to_int(l)), l);
}
}
#[test]
fn every_action_kind_round_trips_through_its_payload() {
let actions = [
PimdirAction::Add {
link_id: Some(ReplicaLinkId("mid:new".into())),
flags: ReplicaFlags::from_iter(["\\Draft"]),
object: Some(ReplicaHash("cafebabe".into())),
meta: Some(ReplicaMeta("{\"subject\":\"hi\",\"v\":1}".into())),
handle: Some(ReplicaHandle("draft-1".into())),
},
PimdirAction::Add {
link_id: None,
flags: ReplicaFlags::default(),
object: None,
meta: None,
handle: None,
},
PimdirAction::SetFlags {
seq: 4,
flags: ReplicaFlags::from_iter(["\\Seen", "$flagged"]),
},
PimdirAction::Remove { seq: 5 },
PimdirAction::Move {
seq: 6,
to: ReplicaCollectionId("Archive".into()),
},
PimdirAction::Copy {
seq: 7,
to: ReplicaCollectionId("Backup".into()),
},
PimdirAction::Update {
seq: 8,
object: ReplicaHash("beef0000".into()),
meta: None,
},
];
for action in actions {
let payload = action_to_payload(&action);
assert!(payload.contains("\"v\":1"), "versioned: {payload}");
let decoded = action_from_payload(action.kind(), &payload).unwrap();
assert_eq!(decoded, action, "round-trip of {payload}");
}
}
#[test]
fn a_non_json_meta_survives_the_payload_embedding() {
let action = PimdirAction::Update {
seq: 1,
object: ReplicaHash("cafebabe".into()),
meta: Some(ReplicaMeta("not json".into())),
};
let payload = action_to_payload(&action);
assert_eq!(action_from_payload("update", &payload).unwrap(), action);
}
#[test]
fn malformed_action_payloads_error_instead_of_decaying() {
assert_eq!(
action_from_payload("remove", "not json"),
Err(PimdirActionError::Json)
);
assert_eq!(
action_from_payload("remove", "{\"seq\":1}"),
Err(PimdirActionError::UnknownVersion(None))
);
assert_eq!(
action_from_payload("remove", "{\"v\":2,\"seq\":1}"),
Err(PimdirActionError::UnknownVersion(Some(2)))
);
assert_eq!(
action_from_payload("remove", "{\"v\":1}"),
Err(PimdirActionError::MissingField("seq"))
);
}
#[test]
fn an_owner_defined_kind_survives_whole_instead_of_erroring() {
let payload = "{\"v\":1,\"object\":\"cafebabe\",\"to\":[\"a@b.c\"]}";
let decoded = action_from_payload("submit", payload).unwrap();
assert_eq!(
decoded,
PimdirAction::Unknown {
kind: "submit".into(),
payload: payload.into(),
object_hash: Some(ReplicaHash("cafebabe".into())),
}
);
assert_eq!(decoded.kind(), "submit");
assert_eq!(decoded.object_hash(), Some(&ReplicaHash("cafebabe".into())));
assert_eq!(action_to_payload(&decoded), payload);
assert_eq!(
action_from_payload("submit", "{\"to\":[]}"),
Err(PimdirActionError::UnknownVersion(None))
);
assert_eq!(
action_from_payload("submit", "nope"),
Err(PimdirActionError::Json)
);
}
#[test]
fn the_pinned_hash_follows_the_payload_body() {
let add = PimdirAction::Add {
link_id: None,
flags: ReplicaFlags::default(),
object: Some(ReplicaHash("cafebabe".into())),
meta: None,
handle: None,
};
assert_eq!(add.object_hash(), Some(&ReplicaHash("cafebabe".into())));
assert_eq!(PimdirAction::Remove { seq: 1 }.object_hash(), None);
}
}