commit cb9bbde65a6dec710e9628c084f3fd56659b1a21
parent 7315ddd1c3d4cff883e5236d9f898884575607c2
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 17:19:20 -0700
events_codec: add farm file metadata codec
- add NIP-94-style farm file metadata tag encoding and decoding
- keep caption text in event content instead of JSON metadata
- validate workspace addresses, owner document refs, hashes, dimensions, and sources
- export the farm_file event model for codec consumers
Diffstat:
5 files changed, 650 insertions(+), 0 deletions(-)
diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs
@@ -13,6 +13,7 @@ pub mod coop;
pub mod document;
pub mod farm;
pub mod farm_crdt;
+pub mod farm_file;
pub mod farm_workspace;
pub mod follow;
pub mod geochat;
diff --git a/crates/events_codec/src/farm_file/decode.rs b/crates/events_codec/src/farm_file/decode.rs
@@ -0,0 +1,295 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ farm_crdt::RadrootsFarmCrdtDocumentKind,
+ farm_file::{
+ KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata,
+ RadrootsFarmFileSource,
+ },
+ farm_workspace::KIND_FARM_WORKSPACE_MANIFEST,
+ tags::{TAG_A, TAG_D, TAG_H, TAG_MIME, TAG_ORIGINAL_SHA256, TAG_SHA256, TAG_URL},
+};
+
+use crate::d_tag::validate_d_tag_tag;
+use crate::error::EventParseError;
+use crate::farm_file::encode::validate_metadata;
+use crate::field_helpers::{
+ optional_tag_value, parse_address_tag_with_kind, required_tag_value, tag_values,
+ validate_lowercase_hex_64_tag, validate_non_empty_tag_value,
+};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+
+const EXPECTED_KIND: &str = "1063";
+const TAG_ALT: &str = "alt";
+const TAG_BLURHASH: &str = "blurhash";
+const TAG_DIMENSIONS: &str = "dim";
+const TAG_FALLBACK: &str = "fallback";
+const TAG_IMAGE: &str = "image";
+const TAG_OWNER_DOCUMENT: &str = "radroots:owner_document";
+const TAG_SIZE: &str = "size";
+const TAG_THUMB: &str = "thumb";
+
+pub fn farm_file_metadata_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsFarmFileMetadata, EventParseError> {
+ if kind != KIND_FARM_FILE_METADATA {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ });
+ }
+ let d_tag = required_single_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let farm_group_id = required_tag_value(tags, TAG_H)?;
+ let workspace_address = required_tag_value(tags, TAG_A)?;
+ let workspace =
+ parse_address_tag_with_kind(&workspace_address, KIND_FARM_WORKSPACE_MANIFEST, TAG_A)?;
+ 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 (owner_document_id, owner_document_kind) = parse_owner_document(tags)?;
+ let size_bytes = parse_size(tags)?;
+ let dimensions = parse_dimensions_tag(tags)?;
+ let blurhash = optional_tag_value(tags, TAG_BLURHASH)?;
+ let thumb = parse_source_tag(tags, TAG_THUMB)?;
+ let image = parse_source_tag(tags, TAG_IMAGE)?;
+ let alt = optional_tag_value(tags, TAG_ALT)?;
+ let fallbacks = tag_values(tags, TAG_FALLBACK)?;
+ let caption = if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ };
+
+ let metadata = RadrootsFarmFileMetadata {
+ d_tag,
+ workspace: radroots_events::farm_workspace::RadrootsFarmWorkspaceRef {
+ pubkey: workspace.pubkey,
+ d_tag: workspace.d_tag,
+ },
+ farm_group_id,
+ owner_document_id,
+ owner_document_kind,
+ caption,
+ url,
+ mime_type,
+ sha256,
+ original_sha256,
+ size_bytes,
+ dimensions,
+ blurhash,
+ thumb,
+ image,
+ alt,
+ fallbacks,
+ };
+ validate_metadata(&metadata).map_err(encode_error_to_parse_error)?;
+ Ok(metadata)
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsFarmFileMetadata>, EventParseError> {
+ let metadata = farm_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<RadrootsFarmFileMetadata>, 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 required_single_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<String, EventParseError> {
+ let values = tag_values(tags, key)?;
+ let Some(first) = values.first() else {
+ return Err(EventParseError::MissingTag(key));
+ };
+ if values.iter().any(|value| value != first) {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(first.clone())
+}
+
+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_owner_document(
+ tags: &[Vec<String>],
+) -> Result<(String, RadrootsFarmCrdtDocumentKind), EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_OWNER_DOCUMENT))
+ .ok_or(EventParseError::MissingTag(TAG_OWNER_DOCUMENT))?;
+ if tag.len() != 3 {
+ return Err(EventParseError::InvalidTag(TAG_OWNER_DOCUMENT));
+ }
+ let document_id = tag[1].clone();
+ validate_d_tag_tag(&document_id, TAG_OWNER_DOCUMENT)?;
+ let kind = parse_document_kind_tag(&tag[2])?;
+ Ok((document_id, kind))
+}
+
+fn parse_document_kind_tag(value: &str) -> Result<RadrootsFarmCrdtDocumentKind, EventParseError> {
+ match value {
+ "FarmTask" => Ok(RadrootsFarmCrdtDocumentKind::FarmTask),
+ "FarmWorkSession" => Ok(RadrootsFarmCrdtDocumentKind::FarmWorkSession),
+ "FarmHarvestRecord" => Ok(RadrootsFarmCrdtDocumentKind::FarmHarvestRecord),
+ "FarmInventoryItem" => Ok(RadrootsFarmCrdtDocumentKind::FarmInventoryItem),
+ "FarmMediaAsset" => Ok(RadrootsFarmCrdtDocumentKind::FarmMediaAsset),
+ "FarmObservation" => Ok(RadrootsFarmCrdtDocumentKind::FarmObservation),
+ _ => Err(EventParseError::InvalidTag(TAG_OWNER_DOCUMENT)),
+ }
+}
+
+fn parse_size(tags: &[Vec<String>]) -> Result<Option<u64>, EventParseError> {
+ let Some(value) = optional_tag_value(tags, TAG_SIZE)? else {
+ return Ok(None);
+ };
+ value
+ .parse::<u64>()
+ .map(Some)
+ .map_err(|err| EventParseError::InvalidNumber(TAG_SIZE, err))
+}
+
+fn parse_dimensions_tag(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsFarmFileDimensions>, EventParseError> {
+ let Some(value) = optional_tag_value(tags, TAG_DIMENSIONS)? else {
+ return Ok(None);
+ };
+ Ok(Some(parse_dimensions(&value, TAG_DIMENSIONS)?))
+}
+
+fn parse_dimensions(
+ value: &str,
+ tag: &'static str,
+) -> Result<RadrootsFarmFileDimensions, EventParseError> {
+ let (w, h) = value
+ .split_once('x')
+ .ok_or(EventParseError::InvalidTag(tag))?;
+ let w = w
+ .parse::<u32>()
+ .map_err(|_| EventParseError::InvalidTag(tag))?;
+ let h = h
+ .parse::<u32>()
+ .map_err(|_| EventParseError::InvalidTag(tag))?;
+ if w == 0 || h == 0 {
+ return Err(EventParseError::InvalidTag(tag));
+ }
+ Ok(RadrootsFarmFileDimensions { w, h })
+}
+
+fn parse_source_tag(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<RadrootsFarmFileSource>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ else {
+ return Ok(None);
+ };
+ if tag.len() < 2 || tag.len() > 4 {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ let url = tag[1].clone();
+ validate_non_empty_tag_value(&url, key)?;
+ let mut mime_type = None;
+ let mut dimensions = None;
+ if let Some(value) = tag.get(2) {
+ validate_non_empty_tag_value(value, key)?;
+ if value.contains('x') {
+ dimensions = Some(parse_dimensions(value, key)?);
+ } else {
+ mime_type = Some(value.clone());
+ }
+ }
+ if let Some(value) = tag.get(3) {
+ validate_non_empty_tag_value(value, key)?;
+ dimensions = Some(parse_dimensions(value, key)?);
+ }
+ Ok(Some(RadrootsFarmFileSource {
+ url,
+ mime_type,
+ dimensions,
+ }))
+}
+
+fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError {
+ match error {
+ crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ },
+ crate::error::EventEncodeError::EmptyRequiredField(field)
+ | crate::error::EventEncodeError::InvalidField(field) => match field {
+ "d_tag" => EventParseError::InvalidTag(TAG_D),
+ "farm_group_id" => EventParseError::InvalidTag(TAG_H),
+ "workspace.pubkey" | "workspace.d_tag" => EventParseError::InvalidTag(TAG_A),
+ "owner_document_id" => EventParseError::InvalidTag(TAG_OWNER_DOCUMENT),
+ "url" => EventParseError::InvalidTag(TAG_URL),
+ "mime_type" => EventParseError::InvalidTag(TAG_MIME),
+ "sha256" => EventParseError::InvalidTag(TAG_SHA256),
+ "original_sha256" => EventParseError::InvalidTag(TAG_ORIGINAL_SHA256),
+ field => EventParseError::InvalidTag(field),
+ },
+ crate::error::EventEncodeError::Json => EventParseError::InvalidTag("content"),
+ }
+}
diff --git a/crates/events_codec/src/farm_file/encode.rs b/crates/events_codec/src/farm_file/encode.rs
@@ -0,0 +1,199 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
+
+use radroots_events::{
+ farm_crdt::RadrootsFarmCrdtDocumentKind,
+ farm_file::{
+ KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata,
+ RadrootsFarmFileSource,
+ },
+ farm_workspace::KIND_FARM_WORKSPACE_MANIFEST,
+ tags::{TAG_A, TAG_D, TAG_H, TAG_MIME, TAG_ORIGINAL_SHA256, TAG_SHA256, TAG_URL},
+};
+
+use crate::d_tag::validate_d_tag;
+use crate::error::EventEncodeError;
+use crate::field_helpers::{
+ address_string, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
+ validate_non_empty_field,
+};
+use crate::wire::WireEventParts;
+
+const TAG_ALT: &str = "alt";
+const TAG_BLURHASH: &str = "blurhash";
+const TAG_DIMENSIONS: &str = "dim";
+const TAG_FALLBACK: &str = "fallback";
+const TAG_IMAGE: &str = "image";
+const TAG_OWNER_DOCUMENT: &str = "radroots:owner_document";
+const TAG_SIZE: &str = "size";
+const TAG_THUMB: &str = "thumb";
+
+pub fn farm_file_metadata_build_tags(
+ metadata: &RadrootsFarmFileMetadata,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_metadata(metadata)?;
+ let workspace = address_string(
+ KIND_FARM_WORKSPACE_MANIFEST,
+ &metadata.workspace.pubkey,
+ &metadata.workspace.d_tag,
+ "workspace",
+ )?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, metadata.d_tag.as_str());
+ push_tag(&mut tags, TAG_H, metadata.farm_group_id.as_str());
+ push_tag(&mut tags, TAG_A, workspace);
+ 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_tag_values(
+ &mut tags,
+ TAG_OWNER_DOCUMENT,
+ [
+ metadata.owner_document_id.as_str(),
+ document_kind_tag(metadata.owner_document_kind),
+ ],
+ );
+ push_optional_tag(
+ &mut tags,
+ TAG_ORIGINAL_SHA256,
+ metadata.original_sha256.as_deref(),
+ );
+ if let Some(size) = metadata.size_bytes {
+ push_tag(&mut tags, TAG_SIZE, size.to_string());
+ }
+ if let Some(dimensions) = metadata.dimensions {
+ push_tag(&mut tags, TAG_DIMENSIONS, dimensions_tag(dimensions));
+ }
+ push_optional_tag(&mut tags, TAG_BLURHASH, metadata.blurhash.as_deref());
+ push_source_tag(&mut tags, TAG_THUMB, metadata.thumb.as_ref())?;
+ push_source_tag(&mut tags, TAG_IMAGE, metadata.image.as_ref())?;
+ push_optional_tag(&mut tags, TAG_ALT, metadata.alt.as_deref());
+ for fallback in &metadata.fallbacks {
+ validate_non_empty_field(fallback, "fallbacks")?;
+ push_tag(&mut tags, TAG_FALLBACK, fallback.clone());
+ }
+ Ok(tags)
+}
+
+pub fn to_wire_parts(
+ metadata: &RadrootsFarmFileMetadata,
+) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(metadata, KIND_FARM_FILE_METADATA)
+}
+
+pub fn to_wire_parts_with_kind(
+ metadata: &RadrootsFarmFileMetadata,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_FARM_FILE_METADATA {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = farm_file_metadata_build_tags(metadata)?;
+ Ok(WireEventParts {
+ kind,
+ content: metadata.caption.clone().unwrap_or_default(),
+ tags,
+ })
+}
+
+pub(crate) fn validate_metadata(
+ metadata: &RadrootsFarmFileMetadata,
+) -> Result<(), EventEncodeError> {
+ validate_d_tag(&metadata.d_tag, "d_tag")?;
+ validate_non_empty_field(&metadata.farm_group_id, "farm_group_id")?;
+ validate_non_empty_field(&metadata.workspace.pubkey, "workspace.pubkey")?;
+ validate_d_tag(&metadata.workspace.d_tag, "workspace.d_tag")?;
+ validate_d_tag(&metadata.owner_document_id, "owner_document_id")?;
+ validate_non_empty_field(&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(caption) = metadata.caption.as_deref() {
+ validate_non_empty_field(caption, "caption")?;
+ }
+ if let Some(dimensions) = metadata.dimensions {
+ validate_dimensions(dimensions, "dimensions")?;
+ }
+ if let Some(blurhash) = metadata.blurhash.as_deref() {
+ validate_non_empty_field(blurhash, "blurhash")?;
+ }
+ validate_source(metadata.thumb.as_ref(), "thumb")?;
+ validate_source(metadata.image.as_ref(), "image")?;
+ if let Some(alt) = metadata.alt.as_deref() {
+ validate_non_empty_field(alt, "alt")?;
+ }
+ for fallback in &metadata.fallbacks {
+ validate_non_empty_field(fallback, "fallbacks")?;
+ }
+ Ok(())
+}
+
+pub(crate) fn document_kind_tag(kind: RadrootsFarmCrdtDocumentKind) -> &'static str {
+ match kind {
+ RadrootsFarmCrdtDocumentKind::FarmTask => "FarmTask",
+ RadrootsFarmCrdtDocumentKind::FarmWorkSession => "FarmWorkSession",
+ RadrootsFarmCrdtDocumentKind::FarmHarvestRecord => "FarmHarvestRecord",
+ RadrootsFarmCrdtDocumentKind::FarmInventoryItem => "FarmInventoryItem",
+ RadrootsFarmCrdtDocumentKind::FarmMediaAsset => "FarmMediaAsset",
+ RadrootsFarmCrdtDocumentKind::FarmObservation => "FarmObservation",
+ }
+}
+
+fn validate_dimensions(
+ dimensions: RadrootsFarmFileDimensions,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if dimensions.w == 0 || dimensions.h == 0 {
+ Err(EventEncodeError::InvalidField(field))
+ } else {
+ Ok(())
+ }
+}
+
+fn validate_source(
+ source: Option<&RadrootsFarmFileSource>,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ let Some(source) = source else {
+ return Ok(());
+ };
+ validate_non_empty_field(&source.url, field)?;
+ if let Some(mime_type) = source.mime_type.as_deref() {
+ validate_non_empty_field(mime_type, field)?;
+ }
+ if let Some(dimensions) = source.dimensions {
+ validate_dimensions(dimensions, field)?;
+ }
+ Ok(())
+}
+
+fn push_source_tag(
+ tags: &mut Vec<Vec<String>>,
+ key: &'static str,
+ source: Option<&RadrootsFarmFileSource>,
+) -> Result<(), EventEncodeError> {
+ let Some(source) = source else {
+ return Ok(());
+ };
+ validate_source(Some(source), key)?;
+ let mut values = vec![source.url.clone()];
+ if let Some(mime_type) = source.mime_type.as_deref() {
+ values.push(mime_type.to_string());
+ }
+ if let Some(dimensions) = source.dimensions {
+ values.push(dimensions_tag(dimensions));
+ }
+ push_tag_values(tags, key, values);
+ Ok(())
+}
+
+fn dimensions_tag(dimensions: RadrootsFarmFileDimensions) -> String {
+ format!("{}x{}", dimensions.w, dimensions.h)
+}
diff --git a/crates/events_codec/src/farm_file/mod.rs b/crates/events_codec/src/farm_file/mod.rs
@@ -0,0 +1,154 @@
+pub mod decode;
+pub mod encode;
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::{
+ farm_crdt::RadrootsFarmCrdtDocumentKind,
+ farm_file::{
+ KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata,
+ RadrootsFarmFileSource,
+ },
+ farm_workspace::RadrootsFarmWorkspaceRef,
+ kinds::KIND_POST,
+ };
+
+ use crate::error::{EventEncodeError, EventParseError};
+ use crate::farm_file::decode::farm_file_metadata_from_event;
+ use crate::farm_file::encode::{
+ farm_file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind,
+ };
+
+ const FILE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ";
+ const WORKSPACE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+ const OWNER_DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg";
+ const GROUP_ID: &str = "field-group";
+ const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ #[test]
+ fn farm_file_metadata_encodes_tags_and_caption_content() {
+ let metadata = sample_metadata();
+ let parts = to_wire_parts(&metadata).expect("file metadata wire parts");
+
+ assert_eq!(parts.kind, KIND_FARM_FILE_METADATA);
+ assert_eq!(parts.content, "Tomatoes harvested from Patch Y.");
+ assert!(parts.tags.contains(&tag("d", FILE_D_TAG)));
+ assert!(parts.tags.contains(&tag("h", GROUP_ID)));
+ assert!(
+ parts
+ .tags
+ .contains(&tag("a", "30078:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA"))
+ );
+ assert!(
+ parts
+ .tags
+ .contains(&tag("url", "https://media.example.invalid/blob/sha256"))
+ );
+ assert!(parts.tags.contains(&tag("m", "image/jpeg")));
+ assert!(parts.tags.contains(&tag("x", SHA256)));
+
+ let decoded = farm_file_metadata_from_event(parts.kind, &parts.tags, &parts.content)
+ .expect("file metadata decode");
+ assert_eq!(decoded, metadata);
+ }
+
+ #[test]
+ fn farm_file_metadata_rejects_missing_x_bad_hash_and_missing_url() {
+ let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts");
+ let without_x = parts
+ .tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) != Some("x"))
+ .cloned()
+ .collect::<Vec<_>>();
+ let missing_x =
+ farm_file_metadata_from_event(parts.kind, &without_x, &parts.content).unwrap_err();
+ assert!(matches!(missing_x, EventParseError::MissingTag("x")));
+
+ let mut bad_hash = sample_metadata();
+ bad_hash.sha256 = "ABC".to_string();
+ let hash_err = farm_file_metadata_build_tags(&bad_hash).unwrap_err();
+ assert!(matches!(hash_err, EventEncodeError::InvalidField("sha256")));
+
+ let mut missing_url = sample_metadata();
+ missing_url.url.clear();
+ let url_err = to_wire_parts(&missing_url).unwrap_err();
+ assert!(matches!(
+ url_err,
+ EventEncodeError::EmptyRequiredField("url")
+ ));
+ }
+
+ #[test]
+ fn farm_file_metadata_rejects_d_mismatch_and_kind_mismatch() {
+ let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts");
+ let mut duplicate_d = parts.tags.clone();
+ duplicate_d.push(vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAw".to_string()]);
+ let mismatch =
+ farm_file_metadata_from_event(parts.kind, &duplicate_d, &parts.content).unwrap_err();
+ assert!(matches!(mismatch, EventParseError::InvalidTag("d")));
+
+ let wrong_kind = to_wire_parts_with_kind(&sample_metadata(), KIND_POST).unwrap_err();
+ assert!(matches!(
+ wrong_kind,
+ EventEncodeError::InvalidKind(KIND_POST)
+ ));
+
+ let decode_wrong_kind =
+ farm_file_metadata_from_event(KIND_POST, &parts.tags, &parts.content).unwrap_err();
+ assert!(matches!(
+ decode_wrong_kind,
+ EventParseError::InvalidKind {
+ expected: "1063",
+ got: KIND_POST
+ }
+ ));
+ }
+
+ #[test]
+ fn farm_file_metadata_decodes_empty_content_as_absent_caption() {
+ let mut metadata = sample_metadata();
+ metadata.caption = None;
+ let parts = to_wire_parts(&metadata).expect("file metadata wire parts");
+
+ assert_eq!(parts.content, "");
+ let decoded =
+ farm_file_metadata_from_event(parts.kind, &parts.tags, "").expect("file decode");
+ assert_eq!(decoded.caption, None);
+ }
+
+ fn sample_metadata() -> RadrootsFarmFileMetadata {
+ RadrootsFarmFileMetadata {
+ d_tag: FILE_D_TAG.to_string(),
+ workspace: RadrootsFarmWorkspaceRef {
+ pubkey: "workspace_pubkey".to_string(),
+ d_tag: WORKSPACE_D_TAG.to_string(),
+ },
+ farm_group_id: GROUP_ID.to_string(),
+ owner_document_id: OWNER_DOCUMENT_ID.to_string(),
+ owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask,
+ caption: Some("Tomatoes harvested from Patch Y.".to_string()),
+ url: "https://media.example.invalid/blob/sha256".to_string(),
+ mime_type: "image/jpeg".to_string(),
+ sha256: SHA256.to_string(),
+ original_sha256: Some(
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
+ ),
+ size_bytes: Some(123_456),
+ dimensions: Some(RadrootsFarmFileDimensions { w: 1600, h: 1200 }),
+ blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()),
+ thumb: Some(RadrootsFarmFileSource {
+ url: "https://media.example.invalid/thumb/sha256".to_string(),
+ mime_type: Some("image/jpeg".to_string()),
+ dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }),
+ }),
+ image: None,
+ alt: Some("Harvested tomatoes in a crate".to_string()),
+ fallbacks: vec!["https://fallback.example.invalid/blob/sha256".to_string()],
+ }
+ }
+
+ fn tag(key: &str, value: &str) -> Vec<String> {
+ vec![key.to_string(), value.to_string()]
+ }
+}
diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs
@@ -19,6 +19,7 @@ pub mod coop;
pub mod document;
pub mod farm;
pub mod farm_crdt;
+pub mod farm_file;
pub mod farm_workspace;
pub mod follow;
pub mod geochat;