commit 240d9b7f2df0218dd40c2b81c8990ab983cffc89
parent dc867c0d5d86e90328d968e49773063736b367d9
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 22:02:41 +0000
event: consolidate operational listing decoding
- make canonical tags authoritative for every operational consumer
- preserve typed parser errors across serde and no-serde lanes
- execute exact envelope and error-taxonomy conformance vectors
- remove the duplicate trade decoder
Diffstat:
11 files changed, 976 insertions(+), 2832 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,6 +9,10 @@ publish policy both pass for the same source revision.
### Changed
+- Operational listing decoding now has one tag-authoritative implementation in
+ `radroots_event_codec`. The typed parts decoder reports the established
+ listing error taxonomy, and JSON content can no longer override canonical
+ product, inventory, or bin tags in trade and replica consumers.
- Generic NIP-01 identifier and signature verification is now independent of
knowledge decoding, and every dynamic Nostr kind conversion rejects values
above `65535` instead of truncating them. Canonical-length author keys that
@@ -65,6 +69,8 @@ publish policy both pass for the same source revision.
### Removed
+- The duplicate private operational listing parser in `radroots_trade` was
+ removed; trade validation now consumes the canonical event codec.
- Legacy calendar event models and permissive calendar tag-builder authoring
paths were removed.
- Direct `RadrootsProfile` draft encoding and the identity profile publisher were
diff --git a/contracts/conformance/vectors/listing/parse_event.v1.json b/contracts/conformance/vectors/listing/parse_event.v1.json
@@ -1,19 +1,214 @@
{
- "suite": "listing",
+ "suite": "operational_listing_parse_event",
"contract_version": "1.0.0",
"vectors": [
{
- "id": "listing_parse_event_minimal_001",
- "kind": "listing.parse_event",
+ "id": "operational_listing_tags_override_json_content_001",
+ "kind": "listing.parse_event.valid",
"input": {
- "event": {
- "kind": "listing",
- "content_shape": "listing_json_or_markdown",
- "tags_shape": "listing_tags_full"
+ "event_kind": 30402,
+ "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"],
+ ["price", "10", "USD"]
+ ]
+ },
+ "expected": {
+ "listing": {
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
+ "title": "Widget",
+ "category": "Tools",
+ "primary_bin_id": "bin-1",
+ "quantity_amount": "1",
+ "quantity_unit": "each",
+ "price_amount": "10",
+ "price_currency": "USD",
+ "inventory_available": null
}
+ }
+ },
+ {
+ "id": "operational_listing_missing_d_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"MissingTag": "d"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_d_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "bad d"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidTag": "d"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_number_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "bad", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidNumber": "radroots:bin"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_unit_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "bad"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": "InvalidUnit"
+ }
+ },
+ {
+ "id": "operational_listing_invalid_currency_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "US", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": "InvalidCurrency"
+ }
+ },
+ {
+ "id": "operational_listing_invalid_discount_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"],
+ ["radroots:discount", "{"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidDiscount": "radroots:discount"}
+ }
+ },
+ {
+ "id": "operational_listing_private_json_location_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "{\"location\":{\"coordinates\":[1,2]}}",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidJson": "location"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_kind_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 0,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
},
"expected": {
- "listing_shape": "radroots_listing"
+ "error": {"InvalidKind": 0}
}
}
]
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -569,9 +569,10 @@ signing = "native"
transport = "native"
[operations.listing_parse_event.implementation]
-rust_modules = ["crates/trade/src/listing/codec.rs"]
+rust_modules = ["crates/event_codec/src/listing/decode.rs"]
rust_types = [
"radroots_event::RadrootsEventEnvelope",
+ "radroots_event::order::RadrootsListingParseError",
"radroots_event::listing::RadrootsListing",
]
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -108,3 +108,13 @@ semver_impacts = [
"change_exported_algorithm_behavior",
]
summary = "Replace permissive kind-1 post authoring with strict Update, PhotoUpdate, and Ask states plus signature-gated tolerant product admission."
+
+[[changes]]
+id = "operational-listing-decoder-authority"
+classification = "breaking"
+semver_impacts = [
+ "add_conformance_vector",
+ "change_exported_function_signature",
+ "change_exported_algorithm_behavior",
+]
+summary = "Consolidate operational listing decoding in radroots_event_codec, return the typed listing error contract, and make canonical tags authoritative over JSON content."
diff --git a/crates/event_codec/src/listing/decode.rs b/crates/event_codec/src/listing/decode.rs
@@ -1,5 +1,3 @@
-#![cfg(feature = "serde_json")]
-
#[cfg(not(feature = "std"))]
use alloc::{
string::{String, ToString},
@@ -21,6 +19,7 @@ use radroots_event::{
RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
},
location::is_public_geohash5,
+ order::RadrootsListingParseError,
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
tags::{TAG_D, TAG_PUBLISHED_AT},
@@ -54,6 +53,50 @@ const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
const TAG_STATUS: &str = "status";
const TAG_EXPIRES_AT: &str = "expires_at";
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum ListingDecodeError {
+ MissingTag(&'static str),
+ InvalidTag(&'static str),
+ InvalidNumber(&'static str),
+ InvalidUnit(&'static str),
+ InvalidCurrency(&'static str),
+ InvalidJson(&'static str),
+ #[cfg(feature = "serde_json")]
+ InvalidDiscount(&'static str),
+}
+
+impl ListingDecodeError {
+ fn into_listing_parse_error(self) -> RadrootsListingParseError {
+ match self {
+ Self::MissingTag(field) => RadrootsListingParseError::MissingTag(field.to_string()),
+ Self::InvalidTag(field) => RadrootsListingParseError::InvalidTag(field.to_string()),
+ Self::InvalidNumber(field) => {
+ RadrootsListingParseError::InvalidNumber(field.to_string())
+ }
+ Self::InvalidUnit(_) => RadrootsListingParseError::InvalidUnit,
+ Self::InvalidCurrency(_) => RadrootsListingParseError::InvalidCurrency,
+ Self::InvalidJson(field) => RadrootsListingParseError::InvalidJson(field.to_string()),
+ #[cfg(feature = "serde_json")]
+ Self::InvalidDiscount(field) => {
+ RadrootsListingParseError::InvalidDiscount(field.to_string())
+ }
+ }
+ }
+
+ fn into_event_parse_error(self) -> EventParseError {
+ match self {
+ Self::MissingTag(field) => EventParseError::MissingTag(field),
+ Self::InvalidTag(field)
+ | Self::InvalidNumber(field)
+ | Self::InvalidUnit(field)
+ | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field),
+ #[cfg(feature = "serde_json")]
+ Self::InvalidDiscount(field) => EventParseError::InvalidTag(field),
+ Self::InvalidJson(_) => EventParseError::InvalidJson("content"),
+ }
+ }
+}
+
struct ListingLocationDraft {
primary: String,
city: Option<String>,
@@ -61,38 +104,44 @@ struct ListingLocationDraft {
country: Option<String>,
}
-fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> {
+fn parse_decimal(
+ value: &str,
+ field: &'static str,
+) -> Result<RadrootsCoreDecimal, ListingDecodeError> {
value
.parse::<RadrootsCoreDecimal>()
- .map_err(|_| EventParseError::InvalidTag(field))
+ .map_err(|_| ListingDecodeError::InvalidNumber(field))
}
-fn reject_private_listing_location_content(content: &str) -> Result<(), EventParseError> {
+fn reject_private_listing_location_content(content: &str) -> Result<(), ListingDecodeError> {
let trimmed = content.trim();
if !trimmed.starts_with('{') {
return Ok(());
}
- let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
- return Ok(());
- };
- let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
- return Ok(());
- };
- for key in [
- "lat",
- "lng",
- "lon",
- "point",
- "polygon",
- "coordinates",
- "accuracy",
- "altitude",
- "label",
- "tag_0",
- "gcs",
- ] {
- if location.contains_key(key) {
- return Err(EventParseError::InvalidJson("content"));
+ #[cfg(feature = "serde_json")]
+ {
+ let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
+ return Ok(());
+ };
+ let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
+ return Ok(());
+ };
+ for key in [
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ "gcs",
+ ] {
+ if location.contains_key(key) {
+ return Err(ListingDecodeError::InvalidJson("location"));
+ }
}
}
Ok(())
@@ -101,44 +150,45 @@ fn reject_private_listing_location_content(content: &str) -> Result<(), EventPar
fn parse_currency(
value: &str,
field: &'static str,
-) -> Result<RadrootsCoreCurrency, EventParseError> {
+) -> Result<RadrootsCoreCurrency, ListingDecodeError> {
let upper = value.trim().to_ascii_uppercase();
- RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| EventParseError::InvalidTag(field))
+ RadrootsCoreCurrency::from_str_upper(&upper)
+ .map_err(|_| ListingDecodeError::InvalidCurrency(field))
}
-fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, EventParseError> {
+fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, ListingDecodeError> {
value
.parse::<RadrootsCoreUnit>()
- .map_err(|_| EventParseError::InvalidTag(field))
+ .map_err(|_| ListingDecodeError::InvalidUnit(field))
}
fn parse_u64_tag_value(
value: Option<&String>,
field: &'static str,
-) -> Result<u64, EventParseError> {
+) -> Result<u64, ListingDecodeError> {
value
- .ok_or(EventParseError::InvalidTag(field))?
+ .ok_or(ListingDecodeError::InvalidTag(field))?
.parse::<u64>()
- .map_err(|_| EventParseError::InvalidTag(field))
+ .map_err(|_| ListingDecodeError::InvalidNumber(field))
}
-fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> {
let tag = tags
.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
- .ok_or(EventParseError::MissingTag(TAG_D))?;
+ .ok_or(ListingDecodeError::MissingTag(TAG_D))?;
let value = tag
.get(1)
.map(|value| value.to_string())
- .ok_or(EventParseError::InvalidTag(TAG_D))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_D))?;
if value.trim().is_empty() {
- return Err(EventParseError::InvalidTag(TAG_D));
+ return Err(ListingDecodeError::InvalidTag(TAG_D));
}
- validate_d_tag_tag(&value, TAG_D)?;
+ validate_d_tag_tag(&value, TAG_D).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?;
Ok(value)
}
-fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseError> {
+fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingDecodeError> {
for tag in tags
.iter()
.filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
@@ -146,50 +196,50 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseErr
let value = tag
.get(1)
.map(|value| value.to_string())
- .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_A))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
.and_then(|raw| raw.parse::<u32>().ok())
- .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_A))?;
if kind != KIND_FARM {
continue;
}
let pubkey = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_A))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_A))?
.to_string();
let d_tag = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_A))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_A))?
.to_string();
if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(EventParseError::InvalidTag(TAG_A));
+ return Err(ListingDecodeError::InvalidTag(TAG_A));
}
- validate_d_tag_tag(&d_tag, TAG_A)?;
+ validate_d_tag_tag(&d_tag, TAG_A).map_err(|_| ListingDecodeError::InvalidTag(TAG_A))?;
return Ok(RadrootsFarmRef { pubkey, d_tag });
}
- Err(EventParseError::MissingTag(TAG_A))
+ Err(ListingDecodeError::MissingTag(TAG_A))
}
-fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> {
let tag = tags
.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P))
- .ok_or(EventParseError::MissingTag(TAG_P))?;
+ .ok_or(ListingDecodeError::MissingTag(TAG_P))?;
let value = tag
.get(1)
.map(|value| value.to_string())
- .ok_or(EventParseError::InvalidTag(TAG_P))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_P))?;
if value.trim().is_empty() {
- return Err(EventParseError::InvalidTag(TAG_P));
+ return Err(ListingDecodeError::InvalidTag(TAG_P));
}
Ok(value)
}
fn parse_resource_area(
tags: &[Vec<String>],
-) -> Result<Option<RadrootsResourceAreaRef>, EventParseError> {
+) -> Result<Option<RadrootsResourceAreaRef>, ListingDecodeError> {
let tag = tags
.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
@@ -199,31 +249,32 @@ fn parse_resource_area(
let value = tag
.get(1)
.map(|value| value.to_string())
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
.and_then(|raw| raw.parse::<u32>().ok())
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
if kind != KIND_RESOURCE_AREA {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
}
let pubkey = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
.to_string();
let d_tag = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
.to_string();
if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
}
- validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)?;
+ validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)
+ .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag }))
}
-fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, EventParseError> {
+fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingDecodeError> {
let tag = tags
.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
@@ -233,27 +284,28 @@ fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, Event
let value = tag
.get(1)
.map(|value| value.to_string())
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
let mut parts = value.splitn(3, ':');
let kind = parts
.next()
.and_then(|raw| raw.parse::<u32>().ok())
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
if kind != KIND_PLOT {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
}
let pubkey = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
.to_string();
let d_tag = parts
.next()
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
.to_string();
if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
}
- validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)?;
+ validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)
+ .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
Ok(Some(RadrootsPlotRef { pubkey, d_tag }))
}
@@ -268,13 +320,31 @@ pub fn listing_from_event(
got: kind,
});
}
- listing_from_event_parts(tags, content)
+ decode_listing_from_event_parts(tags, content)
+ .map_err(ListingDecodeError::into_event_parse_error)
}
pub fn listing_from_event_parts(
tags: &[Vec<String>],
content: &str,
-) -> Result<RadrootsListing, EventParseError> {
+) -> Result<RadrootsListing, RadrootsListingParseError> {
+ decode_listing_from_event_parts(tags, content)
+ .map_err(ListingDecodeError::into_listing_parse_error)
+}
+
+pub fn listing_from_nostr_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsListing, RadrootsListingParseError> {
+ if !is_listing_kind(event.kind_u32()) {
+ return Err(RadrootsListingParseError::InvalidKind(event.kind_u32()));
+ }
+ listing_from_event_parts(&event.tags_as_vec(), event.content())
+}
+
+fn decode_listing_from_event_parts(
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsListing, ListingDecodeError> {
reject_private_listing_location_content(content)?;
let d_tag = parse_d_tag(tags)?;
let farm_ref = parse_farm_ref(tags)?;
@@ -327,7 +397,7 @@ pub fn listing_from_event_parts(
let primary = tag
.get(1)
.and_then(|value| clean_value(value))
- .ok_or(EventParseError::InvalidTag(TAG_LOCATION))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_LOCATION))?;
let mut parsed = ListingLocationDraft {
primary,
city: None,
@@ -355,10 +425,10 @@ pub fn listing_from_event_parts(
let value = tag
.get(1)
.and_then(|value| clean_value(value))
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
if let Some(existing) = primary_bin_id.as_ref() {
if existing != &value {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
}
} else {
primary_bin_id = Some(value);
@@ -366,20 +436,20 @@ pub fn listing_from_event_parts(
}
TAG_RADROOTS_BIN => {
if tag.len() < 4 || tag.len() > 7 {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
}
let bin_id = tag
.get(1)
.and_then(|value| clean_value(value))
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_BIN))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?;
let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?;
if unit != unit.canonical_unit() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
}
let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
if bin.quantity.is_some() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
}
bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit));
@@ -394,18 +464,20 @@ pub fn listing_from_event_parts(
bin.display_label = clean_value(label);
}
}
- [_, _, _, _, _] => return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)),
+ [_, _, _, _, _] => {
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
+ }
_ => {}
}
}
TAG_RADROOTS_PRICE => {
if tag.len() < 6 || tag.len() > 8 {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
}
let bin_id = tag
.get(1)
.and_then(|value| clean_value(value))
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRICE))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?;
let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?;
let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
@@ -415,17 +487,17 @@ pub fn listing_from_event_parts(
RadrootsCoreQuantity::new(per_amount, per_unit),
);
if !price_per_canonical_unit.is_price_per_canonical_unit() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
}
let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
if bin.price_per_canonical_unit.is_some() {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
}
bin.price_per_canonical_unit = Some(price_per_canonical_unit);
match tag.as_slice() {
[_, _, _, _, _, _, _] => {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
}
[_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
@@ -439,32 +511,32 @@ pub fn listing_from_event_parts(
TAG_RADROOTS_DISCOUNT => {
let payload = tag
.get(1)
- .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_DISCOUNT))?;
discounts.push(parse_discount(payload)?);
}
TAG_GEOHASH => {
let value = tag
.get(1)
.and_then(|value| clean_value(value))
- .ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?;
if !is_public_geohash5(&value) {
- return Err(EventParseError::InvalidTag(TAG_GEOHASH));
+ return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH));
}
let value = value.to_ascii_lowercase();
if geohash.as_ref().is_some_and(|existing| existing != &value) {
- return Err(EventParseError::InvalidTag(TAG_GEOHASH));
+ return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH));
}
geohash = Some(value);
}
- TAG_DD => return Err(EventParseError::InvalidTag(TAG_DD)),
- TAG_DD_LAT => return Err(EventParseError::InvalidTag(TAG_DD_LAT)),
- TAG_DD_LON => return Err(EventParseError::InvalidTag(TAG_DD_LON)),
- TAG_LABEL => return Err(EventParseError::InvalidTag(TAG_LABEL)),
- TAG_LABEL_NS => return Err(EventParseError::InvalidTag(TAG_LABEL_NS)),
+ TAG_DD => return Err(ListingDecodeError::InvalidTag(TAG_DD)),
+ TAG_DD_LAT => return Err(ListingDecodeError::InvalidTag(TAG_DD_LAT)),
+ TAG_DD_LON => return Err(ListingDecodeError::InvalidTag(TAG_DD_LON)),
+ TAG_LABEL => return Err(ListingDecodeError::InvalidTag(TAG_LABEL)),
+ TAG_LABEL_NS => return Err(ListingDecodeError::InvalidTag(TAG_LABEL_NS)),
TAG_INVENTORY => {
let value = tag
.get(1)
- .ok_or(EventParseError::InvalidTag(TAG_INVENTORY))?;
+ .ok_or(ListingDecodeError::InvalidTag(TAG_INVENTORY))?;
inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
}
TAG_RADROOTS_AVAILABILITY_START => {
@@ -504,7 +576,9 @@ pub fn listing_from_event_parts(
});
}
TAG_IMAGE => {
- let url = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_IMAGE))?;
+ let url = tag
+ .get(1)
+ .ok_or(ListingDecodeError::InvalidTag(TAG_IMAGE))?;
if url.trim().is_empty() {
continue;
}
@@ -527,8 +601,8 @@ pub fn listing_from_event_parts(
let location = location
.map(|location| {
- let geohash = geohash.ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?;
- Ok::<RadrootsListingPublicLocation, EventParseError>(RadrootsListingPublicLocation {
+ let geohash = geohash.ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?;
+ Ok::<RadrootsListingPublicLocation, ListingDecodeError>(RadrootsListingPublicLocation {
primary: location.primary,
city: location.city,
region: location.region,
@@ -539,19 +613,19 @@ pub fn listing_from_event_parts(
.transpose()?;
if farm_pubkey != farm_ref.pubkey {
- return Err(EventParseError::InvalidTag(TAG_P));
+ return Err(ListingDecodeError::InvalidTag(TAG_P));
}
let primary_bin_id =
- primary_bin_id.ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?;
+ primary_bin_id.ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?;
let bins = build_bins(bin_drafts)?;
if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
}
- let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?;
+ let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?;
let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
- .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
+ .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
Ok(RadrootsListing {
d_tag,
@@ -695,8 +769,17 @@ fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
Some(RadrootsListingImageSize { w, h })
}
-fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, EventParseError> {
- serde_json::from_str(payload).map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))
+fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingDecodeError> {
+ #[cfg(feature = "serde_json")]
+ {
+ serde_json::from_str(payload)
+ .map_err(|_| ListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT))
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _ = payload;
+ Err(ListingDecodeError::InvalidJson("discount"))
+ }
}
#[derive(Clone, Debug)]
@@ -736,22 +819,22 @@ fn upsert_bin<'a>(
&mut bins[index]
}
-fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, EventParseError> {
+fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingDecodeError> {
drafts.sort_by_key(|draft| draft.order_index);
let mut bins = Vec::with_capacity(drafts.len());
for draft in drafts {
let quantity = draft
.quantity
- .ok_or(EventParseError::MissingTag(TAG_RADROOTS_BIN))?;
+ .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?;
let price = draft
.price_per_canonical_unit
- .ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRICE))?;
+ .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRICE))?;
if quantity.unit != price.quantity.unit {
- return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
+ return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
}
bins.push(RadrootsListingBin {
bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
- .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_BIN))?,
+ .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?,
quantity,
price_per_canonical_unit: price,
display_amount: draft.display_amount,
diff --git a/crates/event_codec/tests/fixtures/listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/listing_parse_event.v1.json
@@ -0,0 +1,215 @@
+{
+ "suite": "operational_listing_parse_event",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_tags_override_json_content_001",
+ "kind": "listing.parse_event.valid",
+ "input": {
+ "event_kind": 30402,
+ "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"],
+ ["price", "10", "USD"]
+ ]
+ },
+ "expected": {
+ "listing": {
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
+ "title": "Widget",
+ "category": "Tools",
+ "primary_bin_id": "bin-1",
+ "quantity_amount": "1",
+ "quantity_unit": "each",
+ "price_amount": "10",
+ "price_currency": "USD",
+ "inventory_available": null
+ }
+ }
+ },
+ {
+ "id": "operational_listing_missing_d_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"MissingTag": "d"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_d_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "bad d"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidTag": "d"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_number_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "bad", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidNumber": "radroots:bin"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_unit_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "bad"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": "InvalidUnit"
+ }
+ },
+ {
+ "id": "operational_listing_invalid_currency_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "US", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": "InvalidCurrency"
+ }
+ },
+ {
+ "id": "operational_listing_invalid_discount_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"],
+ ["radroots:discount", "{"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidDiscount": "radroots:discount"}
+ }
+ },
+ {
+ "id": "operational_listing_private_json_location_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 30402,
+ "content": "{\"location\":{\"coordinates\":[1,2]}}",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidJson": "location"}
+ }
+ },
+ {
+ "id": "operational_listing_invalid_kind_001",
+ "kind": "listing.parse_event.invalid",
+ "input": {
+ "event_kind": 0,
+ "content": "# Widget",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "farm_pubkey"],
+ ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "sku"],
+ ["title", "Widget"],
+ ["category", "Tools"],
+ ["radroots:primary_bin", "bin-1"],
+ ["radroots:bin", "bin-1", "1", "each"],
+ ["radroots:price", "bin-1", "10", "USD", "1", "each"]
+ ]
+ },
+ "expected": {
+ "error": {"InvalidKind": 0}
+ }
+ }
+ ]
+}
diff --git a/crates/event_codec/tests/listing.rs b/crates/event_codec/tests/listing.rs
@@ -15,14 +15,15 @@ use radroots_event::{
RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
},
+ order::RadrootsListingParseError,
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
tags::{TAG_D, TAG_PUBLISHED_AT},
};
use radroots_event_codec::error::{EventEncodeError, EventParseError};
use radroots_event_codec::listing::decode::{
- data_from_event, data_from_nostr_event, listing_from_event, parsed_from_event,
- parsed_from_nostr_event,
+ data_from_event, data_from_nostr_event, listing_from_event, listing_from_event_parts,
+ listing_from_nostr_event, parsed_from_event, parsed_from_nostr_event,
};
use radroots_event_codec::listing::encode::{
listing_build_tags, to_json_wire_parts_with_kind, to_wire_parts, to_wire_parts_with_kind,
@@ -30,7 +31,9 @@ use radroots_event_codec::listing::encode::{
use radroots_event_codec::listing::tags::{
ListingTagOptions, listing_tags_full, listing_tags_with_options,
};
-use std::str::FromStr;
+use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr};
+
+use serde_json::Value;
const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
@@ -38,6 +41,10 @@ const EVENT_SIG: &str = concat!(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
);
+const PACKAGED_PARSE_VECTORS: &str = include_str!("fixtures/listing_parse_event.v1.json");
+const WORKSPACE_PARSE_VECTOR_PATH: &str =
+ "../../contracts/conformance/vectors/listing/parse_event.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
fn listing_d_tag(raw: &str) -> RadrootsDTag {
raw.parse().unwrap()
@@ -138,6 +145,205 @@ fn sample_listing(d_tag: &str) -> RadrootsListing {
}
}
+#[test]
+fn listing_from_event_parts_preserves_listing_error_taxonomy() {
+ let mut tags = sample_listing_tags();
+ remove_tags(&mut tags, TAG_D);
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::MissingTag(TAG_D.to_string())
+ );
+
+ let mut tags = sample_listing_tags();
+ replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]);
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::InvalidTag(TAG_D.to_string())
+ );
+
+ let mut tags = sample_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "bad", "each"],
+ );
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::InvalidNumber("radroots:bin".to_string())
+ );
+
+ let mut tags = sample_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "bad"],
+ );
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::InvalidUnit
+ );
+
+ let mut tags = sample_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"],
+ );
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::InvalidCurrency
+ );
+
+ let mut tags = sample_listing_tags();
+ tags.push(vec!["radroots:discount".to_string(), "{".to_string()]);
+ assert_eq!(
+ listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsListingParseError::InvalidDiscount("radroots:discount".to_string())
+ );
+
+ assert_eq!(
+ listing_from_event_parts(
+ &sample_listing_tags(),
+ r#"{"location":{"coordinates":[1,2]}}"#,
+ )
+ .unwrap_err(),
+ RadrootsListingParseError::InvalidJson("location".to_string())
+ );
+}
+
+#[test]
+fn listing_from_event_parts_does_not_allow_json_content_to_override_tags() {
+ let tags = sample_listing_tags();
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.product.title = "Content override".to_string();
+ listing.product.category = "Content category".to_string();
+ listing.inventory_available = Some(RadrootsCoreDecimal::from(99u32));
+ listing.bins[0].quantity.amount = RadrootsCoreDecimal::from(42u32);
+
+ let decoded = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
+ .expect("decode tag-authoritative listing");
+
+ assert_eq!(decoded.product.title, "Widget");
+ assert_eq!(decoded.product.category, "Tools");
+ assert_eq!(decoded.inventory_available, None);
+ assert_eq!(
+ decoded.bins[0].quantity.amount,
+ RadrootsCoreDecimal::from(1u32)
+ );
+}
+
+#[test]
+fn checked_in_listing_parse_vectors_execute_against_the_typed_decoder() {
+ let vectors = listing_parse_vectors();
+ let suite: Value = serde_json::from_str(&vectors).expect("listing parse vectors must parse");
+ assert_eq!(suite["suite"], "operational_listing_parse_event");
+ assert_eq!(suite["contract_version"], "1.0.0");
+ let vectors = suite["vectors"].as_array().expect("listing parse vectors");
+ assert_eq!(vectors.len(), 9, "listing parse vector inventory drifted");
+
+ let mut ids = BTreeSet::new();
+ for vector in vectors {
+ let id = vector["id"].as_str().expect("vector id");
+ assert!(ids.insert(id), "duplicate listing parse vector id {id}");
+ execute_listing_parse_vector(vector, id);
+ }
+}
+
+fn listing_parse_vectors() -> Cow<'static, str> {
+ let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_PARSE_VECTOR_PATH);
+ match fs::read_to_string(&workspace_path) {
+ Ok(canonical) => {
+ assert_eq!(
+ canonical,
+ PACKAGED_PARSE_VECTORS,
+ "packaged listing parse vectors must match {}",
+ workspace_path.display()
+ );
+ Cow::Owned(canonical)
+ }
+ Err(error)
+ if error.kind() == std::io::ErrorKind::NotFound
+ && !Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join(WORKSPACE_CONTRACT_MARKER_PATH)
+ .is_file() =>
+ {
+ Cow::Borrowed(PACKAGED_PARSE_VECTORS)
+ }
+ Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
+ }
+}
+
+fn execute_listing_parse_vector(vector: &Value, id: &str) {
+ let input = &vector["input"];
+ let tags: Vec<Vec<String>> =
+ serde_json::from_value(input["tags"].clone()).expect("vector tags");
+ let content = input["content"].as_str().expect("vector content");
+ let event_kind = input["event_kind"].as_u64().expect("vector event kind") as u32;
+ let event = event_envelope(event_kind, tags, content.to_string());
+
+ match vector["kind"].as_str().expect("vector kind") {
+ "listing.parse_event.valid" => {
+ let listing = listing_from_nostr_event(&event)
+ .unwrap_or_else(|error| panic!("{id} failed: {error}"));
+ let expected = &vector["expected"]["listing"];
+ assert_eq!(listing.d_tag.as_str(), expected["d_tag"], "{id}");
+ assert_eq!(listing.product.title, expected["title"], "{id}");
+ assert_eq!(listing.product.category, expected["category"], "{id}");
+ assert_eq!(
+ listing.primary_bin_id.as_str(),
+ expected["primary_bin_id"],
+ "{id}"
+ );
+ let primary = listing
+ .bins
+ .iter()
+ .find(|bin| bin.bin_id == listing.primary_bin_id)
+ .expect("primary listing bin");
+ assert_eq!(
+ primary.quantity.amount.to_string(),
+ expected["quantity_amount"],
+ "{id}"
+ );
+ assert_eq!(
+ primary.quantity.unit.code(),
+ expected["quantity_unit"],
+ "{id}"
+ );
+ assert_eq!(
+ primary.price_per_canonical_unit.amount.amount.to_string(),
+ expected["price_amount"],
+ "{id}"
+ );
+ assert_eq!(
+ primary.price_per_canonical_unit.amount.currency.as_str(),
+ expected["price_currency"],
+ "{id}"
+ );
+ assert_eq!(
+ serde_json::to_value(
+ listing
+ .inventory_available
+ .as_ref()
+ .map(ToString::to_string)
+ )
+ .expect("inventory projection"),
+ expected["inventory_available"],
+ "{id}"
+ );
+ }
+ "listing.parse_event.invalid" => {
+ let error = listing_from_nostr_event(&event)
+ .expect_err("invalid listing parse vector must fail");
+ assert_eq!(
+ serde_json::to_value(error).expect("listing parse error"),
+ vector["expected"]["error"],
+ "{id}"
+ );
+ }
+ kind => panic!("{id} uses unsupported listing parse vector kind {kind}"),
+ }
+}
+
fn sample_listing_full(d_tag: &str) -> RadrootsListing {
let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap();
let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap();
diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs
@@ -1,2668 +0,0 @@
-#![forbid(unsafe_code)]
-
-#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
-
-use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
- RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
-};
-use radroots_event::farm::RadrootsFarmRef;
-use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId};
-use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
-use radroots_event::listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
-};
-use radroots_event::location::is_public_geohash5;
-pub(crate) use radroots_event::order::RadrootsListingParseError as ListingParseError;
-use radroots_event::plot::RadrootsPlotRef;
-use radroots_event::resource_area::RadrootsResourceAreaRef;
-use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT};
-use radroots_event_codec::d_tag::is_d_tag_base64url;
-
-const TAG_PRICE: &str = "price";
-const TAG_RADROOTS_BIN: &str = "radroots:bin";
-const TAG_RADROOTS_PRICE: &str = "radroots:price";
-const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount";
-const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
-const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area";
-const TAG_RADROOTS_PLOT: &str = "radroots:plot";
-const TAG_LOCATION: &str = "location";
-const TAG_IMAGE: &str = "image";
-const TAG_GEOHASH: &str = "g";
-const TAG_LABEL: &str = "l";
-const TAG_LABEL_NS: &str = "L";
-const TAG_DD: &str = "dd";
-const TAG_DD_LAT: &str = "dd.lat";
-const TAG_DD_LON: &str = "dd.lon";
-const TAG_INVENTORY: &str = "inventory";
-const TAG_DELIVERY: &str = "delivery";
-const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
-const TAG_STATUS: &str = "status";
-const TAG_EXPIRES_AT: &str = "expires_at";
-const TAG_P: &str = "p";
-const TAG_A: &str = "a";
-
-struct ListingLocationDraft {
- primary: String,
- city: Option<String>,
- region: Option<String>,
- country: Option<String>,
-}
-
-fn parse_decimal(s: &str, field: &str) -> Result<RadrootsCoreDecimal, ListingParseError> {
- s.parse::<RadrootsCoreDecimal>()
- .map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
-}
-
-fn parse_currency(s: &str) -> Result<RadrootsCoreCurrency, ListingParseError> {
- let upper = s.trim().to_ascii_uppercase();
- RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| ListingParseError::InvalidCurrency)
-}
-
-fn parse_unit(s: &str) -> Result<RadrootsCoreUnit, ListingParseError> {
- s.parse::<RadrootsCoreUnit>()
- .map_err(|_| ListingParseError::InvalidUnit)
-}
-
-fn parse_u64_tag_value(value: Option<&String>, field: &str) -> Result<u64, ListingParseError> {
- value
- .ok_or_else(|| ListingParseError::InvalidTag(field.to_string()))?
- .parse::<u64>()
- .map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
-}
-
-fn reject_private_listing_location_content(content: &str) -> Result<(), ListingParseError> {
- let trimmed = content.trim();
- if !trimmed.starts_with('{') {
- return Ok(());
- }
- #[cfg(feature = "serde_json")]
- {
- let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
- return Ok(());
- };
- let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
- return Ok(());
- };
- for key in [
- "lat",
- "lng",
- "lon",
- "point",
- "polygon",
- "coordinates",
- "accuracy",
- "altitude",
- "label",
- "tag_0",
- "gcs",
- ] {
- if location.contains_key(key) {
- return Err(ListingParseError::InvalidJson("location".to_string()));
- }
- }
- }
- Ok(())
-}
-
-fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingParseError> {
- let tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_D))
- .ok_or_else(|| ListingParseError::MissingTag(TAG_D.to_string()))?;
- let value = tag
- .get(1)
- .map(|s| s.to_string())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_D.to_string()))?;
- if value.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
- }
- if !is_d_tag_base64url(&value) {
- return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
- }
- Ok(value)
-}
-
-pub fn listing_from_event_parts(
- tags: &[Vec<String>],
- content: &str,
-) -> Result<RadrootsListing, ListingParseError> {
- reject_private_listing_location_content(content)?;
- let d_tag = parse_d_tag(tags)?;
- let farm_ref = parse_farm_ref(tags)?;
- let farm_pubkey = parse_farm_pubkey(tags)?;
- let resource_area = parse_resource_area(tags)?;
- let plot = parse_plot_ref(tags)?;
-
- if !content.trim().is_empty() {
- #[cfg(feature = "serde_json")]
- {
- if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) {
- if let Some(location) = listing.location.as_ref()
- && !is_public_geohash5(&location.geohash)
- {
- return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
- }
- if listing.d_tag != d_tag {
- return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
- }
- if listing.farm.pubkey.trim().is_empty() || listing.farm.d_tag.trim().is_empty() {
- listing.farm = farm_ref;
- } else if listing.farm.pubkey != farm_ref.pubkey
- || listing.farm.d_tag != farm_ref.d_tag
- {
- return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
- }
- if listing.farm.pubkey != farm_pubkey {
- return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
- }
- if let Some(tag_area) = resource_area {
- match listing.resource_area.as_ref() {
- None => listing.resource_area = Some(tag_area),
- Some(area) => {
- if area.pubkey != tag_area.pubkey || area.d_tag != tag_area.d_tag {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_RESOURCE_AREA.to_string(),
- ));
- }
- }
- }
- }
- if let Some(tag_plot) = plot {
- match listing.plot.as_ref() {
- None => listing.plot = Some(tag_plot),
- Some(existing) => {
- if existing.pubkey != tag_plot.pubkey
- || existing.d_tag != tag_plot.d_tag
- {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PLOT.to_string(),
- ));
- }
- }
- }
- }
- return Ok(listing);
- }
- }
- }
-
- listing_from_tags(tags, d_tag, farm_ref, farm_pubkey, resource_area, plot)
-}
-
-fn listing_from_tags(
- tags: &[Vec<String>],
- d_tag: String,
- farm_ref: RadrootsFarmRef,
- farm_pubkey: String,
- resource_area: Option<RadrootsResourceAreaRef>,
- plot: Option<RadrootsPlotRef>,
-) -> Result<RadrootsListing, ListingParseError> {
- if !is_d_tag_base64url(&d_tag) {
- return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
- }
- let d_tag = RadrootsDTag::parse(&d_tag).expect("base64url d tag parses");
- let mut product = RadrootsListingProduct {
- key: String::new(),
- title: String::new(),
- category: String::new(),
- summary: None,
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- };
-
- let mut primary_bin_id: Option<String> = None;
- let mut bin_drafts: Vec<BinDraft> = Vec::new();
- let mut bin_order = 0usize;
- let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new();
- let mut location: Option<ListingLocationDraft> = None;
- let mut inventory_available: Option<RadrootsCoreDecimal> = None;
- let mut availability_status: Option<RadrootsListingStatus> = None;
- let mut availability_start: Option<u64> = None;
- let mut availability_end: Option<u64> = None;
- let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None;
- let mut images: Vec<RadrootsListingImage> = Vec::new();
- let mut geohash: Option<String> = None;
- let mut published_at: Option<u64> = None;
-
- for tag in tags {
- if tag.is_empty() {
- continue;
- }
- let key = tag[0].as_str();
- match key {
- "key" => set_if_empty(&mut product.key, tag.get(1)),
- "title" => set_if_empty(&mut product.title, tag.get(1)),
- "category" => set_if_empty(&mut product.category, tag.get(1)),
- "summary" => set_optional(&mut product.summary, tag.get(1)),
- TAG_PUBLISHED_AT => {
- published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?);
- }
- "process" => set_optional(&mut product.process, tag.get(1)),
- "lot" => set_optional(&mut product.lot, tag.get(1)),
- "location" => {
- let parse_structured_location = !matches!(tag.len(), 0..=2);
- if parse_structured_location {
- let primary = &tag[1];
- if primary.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(TAG_LOCATION.to_string()));
- }
- let mut loc = ListingLocationDraft {
- primary: primary.to_string(),
- city: None,
- region: None,
- country: None,
- };
- if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) {
- loc.city = Some(city);
- }
- if let Some(region) = tag.get(3).and_then(|v| clean_value(v)) {
- loc.region = Some(region);
- }
- if let Some(country) = tag.get(4).and_then(|v| clean_value(v)) {
- loc.country = Some(country);
- }
- location = Some(loc);
- } else {
- set_optional(&mut product.location, tag.get(1));
- }
- }
- "profile" => set_optional(&mut product.profile, tag.get(1)),
- "year" => set_optional(&mut product.year, tag.get(1)),
- TAG_PRICE => {
- let _ = tag;
- }
- TAG_RADROOTS_PRIMARY_BIN => {
- let value = tag.get(1).and_then(|v| clean_value(v)).ok_or_else(|| {
- ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string())
- })?;
- if let Some(existing) = primary_bin_id.as_ref() {
- if existing != &value {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRIMARY_BIN.to_string(),
- ));
- }
- } else {
- primary_bin_id = Some(value);
- }
- }
- TAG_RADROOTS_BIN => {
- if tag.len() < 4 {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
- }
- if tag.len() > 7 {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
- }
- let bin_id = clean_value(&tag[1])
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?;
- let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
- let unit = parse_unit(&tag[3])?;
- if unit != unit.canonical_unit() {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
- }
- let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
- if bin.quantity.is_some() {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
- }
- bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit));
-
- match tag.as_slice() {
- [_, _, _, _, display_amount_raw, display_unit_raw]
- | [_, _, _, _, display_amount_raw, display_unit_raw, _] => {
- let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?;
- let display_unit = parse_unit(display_unit_raw)?;
- bin.display_amount = Some(display_amount);
- bin.display_unit = Some(display_unit);
- if let [_, _, _, _, _, _, label] = tag.as_slice() {
- bin.display_label = clean_value(label);
- }
- }
- [_, _, _, _, _] => {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
- }
- _ => {}
- }
- }
- TAG_RADROOTS_PRICE => {
- if tag.len() < 6 {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- if tag.len() > 8 {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- let bin_id = clean_value(&tag[1])
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?;
- let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
- let currency = parse_currency(&tag[3])?;
- let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
- let per_unit = parse_unit(&tag[5])?;
- let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(amount, currency),
- RadrootsCoreQuantity::new(per_amount, per_unit),
- );
- if !price_per_canonical_unit.is_price_per_canonical_unit() {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
- if bin.price_per_canonical_unit.is_some() {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- bin.price_per_canonical_unit = Some(price_per_canonical_unit);
-
- match tag.as_slice() {
- [_, _, _, _, _, _, _] => {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- [_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
- let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
- let display_unit = parse_unit(display_unit_raw)?;
- bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency));
- bin.display_price_unit = Some(display_unit);
- }
- _ => {}
- }
- }
- TAG_RADROOTS_DISCOUNT => {
- let payload = tag.get(1).ok_or_else(|| {
- ListingParseError::InvalidTag(TAG_RADROOTS_DISCOUNT.to_string())
- })?;
- let discount = parse_discount(payload)?;
- discounts.push(discount);
- }
- TAG_GEOHASH => {
- let value = tag
- .get(1)
- .and_then(|v| clean_value(v))
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?;
- if !is_public_geohash5(&value) {
- return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
- }
- let value = value.to_ascii_lowercase();
- if geohash.as_ref().is_some_and(|existing| existing != &value) {
- return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string()));
- }
- geohash = Some(value);
- }
- TAG_DD | TAG_DD_LAT | TAG_DD_LON | TAG_LABEL | TAG_LABEL_NS => {
- return Err(ListingParseError::InvalidTag(key.to_string()));
- }
- TAG_INVENTORY => {
- let value = tag
- .get(1)
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_INVENTORY.to_string()))?;
- inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
- }
- TAG_RADROOTS_AVAILABILITY_START => {
- let value = tag.get(1).ok_or_else(|| {
- ListingParseError::InvalidTag(TAG_RADROOTS_AVAILABILITY_START.to_string())
- })?;
- availability_start = Some(value.parse::<u64>().map_err(|_| {
- ListingParseError::InvalidNumber(TAG_RADROOTS_AVAILABILITY_START.to_string())
- })?);
- }
- TAG_EXPIRES_AT => {
- let value = tag
- .get(1)
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_EXPIRES_AT.to_string()))?;
- availability_end =
- Some(value.parse::<u64>().map_err(|_| {
- ListingParseError::InvalidNumber(TAG_EXPIRES_AT.to_string())
- })?);
- }
- TAG_STATUS => {
- let status = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default();
- availability_status = Some(parse_status(&status));
- }
- TAG_DELIVERY => {
- let method = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default();
- delivery_method = Some(match method.as_str() {
- "pickup" => RadrootsListingDeliveryMethod::Pickup,
- "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
- "shipping" => RadrootsListingDeliveryMethod::Shipping,
- "other" => {
- let detail = tag.get(2).and_then(|v| clean_value(v)).unwrap_or_default();
- RadrootsListingDeliveryMethod::Other { method: detail }
- }
- other => RadrootsListingDeliveryMethod::Other {
- method: other.to_string(),
- },
- });
- }
- TAG_IMAGE => {
- let url = tag
- .get(1)
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_IMAGE.to_string()))?;
- if url.trim().is_empty() {
- continue;
- }
- let size = tag.get(2).and_then(|s| parse_image_size(s));
- images.push(RadrootsListingImage {
- url: url.to_string(),
- size,
- });
- }
- _ => {}
- }
- }
-
- let availability = match availability_status {
- Some(status) => Some(RadrootsListingAvailability::Status { status }),
- None => match (availability_start, availability_end) {
- (None, None) => None,
- (start, end) => Some(RadrootsListingAvailability::Window { start, end }),
- },
- };
-
- let location = location
- .map(|loc| {
- let geohash =
- geohash.ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?;
- Ok(RadrootsListingPublicLocation {
- primary: loc.primary,
- city: loc.city,
- region: loc.region,
- country: loc.country,
- geohash,
- })
- })
- .transpose()?;
-
- if farm_pubkey != farm_ref.pubkey {
- return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
- }
-
- let primary_bin_id = primary_bin_id
- .and_then(|v| clean_value(&v))
- .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?;
- let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
- .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?;
- let bins = build_bins(bin_drafts)?;
- if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRIMARY_BIN.to_string(),
- ));
- }
-
- Ok(RadrootsListing {
- d_tag,
- published_at,
- farm: farm_ref,
- product,
- primary_bin_id,
- bins,
- resource_area,
- plot,
- discounts: if discounts.is_empty() {
- None
- } else {
- Some(discounts)
- },
- inventory_available,
- availability,
- delivery_method,
- location,
- images: if images.is_empty() {
- None
- } else {
- Some(images)
- },
- })
-}
-
-fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingParseError> {
- for tag in tags
- .iter()
- .filter(|t| t.first().map(|s| s.as_str()) == Some(TAG_A))
- {
- let value = tag
- .get(1)
- .map(|s| s.to_string())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?;
- let mut parts = value.splitn(3, ':');
- let kind = parts
- .next()
- .and_then(|v| v.parse::<u32>().ok())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?;
- if kind != KIND_FARM {
- continue;
- }
- let pubkey = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
- }
- if !is_d_tag_base64url(&d_tag) {
- return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
- }
- return Ok(RadrootsFarmRef { pubkey, d_tag });
- }
- Err(ListingParseError::MissingTag(TAG_A.to_string()))
-}
-
-fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingParseError> {
- let tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_P))
- .ok_or_else(|| ListingParseError::MissingTag(TAG_P.to_string()))?;
- let value = tag
- .get(1)
- .map(|s| s.to_string())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_P.to_string()))?;
- if value.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
- }
- Ok(value)
-}
-
-fn parse_resource_area(
- tags: &[Vec<String>],
-) -> Result<Option<RadrootsResourceAreaRef>, ListingParseError> {
- let tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
- let Some(tag) = tag else {
- return Ok(None);
- };
- let value = tag
- .get(1)
- .map(|s| s.to_string())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?;
- let mut parts = value.splitn(3, ':');
- let kind = parts
- .next()
- .and_then(|v| v.parse::<u32>().ok())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?;
- if kind != KIND_RESOURCE_AREA {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_RESOURCE_AREA.to_string(),
- ));
- }
- let pubkey = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_RESOURCE_AREA.to_string(),
- ));
- }
- if !is_d_tag_base64url(&d_tag) {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_RESOURCE_AREA.to_string(),
- ));
- }
- Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag }))
-}
-
-fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingParseError> {
- let tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT));
- let Some(tag) = tag else {
- return Ok(None);
- };
- let value = tag
- .get(1)
- .map(|s| s.to_string())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?;
- let mut parts = value.splitn(3, ':');
- let kind = parts
- .next()
- .and_then(|v| v.parse::<u32>().ok())
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?;
- if kind != KIND_PLOT {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
- }
- let pubkey = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
- }
- if !is_d_tag_base64url(&d_tag) {
- return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
- }
- Ok(Some(RadrootsPlotRef { pubkey, d_tag }))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
- RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
- RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::farm::RadrootsFarmRef;
- use radroots_event::listing::RadrootsListing;
- use radroots_event_codec::error::EventEncodeError;
- use radroots_event_codec::listing::tags::listing_tags_full;
-
- fn listing_tags_build(
- listing: &RadrootsListing,
- ) -> Result<Vec<Vec<String>>, ListingParseError> {
- listing_tags_full(listing).map_err(map_listing_tags_error)
- }
-
- fn map_listing_tags_error(err: EventEncodeError) -> ListingParseError {
- match err {
- EventEncodeError::EmptyRequiredField(field) => {
- ListingParseError::MissingTag(field.to_string())
- }
- EventEncodeError::InvalidField(field) => {
- ListingParseError::InvalidTag(field.to_string())
- }
- EventEncodeError::Json => ListingParseError::InvalidJson("discount".to_string()),
- EventEncodeError::InvalidKind(kind) => ListingParseError::InvalidKind(kind),
- }
- }
-
- fn farm_ref() -> RadrootsFarmRef {
- RadrootsFarmRef {
- pubkey: "seller".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- }
- }
-
- fn listing_d_tag() -> String {
- "AAAAAAAAAAAAAAAAAAAAAg".to_string()
- }
-
- fn d_tag(raw: &str) -> RadrootsDTag {
- RadrootsDTag::parse(raw).expect("d tag")
- }
-
- fn base_event_tags() -> Vec<Vec<String>> {
- vec![
- vec![TAG_D.into(), listing_d_tag()],
- vec![TAG_P.into(), "seller".into()],
- vec![
- TAG_A.into(),
- format!("{KIND_FARM}:seller:{}", farm_ref().d_tag),
- ],
- ]
- }
-
- fn base_trade_tags() -> Vec<Vec<String>> {
- vec![
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec!["summary".into(), "Single origin".into()],
- vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()],
- vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "1000".into(),
- "g".into(),
- "1".into(),
- "kg".into(),
- "bag".into(),
- ],
- vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "0.01".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "10".into(),
- "kg".into(),
- ],
- ]
- }
-
- fn parse_base_listing_from_tags() -> RadrootsListing {
- listing_from_tags(
- &base_trade_tags(),
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing")
- }
-
- fn parse_error_tag(error: ListingParseError) -> String {
- match error {
- ListingParseError::InvalidKind(_) => "kind".to_string(),
- ListingParseError::MissingTag(tag) => tag,
- ListingParseError::InvalidTag(tag) => tag,
- ListingParseError::InvalidNumber(field) => field,
- ListingParseError::InvalidUnit => "unit".to_string(),
- ListingParseError::InvalidCurrency => "currency".to_string(),
- ListingParseError::InvalidJson(field) => field,
- ListingParseError::InvalidDiscount(kind) => kind,
- }
- }
-
- #[test]
- fn production_source_has_no_dead_code_allowance() {
- let forbidden = ["#[allow(", "dead_code", ")]"].concat();
- assert!(!include_str!("codec.rs").contains(forbidden.as_str()));
- }
-
- #[test]
- fn listing_parses_radroots_bins() {
- let tags = base_trade_tags();
-
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing");
-
- assert_eq!(listing.primary_bin_id, "bin-1");
- assert_eq!(listing.bins.len(), 1);
- assert_eq!(listing.bins[0].quantity.unit, RadrootsCoreUnit::MassG);
- assert_eq!(
- listing.bins[0].price_per_canonical_unit.quantity.unit,
- RadrootsCoreUnit::MassG
- );
- assert_eq!(
- listing.bins[0].display_unit.expect("display unit").code(),
- "kg"
- );
- }
-
- #[test]
- fn listing_from_tags_roundtrips_published_at_tag() {
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_PUBLISHED_AT.into(), "1781895600".into()]);
-
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing");
-
- assert_eq!(listing.published_at, Some(1_781_895_600));
-
- let published_at = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT))
- .expect("published_at tag");
- published_at[1] = "bad".to_string();
-
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
-
- assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
-
- let mut missing_value = base_trade_tags();
- missing_value.push(vec![TAG_PUBLISHED_AT.into()]);
- let err = listing_from_tags(
- &missing_value,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_invalid_d_tag() {
- let tags = base_trade_tags();
-
- let err = listing_from_tags(
- &tags,
- "invalid:tag".to_string(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
-
- assert_eq!(parse_error_tag(err), TAG_D.to_string());
- }
-
- #[test]
- fn parse_scalar_helpers_cover_success_and_error_paths() {
- assert_eq!(
- parse_decimal("1.5", "f").unwrap(),
- "1.5".parse::<RadrootsCoreDecimal>().unwrap()
- );
- assert_eq!(
- parse_error_tag(parse_decimal("x", "f").unwrap_err()),
- "f".to_string()
- );
- assert_eq!(parse_currency(" usd ").unwrap(), RadrootsCoreCurrency::USD);
- assert_eq!(
- parse_error_tag(parse_currency("12").unwrap_err()),
- "currency".to_string()
- );
- assert_eq!(parse_unit("g").unwrap(), RadrootsCoreUnit::MassG);
- assert_eq!(
- parse_error_tag(parse_unit("not-unit").unwrap_err()),
- "unit".to_string()
- );
- }
-
- #[test]
- fn parse_error_display_covers_all_variants() {
- let errors = [
- ListingParseError::MissingTag("d".into()),
- ListingParseError::InvalidTag("a".into()),
- ListingParseError::InvalidNumber("n".into()),
- ListingParseError::InvalidUnit,
- ListingParseError::InvalidCurrency,
- ListingParseError::InvalidJson("j".into()),
- ListingParseError::InvalidDiscount("x".into()),
- ];
- for error in errors {
- assert!(!error.to_string().trim().is_empty());
- }
- }
-
- #[test]
- fn parse_d_tag_covers_all_paths() {
- assert_eq!(
- parse_error_tag(parse_d_tag(&[]).unwrap_err()),
- TAG_D.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_d_tag(&[vec![TAG_D.into()]]).unwrap_err()),
- TAG_D.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), " ".into()]]).unwrap_err()),
- TAG_D.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), "invalid".into()]]).unwrap_err()),
- TAG_D.to_string()
- );
- assert_eq!(
- parse_d_tag(&[vec![TAG_D.into(), listing_d_tag()]]).unwrap(),
- listing_d_tag()
- );
- }
-
- #[test]
- fn listing_from_event_parts_rejects_private_location_json_fields() {
- let err =
- listing_from_event_parts(&base_event_tags(), r#"{"location":{"lat":1}}"#).unwrap_err();
-
- assert_eq!(parse_error_tag(err), "location".to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_private_coordinate_and_geohash_edges() {
- let mut invalid_geohash = base_trade_tags();
- invalid_geohash.push(vec![
- TAG_LOCATION.into(),
- "Farm".into(),
- "Town".into(),
- "Region".into(),
- "US".into(),
- ]);
- invalid_geohash.push(vec![TAG_GEOHASH.into(), "9q8yyz".into()]);
- let err = listing_from_tags(
- &invalid_geohash,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
-
- let mut missing_geohash_value = base_trade_tags();
- missing_geohash_value.push(vec![TAG_GEOHASH.into()]);
- let err = listing_from_tags(
- &missing_geohash_value,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
-
- let mut duplicate_geohash = base_trade_tags();
- duplicate_geohash.push(vec![
- TAG_LOCATION.into(),
- "Farm".into(),
- "Town".into(),
- "Region".into(),
- "US".into(),
- ]);
- duplicate_geohash.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]);
- duplicate_geohash.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
- let err = listing_from_tags(
- &duplicate_geohash,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string());
-
- let mut private_coordinates = base_trade_tags();
- private_coordinates.push(vec![TAG_DD.into(), "1.0".into()]);
- let err = listing_from_tags(
- &private_coordinates,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_DD.to_string());
-
- let mut blank_optional_locality = base_trade_tags();
- blank_optional_locality.push(vec![
- TAG_LOCATION.into(),
- "Farm".into(),
- " ".into(),
- " ".into(),
- "US".into(),
- ]);
- blank_optional_locality.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]);
- let listing = listing_from_tags(
- &blank_optional_locality,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing with blank optional locality");
- let location = listing.location.expect("location");
- assert_eq!(location.city, None);
- assert_eq!(location.region, None);
- assert_eq!(location.country.as_deref(), Some("US"));
- }
-
- #[test]
- fn listing_from_event_parts_uses_json_content_and_backfills_tags() {
- let mut listing = parse_base_listing_from_tags();
- listing.farm.pubkey = String::new();
- listing.farm.d_tag = String::new();
- listing.resource_area = None;
- listing.plot = None;
-
- let mut tags = base_event_tags();
- tags.push(vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
- ]);
- tags.push(vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
-
- let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
- .expect("event listing");
- assert_eq!(parsed.d_tag, listing_d_tag());
- assert_eq!(parsed.farm.pubkey, farm_ref().pubkey);
- assert_eq!(parsed.farm.d_tag, farm_ref().d_tag);
- assert_eq!(
- parsed.resource_area.unwrap().d_tag,
- "AAAAAAAAAAAAAAAAAAAAAQ"
- );
- assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw");
- }
-
- #[test]
- fn listing_from_event_parts_rejects_conflicting_content_values() {
- let tags = base_event_tags();
-
- let mut listing = parse_base_listing_from_tags();
- listing.d_tag = d_tag("AAAAAAAAAAAAAAAAAAAAAw");
- let err =
- listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_D.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.farm.pubkey = "other".into();
- let err =
- listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_A.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".into();
- let err =
- listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_A.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.farm.d_tag = String::new();
- let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
- .expect("backfill empty farm d_tag");
- assert_eq!(parsed.farm.d_tag, farm_ref().d_tag);
-
- let listing = parse_base_listing_from_tags();
- let mut mismatched_pubkey_tags = tags.clone();
- mismatched_pubkey_tags[1][1] = "other".into();
- let err = listing_from_event_parts(
- &mismatched_pubkey_tags,
- &serde_json::to_string(&listing).unwrap(),
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_P.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "seller".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
- });
- let mut resource_tags = tags.clone();
- resource_tags.push(vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- let err =
- listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap())
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "other".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
- });
- let mut resource_tags = tags.clone();
- resource_tags.push(vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- let err =
- listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap())
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.plot = Some(RadrootsPlotRef {
- pubkey: "seller".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
- });
- let mut plot_tags = tags.clone();
- plot_tags.push(vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap())
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
-
- let mut listing = parse_base_listing_from_tags();
- listing.plot = Some(RadrootsPlotRef {
- pubkey: "other".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
- });
- let mut plot_tags = tags.clone();
- plot_tags.push(vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap())
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
- }
-
- #[test]
- fn listing_from_event_parts_accepts_matching_resource_and_plot_refs() {
- let mut listing = parse_base_listing_from_tags();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "seller".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
- });
- listing.plot = Some(RadrootsPlotRef {
- pubkey: "seller".into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
- });
- let mut tags = base_event_tags();
- tags.push(vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
- ]);
- tags.push(vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
- .expect("matching refs");
- assert_eq!(
- parsed.resource_area.unwrap().d_tag,
- "AAAAAAAAAAAAAAAAAAAAAQ"
- );
- assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw");
- }
-
- #[test]
- fn listing_from_event_parts_rejects_invalid_plot_tag_shapes() {
- let mut tags = base_event_tags();
- tags.push(vec![TAG_RADROOTS_PLOT.into()]);
- let err = listing_from_event_parts(&tags, "").unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
-
- let mut tags = base_event_tags();
- tags.push(vec![TAG_RADROOTS_PLOT.into(), "bad".into()]);
- let err = listing_from_event_parts(&tags, "").unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
- }
-
- #[test]
- fn listing_from_event_parts_falls_back_to_tag_parser() {
- let mut tags = base_event_tags();
- tags.extend(base_trade_tags());
- let listing =
- listing_from_event_parts(&tags, "{invalid-json").expect("fallback tags parse");
- assert_eq!(listing.primary_bin_id, "bin-1");
- assert_eq!(listing.bins.len(), 1);
- }
-
- #[test]
- fn listing_from_event_parts_rejects_missing_reference_tags() {
- let mut missing_farm_ref = base_event_tags();
- missing_farm_ref.extend(base_trade_tags());
- missing_farm_ref.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_A));
- let err = listing_from_event_parts(&missing_farm_ref, "").unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_A.to_string());
-
- let mut missing_farm_pubkey = base_event_tags();
- missing_farm_pubkey.extend(base_trade_tags());
- missing_farm_pubkey.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_P));
- let err = listing_from_event_parts(&missing_farm_pubkey, "").unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_P.to_string());
-
- let mut invalid_resource_area = base_event_tags();
- invalid_resource_area.extend(base_trade_tags());
- invalid_resource_area.push(vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]);
- let err = listing_from_event_parts(&invalid_resource_area, "").unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
- }
-
- #[test]
- fn listing_tags_build_and_error_mapping_cover_paths() {
- let listing = parse_base_listing_from_tags();
- let built = listing_tags_build(&listing).expect("build tags");
- assert!(
- built
- .iter()
- .any(|tag| tag.first().map(|v| v.as_str()) == Some(TAG_RADROOTS_PRIMARY_BIN))
- );
-
- let mapped = map_listing_tags_error(EventEncodeError::EmptyRequiredField("d"));
- assert_eq!(parse_error_tag(mapped), "d".to_string());
- let mapped = map_listing_tags_error(EventEncodeError::InvalidField("f"));
- assert_eq!(parse_error_tag(mapped), "f".to_string());
- let mapped = map_listing_tags_error(EventEncodeError::Json);
- assert_eq!(parse_error_tag(mapped), "discount".to_string());
- let mapped = map_listing_tags_error(EventEncodeError::InvalidKind(1));
- assert_eq!(parse_error_tag(mapped), "kind".to_string());
- }
-
- #[test]
- fn listing_from_tags_parses_trade_specific_optional_fields() {
- let mut tags = base_trade_tags();
- tags.push(Vec::new());
- tags.push(vec![TAG_PRICE.into(), "ignored".into()]);
- tags.push(vec!["process".into(), "washed".into()]);
- tags.push(vec!["lot".into(), "lot-7".into()]);
- tags.push(vec!["profile".into(), "fruity".into()]);
- tags.push(vec!["year".into(), "2024".into()]);
- tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()]);
- tags.push(vec![
- TAG_LOCATION.into(),
- "Farm".into(),
- "Town".into(),
- "Region".into(),
- "SE".into(),
- ]);
- tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
- tags.push(vec![TAG_INVENTORY.into(), "8".into()]);
- tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "10".into()]);
- tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]);
- tags.push(vec![TAG_DELIVERY.into(), "other".into(), "drone".into()]);
- tags.push(vec![
- TAG_IMAGE.into(),
- "https://cdn/image.png".into(),
- "100x200".into(),
- ]);
- tags.push(vec![TAG_IMAGE.into(), " ".into()]);
- let discount = RadrootsCoreDiscount {
- scope: RadrootsCoreDiscountScope::Bin,
- threshold: RadrootsCoreDiscountThreshold::BinCount {
- bin_id: "bin-1".into(),
- min: 2,
- },
- value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new(
- "5".parse::<RadrootsCoreDecimal>().unwrap(),
- )),
- };
- tags.push(vec![
- TAG_RADROOTS_DISCOUNT.into(),
- serde_json::to_string(&discount).unwrap(),
- ]);
-
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing");
-
- assert_eq!(
- format!("{:?}", listing.availability),
- "Some(Window { start: Some(10), end: Some(20) })"
- );
- assert_eq!(
- format!("{:?}", listing.delivery_method),
- "Some(Other { method: \"drone\" })"
- );
- assert_eq!(
- listing.location.as_ref().unwrap().geohash,
- "u6sep".to_string()
- );
- assert_eq!(listing.product.process.as_deref(), Some("washed"));
- assert_eq!(listing.product.lot.as_deref(), Some("lot-7"));
- assert_eq!(listing.product.profile.as_deref(), Some("fruity"));
- assert_eq!(listing.product.year.as_deref(), Some("2024"));
- assert_eq!(listing.images.as_ref().unwrap().len(), 1);
- assert_eq!(listing.discounts.as_ref().unwrap().len(), 1);
- }
-
- #[test]
- fn listing_from_tags_uses_unstructured_location_and_custom_delivery() {
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_LOCATION.into(), "Farm".into()]);
- tags.push(vec![TAG_LOCATION.into(), "fallback".into()]);
- tags.push(vec![TAG_DELIVERY.into(), "parcel".into()]);
-
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing");
-
- assert_eq!(listing.product.location.as_deref(), Some("Farm"));
- assert_eq!(
- format!("{:?}", listing.delivery_method),
- "Some(Other { method: \"parcel\" })"
- );
- }
-
- #[test]
- fn listing_from_tags_parses_delivery_enum_variants() {
- let mut local_delivery = base_trade_tags();
- local_delivery.push(vec![TAG_DELIVERY.into(), "local_delivery".into()]);
- let listing = listing_from_tags(
- &local_delivery,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("local delivery parse");
- assert_eq!(
- format!("{:?}", listing.delivery_method),
- "Some(LocalDelivery)"
- );
-
- let mut shipping = base_trade_tags();
- shipping.push(vec![TAG_DELIVERY.into(), "shipping".into()]);
- let listing = listing_from_tags(
- &shipping,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("shipping parse");
- assert_eq!(format!("{:?}", listing.delivery_method), "Some(Shipping)");
- }
-
- #[test]
- fn listing_from_tags_rejects_empty_structured_location_primary() {
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_LOCATION.into(),
- " ".into(),
- "Town".into(),
- "Region".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_LOCATION.to_string());
- }
-
- #[test]
- fn listing_from_tags_handles_short_location_tags_when_structured_present() {
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_LOCATION.into(),
- "Farm".into(),
- "Town".into(),
- "Region".into(),
- ]);
- tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]);
- tags.push(vec![TAG_LOCATION.into()]);
- tags.push(vec![TAG_LOCATION.into(), "fallback".into()]);
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("listing");
- assert_eq!(listing.location.unwrap().primary, "Farm".to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_invalid_tag_forms() {
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "other".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_BIN.into(), "bin-1".into(), "1000".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "1000".into(),
- "g".into(),
- "1".into(),
- "kg".into(),
- "label".into(),
- "extra".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- " ".into(),
- "1000".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "1000".into(),
- "kg".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "1000".into(),
- "g".into(),
- "1".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- " ".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- "kg".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "9".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "9".into(),
- "bad".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "1".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "9".into(),
- "kg".into(),
- "x".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_trade_field_parse_failures() {
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_DISCOUNT.into(), "{".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_INVENTORY.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "bad".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(
- parse_error_tag(err),
- TAG_RADROOTS_AVAILABILITY_START.to_string()
- );
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_EXPIRES_AT.into(), "bad".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_DISCOUNT.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(
- parse_error_tag(err),
- TAG_RADROOTS_AVAILABILITY_START.to_string()
- );
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_EXPIRES_AT.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_IMAGE.into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_IMAGE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![TAG_INVENTORY.into(), "bad".into()]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "bad".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "500".into(),
- "bad".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), "unit".to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-2".into(),
- "500".into(),
- "g".into(),
- "bad".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_BIN.into(),
- "bin-2".into(),
- "500".into(),
- "g".into(),
- "1".into(),
- "bad".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), "unit".to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "bad".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "10".into(),
- "US".into(),
- "1".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), "currency".to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "10".into(),
- "USD".into(),
- "bad".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-1".into(),
- "10".into(),
- "USD".into(),
- "1".into(),
- "bad".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), "unit".to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-2".into(),
- "10".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "bad".into(),
- "g".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
-
- let mut tags = base_trade_tags();
- tags.push(vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-2".into(),
- "10".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "12".into(),
- "bad".into(),
- ]);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), "unit".to_string());
- }
-
- #[test]
- fn listing_from_tags_covers_bin_display_and_price_shape_edges() {
- let tags = vec![
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
- vec![
- TAG_RADROOTS_BIN.into(),
- "bin-2".into(),
- "500".into(),
- "g".into(),
- "1".into(),
- ],
- vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-2".into(),
- "0.02".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "10".into(),
- ],
- ];
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
-
- let tags = vec![
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
- vec![
- TAG_RADROOTS_BIN.into(),
- "bin-2".into(),
- "500".into(),
- "g".into(),
- "1".into(),
- "kg".into(),
- ],
- vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-2".into(),
- "0.02".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- "10".into(),
- ],
- ];
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_missing_primary_bin_and_invalid_seller() {
- let mut tags = base_trade_tags();
- tags.retain(|tag| tag[0] != TAG_RADROOTS_PRIMARY_BIN);
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
-
- let err = listing_from_tags(
- &base_trade_tags(),
- listing_d_tag(),
- farm_ref(),
- "other".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_P.to_string());
-
- let mut tags = base_trade_tags();
- tags[4][1] = "missing".into();
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
-
- let mut tags = base_trade_tags();
- tags[4][1] = "bad id".into();
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
- }
-
- #[test]
- fn listing_from_tags_rejects_incomplete_bin_draft() {
- let tags = vec![
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()],
- vec![
- TAG_RADROOTS_BIN.into(),
- "bin-1".into(),
- "500".into(),
- "g".into(),
- ],
- ];
- let err = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .unwrap_err();
- assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
- }
-
- #[test]
- fn parse_farm_and_reference_helpers_cover_all_paths() {
- let valid_farm_tags = vec![vec![
- TAG_A.into(),
- format!("{KIND_FARM}:seller:AAAAAAAAAAAAAAAAAAAAAA"),
- ]];
- let farm = parse_farm_ref(&valid_farm_tags).unwrap();
- assert_eq!(farm.pubkey, farm_ref().pubkey);
- assert_eq!(farm.d_tag, farm_ref().d_tag);
- assert_eq!(
- parse_error_tag(parse_farm_ref(&[]).unwrap_err()),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_farm_ref(&[vec![TAG_A.into()]]).unwrap_err()),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_farm_ref(&[vec![TAG_A.into(), "bad".into()]]).unwrap_err()),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![TAG_A.into(), format!("1:seller:{}", farm_ref().d_tag)]])
- .unwrap_err()
- ),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![
- TAG_A.into(),
- format!("{KIND_FARM}: :{}", farm_ref().d_tag)
- ]])
- .unwrap_err()
- ),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:")]]).unwrap_err()
- ),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}")]]).unwrap_err()
- ),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller")]]).unwrap_err()
- ),
- TAG_A.to_string()
- );
- assert_eq!(
- parse_error_tag(
- parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:not-base64")]])
- .unwrap_err()
- ),
- TAG_A.to_string()
- );
-
- assert_eq!(
- parse_farm_pubkey(&[vec![TAG_P.into(), "seller".into()]]).unwrap(),
- "seller".to_string()
- );
- assert_eq!(
- parse_error_tag(parse_farm_pubkey(&[]).unwrap_err()),
- TAG_P.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into()]]).unwrap_err()),
- TAG_P.to_string()
- );
- assert_eq!(
- parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into(), " ".into()]]).unwrap_err()),
- TAG_P.to_string()
- );
-
- assert!(parse_resource_area(&[]).unwrap().is_none());
- let area_tag = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
- ]];
- assert!(parse_resource_area(&area_tag).unwrap().is_some());
- let missing_area = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into()]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&missing_area).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let invalid_area_kind = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&invalid_area_kind).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let missing_area_pubkey = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}"),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&missing_area_pubkey).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let missing_area_d = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller"),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&missing_area_d).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let bad_area = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- "1:seller:bad".into(),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&bad_area).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let empty_area = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}: :{}", listing_d_tag()),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&empty_area).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let empty_area_d = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:"),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&empty_area_d).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
- let invalid_area_d = vec![vec![
- TAG_RADROOTS_RESOURCE_AREA.into(),
- format!("{KIND_RESOURCE_AREA}:seller:not-base64"),
- ]];
- assert_eq!(
- parse_error_tag(parse_resource_area(&invalid_area_d).unwrap_err()),
- TAG_RADROOTS_RESOURCE_AREA.to_string()
- );
-
- assert!(parse_plot_ref(&[]).unwrap().is_none());
- let plot_tag = vec![vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
- ]];
- assert!(parse_plot_ref(&plot_tag).unwrap().is_some());
- let missing_plot = vec![vec![TAG_RADROOTS_PLOT.into()]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&missing_plot).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let missing_plot_pubkey = vec![vec![TAG_RADROOTS_PLOT.into(), format!("{KIND_PLOT}")]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&missing_plot_pubkey).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let missing_plot_d = vec![vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller"),
- ]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&missing_plot_d).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let bad_plot = vec![vec![TAG_RADROOTS_PLOT.into(), "1:seller:bad".into()]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&bad_plot).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let empty_plot = vec![vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}: :{}", listing_d_tag()),
- ]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&empty_plot).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let empty_plot_d = vec![vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:"),
- ]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&empty_plot_d).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- let invalid_plot_d = vec![vec![
- TAG_RADROOTS_PLOT.into(),
- format!("{KIND_PLOT}:seller:not-base64"),
- ]];
- assert_eq!(
- parse_error_tag(parse_plot_ref(&invalid_plot_d).unwrap_err()),
- TAG_RADROOTS_PLOT.to_string()
- );
- }
-
- #[test]
- fn helper_functions_cover_assigners_and_classifiers() {
- assert_eq!(clean_value(" value "), Some("value".into()));
- assert_eq!(clean_value(" "), None);
- assert_eq!(clean_value("null"), None);
-
- let mut s = String::new();
- let val = "one".to_string();
- set_if_empty(&mut s, Some(&val));
- assert_eq!(s, "one");
- let next = "two".to_string();
- set_if_empty(&mut s, Some(&next));
- assert_eq!(s, "one");
- let mut empty = String::new();
- let nullish = "null".to_string();
- set_if_empty(&mut empty, Some(&nullish));
- assert_eq!(empty, "");
-
- let mut opt = None;
- let v = "set".to_string();
- set_optional(&mut opt, Some(&v));
- assert_eq!(opt.as_deref(), Some("set"));
- let w = "skip".to_string();
- set_optional(&mut opt, Some(&w));
- assert_eq!(opt.as_deref(), Some("set"));
- let mut opt_none = None;
- let blank = " ".to_string();
- set_optional(&mut opt_none, Some(&blank));
- assert_eq!(opt_none, None);
-
- assert_eq!(format!("{:?}", parse_status("ACTIVE")), "Active");
- assert_eq!(format!("{:?}", parse_status("sold")), "Sold");
- assert_eq!(
- format!("{:?}", parse_status("queued")),
- "Other { value: \"queued\" }"
- );
-
- assert_eq!(parse_image_size("100x200").unwrap().w, 100);
- assert!(parse_image_size("100").is_none());
- assert!(parse_image_size("invalid").is_none());
- assert!(parse_image_size("badx100").is_none());
- assert!(parse_image_size("100xbad").is_none());
- }
-
- #[test]
- fn parse_discount_and_bin_helpers_cover_error_paths() {
- let discount = RadrootsCoreDiscount {
- scope: RadrootsCoreDiscountScope::OrderTotal,
- threshold: RadrootsCoreDiscountThreshold::OrderQuantity {
- min: RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
- },
- value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
- "1".parse().unwrap(),
- RadrootsCoreCurrency::USD,
- )),
- };
- let payload = serde_json::to_string(&discount).unwrap();
- assert!(parse_discount(&payload).is_ok());
- assert_eq!(
- parse_error_tag(parse_discount("{").unwrap_err()),
- TAG_RADROOTS_DISCOUNT.to_string()
- );
- assert_eq!(
- parse_error_tag(ListingParseError::InvalidJson("x".into())),
- "x".to_string()
- );
-
- let mut drafts = Vec::new();
- let mut order_index = 0usize;
- let first = upsert_bin(&mut drafts, "a", &mut order_index);
- first.quantity = Some(RadrootsCoreQuantity::new(
- "1".parse().unwrap(),
- RadrootsCoreUnit::MassG,
- ));
- first.price_per_canonical_unit = Some(RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
- ));
-
- let second = upsert_bin(&mut drafts, "a", &mut order_index);
- assert_eq!(second.order_index, 0);
- assert_eq!(order_index, 1);
- assert!(build_bins(drafts).is_ok());
-
- let draft_missing_qty = BinDraft {
- bin_id: "b".into(),
- order_index: 0,
- quantity: None,
- display_amount: None,
- display_unit: None,
- display_label: None,
- price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
- )),
- display_price: None,
- display_price_unit: None,
- };
- assert_eq!(
- parse_error_tag(build_bins(vec![draft_missing_qty]).unwrap_err()),
- TAG_RADROOTS_BIN.to_string()
- );
-
- let draft_missing_price = BinDraft {
- bin_id: "b".into(),
- order_index: 0,
- quantity: Some(RadrootsCoreQuantity::new(
- "1".parse().unwrap(),
- RadrootsCoreUnit::MassG,
- )),
- display_amount: None,
- display_unit: None,
- display_label: None,
- price_per_canonical_unit: None,
- display_price: None,
- display_price_unit: None,
- };
- assert_eq!(
- parse_error_tag(build_bins(vec![draft_missing_price]).unwrap_err()),
- TAG_RADROOTS_PRICE.to_string()
- );
-
- let draft_mismatch = BinDraft {
- bin_id: "b".into(),
- order_index: 0,
- quantity: Some(RadrootsCoreQuantity::new(
- "1".parse().unwrap(),
- RadrootsCoreUnit::MassG,
- )),
- display_amount: None,
- display_unit: None,
- display_label: None,
- price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::Each),
- )),
- display_price: None,
- display_price_unit: None,
- };
- assert_eq!(
- parse_error_tag(build_bins(vec![draft_mismatch]).unwrap_err()),
- TAG_RADROOTS_PRICE.to_string()
- );
-
- let draft_invalid_bin = BinDraft {
- bin_id: "bad id".into(),
- order_index: 0,
- quantity: Some(RadrootsCoreQuantity::new(
- "1".parse().unwrap(),
- RadrootsCoreUnit::MassG,
- )),
- display_amount: None,
- display_unit: None,
- display_label: None,
- price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
- )),
- display_price: None,
- display_price_unit: None,
- };
- assert_eq!(
- parse_error_tag(build_bins(vec![draft_invalid_bin]).unwrap_err()),
- TAG_RADROOTS_BIN.to_string()
- );
-
- let tags = vec![
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
- vec![
- TAG_RADROOTS_BIN.into(),
- "bin-2".into(),
- "500".into(),
- "g".into(),
- ],
- vec![
- TAG_RADROOTS_PRICE.into(),
- "bin-2".into(),
- "0.02".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- ],
- ];
- let listing = listing_from_tags(
- &tags,
- listing_d_tag(),
- farm_ref(),
- "seller".to_string(),
- None,
- None,
- )
- .expect("compact listing");
- assert_eq!(listing.primary_bin_id, "bin-2");
- }
-}
-
-fn clean_value(value: &str) -> Option<String> {
- let trimmed = value.trim();
- if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
- None
- } else {
- Some(trimmed.to_string())
- }
-}
-
-fn set_if_empty(target: &mut String, value: Option<&String>) {
- if target.trim().is_empty()
- && let Some(v) = value.and_then(|v| clean_value(v))
- {
- *target = v;
- }
-}
-
-fn set_optional(target: &mut Option<String>, value: Option<&String>) {
- if target.is_none()
- && let Some(v) = value.and_then(|v| clean_value(v))
- {
- *target = Some(v);
- }
-}
-
-fn parse_status(value: &str) -> RadrootsListingStatus {
- match value.trim().to_ascii_lowercase().as_str() {
- "active" => RadrootsListingStatus::Active,
- "sold" => RadrootsListingStatus::Sold,
- other => RadrootsListingStatus::Other {
- value: other.to_string(),
- },
- }
-}
-
-fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
- let (w_raw, h_raw) = value.split_once('x')?;
- let w = w_raw.parse::<u32>().ok()?;
- let h = h_raw.parse::<u32>().ok()?;
- Some(RadrootsListingImageSize { w, h })
-}
-
-fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingParseError> {
- #[cfg(feature = "serde_json")]
- {
- serde_json::from_str(payload)
- .map_err(|_| ListingParseError::InvalidDiscount(TAG_RADROOTS_DISCOUNT.to_string()))
- }
- #[cfg(not(feature = "serde_json"))]
- {
- let _ = payload;
- Err(ListingParseError::InvalidJson("discount".to_string()))
- }
-}
-
-#[derive(Clone, Debug)]
-struct BinDraft {
- bin_id: String,
- order_index: usize,
- quantity: Option<RadrootsCoreQuantity>,
- display_amount: Option<RadrootsCoreDecimal>,
- display_unit: Option<RadrootsCoreUnit>,
- display_label: Option<String>,
- price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>,
- display_price: Option<RadrootsCoreMoney>,
- display_price_unit: Option<RadrootsCoreUnit>,
-}
-
-fn upsert_bin<'a>(
- bins: &'a mut Vec<BinDraft>,
- bin_id: &str,
- order_index: &mut usize,
-) -> &'a mut BinDraft {
- if let Some(pos) = bins.iter().position(|bin| bin.bin_id == bin_id) {
- return &mut bins[pos];
- }
- let draft = BinDraft {
- bin_id: bin_id.to_string(),
- order_index: *order_index,
- quantity: None,
- display_amount: None,
- display_unit: None,
- display_label: None,
- price_per_canonical_unit: None,
- display_price: None,
- display_price_unit: None,
- };
- bins.push(draft);
- *order_index += 1;
- let idx = bins.len() - 1;
- &mut bins[idx]
-}
-
-fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingParseError> {
- drafts.sort_by_key(|draft| draft.order_index);
- let mut bins = Vec::with_capacity(drafts.len());
- for draft in drafts {
- let quantity = draft
- .quantity
- .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_BIN.to_string()))?;
- let price = draft
- .price_per_canonical_unit
- .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRICE.to_string()))?;
- if quantity.unit != price.quantity.unit {
- return Err(ListingParseError::InvalidTag(
- TAG_RADROOTS_PRICE.to_string(),
- ));
- }
- let bin = RadrootsListingBin {
- bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
- .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?,
- quantity,
- price_per_canonical_unit: price,
- display_amount: draft.display_amount,
- display_unit: draft.display_unit,
- display_label: draft.display_label,
- display_price: draft.display_price,
- display_price_unit: draft.display_price_unit,
- };
- bins.push(bin);
- }
- Ok(bins)
-}
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -1,4 +1,3 @@
-mod codec;
pub mod draft;
pub mod model;
pub mod mutation;
@@ -14,6 +13,7 @@ use radroots_event::{
kinds::is_listing_kind,
listing::RadrootsListing,
};
+use radroots_event_codec::listing::decode::listing_from_nostr_event;
use thiserror::Error;
pub use self::draft::{
@@ -120,11 +120,7 @@ fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> {
pub fn parse_listing_event(
event: &RadrootsEventEnvelope,
) -> Result<RadrootsListing, ListingParseError> {
- if !is_listing_kind(event.kind_u32()) {
- return Err(ListingParseError::InvalidKind(event.kind_u32()));
- }
- let tags = event.tags_as_vec();
- self::codec::listing_from_event_parts(&tags, event.content())
+ listing_from_nostr_event(event)
}
#[cfg(test)]
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -1,7 +1,7 @@
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
-use alloc::{format, string::String, vec::Vec};
+use alloc::{format, string::String};
use radroots_core::{
RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit,
@@ -18,7 +18,7 @@ use radroots_event::{
trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError,
};
-use crate::listing::codec::listing_from_event_parts;
+use radroots_event_codec::listing::decode::listing_from_nostr_event;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
@@ -49,8 +49,7 @@ pub fn validate_listing_event(
});
}
- let tags = event.tags_as_vec();
- let listing = listing_from_event_parts(&tags, event.content())
+ let listing = listing_from_nostr_event(event)
.map_err(|error| TradeListingValidationError::ParseError { error })?;
let listing_id = listing.d_tag.trim().to_string();
@@ -271,19 +270,102 @@ mod tests {
}
fn base_event(listing: &RadrootsListing) -> RadrootsEventEnvelope {
- event_with_parts(
- SELLER,
- KIND_LISTING,
+ let mut tags = vec![
+ vec!["d".into(), listing.d_tag.to_string()],
+ vec!["p".into(), listing.farm.pubkey.clone()],
vec![
- vec!["d".into(), listing.d_tag.to_string()],
- vec!["p".into(), listing.farm.pubkey.clone()],
- vec![
- "a".into(),
- format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag),
- ],
+ "a".into(),
+ format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag),
+ ],
+ vec!["key".into(), listing.product.key.clone()],
+ vec!["title".into(), listing.product.title.clone()],
+ vec!["category".into(), listing.product.category.clone()],
+ vec![
+ "summary".into(),
+ listing.product.summary.clone().unwrap_or_default(),
+ ],
+ vec![
+ "radroots:primary_bin".into(),
+ listing.primary_bin_id.to_string(),
],
- serde_json::to_string(listing).unwrap(),
- )
+ ];
+ for bin in &listing.bins {
+ tags.push(vec![
+ "radroots:bin".into(),
+ bin.bin_id.to_string(),
+ bin.quantity.amount.to_string(),
+ bin.quantity.unit.code().to_string(),
+ ]);
+ tags.push(vec![
+ "radroots:price".into(),
+ bin.bin_id.to_string(),
+ bin.price_per_canonical_unit.amount.amount.to_string(),
+ bin.price_per_canonical_unit
+ .amount
+ .currency
+ .as_str()
+ .to_string(),
+ bin.price_per_canonical_unit.quantity.amount.to_string(),
+ bin.price_per_canonical_unit
+ .quantity
+ .unit
+ .code()
+ .to_string(),
+ ]);
+ }
+ if let Some(inventory) = listing.inventory_available {
+ tags.push(vec!["inventory".into(), inventory.to_string()]);
+ }
+ if let Some(availability) = &listing.availability {
+ match availability {
+ RadrootsListingAvailability::Status { status } => tags.push(vec![
+ "status".into(),
+ match status {
+ radroots_event::listing::RadrootsListingStatus::Active => "active".into(),
+ radroots_event::listing::RadrootsListingStatus::Sold => "sold".into(),
+ radroots_event::listing::RadrootsListingStatus::Other { value } => {
+ value.clone()
+ }
+ },
+ ]),
+ RadrootsListingAvailability::Window { start, end } => {
+ if let Some(start) = start {
+ tags.push(vec![
+ "radroots:availability_start".into(),
+ start.to_string(),
+ ]);
+ }
+ if let Some(end) = end {
+ tags.push(vec!["expires_at".into(), end.to_string()]);
+ }
+ }
+ }
+ }
+ if let Some(delivery) = &listing.delivery_method {
+ let mut tag = vec!["delivery".into()];
+ match delivery {
+ RadrootsListingDeliveryMethod::Pickup => tag.push("pickup".into()),
+ RadrootsListingDeliveryMethod::LocalDelivery => tag.push("local_delivery".into()),
+ RadrootsListingDeliveryMethod::Shipping => tag.push("shipping".into()),
+ RadrootsListingDeliveryMethod::Other { method } => {
+ tag.push("other".into());
+ tag.push(method.clone());
+ }
+ }
+ tags.push(tag);
+ }
+ if let Some(location) = &listing.location {
+ tags.push(vec![
+ "location".into(),
+ location.primary.clone(),
+ location.city.clone().unwrap_or_default(),
+ location.region.clone().unwrap_or_default(),
+ location.country.clone().unwrap_or_default(),
+ ]);
+ tags.push(vec!["g".into(), location.geohash.clone()]);
+ }
+
+ event_with_parts(SELLER, KIND_LISTING, tags, String::new())
}
fn event_with_parts(
@@ -324,7 +406,7 @@ mod tests {
SELLER,
30403,
base_event(&listing).tags_as_vec(),
- serde_json::to_string(&listing).unwrap(),
+ String::new(),
);
let err = validate_listing_event(&event).unwrap_err();
assert_eq!(
@@ -335,18 +417,12 @@ mod tests {
#[test]
fn validate_listing_rejects_missing_d_tag() {
- let listing = base_listing();
- let event = event_with_parts(
- SELLER,
- KIND_LISTING,
- Vec::new(),
- serde_json::to_string(&listing).unwrap(),
- );
+ let event = event_with_parts(SELLER, KIND_LISTING, Vec::new(), String::new());
let err = validate_listing_event(&event).unwrap_err();
assert_eq!(
err,
TradeListingValidationError::ParseError {
- error: crate::listing::codec::ListingParseError::MissingTag("d".to_string())
+ error: crate::listing::ListingParseError::MissingTag("d".to_string())
}
);
}
@@ -400,7 +476,7 @@ mod tests {
OTHER_SELLER,
KIND_LISTING,
base_event(&listing).tags_as_vec(),
- serde_json::to_string(&listing).unwrap(),
+ String::new(),
);
let err = validate_listing_event(&event).unwrap_err();
assert_eq!(err, TradeListingValidationError::InvalidSeller);
@@ -418,12 +494,7 @@ mod tests {
#[test]
fn validate_listing_rejects_invalid_kind() {
let listing = base_listing();
- let event = event_with_parts(
- SELLER,
- 0,
- base_event(&listing).tags_as_vec(),
- serde_json::to_string(&listing).unwrap(),
- );
+ let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new());
let err = validate_listing_event(&event).unwrap_err();
assert_eq!(err, TradeListingValidationError::InvalidKind { kind: 0 });
}
@@ -454,7 +525,12 @@ mod tests {
fn validate_listing_rejects_missing_bins() {
let mut listing = base_listing();
listing.bins.clear();
- assert_validation_err(listing, TradeListingValidationError::MissingBins);
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()),
+ },
+ );
}
#[test]
@@ -466,7 +542,12 @@ mod tests {
fn validate_listing_rejects_primary_bin_not_found() {
let mut listing = base_listing();
listing.primary_bin_id = bin_id("missing");
- assert_validation_err(listing, TradeListingValidationError::MissingPrimaryBin);
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()),
+ },
+ );
}
#[test]
@@ -480,14 +561,24 @@ mod tests {
fn validate_listing_rejects_non_canonical_quantity() {
let mut listing = base_listing();
listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg;
- assert_validation_err(listing, TradeListingValidationError::InvalidBin);
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::ListingParseError::InvalidTag("radroots:bin".into()),
+ },
+ );
}
#[test]
fn validate_listing_rejects_non_canonical_price_quantity() {
let mut listing = base_listing();
listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg;
- assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()),
+ },
+ );
}
#[test]
@@ -501,7 +592,12 @@ mod tests {
fn validate_listing_rejects_price_unit_mismatch() {
let mut listing = base_listing();
listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each;
- assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
+ assert_validation_err(
+ listing,
+ TradeListingValidationError::ParseError {
+ error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()),
+ },
+ );
}
#[test]
@@ -545,7 +641,7 @@ mod tests {
assert_validation_err(
listing,
TradeListingValidationError::ParseError {
- error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()),
+ error: crate::listing::ListingParseError::InvalidTag("g".to_string()),
},
);
}
@@ -557,7 +653,7 @@ mod tests {
assert_validation_err(
listing,
TradeListingValidationError::ParseError {
- error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()),
+ error: crate::listing::ListingParseError::InvalidTag("g".to_string()),
},
);
}
@@ -581,7 +677,7 @@ mod tests {
listing_addr: "addr".into(),
},
TradeListingValidationError::ParseError {
- error: crate::listing::codec::ListingParseError::InvalidTag("d".into()),
+ error: crate::listing::ListingParseError::InvalidTag("d".into()),
},
TradeListingValidationError::InvalidSeller,
TradeListingValidationError::MissingFarmProfile,
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -30,7 +30,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract";
const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION";
const REPLICA_TRANSFER_VERSION: u32 = 2;
const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3";
-const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 8] = [
+const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [
(
"contracts/conformance/vectors/blossom/bud11_claims.v1.json",
"crates/blossom/tests/fixtures/bud11_claims.v1.json",
@@ -52,6 +52,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 8] = [
"crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json",
),
(
+ "contracts/conformance/vectors/listing/parse_event.v1.json",
+ "crates/event_codec/tests/fixtures/listing_parse_event.v1.json",
+ ),
+ (
"contracts/conformance/vectors/profile/metadata.v1.json",
"crates/event_codec/tests/fixtures/profile_metadata.v1.json",
),