commit bc143375b852dbd1e743c84e59338a062e3fffca
parent 8c01bc55dc0471535284a1ab7d13c4cb5b44ddb8
Author: triesap <tyson@radroots.org>
Date: Mon, 5 Jan 2026 17:34:38 +0000
follow: apply follow list mutations
- Add FollowMutation enum with serde support
- Implement follow_apply to follow/unfollow/toggle entries
- Normalize keys and optional fields; dedupe list by pubkey
- Add unit tests covering add/update/remove/toggle and validation
Diffstat:
2 files changed, 267 insertions(+), 1 deletion(-)
diff --git a/events-codec/src/follow/encode.rs b/events-codec/src/follow/encode.rs
@@ -35,6 +35,25 @@ pub fn follow_build_tags(follow: &RadrootsFollow) -> Result<Vec<Vec<String>>, Ev
Ok(tags)
}
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum FollowMutation {
+ Follow {
+ public_key: String,
+ relay_url: Option<String>,
+ contact_name: Option<String>,
+ },
+ Unfollow {
+ public_key: String,
+ },
+ Toggle {
+ public_key: String,
+ relay_url: Option<String>,
+ contact_name: Option<String>,
+ },
+}
+
pub fn to_wire_parts(follow: &RadrootsFollow) -> Result<WireEventParts, EventEncodeError> {
to_wire_parts_with_kind(follow, DEFAULT_KIND)
}
@@ -50,3 +69,121 @@ pub fn to_wire_parts_with_kind(
tags,
})
}
+
+pub fn follow_apply(
+ follow: &RadrootsFollow,
+ mutation: FollowMutation,
+) -> Result<RadrootsFollow, EventEncodeError> {
+ let mut list = normalize_list(&follow.list)?;
+
+ match mutation {
+ FollowMutation::Follow {
+ public_key,
+ relay_url,
+ contact_name,
+ } => {
+ let public_key = normalize_public_key(&public_key)?;
+ let relay_url = normalize_optional(relay_url);
+ let contact_name = normalize_optional(contact_name);
+ apply_follow(&mut list, public_key, relay_url, contact_name);
+ }
+ FollowMutation::Unfollow { public_key } => {
+ let public_key = normalize_public_key(&public_key)?;
+ list.retain(|entry| entry.public_key != public_key);
+ }
+ FollowMutation::Toggle {
+ public_key,
+ relay_url,
+ contact_name,
+ } => {
+ let public_key = normalize_public_key(&public_key)?;
+ if list.iter().any(|entry| entry.public_key == public_key) {
+ list.retain(|entry| entry.public_key != public_key);
+ } else {
+ let relay_url = normalize_optional(relay_url);
+ let contact_name = normalize_optional(contact_name);
+ list.push(RadrootsFollowProfile {
+ published_at: 0,
+ public_key,
+ relay_url,
+ contact_name,
+ });
+ }
+ }
+ }
+
+ Ok(RadrootsFollow { list })
+}
+
+pub fn follow_to_wire_parts_after(
+ follow: &RadrootsFollow,
+ mutation: FollowMutation,
+) -> Result<WireEventParts, EventEncodeError> {
+ let updated = follow_apply(follow, mutation)?;
+ to_wire_parts(&updated)
+}
+
+fn normalize_public_key(value: &str) -> Result<String, EventEncodeError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("follow.public_key"));
+ }
+ Ok(trimmed.to_string())
+}
+
+fn normalize_optional(value: Option<String>) -> Option<String> {
+ value.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+ })
+}
+
+fn normalize_list(
+ list: &[RadrootsFollowProfile],
+) -> Result<Vec<RadrootsFollowProfile>, EventEncodeError> {
+ let mut out = Vec::with_capacity(list.len());
+ for entry in list {
+ let public_key = normalize_public_key(&entry.public_key)?;
+ if out
+ .iter()
+ .any(|item: &RadrootsFollowProfile| item.public_key == public_key)
+ {
+ continue;
+ }
+ let mut normalized = entry.clone();
+ normalized.public_key = public_key;
+ normalized.relay_url = normalize_optional(normalized.relay_url);
+ normalized.contact_name = normalize_optional(normalized.contact_name);
+ out.push(normalized);
+ }
+ Ok(out)
+}
+
+fn apply_follow(
+ list: &mut Vec<RadrootsFollowProfile>,
+ public_key: String,
+ relay_url: Option<String>,
+ contact_name: Option<String>,
+) {
+ if let Some(pos) = list.iter().position(|entry| entry.public_key == public_key) {
+ let mut entry = list[pos].clone();
+ if let Some(relay_url) = relay_url {
+ entry.relay_url = Some(relay_url);
+ }
+ if let Some(contact_name) = contact_name {
+ entry.contact_name = Some(contact_name);
+ }
+ list[pos] = entry;
+ } else {
+ list.push(RadrootsFollowProfile {
+ published_at: 0,
+ public_key,
+ relay_url,
+ contact_name,
+ });
+ }
+}
diff --git a/events-codec/tests/follow.rs b/events-codec/tests/follow.rs
@@ -5,7 +5,7 @@ use radroots_events::{
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::follow::decode::follow_from_tags;
-use radroots_events_codec::follow::encode::to_wire_parts;
+use radroots_events_codec::follow::encode::{follow_apply, FollowMutation, to_wire_parts};
#[test]
fn follow_to_wire_parts_builds_p_tags() {
@@ -101,3 +101,132 @@ fn follow_from_tags_rejects_wrong_kind() {
}
));
}
+
+#[test]
+fn follow_apply_adds_and_updates_entries() {
+ let follow = RadrootsFollow {
+ list: vec![
+ RadrootsFollowProfile {
+ published_at: 1,
+ public_key: "pubkey-a".to_string(),
+ relay_url: None,
+ contact_name: Some("alice".to_string()),
+ },
+ RadrootsFollowProfile {
+ published_at: 1,
+ public_key: "pubkey-b".to_string(),
+ relay_url: None,
+ contact_name: Some("bob".to_string()),
+ },
+ ],
+ };
+
+ let updated = follow_apply(
+ &follow,
+ FollowMutation::Follow {
+ public_key: "pubkey-a".to_string(),
+ relay_url: Some("wss://relay".to_string()),
+ contact_name: Some("alice-updated".to_string()),
+ },
+ )
+ .unwrap();
+ assert_eq!(updated.list.len(), 2);
+ assert_eq!(updated.list[0].public_key, "pubkey-a");
+ assert_eq!(updated.list[0].relay_url.as_deref(), Some("wss://relay"));
+ assert_eq!(updated.list[0].contact_name.as_deref(), Some("alice-updated"));
+
+ let added = follow_apply(
+ &follow,
+ FollowMutation::Follow {
+ public_key: "pubkey-c".to_string(),
+ relay_url: None,
+ contact_name: Some("cara".to_string()),
+ },
+ )
+ .unwrap();
+ assert_eq!(added.list.len(), 3);
+ assert_eq!(added.list[2].public_key, "pubkey-c");
+}
+
+#[test]
+fn follow_apply_unfollow_removes_entries() {
+ let follow = RadrootsFollow {
+ list: vec![
+ RadrootsFollowProfile {
+ published_at: 1,
+ public_key: "pubkey-a".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ RadrootsFollowProfile {
+ published_at: 1,
+ public_key: "pubkey-b".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ ],
+ };
+
+ let removed = follow_apply(
+ &follow,
+ FollowMutation::Unfollow {
+ public_key: "pubkey-b".to_string(),
+ },
+ )
+ .unwrap();
+ assert_eq!(removed.list.len(), 1);
+ assert_eq!(removed.list[0].public_key, "pubkey-a");
+}
+
+#[test]
+fn follow_apply_toggle_adds_or_removes() {
+ let follow = RadrootsFollow {
+ list: vec![RadrootsFollowProfile {
+ published_at: 1,
+ public_key: "pubkey-a".to_string(),
+ relay_url: None,
+ contact_name: None,
+ }],
+ };
+
+ let removed = follow_apply(
+ &follow,
+ FollowMutation::Toggle {
+ public_key: "pubkey-a".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ )
+ .unwrap();
+ assert!(removed.list.is_empty());
+
+ let added = follow_apply(
+ &follow,
+ FollowMutation::Toggle {
+ public_key: "pubkey-b".to_string(),
+ relay_url: None,
+ contact_name: Some("bob".to_string()),
+ },
+ )
+ .unwrap();
+ assert_eq!(added.list.len(), 2);
+ assert_eq!(added.list[1].public_key, "pubkey-b");
+}
+
+#[test]
+fn follow_apply_rejects_empty_pubkey() {
+ let follow = RadrootsFollow { list: Vec::new() };
+ let err = follow_apply(
+ &follow,
+ FollowMutation::Follow {
+ public_key: " ".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("follow.public_key")
+ ));
+}