lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
Mevents-codec/src/follow/encode.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/tests/follow.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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") + )); +}