lib

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

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:
Mcrates/events/src/tags.rs | 4+++-
Acrates/events_codec/src/article/decode.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/article/encode.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/article/mod.rs | 2++
Acrates/events_codec/src/calendar/decode.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/calendar/encode.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/calendar/mod.rs | 2++
Acrates/events_codec/src/file_metadata/decode.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/file_metadata/encode.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/file_metadata/mod.rs | 2++
Mcrates/events_codec/src/lib.rs | 4++++
Acrates/events_codec/src/social_helpers.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/tests/article.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/tests/calendar.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/tests/file_metadata.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}