commit 4a1c0e87de4d04b5e326ce69b456dc71bf13d318
parent 2416d2395020067053afcb1e042a01f59259a40a
Author: triesap <tyson@radroots.org>
Date: Fri, 12 Jun 2026 02:55:40 -0700
events_codec: add social mvp codecs
- add shared social helpers for dates, locations, participants, farm anchors, and media dimensions
- add article, public file metadata, and calendar date/time codecs with strict kind and tag validation
- expose MVP codec modules through radroots_events_codec and add the TAG_THUMB tag constant
- cover round trips, wrappers, and negative parse or encode paths with serde_json tests
Diffstat:
15 files changed, 1698 insertions(+), 1 deletion(-)
diff --git a/crates/events/src/tags.rs b/crates/events/src/tags.rs
@@ -28,7 +28,8 @@ pub const TAG_ORIGINAL_SHA256: &str = "ox";
pub const TAG_SIZE: &str = "size";
pub const TAG_DIMENSIONS: &str = "dim";
pub const TAG_BLURHASH: &str = "blurhash";
-pub const TAG_THUMBNAIL: &str = "thumb";
+pub const TAG_THUMB: &str = "thumb";
+pub const TAG_THUMBNAIL: &str = TAG_THUMB;
pub const TAG_IMAGE: &str = "image";
pub const TAG_SUMMARY: &str = "summary";
pub const TAG_ALT: &str = "alt";
@@ -92,6 +93,7 @@ mod tests {
assert_eq!(TAG_SIZE, "size");
assert_eq!(TAG_DIMENSIONS, "dim");
assert_eq!(TAG_BLURHASH, "blurhash");
+ assert_eq!(TAG_THUMB, "thumb");
assert_eq!(TAG_THUMBNAIL, "thumb");
assert_eq!(TAG_IMAGE, "image");
assert_eq!(TAG_SUMMARY, "summary");
diff --git a/crates/events_codec/src/article/decode.rs b/crates/events_codec/src/article/decode.rs
@@ -0,0 +1,130 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ article::RadrootsArticle,
+ farm::RadrootsFarmRef,
+ kinds::{KIND_ARTICLE, KIND_FARM},
+ social::RadrootsSocialFarmAnchor,
+ tags::{TAG_A, TAG_D, TAG_IMAGE, TAG_PUBLISHED_AT, TAG_SUMMARY, TAG_T, TAG_TITLE},
+};
+
+use crate::d_tag::validate_d_tag_tag;
+use crate::error::EventParseError;
+use crate::field_helpers::{
+ parse_address_tag_with_kind, required_tag_value, tag_values, validate_non_empty_tag_value,
+};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::{first_tag_value, location_from_tags};
+
+const EXPECTED_KIND: &str = "30023";
+
+pub fn article_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsArticle, EventParseError> {
+ if kind != KIND_ARTICLE {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ });
+ }
+ validate_non_empty_tag_value(content, "content")?;
+ let d_tag = required_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let title = required_tag_value(tags, TAG_TITLE)?;
+ let published_at = first_tag_value(tags, TAG_PUBLISHED_AT)
+ .map(|value| {
+ value
+ .parse::<u64>()
+ .map_err(|err| EventParseError::InvalidNumber(TAG_PUBLISHED_AT, err))
+ })
+ .transpose()?;
+ let farm = parse_farm_anchor(tags)?;
+ Ok(RadrootsArticle {
+ d_tag,
+ title,
+ content: content.to_string(),
+ summary: first_tag_value(tags, TAG_SUMMARY),
+ image: first_tag_value(tags, TAG_IMAGE),
+ published_at,
+ farm,
+ location: location_from_tags(tags),
+ topics: non_empty_vec(tag_values(tags, TAG_T)?),
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsArticle>, EventParseError> {
+ let article = article_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ article,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsArticle>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+fn parse_farm_anchor(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsSocialFarmAnchor>, EventParseError> {
+ let Some(value) = first_tag_value(tags, TAG_A) else {
+ return Ok(None);
+ };
+ let address = parse_address_tag_with_kind(&value, KIND_FARM, TAG_A)?;
+ Ok(Some(RadrootsSocialFarmAnchor {
+ farm: RadrootsFarmRef {
+ pubkey: address.pubkey,
+ d_tag: address.d_tag,
+ },
+ relays: None,
+ }))
+}
+
+fn non_empty_vec(values: Vec<String>) -> Option<Vec<String>> {
+ if values.is_empty() {
+ None
+ } else {
+ Some(values)
+ }
+}
diff --git a/crates/events_codec/src/article/encode.rs b/crates/events_codec/src/article/encode.rs
@@ -0,0 +1,65 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ article::RadrootsArticle,
+ kinds::KIND_ARTICLE,
+ tags::{TAG_D, TAG_IMAGE, TAG_PUBLISHED_AT, TAG_SUMMARY, TAG_T, TAG_TITLE},
+};
+
+use crate::d_tag::validate_d_tag;
+use crate::error::EventEncodeError;
+use crate::field_helpers::{push_optional_tag, push_tag, validate_non_empty_field};
+use crate::social_helpers::push_location_tags;
+use crate::wire::WireEventParts;
+
+const DEFAULT_KIND: u32 = KIND_ARTICLE;
+
+pub fn article_build_tags(article: &RadrootsArticle) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_article(article)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, article.d_tag.as_str());
+ push_tag(&mut tags, TAG_TITLE, article.title.as_str());
+ push_optional_tag(&mut tags, TAG_SUMMARY, article.summary.as_deref());
+ push_optional_tag(&mut tags, TAG_IMAGE, article.image.as_deref());
+ if let Some(published_at) = article.published_at {
+ push_tag(&mut tags, TAG_PUBLISHED_AT, published_at.to_string());
+ }
+ if let Some(farm) = article.farm.as_ref() {
+ crate::social_helpers::push_farm_anchor(&mut tags, farm);
+ }
+ if let Some(location) = article.location.as_ref() {
+ push_location_tags(&mut tags, location);
+ }
+ if let Some(topics) = article.topics.as_ref() {
+ for topic in topics {
+ push_optional_tag(&mut tags, TAG_T, Some(topic.as_str()));
+ }
+ }
+ Ok(tags)
+}
+
+pub fn to_wire_parts(article: &RadrootsArticle) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(article, DEFAULT_KIND)
+}
+
+pub fn to_wire_parts_with_kind(
+ article: &RadrootsArticle,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != DEFAULT_KIND {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: article.content.clone(),
+ tags: article_build_tags(article)?,
+ })
+}
+
+fn validate_article(article: &RadrootsArticle) -> Result<(), EventEncodeError> {
+ validate_d_tag(&article.d_tag, "d_tag")?;
+ validate_non_empty_field(&article.title, "title")?;
+ validate_non_empty_field(&article.content, "content")?;
+ Ok(())
+}
diff --git a/crates/events_codec/src/article/mod.rs b/crates/events_codec/src/article/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/crates/events_codec/src/calendar/decode.rs b/crates/events_codec/src/calendar/decode.rs
@@ -0,0 +1,230 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
+ kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT},
+ social::RadrootsCalendarDateValue,
+ tags::{
+ TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY,
+ TAG_TITLE,
+ },
+};
+
+use crate::d_tag::validate_d_tag_tag;
+use crate::error::EventParseError;
+use crate::field_helpers::{optional_tag_value, required_tag_value, tag_values};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::{
+ location_from_tags, participants_from_tags, validate_date_tag, validate_end_after_start,
+};
+
+const EXPECTED_DATE_KIND: &str = "31922";
+const EXPECTED_TIME_KIND: &str = "31923";
+
+pub fn calendar_date_event_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsCalendarDateEvent, EventParseError> {
+ if kind != KIND_CALENDAR_DATE_EVENT {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_DATE_KIND,
+ got: kind,
+ });
+ }
+ if !content.is_empty() {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ let d_tag = required_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let title = required_tag_value(tags, TAG_TITLE)?;
+ let start = required_tag_value(tags, TAG_START)?;
+ validate_date_tag(&start, TAG_START)?;
+ let end = optional_tag_value(tags, TAG_END)?;
+ if let Some(end) = end.as_deref() {
+ validate_date_tag(end, TAG_END)?;
+ if end < start.as_str() {
+ return Err(EventParseError::InvalidTag(TAG_END));
+ }
+ }
+ let days = tag_values(tags, TAG_D_DAY)?
+ .into_iter()
+ .map(|value| {
+ validate_date_tag(&value, TAG_D_DAY)?;
+ Ok(RadrootsCalendarDateValue { value })
+ })
+ .collect::<Result<Vec<_>, EventParseError>>()?;
+ Ok(RadrootsCalendarDateEvent {
+ d_tag,
+ title,
+ start,
+ end,
+ days: non_empty_vec(days),
+ location: location_from_tags(tags),
+ summary: optional_tag_value(tags, TAG_SUMMARY)?,
+ image: optional_tag_value(tags, TAG_IMAGE)?,
+ participants: participants_from_tags(tags),
+ })
+}
+
+pub fn calendar_time_event_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsCalendarTimeEvent, EventParseError> {
+ if kind != KIND_CALENDAR_TIME_EVENT {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_TIME_KIND,
+ got: kind,
+ });
+ }
+ if !content.is_empty() {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ let d_tag = required_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let title = required_tag_value(tags, TAG_TITLE)?;
+ let start = parse_required_u64(tags, TAG_START)?;
+ let end = parse_optional_u64(tags, TAG_END)?;
+ validate_end_after_start(start, end, TAG_END)
+ .map_err(|_| EventParseError::InvalidTag(TAG_END))?;
+ Ok(RadrootsCalendarTimeEvent {
+ d_tag,
+ title,
+ start,
+ end,
+ start_tzid: optional_tag_value(tags, TAG_START_TZID)?,
+ end_tzid: optional_tag_value(tags, TAG_END_TZID)?,
+ location: location_from_tags(tags),
+ summary: optional_tag_value(tags, TAG_SUMMARY)?,
+ image: optional_tag_value(tags, TAG_IMAGE)?,
+ participants: participants_from_tags(tags),
+ })
+}
+
+pub fn date_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsCalendarDateEvent>, EventParseError> {
+ let event = calendar_date_event_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ event,
+ ))
+}
+
+pub fn time_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsCalendarTimeEvent>, EventParseError> {
+ let event = calendar_time_event_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ event,
+ ))
+}
+
+pub fn date_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsCalendarDateEvent>, EventParseError> {
+ let data = date_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+pub fn time_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsCalendarTimeEvent>, EventParseError> {
+ let data = time_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+fn parse_required_u64(tags: &[Vec<String>], key: &'static str) -> Result<u64, EventParseError> {
+ required_tag_value(tags, key)?
+ .parse::<u64>()
+ .map_err(|err| EventParseError::InvalidNumber(key, err))
+}
+
+fn parse_optional_u64(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<u64>, EventParseError> {
+ optional_tag_value(tags, key)?
+ .map(|value| {
+ value
+ .parse::<u64>()
+ .map_err(|err| EventParseError::InvalidNumber(key, err))
+ })
+ .transpose()
+}
+
+fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
+ if values.is_empty() {
+ None
+ } else {
+ Some(values)
+ }
+}
diff --git a/crates/events_codec/src/calendar/encode.rs b/crates/events_codec/src/calendar/encode.rs
@@ -0,0 +1,124 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
+ kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT},
+ tags::{
+ TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY,
+ TAG_TITLE,
+ },
+};
+
+use crate::d_tag::validate_d_tag;
+use crate::error::EventEncodeError;
+use crate::field_helpers::{push_optional_tag, push_tag, validate_non_empty_field};
+use crate::social_helpers::{
+ push_location_tags, push_participants, validate_date, validate_date_end_after_start,
+ validate_end_after_start,
+};
+use crate::wire::{WireEventParts, empty_content};
+
+pub fn calendar_date_event_build_tags(
+ event: &RadrootsCalendarDateEvent,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_date_event(event)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, event.d_tag.as_str());
+ push_tag(&mut tags, TAG_TITLE, event.title.as_str());
+ push_tag(&mut tags, TAG_START, event.start.as_str());
+ push_optional_tag(&mut tags, TAG_END, event.end.as_deref());
+ if let Some(days) = event.days.as_ref() {
+ for day in days {
+ validate_date(&day.value, "days")?;
+ push_tag(&mut tags, TAG_D_DAY, day.value.as_str());
+ }
+ }
+ if let Some(location) = event.location.as_ref() {
+ push_location_tags(&mut tags, location);
+ }
+ push_optional_tag(&mut tags, TAG_SUMMARY, event.summary.as_deref());
+ push_optional_tag(&mut tags, TAG_IMAGE, event.image.as_deref());
+ push_participants(&mut tags, event.participants.as_ref());
+ Ok(tags)
+}
+
+pub fn calendar_time_event_build_tags(
+ event: &RadrootsCalendarTimeEvent,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_time_event(event)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, event.d_tag.as_str());
+ push_tag(&mut tags, TAG_TITLE, event.title.as_str());
+ push_tag(&mut tags, TAG_START, event.start.to_string());
+ if let Some(end) = event.end {
+ push_tag(&mut tags, TAG_END, end.to_string());
+ }
+ push_optional_tag(&mut tags, TAG_START_TZID, event.start_tzid.as_deref());
+ push_optional_tag(&mut tags, TAG_END_TZID, event.end_tzid.as_deref());
+ if let Some(location) = event.location.as_ref() {
+ push_location_tags(&mut tags, location);
+ }
+ push_optional_tag(&mut tags, TAG_SUMMARY, event.summary.as_deref());
+ push_optional_tag(&mut tags, TAG_IMAGE, event.image.as_deref());
+ push_participants(&mut tags, event.participants.as_ref());
+ Ok(tags)
+}
+
+pub fn date_to_wire_parts(
+ event: &RadrootsCalendarDateEvent,
+) -> Result<WireEventParts, EventEncodeError> {
+ date_to_wire_parts_with_kind(event, KIND_CALENDAR_DATE_EVENT)
+}
+
+pub fn time_to_wire_parts(
+ event: &RadrootsCalendarTimeEvent,
+) -> Result<WireEventParts, EventEncodeError> {
+ time_to_wire_parts_with_kind(event, KIND_CALENDAR_TIME_EVENT)
+}
+
+pub fn date_to_wire_parts_with_kind(
+ event: &RadrootsCalendarDateEvent,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_CALENDAR_DATE_EVENT {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: empty_content(),
+ tags: calendar_date_event_build_tags(event)?,
+ })
+}
+
+pub fn time_to_wire_parts_with_kind(
+ event: &RadrootsCalendarTimeEvent,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_CALENDAR_TIME_EVENT {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: empty_content(),
+ tags: calendar_time_event_build_tags(event)?,
+ })
+}
+
+fn validate_date_event(event: &RadrootsCalendarDateEvent) -> Result<(), EventEncodeError> {
+ validate_d_tag(&event.d_tag, "d_tag")?;
+ validate_non_empty_field(&event.title, "title")?;
+ validate_date(&event.start, "start")?;
+ if let Some(end) = event.end.as_deref() {
+ validate_date(end, "end")?;
+ }
+ validate_date_end_after_start(&event.start, event.end.as_deref(), "end")?;
+ Ok(())
+}
+
+fn validate_time_event(event: &RadrootsCalendarTimeEvent) -> Result<(), EventEncodeError> {
+ validate_d_tag(&event.d_tag, "d_tag")?;
+ validate_non_empty_field(&event.title, "title")?;
+ validate_end_after_start(event.start, event.end, "end")?;
+ Ok(())
+}
diff --git a/crates/events_codec/src/calendar/mod.rs b/crates/events_codec/src/calendar/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/crates/events_codec/src/file_metadata/decode.rs b/crates/events_codec/src/file_metadata/decode.rs
@@ -0,0 +1,162 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ file_metadata::RadrootsFileMetadata,
+ kinds::KIND_PUBLIC_FILE_METADATA,
+ social::RadrootsSocialMediaThumbnail,
+ tags::{
+ TAG_ALT, TAG_BLURHASH, TAG_DIMENSIONS, TAG_FALLBACK, TAG_MAGNET, TAG_MIME,
+ TAG_ORIGINAL_SHA256, TAG_SERVICE, TAG_SHA256, TAG_SIZE, TAG_SUMMARY, TAG_THUMB, TAG_URL,
+ },
+};
+
+use crate::error::EventParseError;
+use crate::field_helpers::{
+ optional_tag_value, required_tag_value, tag_values, validate_lowercase_hex_64_tag,
+};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::{first_tag_value, parse_dimensions_tag};
+
+const EXPECTED_KIND: &str = "1063";
+
+pub fn file_metadata_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsFileMetadata, EventParseError> {
+ if kind != KIND_PUBLIC_FILE_METADATA {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ });
+ }
+ let url = required_tag_value(tags, TAG_URL)?;
+ let mime_type = required_tag_value(tags, TAG_MIME)?;
+ let sha256 = required_tag_value(tags, TAG_SHA256)?;
+ validate_lowercase_hex_64_tag(&sha256, TAG_SHA256)?;
+ let original_sha256 = optional_hash_tag(tags, TAG_ORIGINAL_SHA256)?;
+ let size = optional_tag_value(tags, TAG_SIZE)?
+ .map(|value| {
+ value
+ .parse::<u64>()
+ .map_err(|err| EventParseError::InvalidNumber(TAG_SIZE, err))
+ })
+ .transpose()?;
+ let dimensions = optional_tag_value(tags, TAG_DIMENSIONS)?
+ .map(|value| parse_dimensions_tag(&value, TAG_DIMENSIONS))
+ .transpose()?;
+
+ Ok(RadrootsFileMetadata {
+ url,
+ mime_type,
+ sha256,
+ original_sha256,
+ size,
+ dimensions,
+ blurhash: first_tag_value(tags, TAG_BLURHASH),
+ thumbnails: parse_thumbnails(tags)?,
+ summary: first_tag_value(tags, TAG_SUMMARY),
+ alt: first_tag_value(tags, TAG_ALT),
+ fallback: first_tag_value(tags, TAG_FALLBACK),
+ magnet: first_tag_value(tags, TAG_MAGNET),
+ content_hashes: non_empty_vec(tag_values(tags, "i")?),
+ services: non_empty_vec(tag_values(tags, TAG_SERVICE)?),
+ content: if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ },
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsFileMetadata>, EventParseError> {
+ let metadata = file_metadata_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ metadata,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsFileMetadata>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+fn optional_hash_tag(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let Some(value) = optional_tag_value(tags, key)? else {
+ return Ok(None);
+ };
+ validate_lowercase_hex_64_tag(&value, key)?;
+ Ok(Some(value))
+}
+
+fn parse_thumbnails(
+ tags: &[Vec<String>],
+) -> Result<Option<Vec<RadrootsSocialMediaThumbnail>>, EventParseError> {
+ let thumbnails = tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_THUMB))
+ .map(|tag| {
+ let url = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_THUMB))?;
+ let dimensions = tag
+ .get(2)
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| parse_dimensions_tag(value, TAG_THUMB))
+ .transpose()?;
+ Ok(RadrootsSocialMediaThumbnail { url, dimensions })
+ })
+ .collect::<Result<Vec<_>, EventParseError>>()?;
+ Ok(non_empty_vec(thumbnails))
+}
+
+fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
+ if values.is_empty() {
+ None
+ } else {
+ Some(values)
+ }
+}
diff --git a/crates/events_codec/src/file_metadata/encode.rs b/crates/events_codec/src/file_metadata/encode.rs
@@ -0,0 +1,100 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ file_metadata::RadrootsFileMetadata,
+ kinds::KIND_PUBLIC_FILE_METADATA,
+ tags::{
+ TAG_ALT, TAG_BLURHASH, TAG_DIMENSIONS, TAG_FALLBACK, TAG_MAGNET, TAG_MIME,
+ TAG_ORIGINAL_SHA256, TAG_SERVICE, TAG_SHA256, TAG_SIZE, TAG_SUMMARY, TAG_URL,
+ },
+};
+
+use crate::error::EventEncodeError;
+use crate::field_helpers::{
+ push_optional_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
+use crate::social_helpers::{dimensions_tag, push_thumbnail, validate_http_url};
+use crate::wire::WireEventParts;
+
+const DEFAULT_KIND: u32 = KIND_PUBLIC_FILE_METADATA;
+
+pub fn file_metadata_build_tags(
+ metadata: &RadrootsFileMetadata,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_metadata(metadata)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_URL, metadata.url.as_str());
+ push_tag(&mut tags, TAG_MIME, metadata.mime_type.as_str());
+ push_tag(&mut tags, TAG_SHA256, metadata.sha256.as_str());
+ push_optional_tag(
+ &mut tags,
+ TAG_ORIGINAL_SHA256,
+ metadata.original_sha256.as_deref(),
+ );
+ if let Some(size) = metadata.size {
+ push_tag(&mut tags, TAG_SIZE, size.to_string());
+ }
+ if let Some(dimensions) = metadata.dimensions.as_ref() {
+ push_tag(&mut tags, TAG_DIMENSIONS, dimensions_tag(dimensions));
+ }
+ push_optional_tag(&mut tags, TAG_BLURHASH, metadata.blurhash.as_deref());
+ if let Some(thumbnails) = metadata.thumbnails.as_ref() {
+ for thumbnail in thumbnails {
+ push_thumbnail(&mut tags, thumbnail);
+ }
+ }
+ push_optional_tag(&mut tags, TAG_SUMMARY, metadata.summary.as_deref());
+ push_optional_tag(&mut tags, TAG_ALT, metadata.alt.as_deref());
+ push_optional_tag(&mut tags, TAG_FALLBACK, metadata.fallback.as_deref());
+ push_optional_tag(&mut tags, TAG_MAGNET, metadata.magnet.as_deref());
+ if let Some(content_hashes) = metadata.content_hashes.as_ref() {
+ for hash in content_hashes {
+ push_optional_tag(&mut tags, "i", Some(hash.as_str()));
+ }
+ }
+ if let Some(services) = metadata.services.as_ref() {
+ for service in services {
+ push_optional_tag(&mut tags, TAG_SERVICE, Some(service.as_str()));
+ }
+ }
+ Ok(tags)
+}
+
+pub fn to_wire_parts(metadata: &RadrootsFileMetadata) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(metadata, DEFAULT_KIND)
+}
+
+pub fn to_wire_parts_with_kind(
+ metadata: &RadrootsFileMetadata,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != DEFAULT_KIND {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: metadata.content.clone().unwrap_or_default(),
+ tags: file_metadata_build_tags(metadata)?,
+ })
+}
+
+fn validate_metadata(metadata: &RadrootsFileMetadata) -> Result<(), EventEncodeError> {
+ validate_http_url(&metadata.url, "url")?;
+ validate_non_empty_field(&metadata.mime_type, "mime_type")?;
+ validate_lowercase_hex_64(&metadata.sha256, "sha256")?;
+ if let Some(hash) = metadata.original_sha256.as_deref() {
+ validate_lowercase_hex_64(hash, "original_sha256")?;
+ }
+ if let Some(dimensions) = metadata.dimensions.as_ref() {
+ if dimensions.width == 0 || dimensions.height == 0 {
+ return Err(EventEncodeError::InvalidField("dimensions"));
+ }
+ }
+ if let Some(thumbnails) = metadata.thumbnails.as_ref() {
+ for thumbnail in thumbnails {
+ validate_http_url(&thumbnail.url, "thumb")?;
+ }
+ }
+ Ok(())
+}
diff --git a/crates/events_codec/src/file_metadata/mod.rs b/crates/events_codec/src/file_metadata/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs
@@ -10,10 +10,13 @@ mod field_helpers;
pub mod job;
pub mod parsed;
pub mod profile;
+mod social_helpers;
pub mod tag_builders;
pub mod wire;
pub mod app_data;
+pub mod article;
+pub mod calendar;
pub mod comment;
pub mod coop;
pub mod document;
@@ -21,6 +24,7 @@ pub mod farm;
pub mod farm_crdt;
pub mod farm_file;
pub mod farm_workspace;
+pub mod file_metadata;
pub mod follow;
pub mod geochat;
pub mod gift_wrap;
diff --git a/crates/events_codec/src/social_helpers.rs b/crates/events_codec/src/social_helpers.rs
@@ -0,0 +1,261 @@
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::String, vec::Vec};
+
+use radroots_events::social::{
+ RadrootsCalendarParticipant, RadrootsSocialFarmAnchor, RadrootsSocialLocation,
+ RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail,
+};
+
+use crate::error::{EventEncodeError, EventParseError};
+use crate::field_helpers::{push_tag, push_tag_values, validate_non_empty_field};
+
+pub(crate) fn validate_http_url(value: &str, field: &'static str) -> Result<(), EventEncodeError> {
+ if value.starts_with("https://") || value.starts_with("http://") {
+ validate_non_empty_field(value, field)
+ } else {
+ Err(EventEncodeError::InvalidField(field))
+ }
+}
+
+pub(crate) fn validate_date(value: &str, field: &'static str) -> Result<(), EventEncodeError> {
+ if is_date(value) {
+ Ok(())
+ } else {
+ Err(EventEncodeError::InvalidField(field))
+ }
+}
+
+pub(crate) fn validate_date_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> {
+ if is_date(value) {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidTag(tag))
+ }
+}
+
+pub(crate) fn is_date(value: &str) -> bool {
+ let bytes = value.as_bytes();
+ bytes.len() == 10
+ && matches!(bytes[0], b'0'..=b'9')
+ && matches!(bytes[1], b'0'..=b'9')
+ && matches!(bytes[2], b'0'..=b'9')
+ && matches!(bytes[3], b'0'..=b'9')
+ && bytes[4] == b'-'
+ && matches!(bytes[5], b'0'..=b'9')
+ && matches!(bytes[6], b'0'..=b'9')
+ && bytes[7] == b'-'
+ && matches!(bytes[8], b'0'..=b'9')
+ && matches!(bytes[9], b'0'..=b'9')
+}
+
+pub(crate) fn validate_end_after_start(
+ start: u64,
+ end: Option<u64>,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if end.is_some_and(|end| end < start) {
+ Err(EventEncodeError::InvalidField(field))
+ } else {
+ Ok(())
+ }
+}
+
+pub(crate) fn validate_date_end_after_start(
+ start: &str,
+ end: Option<&str>,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if end.is_some_and(|end| end < start) {
+ Err(EventEncodeError::InvalidField(field))
+ } else {
+ Ok(())
+ }
+}
+
+pub(crate) fn push_location_tags(tags: &mut Vec<Vec<String>>, location: &RadrootsSocialLocation) {
+ if let Some(name) = location
+ .name
+ .as_deref()
+ .filter(|value| !value.trim().is_empty())
+ {
+ push_tag(tags, "location", name);
+ }
+ if let Some(geohash) = location
+ .geohash
+ .as_deref()
+ .filter(|value| !value.trim().is_empty())
+ {
+ push_tag(tags, "g", geohash);
+ }
+}
+
+pub(crate) fn location_from_tags(tags: &[Vec<String>]) -> Option<RadrootsSocialLocation> {
+ let name = first_tag_value(tags, "location");
+ let geohash = first_tag_value(tags, "g");
+ if name.is_none() && geohash.is_none() {
+ None
+ } else {
+ Some(RadrootsSocialLocation { name, geohash })
+ }
+}
+
+pub(crate) fn push_farm_anchor(tags: &mut Vec<Vec<String>>, farm: &RadrootsSocialFarmAnchor) {
+ if farm.farm.pubkey.trim().is_empty() || farm.farm.d_tag.trim().is_empty() {
+ return;
+ }
+ let address = format!("30340:{}:{}", farm.farm.pubkey, farm.farm.d_tag);
+ push_tag(tags, "a", address);
+}
+
+pub(crate) fn participants_from_tags(
+ tags: &[Vec<String>],
+) -> Option<Vec<RadrootsCalendarParticipant>> {
+ let participants = tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some("p"))
+ .filter_map(|tag| {
+ let pubkey = tag.get(1)?.clone();
+ if pubkey.trim().is_empty() {
+ return None;
+ }
+ Some(RadrootsCalendarParticipant {
+ pubkey,
+ relay: tag.get(2).filter(|value| !value.trim().is_empty()).cloned(),
+ role: tag.get(3).filter(|value| !value.trim().is_empty()).cloned(),
+ })
+ })
+ .collect::<Vec<_>>();
+ if participants.is_empty() {
+ None
+ } else {
+ Some(participants)
+ }
+}
+
+pub(crate) fn push_participants(
+ tags: &mut Vec<Vec<String>>,
+ participants: Option<&Vec<RadrootsCalendarParticipant>>,
+) {
+ let Some(participants) = participants else {
+ return;
+ };
+ for participant in participants {
+ if participant.pubkey.trim().is_empty() {
+ continue;
+ }
+ let mut tag = vec!["p".to_string(), participant.pubkey.clone()];
+ if let Some(relay) = participant.relay.as_ref() {
+ tag.push(relay.clone());
+ }
+ if let Some(role) = participant.role.as_ref() {
+ if participant.relay.is_none() {
+ tag.push(String::new());
+ }
+ tag.push(role.clone());
+ }
+ tags.push(tag);
+ }
+}
+
+pub(crate) fn first_tag_value(tags: &[Vec<String>], key: &str) -> Option<String> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ .and_then(|tag| tag.get(1))
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+}
+
+pub(crate) fn dimensions_tag(dimensions: &RadrootsSocialMediaDimensions) -> String {
+ format!("{}x{}", dimensions.width, dimensions.height)
+}
+
+pub(crate) fn parse_dimensions_tag(
+ value: &str,
+ tag: &'static str,
+) -> Result<RadrootsSocialMediaDimensions, EventParseError> {
+ let Some((width, height)) = value.split_once('x') else {
+ return Err(EventParseError::InvalidTag(tag));
+ };
+ let width = width
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(tag, err))?;
+ let height = height
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(tag, err))?;
+ if width == 0 || height == 0 {
+ return Err(EventParseError::InvalidTag(tag));
+ }
+ Ok(RadrootsSocialMediaDimensions { width, height })
+}
+
+pub(crate) fn push_thumbnail(
+ tags: &mut Vec<Vec<String>>,
+ thumbnail: &RadrootsSocialMediaThumbnail,
+) {
+ if thumbnail.url.trim().is_empty() {
+ return;
+ }
+ if let Some(dimensions) = thumbnail.dimensions.as_ref() {
+ push_tag_values(
+ tags,
+ "thumb",
+ [thumbnail.url.clone(), dimensions_tag(dimensions)],
+ );
+ } else {
+ push_tag(tags, "thumb", thumbnail.url.clone());
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn validates_dates_and_ordered_time_ranges() {
+ assert!(is_date("2026-06-20"));
+ assert!(!is_date("2026-6-20"));
+ assert!(validate_end_after_start(10, Some(10), "end").is_ok());
+ assert!(matches!(
+ validate_end_after_start(10, Some(9), "end"),
+ Err(EventEncodeError::InvalidField("end"))
+ ));
+ assert!(matches!(
+ validate_date_tag("bad", "start"),
+ Err(EventParseError::InvalidTag("start"))
+ ));
+ }
+
+ #[test]
+ fn encodes_and_decodes_location_participant_and_dimensions_tags() {
+ let mut tags = Vec::new();
+ push_location_tags(
+ &mut tags,
+ &RadrootsSocialLocation {
+ name: Some("Pack shed".to_string()),
+ geohash: Some("c23nb62w20st".to_string()),
+ },
+ );
+ push_participants(
+ &mut tags,
+ Some(&vec![RadrootsCalendarParticipant {
+ pubkey: "crew_pubkey".to_string(),
+ relay: None,
+ role: Some("participant".to_string()),
+ }]),
+ );
+
+ let location = location_from_tags(&tags).expect("location");
+ assert_eq!(location.name.as_deref(), Some("Pack shed"));
+ assert_eq!(location.geohash.as_deref(), Some("c23nb62w20st"));
+ let participants = participants_from_tags(&tags).expect("participants");
+ assert_eq!(participants[0].pubkey, "crew_pubkey");
+ assert_eq!(participants[0].role.as_deref(), Some("participant"));
+
+ let dimensions = parse_dimensions_tag("1200x800", "dim").unwrap();
+ assert_eq!(dimensions_tag(&dimensions), "1200x800");
+ assert!(matches!(
+ parse_dimensions_tag("0x800", "dim"),
+ Err(EventParseError::InvalidTag("dim"))
+ ));
+ }
+}
diff --git a/crates/events_codec/tests/article.rs b/crates/events_codec/tests/article.rs
@@ -0,0 +1,168 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ article::RadrootsArticle,
+ farm::RadrootsFarmRef,
+ kinds::{KIND_ARTICLE, KIND_POST},
+ social::{RadrootsSocialFarmAnchor, RadrootsSocialLocation},
+ tags::{TAG_A, TAG_D, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_PUBLISHED_AT, TAG_T, TAG_TITLE},
+};
+use radroots_events_codec::{
+ article::{
+ decode::{article_from_event, data_from_event, parsed_from_event},
+ encode::{article_build_tags, to_wire_parts, to_wire_parts_with_kind},
+ },
+ error::{EventEncodeError, EventParseError},
+};
+
+const VALID_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+const FARM_D_TAG: &str = "BBBBBBBBBBBBBBBBBBBBBA";
+const FARM_PUBKEY: &str = "farm_pubkey";
+
+fn sample_article() -> RadrootsArticle {
+ RadrootsArticle {
+ d_tag: VALID_D_TAG.to_string(),
+ title: "Spring soil notes".to_string(),
+ content: "# Spring soil notes".to_string(),
+ summary: Some("Field update".to_string()),
+ image: Some("https://media.example.test/soil.jpg".to_string()),
+ published_at: Some(1_781_895_600),
+ farm: Some(RadrootsSocialFarmAnchor {
+ farm: RadrootsFarmRef {
+ pubkey: FARM_PUBKEY.to_string(),
+ d_tag: FARM_D_TAG.to_string(),
+ },
+ relays: None,
+ }),
+ location: Some(RadrootsSocialLocation {
+ name: Some("North field".to_string()),
+ geohash: Some("c23nb62w20st".to_string()),
+ }),
+ topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]),
+ }
+}
+
+fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
+ tags.iter().any(|tag| {
+ tag.first().map(|entry| entry.as_str()) == Some(key)
+ && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ })
+}
+
+#[test]
+fn article_to_wire_parts_roundtrips_social_metadata() {
+ let article = sample_article();
+ let parts = to_wire_parts(&article).unwrap();
+
+ assert_eq!(parts.kind, KIND_ARTICLE);
+ assert_eq!(parts.content, article.content);
+ assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(&parts.tags, TAG_TITLE, "Spring soil notes"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_IMAGE,
+ "https://media.example.test/soil.jpg"
+ ));
+ assert!(has_tag(&parts.tags, TAG_PUBLISHED_AT, "1781895600"));
+ assert!(has_tag(&parts.tags, TAG_LOCATION, "North field"));
+ assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_A,
+ "30340:farm_pubkey:BBBBBBBBBBBBBBBBBBBBBA"
+ ));
+ assert!(has_tag(&parts.tags, TAG_T, "soil"));
+ assert!(has_tag(&parts.tags, TAG_T, "cover-crops"));
+
+ let decoded = article_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.d_tag, VALID_D_TAG);
+ assert_eq!(decoded.title, "Spring soil notes");
+ assert_eq!(decoded.content, "# Spring soil notes");
+ assert_eq!(decoded.summary.as_deref(), Some("Field update"));
+ assert_eq!(decoded.published_at, Some(1_781_895_600));
+ assert_eq!(
+ decoded.farm.as_ref().map(|farm| farm.farm.pubkey.as_str()),
+ Some(FARM_PUBKEY)
+ );
+ assert_eq!(
+ decoded
+ .location
+ .as_ref()
+ .and_then(|location| location.name.as_deref()),
+ Some("North field")
+ );
+ assert_eq!(decoded.topics.as_ref().map(Vec::len), Some(2));
+}
+
+#[test]
+fn article_codec_requires_kind_required_fields_and_valid_d_tag() {
+ let mut article = sample_article();
+ article.title = " ".to_string();
+ assert!(matches!(
+ article_build_tags(&article),
+ Err(EventEncodeError::EmptyRequiredField("title"))
+ ));
+
+ let mut article = sample_article();
+ article.d_tag = "bad".to_string();
+ assert!(matches!(
+ to_wire_parts(&article),
+ Err(EventEncodeError::InvalidField("d_tag"))
+ ));
+
+ assert!(matches!(
+ to_wire_parts_with_kind(&sample_article(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+
+ let mut tags = article_build_tags(&sample_article()).unwrap();
+ tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_TITLE));
+ assert!(matches!(
+ article_from_event(KIND_ARTICLE, &tags, "# Spring soil notes"),
+ Err(EventParseError::MissingTag(TAG_TITLE))
+ ));
+
+ let err = article_from_event(KIND_POST, &tags, "# Spring soil notes").unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "30023",
+ got: KIND_POST
+ }
+ ));
+}
+
+#[test]
+fn article_wrappers_preserve_event_metadata() {
+ let article = sample_article();
+ let parts = to_wire_parts(&article).unwrap();
+ let data = data_from_event(
+ "event_id".to_string(),
+ "author".to_string(),
+ 42,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+
+ assert_eq!(data.id, "event_id");
+ assert_eq!(data.author, "author");
+ assert_eq!(data.published_at, 42);
+ assert_eq!(data.kind, KIND_ARTICLE);
+ assert_eq!(data.data.title, "Spring soil notes");
+
+ let parsed = parsed_from_event(
+ "event_id".to_string(),
+ "author".to_string(),
+ 42,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+
+ assert_eq!(parsed.event.sig, "sig");
+ assert_eq!(parsed.data.data.d_tag, VALID_D_TAG);
+}
diff --git a/crates/events_codec/tests/calendar.rs b/crates/events_codec/tests/calendar.rs
@@ -0,0 +1,248 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
+ kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST},
+ social::{RadrootsCalendarDateValue, RadrootsCalendarParticipant, RadrootsSocialLocation},
+ tags::{
+ TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, TAG_START,
+ TAG_START_TZID, TAG_SUMMARY, TAG_TITLE,
+ },
+};
+use radroots_events_codec::{
+ calendar::{
+ decode::{
+ calendar_date_event_from_event, calendar_time_event_from_event, date_data_from_event,
+ date_parsed_from_event, time_data_from_event, time_parsed_from_event,
+ },
+ encode::{
+ calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts,
+ date_to_wire_parts_with_kind, time_to_wire_parts, time_to_wire_parts_with_kind,
+ },
+ },
+ error::{EventEncodeError, EventParseError},
+};
+
+const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA";
+
+fn sample_date_event() -> RadrootsCalendarDateEvent {
+ RadrootsCalendarDateEvent {
+ d_tag: VALID_D_TAG.to_string(),
+ title: "CSA pickup".to_string(),
+ start: "2026-06-20".to_string(),
+ end: Some("2026-06-21".to_string()),
+ days: Some(vec![RadrootsCalendarDateValue {
+ value: "2026-06-20".to_string(),
+ }]),
+ location: Some(RadrootsSocialLocation {
+ name: Some("Farm stand".to_string()),
+ geohash: Some("c23nb62w20st".to_string()),
+ }),
+ summary: Some("Weekly pickup".to_string()),
+ image: Some("https://media.example.test/calendar.jpg".to_string()),
+ participants: Some(vec![RadrootsCalendarParticipant {
+ pubkey: "host_pubkey".to_string(),
+ relay: Some("wss://relay.example.test".to_string()),
+ role: Some("host".to_string()),
+ }]),
+ }
+}
+
+fn sample_time_event() -> RadrootsCalendarTimeEvent {
+ RadrootsCalendarTimeEvent {
+ d_tag: VALID_D_TAG.to_string(),
+ title: "Wash pack shift".to_string(),
+ start: 1_781_895_600,
+ end: Some(1_781_899_200),
+ start_tzid: Some("America/Vancouver".to_string()),
+ end_tzid: Some("America/Vancouver".to_string()),
+ location: Some(RadrootsSocialLocation {
+ name: Some("Pack shed".to_string()),
+ geohash: Some("c23nb62w20st".to_string()),
+ }),
+ summary: Some("Prepare CSA bins".to_string()),
+ image: None,
+ participants: Some(vec![RadrootsCalendarParticipant {
+ pubkey: "crew_pubkey".to_string(),
+ relay: None,
+ role: Some("participant".to_string()),
+ }]),
+ }
+}
+
+fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
+ tags.iter().any(|tag| {
+ tag.first().map(|entry| entry.as_str()) == Some(key)
+ && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ })
+}
+
+#[test]
+fn calendar_date_event_to_wire_parts_roundtrips_tags() {
+ let event = sample_date_event();
+ let parts = date_to_wire_parts(&event).unwrap();
+
+ assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT);
+ assert!(parts.content.is_empty());
+ assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup"));
+ assert!(has_tag(&parts.tags, TAG_START, "2026-06-20"));
+ assert!(has_tag(&parts.tags, TAG_END, "2026-06-21"));
+ assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
+ assert!(has_tag(&parts.tags, TAG_LOCATION, "Farm stand"));
+ assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st"));
+ assert!(has_tag(&parts.tags, TAG_SUMMARY, "Weekly pickup"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_IMAGE,
+ "https://media.example.test/calendar.jpg"
+ ));
+ assert!(has_tag(&parts.tags, TAG_P, "host_pubkey"));
+
+ let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.d_tag, VALID_D_TAG);
+ assert_eq!(decoded.title, "CSA pickup");
+ assert_eq!(decoded.start, "2026-06-20");
+ assert_eq!(decoded.end.as_deref(), Some("2026-06-21"));
+ assert_eq!(decoded.days.as_ref().map(Vec::len), Some(1));
+ assert_eq!(
+ decoded
+ .location
+ .as_ref()
+ .and_then(|location| location.name.as_deref()),
+ Some("Farm stand")
+ );
+ assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
+}
+
+#[test]
+fn calendar_time_event_to_wire_parts_roundtrips_tags() {
+ let event = sample_time_event();
+ let parts = time_to_wire_parts(&event).unwrap();
+
+ assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT);
+ assert!(parts.content.is_empty());
+ assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(&parts.tags, TAG_TITLE, "Wash pack shift"));
+ assert!(has_tag(&parts.tags, TAG_START, "1781895600"));
+ assert!(has_tag(&parts.tags, TAG_END, "1781899200"));
+ assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver"));
+ assert!(has_tag(&parts.tags, TAG_END_TZID, "America/Vancouver"));
+ assert!(has_tag(&parts.tags, TAG_LOCATION, "Pack shed"));
+ assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
+
+ let decoded = calendar_time_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.d_tag, VALID_D_TAG);
+ assert_eq!(decoded.title, "Wash pack shift");
+ assert_eq!(decoded.start, 1_781_895_600);
+ assert_eq!(decoded.end, Some(1_781_899_200));
+ assert_eq!(decoded.start_tzid.as_deref(), Some("America/Vancouver"));
+ assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
+}
+
+#[test]
+fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
+ assert!(matches!(
+ date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+ assert!(matches!(
+ time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+
+ let mut event = sample_date_event();
+ event.start = "2026-6-20".to_string();
+ assert!(matches!(
+ calendar_date_event_build_tags(&event),
+ Err(EventEncodeError::InvalidField("start"))
+ ));
+
+ let mut event = sample_time_event();
+ event.end = Some(event.start - 1);
+ assert!(matches!(
+ calendar_time_event_build_tags(&event),
+ Err(EventEncodeError::InvalidField("end"))
+ ));
+
+ let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
+ assert!(matches!(
+ calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body"),
+ Err(EventParseError::InvalidJson("content"))
+ ));
+
+ let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
+ let start = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_START))
+ .expect("start tag");
+ start[1] = "bad".to_string();
+ assert!(matches!(
+ calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
+ Err(EventParseError::InvalidTag(TAG_START))
+ ));
+
+ let err = calendar_time_event_from_event(KIND_POST, &tags, "").unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "31923",
+ got: KIND_POST
+ }
+ ));
+}
+
+#[test]
+fn calendar_wrappers_preserve_event_metadata() {
+ let date = sample_date_event();
+ let date_parts = date_to_wire_parts(&date).unwrap();
+ let date_data = date_data_from_event(
+ "date_id".to_string(),
+ "author".to_string(),
+ 7,
+ date_parts.kind,
+ date_parts.content.clone(),
+ date_parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(date_data.kind, KIND_CALENDAR_DATE_EVENT);
+ assert_eq!(date_data.data.title, "CSA pickup");
+
+ let date_parsed = date_parsed_from_event(
+ "date_id".to_string(),
+ "author".to_string(),
+ 7,
+ date_parts.kind,
+ date_parts.content,
+ date_parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(date_parsed.event.sig, "sig");
+
+ let time = sample_time_event();
+ let time_parts = time_to_wire_parts(&time).unwrap();
+ let time_data = time_data_from_event(
+ "time_id".to_string(),
+ "author".to_string(),
+ 8,
+ time_parts.kind,
+ time_parts.content.clone(),
+ time_parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(time_data.kind, KIND_CALENDAR_TIME_EVENT);
+ assert_eq!(time_data.data.title, "Wash pack shift");
+
+ let time_parsed = time_parsed_from_event(
+ "time_id".to_string(),
+ "author".to_string(),
+ 8,
+ time_parts.kind,
+ time_parts.content,
+ time_parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(time_parsed.event.created_at, 8);
+}
diff --git a/crates/events_codec/tests/file_metadata.rs b/crates/events_codec/tests/file_metadata.rs
@@ -0,0 +1,197 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ file_metadata::RadrootsFileMetadata,
+ kinds::{KIND_POST, KIND_PUBLIC_FILE_METADATA},
+ social::{RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail},
+ tags::{
+ TAG_ALT, TAG_DIMENSIONS, TAG_FALLBACK, TAG_MAGNET, TAG_MIME, TAG_ORIGINAL_SHA256,
+ TAG_SERVICE, TAG_SHA256, TAG_SIZE, TAG_SUMMARY, TAG_THUMB, TAG_URL,
+ },
+};
+use radroots_events_codec::{
+ error::{EventEncodeError, EventParseError},
+ file_metadata::{
+ decode::{data_from_event, file_metadata_from_event, parsed_from_event},
+ encode::{file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind},
+ },
+};
+
+const VALID_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const OTHER_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
+
+fn sample_metadata() -> RadrootsFileMetadata {
+ RadrootsFileMetadata {
+ url: "https://media.example.test/field.jpg".to_string(),
+ mime_type: "image/jpeg".to_string(),
+ sha256: VALID_HASH.to_string(),
+ original_sha256: Some(OTHER_HASH.to_string()),
+ size: Some(4096),
+ dimensions: Some(RadrootsSocialMediaDimensions {
+ width: 1200,
+ height: 800,
+ }),
+ blurhash: Some("L6PZfSi_.AyE_3t7t7R**0o#DgR4".to_string()),
+ thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
+ url: "https://media.example.test/field-thumb.jpg".to_string(),
+ dimensions: Some(RadrootsSocialMediaDimensions {
+ width: 320,
+ height: 200,
+ }),
+ }]),
+ summary: Some("Field image".to_string()),
+ alt: Some("Rows of greens after harvest".to_string()),
+ fallback: Some("https://backup.example.test/field.jpg".to_string()),
+ magnet: Some("magnet:?xt=urn:btih:example".to_string()),
+ content_hashes: Some(vec![format!("sha256:{VALID_HASH}")]),
+ services: Some(vec!["https://media.example.test".to_string()]),
+ content: Some("Harvest block photo".to_string()),
+ }
+}
+
+fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
+ tags.iter().any(|tag| {
+ tag.first().map(|entry| entry.as_str()) == Some(key)
+ && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ })
+}
+
+#[test]
+fn file_metadata_to_wire_parts_roundtrips_nip94_tags() {
+ let metadata = sample_metadata();
+ let parts = to_wire_parts(&metadata).unwrap();
+
+ assert_eq!(parts.kind, KIND_PUBLIC_FILE_METADATA);
+ assert_eq!(parts.content, "Harvest block photo");
+ assert!(has_tag(
+ &parts.tags,
+ TAG_URL,
+ "https://media.example.test/field.jpg"
+ ));
+ assert!(has_tag(&parts.tags, TAG_MIME, "image/jpeg"));
+ assert!(has_tag(&parts.tags, TAG_SHA256, VALID_HASH));
+ assert!(has_tag(&parts.tags, TAG_ORIGINAL_SHA256, OTHER_HASH));
+ assert!(has_tag(&parts.tags, TAG_SIZE, "4096"));
+ assert!(has_tag(&parts.tags, TAG_DIMENSIONS, "1200x800"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_THUMB,
+ "https://media.example.test/field-thumb.jpg"
+ ));
+ assert!(has_tag(&parts.tags, TAG_SUMMARY, "Field image"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_ALT,
+ "Rows of greens after harvest"
+ ));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_FALLBACK,
+ "https://backup.example.test/field.jpg"
+ ));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_MAGNET,
+ "magnet:?xt=urn:btih:example"
+ ));
+ assert!(has_tag(
+ &parts.tags,
+ "i",
+ format!("sha256:{VALID_HASH}").as_str()
+ ));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_SERVICE,
+ "https://media.example.test"
+ ));
+
+ let decoded = file_metadata_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.url, "https://media.example.test/field.jpg");
+ assert_eq!(decoded.mime_type, "image/jpeg");
+ assert_eq!(decoded.sha256, VALID_HASH);
+ assert_eq!(decoded.original_sha256.as_deref(), Some(OTHER_HASH));
+ assert_eq!(decoded.size, Some(4096));
+ assert_eq!(decoded.dimensions.as_ref().map(|dim| dim.width), Some(1200));
+ assert_eq!(
+ decoded
+ .thumbnails
+ .as_ref()
+ .map(|thumbnails| thumbnails[0].url.as_str()),
+ Some("https://media.example.test/field-thumb.jpg")
+ );
+ assert_eq!(decoded.content.as_deref(), Some("Harvest block photo"));
+}
+
+#[test]
+fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() {
+ let mut metadata = sample_metadata();
+ metadata.url = "ipfs://field.jpg".to_string();
+ assert!(matches!(
+ file_metadata_build_tags(&metadata),
+ Err(EventEncodeError::InvalidField("url"))
+ ));
+
+ let mut metadata = sample_metadata();
+ metadata.sha256 = "ABC".to_string();
+ assert!(matches!(
+ to_wire_parts(&metadata),
+ Err(EventEncodeError::InvalidField("sha256"))
+ ));
+
+ assert!(matches!(
+ to_wire_parts_with_kind(&sample_metadata(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+
+ let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
+ tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_URL));
+ assert!(matches!(
+ file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
+ Err(EventParseError::MissingTag(TAG_URL))
+ ));
+
+ let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
+ let hash_tag = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SHA256))
+ .expect("x tag");
+ hash_tag[1] = "not-a-hash".to_string();
+ assert!(matches!(
+ file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
+ Err(EventParseError::InvalidTag(TAG_SHA256))
+ ));
+}
+
+#[test]
+fn file_metadata_wrappers_preserve_event_metadata() {
+ let metadata = sample_metadata();
+ let parts = to_wire_parts(&metadata).unwrap();
+ let data = data_from_event(
+ "file_id".to_string(),
+ "author".to_string(),
+ 90,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+
+ assert_eq!(data.id, "file_id");
+ assert_eq!(data.kind, KIND_PUBLIC_FILE_METADATA);
+ assert_eq!(data.data.url, "https://media.example.test/field.jpg");
+
+ let parsed = parsed_from_event(
+ "file_id".to_string(),
+ "author".to_string(),
+ 90,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+
+ assert_eq!(parsed.event.created_at, 90);
+ assert_eq!(parsed.event.sig, "sig");
+ assert_eq!(parsed.data.data.sha256, VALID_HASH);
+}