commit 9ef5c5a3e1a12fb6e3989aa53c669608b9c2fd9f
parent 7e99f9c93f937324564b31c4f38d1f15afca4545
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 02:03:25 +0000
refactor: separate classified and operational listings
- reserve generic kind-30402 taxonomy and coordinates for Classified Listing
- rename rich farm and inventory event authority to Operational Listing
- migrate non-client consumers while preserving stable wire and persistence fields
- govern generated roots, contracts, release metadata, and retired-alias hygiene
Diffstat:
87 files changed, 8772 insertions(+), 7884 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,10 +9,18 @@ publish policy both pass for the same source revision.
### Changed
+- NIP-99 kind `30402` now has an explicit two-level taxonomy: the standard
+ protocol kind and coordinate are **Classified Listing**, while the richer
+ Radroots farm, bin, inventory, and price profile is **Operational Listing**.
+ Public constants, types, functions, modules, operation IDs, and generated DTO
+ roots use those unambiguous names with no legacy `listing` aliases or modules.
- 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.
+- Operational listing authoring now emits canonical Markdown content from the
+ tag-authoritative model. Tolerant inbound JSON inspection remains a decode
+ compatibility boundary only and is not an authoring format.
- 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
@@ -34,7 +42,7 @@ publish policy both pass for the same source revision.
access; externally supplied unsigned events, NIP-46 signing, and signed-event
relay remain explicit low-level Nostr interoperability with no typed product
authoring claim.
-- Frozen drafts now carry event-contract registry version `2`, revalidate all
+- Frozen drafts now carry event-contract registry version `3`, revalidate all
persisted fields and the recomputed event id during deserialization and
signing, and enforce explicit `GenericDraft`, `TypedOnly`, and `ReadOnly`
authoring policies.
@@ -71,6 +79,11 @@ publish policy both pass for the same source revision.
- The duplicate private operational listing parser in `radroots_trade` was
removed; trade validation now consumes the canonical event codec.
+- The ambiguous `listing` source modules and public compatibility aliases were
+ removed. Consumers must migrate to the Classified Listing protocol names or
+ Operational Listing product names according to the API's responsibility.
+- The public operational-listing JSON wire-parts authoring helper was removed;
+ product authoring uses the canonical Markdown wire-parts path.
- Legacy calendar event models and permissive calendar tag-builder authoring
paths were removed.
- Direct `RadrootsProfile` draft encoding and the identity profile publisher were
@@ -104,10 +117,13 @@ publish policy both pass for the same source revision.
compatibility version instead of package SemVer. Existing backups stamped by
`0.1.0-alpha.2` remain restorable because this release does not change their
stored schema.
-- Persisted frozen drafts with event-contract registry version `1` are rejected
- and must be reconstructed against registry version `2`; strict Profile plus
- typed and read-only post contracts can no longer be reconstructed as generic
- drafts.
+- Persisted frozen drafts with event-contract registry versions `1` or `2` are
+ rejected and must be reconstructed against registry version `3`; strict
+ Profile plus typed and read-only post contracts can no longer be
+ reconstructed as generic drafts.
+- This breaking capsule revision must not be pinned or published through
+ downstream product clients until `radroots_app_rt` is migrated, generated FFI
+ bindings are rebuilt, and downstream compile and contract qualification pass.
- `radroots_event_from_nostr` now returns `Result<RadrootsEventEnvelope,
RadrootsEventEnvelopeError>`; callers must handle hostile or oversized SDK
events explicitly.
diff --git a/contracts/conformance/vectors/events/listing_tags_full.v1.json b/contracts/conformance/vectors/events/listing_tags_full.v1.json
@@ -1,27 +0,0 @@
-{
- "suite": "events",
- "contract_version": "1.0.0",
- "vectors": [
- {
- "id": "listing_tags_full_minimal_001",
- "kind": "listing_tags_full",
- "input": {
- "d_tag": "listing-001",
- "farm": {
- "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "d_tag": "farm-001"
- },
- "product": {
- "key": "coffee.green",
- "title": "green coffee lot",
- "category": "coffee"
- },
- "primary_bin_id": "bin-a",
- "bins": []
- },
- "expected": {
- "tags": []
- }
- }
- ]
-}
diff --git a/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json b/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json
@@ -0,0 +1,27 @@
+{
+ "suite": "events",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_tags_full_minimal_001",
+ "kind": "operational_listing_tags_full",
+ "input": {
+ "d_tag": "listing-001",
+ "farm": {
+ "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "d_tag": "farm-001"
+ },
+ "product": {
+ "key": "coffee.green",
+ "title": "green coffee lot",
+ "category": "coffee"
+ },
+ "primary_bin_id": "bin-a",
+ "bins": []
+ },
+ "expected": {
+ "tags": []
+ }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json
@@ -6,11 +6,11 @@
"id": "knowledge_manifest_fields_valid_001",
"kind": "knowledge.contract_manifest_json.valid",
"input": {
- "registry": "radroots_event_contract_registry_v2"
+ "registry": "radroots_event_contract_registry_v3"
},
"expected": {
"schema_version": 2,
- "registry_version": 2,
+ "registry_version": 3,
"required_fields": [
"schema_version",
"registry_version",
diff --git a/contracts/conformance/vectors/listing/build_draft.v1.json b/contracts/conformance/vectors/listing/build_draft.v1.json
@@ -1,33 +0,0 @@
-{
- "suite": "listing",
- "contract_version": "1.0.0",
- "vectors": [
- {
- "id": "listing_build_draft_minimal_001",
- "kind": "listing.build_draft",
- "input": {
- "listing": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
- "farm": {
- "pubkey": "farm_pubkey",
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAA"
- },
- "product": {
- "key": "sku",
- "title": "widget",
- "category": "tools"
- },
- "primary_bin_id": "bin-1",
- "bins": []
- }
- },
- "expected": {
- "wire_parts": {
- "kind": "listing",
- "content_shape": "listing_markdown_or_empty",
- "tags_shape": "listing_tags_full"
- }
- }
- }
- ]
-}
diff --git a/contracts/conformance/vectors/listing/build_tags.v1.json b/contracts/conformance/vectors/listing/build_tags.v1.json
@@ -1,29 +0,0 @@
-{
- "suite": "listing",
- "contract_version": "1.0.0",
- "vectors": [
- {
- "id": "listing_build_tags_minimal_001",
- "kind": "listing.build_tags",
- "input": {
- "listing": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
- "farm": {
- "pubkey": "farm_pubkey",
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAA"
- },
- "product": {
- "key": "sku",
- "title": "widget",
- "category": "tools"
- },
- "primary_bin_id": "bin-1",
- "bins": []
- }
- },
- "expected": {
- "tags_shape": "listing_tags"
- }
- }
- ]
-}
diff --git a/contracts/conformance/vectors/listing/parse_event.v1.json b/contracts/conformance/vectors/listing/parse_event.v1.json
@@ -1,215 +0,0 @@
-{
- "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/contracts/conformance/vectors/operational_listing/build_draft.v1.json b/contracts/conformance/vectors/operational_listing/build_draft.v1.json
@@ -0,0 +1,33 @@
+{
+ "suite": "operational_listing",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_build_draft_minimal_001",
+ "kind": "operational_listing.build_draft",
+ "input": {
+ "listing": {
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
+ "farm": {
+ "pubkey": "farm_pubkey",
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAA"
+ },
+ "product": {
+ "key": "sku",
+ "title": "widget",
+ "category": "tools"
+ },
+ "primary_bin_id": "bin-1",
+ "bins": []
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": "classified_listing",
+ "content_shape": "operational_listing_markdown_or_empty",
+ "tags_shape": "operational_listing_tags_full"
+ }
+ }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/operational_listing/build_tags.v1.json b/contracts/conformance/vectors/operational_listing/build_tags.v1.json
@@ -0,0 +1,29 @@
+{
+ "suite": "operational_listing",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_build_tags_minimal_001",
+ "kind": "operational_listing.build_tags",
+ "input": {
+ "listing": {
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAg",
+ "farm": {
+ "pubkey": "farm_pubkey",
+ "d_tag": "AAAAAAAAAAAAAAAAAAAAAA"
+ },
+ "product": {
+ "key": "sku",
+ "title": "widget",
+ "category": "tools"
+ },
+ "primary_bin_id": "bin-1",
+ "bins": []
+ }
+ },
+ "expected": {
+ "tags_shape": "operational_listing_tags"
+ }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/operational_listing/parse_event.v1.json b/contracts/conformance/vectors/operational_listing/parse_event.v1.json
@@ -0,0 +1,215 @@
+{
+ "suite": "operational_listing",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_tags_override_json_content_001",
+ "kind": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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/contracts/conformance/vectors/social/upgraded_boundaries.v1.json b/contracts/conformance/vectors/social/upgraded_boundaries.v1.json
@@ -3,12 +3,12 @@
"contract_version": "1.0.0",
"vectors": [
{
- "id": "social_listing_published_at_valid_001",
- "kind": "social.listing.parse_event.valid",
+ "id": "social_operational_listing_published_at_valid_001",
+ "kind": "social.operational_listing.parse_event.valid",
"input": {
"event": {
"kind": 30402,
- "content_shape": "listing_markdown",
+ "content_shape": "operational_listing_markdown",
"tags": [
[
"published_at",
@@ -23,12 +23,12 @@
}
},
{
- "id": "social_listing_published_at_invalid_002",
- "kind": "social.listing.parse_event.invalid",
+ "id": "social_operational_listing_published_at_invalid_002",
+ "kind": "social.operational_listing.parse_event.invalid",
"input": {
"event": {
"kind": 30402,
- "content_shape": "listing_markdown",
+ "content_shape": "operational_listing_markdown",
"tags": [
[
"published_at",
@@ -189,8 +189,8 @@
"kind": "social.surface_kinds.classification.valid",
"input": {
"kinds": {
- "active_listing": 30402,
- "retired_listing_kind": 30403,
+ "classified_listing": 30402,
+ "retired_legacy_listing_kind": 30403,
"report": 1984,
"farm_crdt_change": 78,
"relay_auth": 22242,
@@ -199,16 +199,16 @@
},
"expected": {
"result": "ok",
- "home_feed_active_listing": true,
- "home_feed_retired_listing_kind": false,
+ "home_feed_classified_listing": true,
+ "home_feed_retired_legacy_listing_kind": false,
"home_feed_report": false,
"home_feed_farm_crdt_change": false,
"home_feed_relay_auth": false,
"home_feed_http_auth": false,
- "market_active_listing": true,
- "market_retired_listing_kind": false,
+ "market_classified_listing": true,
+ "market_retired_legacy_listing_kind": false,
"moderation_report": true,
- "retired_listing_kind_accepted": false,
+ "retired_legacy_listing_kind_accepted": false,
"private_farm_ops_farm_crdt_change": true,
"private_farm_ops_relay_auth": false,
"private_farm_ops_http_auth": false
diff --git a/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json
@@ -0,0 +1,16 @@
+{
+ "suite": "trade",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "trade_parse_classified_listing_address_minimal_001",
+ "kind": "trade.parse_classified_listing_address",
+ "input": {
+ "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg"
+ },
+ "expected": {
+ "address_shape": "classified_listing_address"
+ }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/trade/parse_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_listing_address.v1.json
@@ -1,16 +0,0 @@
-{
- "suite": "trade",
- "contract_version": "1.0.0",
- "vectors": [
- {
- "id": "trade_parse_listing_address_minimal_001",
- "kind": "trade.parse_listing_address",
- "input": {
- "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg"
- },
- "expected": {
- "address_shape": "trade_listing_address"
- }
- }
- ]
-}
diff --git a/contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json b/contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json
@@ -1,20 +0,0 @@
-{
- "suite": "trade_validation",
- "contract_version": "1.0.0",
- "vectors": [
- {
- "id": "trade_validation_validate_listing_event_minimal_001",
- "kind": "trade_validation.validate_listing_event",
- "input": {
- "event": {
- "kind": "listing",
- "content_shape": "listing_json_or_markdown",
- "tags_shape": "listing_tags_full"
- }
- },
- "expected": {
- "validation_shape": "trade_validation_receipt"
- }
- }
- ]
-}
diff --git a/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json b/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json
@@ -0,0 +1,20 @@
+{
+ "suite": "trade_validation",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "trade_validation_validate_operational_listing_event_minimal_001",
+ "kind": "trade_validation.validate_operational_listing_event",
+ "input": {
+ "event": {
+ "kind": "classified_listing",
+ "content_shape": "operational_listing_json_or_markdown",
+ "tags_shape": "operational_listing_tags_full"
+ }
+ },
+ "expected": {
+ "validation_shape": "operational_listing_trade_projection"
+ }
+ }
+ ]
+}
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -31,6 +31,16 @@ an envelope whose id and signature it has independently verified and whose kind
the corresponding parser accepted. Outbound authored models produce unsigned
wire parts and require runtime signing and transport.
+## Classified and operational listing boundary rule
+
+Kind `30402` is the standard NIP-99 classified-listing kind and
+`RadrootsClassifiedListingAddress` is the exact coordinate authority for that
+kind. `RadrootsOperationalListing` is the richer
+`radroots.operational_listing.published.v1` profile accepted at the same kind;
+its typed codec, tags, and publication operations are exposed under the
+`operational_listing` domain. The standard kind identity does not by itself
+establish that an event satisfies the operational profile.
+
## Kind-1 post boundary rule
Ordinary kind-1 events remain interoperable at the generic
@@ -80,7 +90,7 @@ implementation.
| document | 30361 | RadrootsDocument | events.document.publish, events.document.list, events.document.get | requires `d` and pubkey tags; optional address tag |
| resource_area | 30370 | RadrootsResourceArea | events.resource_area.publish, events.resource_area.list, events.resource_area.get | addressable; GCS location and `g` tag required |
| resource_cap | 30371 | RadrootsResourceHarvestCap | events.resource_cap.publish, events.resource_cap.list, events.resource_cap.get | addressable; required address, pubkey, key, start, and end tags |
-| listing | 30402 | RadrootsListing | events.listing.publish, events.listing.list, events.listing.get | canonical Markdown content and tags; farm author required |
+| operational_listing | 30402 | RadrootsOperationalListing | events.operational_listing.publish, events.operational_listing.list, events.operational_listing.get | NIP-99 classified-listing kind with the richer Radroots operational profile; canonical Markdown content and tags; farm author required |
| dvm_request | 5000-5999 | RadrootsJobRequest | events.dvm_request.publish, events.dvm_request.list, events.dvm_request.get | generic DVM request surface |
| dvm_result | 6000-6999 | RadrootsJobResult | events.dvm_result.publish, events.dvm_result.list, events.dvm_result.get | generic DVM result surface |
| dvm_feedback | 7000 | RadrootsJobFeedback | events.dvm_feedback.publish, events.dvm_feedback.list, events.dvm_feedback.get | generic DVM feedback surface |
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -7,9 +7,10 @@ this repository.
## Purpose
-The public social event substrate extends the Radroots event family beyond profile, farm, listing,
-and trade workflows while keeping relay runtime behavior, application projections, moderation
-services, and private Field business documents outside this repository's event-contract boundary.
+The public social event substrate extends the Radroots event family beyond profile, farm,
+operational listings, and trade workflows while keeping relay runtime behavior, application
+projections, moderation services, and private Field business documents outside this repository's
+event-contract boundary.
The target implementation is standards-first and Radroots-named. Event models live in
`radroots_event`, canonical encode/decode behavior lives in `radroots_event_codec`, and
@@ -27,8 +28,8 @@ authoring and admission profile are separate contract layers.
## Implementation Inventory
The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111`
-`RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, stable listing
-records through `RadrootsListing`, articles, generic public file metadata, calendar date events,
+`RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, operational listing
+records through `RadrootsOperationalListing`, articles, generic public file metadata, calendar date events,
calendar time events, reposts, generic reposts, calendar collections, RSVP events, and reports.
The closeout contract requires:
@@ -38,7 +39,7 @@ The closeout contract requires:
- ordinary kind-1 compatibility reads plus strict Update, PhotoUpdate, and Ask authoring
- strict NIP-22 `RadrootsComment` behavior without legacy `e_root` or `e_prev` fallback tags
- strict NIP-25 `RadrootsReaction` behavior where empty content is a valid like
-- explicit optional `published_at` support for NIP-99 listing parity
+- explicit optional `published_at` support for NIP-99 classified-listing parity
- NIP-65 relay-list validation evidence through `RadrootsList`
- conformance vectors and canonical-event witnesses for every new or upgraded social event family
@@ -69,7 +70,8 @@ The production-v1 public social substrate includes:
`RadrootsParsedNip52CalendarEventRsvp`, and strict admitted
`RadrootsAdmittedCalendarEventRsvp` models for NIP-52 kind `31925`
- `RadrootsReport` for NIP-56 kind `1984`
-- stable listing kind `30402` validation through `RadrootsListing`
+- operational-listing profile validation through `RadrootsOperationalListing` at NIP-99
+ classified-listing kind `30402`
- relay-list kind `10002` validation through `RadrootsList`
## Contract Decisions
@@ -138,11 +140,11 @@ contract. Strict Profile, Update, PhotoUpdate, and Ask contracts are
`TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft
contracts remain `GenericDraft`. `RadrootsEventDraft::new` therefore rejects
the strict Profile contract and all four governed kind-1 post contracts with
-`contract_not_draft_authorable`. Serialized drafts record registry version `2`
+`contract_not_draft_authorable`. Serialized drafts record registry version `3`
and are accepted only after deserialization revalidates the registry version,
contract, kind, shape, policy, recomputed event id, and known fields. The
frozen-draft signing boundary repeats that validation, so stale version-`1`
-drafts must be rebuilt. Typed root posts enter Nostr signing and client
+and version-`2` drafts must be rebuilt. Typed root posts enter Nostr signing and client
publication only through an opaque post builder that exposes timestamp
selection and signing, but no raw tag/content mutation or public conversion to
the upstream builder. The opaque generic builder rejects kind `0` and unmarked
@@ -317,17 +319,18 @@ format-safety policy. No calendar model upgrades an observed relay URL into eith
or an availability guarantee.
Product routing uses surface-specific kind classifiers rather than a broad public-social set. Home,
-Events, Market, Map, and Profile public-content candidates are explicit. Active listing kind `30402`
+Events, Market, Map, and Profile public-content candidates are explicit. Active NIP-99
+classified-listing kind `30402`
can appear in public product surfaces. Report kind `1984` is a moderation/admin candidate, not
normal feed content. Relay and HTTP auth kinds are transient and excluded from durable social and
farm-ops candidate sets. Private farm operations candidates include the farm workspace manifest,
farm CRDT change envelope, farm file metadata, and the supported NIP-29 group event subset.
-`RadrootsRelayList` is not a separate model type in the target contract. Stable listings are
-represented through `RadrootsListing`, and NIP-51 standard and list-set entries, including NIP-65
-relay metadata kind `10002`, are represented through `RadrootsList`. NIP-51 taxonomy may classify
-kind `31924` as a calendar list, but generic list and list-set decoding or authoring must reject that kind;
-only the calendar-specific model and codec may parse or publish it.
+`RadrootsRelayList` is not a separate model type in the target contract. Operational listing records
+are represented through `RadrootsOperationalListing`, and NIP-51 standard and list-set entries,
+including NIP-65 relay metadata kind `10002`, are represented through `RadrootsList`. NIP-51
+taxonomy may classify kind `31924` as a calendar list, but generic list and list-set decoding or
+authoring must reject that kind; only the calendar-specific model and codec may parse or publish it.
## Exclusions
@@ -350,7 +353,7 @@ this contract boundary.
Every new social codec and every upgraded existing social codec must have deterministic valid and
invalid conformance vectors before closeout. Upgraded vectors must include the strict comment,
-reaction, listing, farm, list, and list-set behavior whose public contract changes during the
+reaction, operational-listing, farm, list, and list-set behavior whose public contract changes during the
refactor.
Social vectors are repo-owned and synthetic. They must not depend on application relay state, local
diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.json b/contracts/knowledge/knowledge_event_contract_manifest.v2.json
@@ -1,6 +1,6 @@
{
"schema_version": 2,
- "registry_version": 2,
+ "registry_version": 3,
"radroots_event_version": "1.0.0-alpha.1",
"radroots_event_codec_version": "1.0.0-alpha.1",
"contract_count": 11,
diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 b/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256
@@ -1 +1 @@
-19c3baa86b56f418d5aa8eefbd315b5685d54deb068ca29e96fd8dec8b0ef7e1
+9a8f8495aa9bbc040a707e2b77c415aa71949164cdf3647c91060d3baf36fe6f
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -9,7 +9,7 @@ domains = [
"event",
"profile",
"farm",
- "listing",
+ "operational_listing",
"trade",
"trade_validation",
"social",
@@ -54,7 +54,7 @@ public = [
"RadrootsEventHeadDecision",
"RadrootsEventRef",
"RadrootsEventPtr",
- "RadrootsListingAddress",
+ "RadrootsClassifiedListingAddress",
"RadrootsAuthoredImage",
"RadrootsAuthoredImageError",
"RadrootsNip05Identifier",
@@ -69,7 +69,7 @@ public = [
"RadrootsNip05IdentityVerification",
"RadrootsUnverifiedProfileMediaReference",
"RadrootsFarm",
- "RadrootsListing",
+ "RadrootsOperationalListing",
"RadrootsPost",
"RadrootsAuthoredPostError",
"RadrootsPostImageDimensions",
@@ -515,69 +515,69 @@ rust_types = ["radroots_event::farm::RadrootsFarm"]
[operations.farm_build_draft.conformance]
vector = "contracts/conformance/vectors/farm/build_draft.v1.json"
-[operations.listing_build_tags]
-domain = "listing"
-id = "listing.build_tags"
+[operations.operational_listing_build_tags]
+domain = "operational_listing"
+id = "operational_listing.build_tags"
stability = "beta"
-inputs = ["RadrootsListing"]
+inputs = ["RadrootsOperationalListing"]
outputs = ["NostrTags"]
error_class = "encode_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.listing_build_tags.implementation]
+[operations.operational_listing_build_tags.implementation]
rust_modules = [
- "crates/event_codec/src/listing/encode.rs",
- "crates/event_codec/src/listing/tags.rs",
+ "crates/event_codec/src/operational_listing/encode.rs",
+ "crates/event_codec/src/operational_listing/tags.rs",
]
-rust_types = ["radroots_event::listing::RadrootsListing"]
+rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"]
-[operations.listing_build_tags.conformance]
-vector = "contracts/conformance/vectors/listing/build_tags.v1.json"
+[operations.operational_listing_build_tags.conformance]
+vector = "contracts/conformance/vectors/operational_listing/build_tags.v1.json"
-[operations.listing_build_draft]
-domain = "listing"
-id = "listing.build_draft"
+[operations.operational_listing_build_draft]
+domain = "operational_listing"
+id = "operational_listing.build_draft"
stability = "beta"
-inputs = ["RadrootsListing"]
+inputs = ["RadrootsOperationalListing"]
outputs = ["RadrootsNip01EventWireParts"]
error_class = "encode_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.listing_build_draft.implementation]
+[operations.operational_listing_build_draft.implementation]
rust_modules = [
- "crates/event_codec/src/listing/encode.rs",
+ "crates/event_codec/src/operational_listing/encode.rs",
"crates/event_codec/src/wire.rs",
]
-rust_types = ["radroots_event::listing::RadrootsListing"]
+rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"]
-[operations.listing_build_draft.conformance]
-vector = "contracts/conformance/vectors/listing/build_draft.v1.json"
+[operations.operational_listing_build_draft.conformance]
+vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json"
-[operations.listing_parse_event]
-domain = "listing"
-id = "listing.parse_event"
+[operations.operational_listing_parse_event]
+domain = "operational_listing"
+id = "operational_listing.parse_event"
stability = "beta"
inputs = ["RadrootsEventEnvelope"]
-outputs = ["RadrootsListing"]
+outputs = ["RadrootsOperationalListing"]
error_class = "parse_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.listing_parse_event.implementation]
-rust_modules = ["crates/event_codec/src/listing/decode.rs"]
+[operations.operational_listing_parse_event.implementation]
+rust_modules = ["crates/event_codec/src/operational_listing/decode.rs"]
rust_types = [
"radroots_event::RadrootsEventEnvelope",
- "radroots_event::order::RadrootsListingParseError",
- "radroots_event::listing::RadrootsListing",
+ "radroots_event::operational_listing::RadrootsOperationalListingParseError",
+ "radroots_event::operational_listing::RadrootsOperationalListing",
]
-[operations.listing_parse_event.conformance]
-vector = "contracts/conformance/vectors/listing/parse_event.v1.json"
+[operations.operational_listing_parse_event.conformance]
+vector = "contracts/conformance/vectors/operational_listing/parse_event.v1.json"
[operations.social_update_build_authored_draft]
domain = "social"
@@ -1211,23 +1211,23 @@ rust_types = [
[operations.trade_parse_mutation_event.conformance]
vector = "contracts/conformance/vectors/trade/parse_mutation_event.v1.json"
-[operations.trade_parse_listing_address]
+[operations.trade_parse_classified_listing_address]
domain = "trade"
-id = "trade.parse_listing_address"
+id = "trade.parse_classified_listing_address"
stability = "beta"
inputs = ["listing_addr"]
-outputs = ["RadrootsListingAddress"]
+outputs = ["RadrootsClassifiedListingAddress"]
error_class = "address_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.trade_parse_listing_address.implementation]
+[operations.trade_parse_classified_listing_address.implementation]
rust_modules = ["crates/event/src/ids.rs"]
-rust_types = ["radroots_event::ids::RadrootsListingAddress"]
+rust_types = ["radroots_event::ids::RadrootsClassifiedListingAddress"]
-[operations.trade_parse_listing_address.conformance]
-vector = "contracts/conformance/vectors/trade/parse_listing_address.v1.json"
+[operations.trade_parse_classified_listing_address.conformance]
+vector = "contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json"
[operations.trade_reduce_records]
domain = "trade"
@@ -1250,26 +1250,26 @@ rust_types = [
[operations.trade_reduce_records.conformance]
vector = "contracts/conformance/vectors/trade/reduce_records.v1.json"
-[operations.trade_validation_validate_listing_event]
+[operations.trade_validation_validate_operational_listing_event]
domain = "trade_validation"
-id = "trade_validation.validate_listing_event"
+id = "trade_validation.validate_operational_listing_event"
stability = "beta"
inputs = ["RadrootsEventEnvelope"]
-outputs = ["TradeListingValidateResult"]
+outputs = ["RadrootsOperationalListingTradeProjection"]
error_class = "validation_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.trade_validation_validate_listing_event.implementation]
-rust_modules = ["crates/trade/src/listing/validation.rs"]
+[operations.trade_validation_validate_operational_listing_event.implementation]
+rust_modules = ["crates/trade/src/operational_listing/validation.rs"]
rust_types = [
"radroots_event::RadrootsEventEnvelope",
- "radroots_trade::listing::validation::RadrootsTradeListing",
+ "radroots_trade::operational_listing::validation::RadrootsOperationalListingTradeProjection",
]
-[operations.trade_validation_validate_listing_event.conformance]
-vector = "contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json"
+[operations.trade_validation_validate_operational_listing_event.conformance]
+vector = "contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json"
[operations.knowledge_contract_manifest_json]
domain = "knowledge"
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -117,4 +117,22 @@ semver_impacts = [
"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."
+summary = "Consolidate operational listing decoding in radroots_event_codec, return the typed operational-listing error contract, and make canonical tags authoritative over JSON content."
+
+[[changes]]
+id = "classified-operational-listing-taxonomy"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_constant",
+ "add_enum_variant",
+ "add_conformance_vector",
+ "remove_exported_type",
+ "remove_exported_function",
+ "remove_exported_module",
+ "change_exported_function_signature",
+ "change_exported_enum_variant",
+ "change_exported_constant_value",
+ "change_exported_algorithm_behavior",
+]
+summary = "Separate the standard NIP-99 Classified Listing kind and coordinate authority from the richer Radroots Operational Listing profile, operations, codecs, and conformance namespace."
diff --git a/crates/authority/src/actor.rs b/crates/authority/src/actor.rs
@@ -428,7 +428,7 @@ mod tests {
let request = RadrootsActorResolutionRequest::new(
selector,
RadrootsActorRole::Seller,
- Some("radroots.listing.published.v1".to_owned()),
+ Some("radroots.operational_listing.published.v1".to_owned()),
);
assert_eq!(
@@ -436,7 +436,10 @@ mod tests {
&RadrootsActorSelector::account_id("acct-field-01").expect("selector")
);
assert_eq!(request.required_role(), RadrootsActorRole::Seller);
- assert_eq!(request.contract_id(), Some("radroots.listing.published.v1"));
+ assert_eq!(
+ request.contract_id(),
+ Some("radroots.operational_listing.published.v1")
+ );
}
#[test]
diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs
@@ -170,9 +170,11 @@ mod tests {
use super::*;
use crate::RadrootsSignerError;
use core::cell::Cell;
- use radroots_event::contract::{RadrootsActorRole, event_contract};
+ use radroots_event::contract::{
+ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsActorRole, event_contract,
+ };
use radroots_event::ids::RadrootsPublicKey;
- use radroots_event::kinds::{KIND_LISTING, KIND_POST, KIND_TRADE_PROPOSAL};
+ use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_POST, KIND_TRADE_PROPOSAL};
fn hex_64(character: char) -> String {
std::iter::repeat_n(character, 64).collect()
@@ -190,16 +192,38 @@ mod tests {
RadrootsActorContext::explicit_pubkey(pubkey, [RadrootsActorRole::Buyer]).expect("buyer")
}
- fn listing_event_draft(pubkey: &str) -> RadrootsEventDraft {
+ fn operational_listing_event_draft(pubkey: &str) -> RadrootsEventDraft {
RadrootsEventDraft::new(
- "radroots.listing.published.v1",
- KIND_LISTING,
+ "radroots.operational_listing.published.v1",
+ KIND_CLASSIFIED_LISTING,
1_700_000_000,
- vec![vec!["d".to_owned(), "listing-a".to_owned()]],
- "{}",
+ vec![
+ vec!["d".to_owned(), "listing-a".to_owned()],
+ vec!["p".to_owned(), pubkey.to_owned()],
+ vec!["a".to_owned(), format!("30340:{pubkey}:farm-a")],
+ vec!["key".to_owned(), "carrots".to_owned()],
+ vec!["title".to_owned(), "Carrots".to_owned()],
+ vec!["category".to_owned(), "produce".to_owned()],
+ vec!["radroots:primary_bin".to_owned(), "field-bin".to_owned()],
+ vec![
+ "radroots:bin".to_owned(),
+ "field-bin".to_owned(),
+ "1000".to_owned(),
+ "g".to_owned(),
+ ],
+ vec![
+ "radroots:price".to_owned(),
+ "field-bin".to_owned(),
+ "3".to_owned(),
+ "CAD".to_owned(),
+ "1".to_owned(),
+ "lb".to_owned(),
+ ],
+ ],
+ "# Carrots",
pubkey,
)
- .expect("listing event draft")
+ .expect("operational listing event draft")
}
#[derive(Default)]
@@ -368,7 +392,8 @@ mod tests {
#[test]
fn buyer_and_seller_contract_roles_match_current_contracts() {
- let listing = event_contract("radroots.listing.published.v1").expect("listing contract");
+ let operational_listing = event_contract("radroots.operational_listing.published.v1")
+ .expect("operational listing contract");
let trade_proposal =
event_contract("radroots.trade.proposal.v1").expect("trade proposal contract");
let trade_decision =
@@ -376,10 +401,10 @@ mod tests {
let seller = seller_actor(hex_64('a').as_str());
let buyer = buyer_actor(hex_64('b').as_str());
- assert_eq!(listing.author_role, RadrootsActorRole::Seller);
- assert!(authorize_actor_for_contract(&seller, listing).is_ok());
+ assert_eq!(operational_listing.author_role, RadrootsActorRole::Seller);
+ assert!(authorize_actor_for_contract(&seller, operational_listing).is_ok());
assert!(matches!(
- authorize_actor_for_contract(&buyer, listing),
+ authorize_actor_for_contract(&buyer, operational_listing),
Err(RadrootsAuthorityError::ActorRoleUnsatisfied { .. })
));
assert!(authorize_actor_for_contract(&buyer, trade_proposal).is_ok());
@@ -397,7 +422,7 @@ mod tests {
#[test]
fn actor_pubkey_mismatch_fails() {
- let draft = listing_event_draft(hex_64('a').as_str());
+ let draft = operational_listing_event_draft(hex_64('a').as_str());
let actor = seller_actor(hex_64('b').as_str());
assert!(matches!(
@@ -408,7 +433,7 @@ mod tests {
#[test]
fn signer_pubkey_mismatch_fails() {
- let draft = listing_event_draft(hex_64('a').as_str());
+ let draft = operational_listing_event_draft(hex_64('a').as_str());
let signer = StaticSigner::new(hex_64('b').as_str());
assert!(matches!(
@@ -418,36 +443,39 @@ mod tests {
}
#[test]
- fn draft_validation_failure_does_not_invoke_custom_signer() {
+ fn stale_v1_and_v2_draft_validation_failures_do_not_invoke_custom_signer() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = CountingSigner::new(pubkey.as_str());
- let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| {
- Err(RadrootsDraftError::ContractRegistryVersionMismatch {
- expected: 2,
- actual: 1,
+ assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 3);
+ for actual in [1, 2] {
+ let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| {
+ Err(RadrootsDraftError::ContractRegistryVersionMismatch {
+ expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
+ actual,
+ })
})
- })
- .expect_err("stale draft validation must fail");
-
- assert!(matches!(
- error,
- RadrootsAuthorityError::DraftValidation(
- RadrootsDraftError::ContractRegistryVersionMismatch {
- expected: 2,
- actual: 1
- }
- )
- ));
+ .expect_err("stale draft validation must fail");
+
+ assert_eq!(
+ error,
+ RadrootsAuthorityError::DraftValidation(
+ RadrootsDraftError::ContractRegistryVersionMismatch {
+ expected: 3,
+ actual,
+ }
+ )
+ );
+ }
assert_eq!(signer.sign_invocations.get(), 0);
}
#[test]
fn signer_explicit_id_mismatch_fails_before_authorized_signing() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::with_event_id(pubkey.as_str(), hex_64('e'));
@@ -462,7 +490,7 @@ mod tests {
#[test]
fn signed_event_created_at_mismatch_fails() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::with_overrides(
pubkey.as_str(),
@@ -481,7 +509,7 @@ mod tests {
#[test]
fn signed_event_kind_mismatch_fails() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::with_overrides(
pubkey.as_str(),
@@ -494,7 +522,7 @@ mod tests {
assert!(matches!(
sign_authorized_draft(&actor, &signer, &draft),
Err(RadrootsAuthorityError::SignedEventKindMismatch {
- expected_kind: KIND_LISTING,
+ expected_kind: KIND_CLASSIFIED_LISTING,
actual_kind: KIND_POST
})
));
@@ -503,7 +531,7 @@ mod tests {
#[test]
fn signed_event_tags_mismatch_fails() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::with_overrides(
pubkey.as_str(),
@@ -518,7 +546,7 @@ mod tests {
assert_eq!(
error,
RadrootsAuthorityError::SignedEventTagsMismatch {
- expected_len: 1,
+ expected_len: 9,
actual_len: 1
}
);
@@ -529,7 +557,7 @@ mod tests {
#[test]
fn signed_event_content_mismatch_fails() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::with_overrides(
pubkey.as_str(),
@@ -544,7 +572,7 @@ mod tests {
assert_eq!(
error,
RadrootsAuthorityError::SignedEventContentMismatch {
- expected_len: 2,
+ expected_len: 9,
actual_len: 16
}
);
@@ -555,7 +583,7 @@ mod tests {
#[test]
fn signed_event_exactly_matching_draft_passes() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let signed = signed_event_from_draft(&draft);
validate_signed_event_matches_draft(&signed, &draft).expect("signed event matches draft");
@@ -564,7 +592,7 @@ mod tests {
#[test]
fn signed_event_pubkey_mismatch_fails() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let signed = signed_event_from_parts(
hex_64('b'),
draft.created_at_u64(),
@@ -594,7 +622,7 @@ mod tests {
#[test]
fn static_signer_maps_invalid_signed_event_parts() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let signer = StaticSigner::with_event_id(pubkey.as_str(), "bad-id".to_owned());
assert!(matches!(
@@ -606,7 +634,7 @@ mod tests {
#[test]
fn authorized_actor_and_signer_return_signed_event() {
let pubkey = hex_64('a');
- let draft = listing_event_draft(pubkey.as_str());
+ let draft = operational_listing_event_draft(pubkey.as_str());
let actor = seller_actor(pubkey.as_str());
let signer = StaticSigner::new(pubkey.as_str());
@@ -614,7 +642,7 @@ mod tests {
assert_eq!(signed.id_str(), draft.expected_event_id_str());
assert_eq!(signed.pubkey_str(), draft.expected_pubkey_str());
- assert_eq!(signed.kind(), KIND_LISTING);
+ assert_eq!(signed.kind(), KIND_CLASSIFIED_LISTING);
}
#[test]
diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs
@@ -294,10 +294,10 @@ mod tests {
),
(
RadrootsAuthorityError::ActorRoleUnsatisfied {
- contract_id: "radroots.listing.published.v1".to_owned(),
+ contract_id: "radroots.operational_listing.published.v1".to_owned(),
required_role: RadrootsActorRole::Seller,
},
- "actor does not satisfy role Seller for contract `radroots.listing.published.v1`",
+ "actor does not satisfy role Seller for contract `radroots.operational_listing.published.v1`",
),
(
RadrootsAuthorityError::ActorPubkeyMismatch {
@@ -407,24 +407,36 @@ mod tests {
}
#[test]
- fn draft_validation_error_preserves_typed_source() {
- let authority_error = RadrootsAuthorityError::DraftValidation(
- radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch {
- expected: 2,
- actual: 1,
- },
- );
+ fn stale_v1_and_v2_draft_validation_errors_preserve_typed_sources() {
+ let cases = [
+ (
+ 1,
+ "draft validation failed: event contract registry version mismatch: expected 3, got 1",
+ "event contract registry version mismatch: expected 3, got 1",
+ ),
+ (
+ 2,
+ "draft validation failed: event contract registry version mismatch: expected 3, got 2",
+ "event contract registry version mismatch: expected 3, got 2",
+ ),
+ ];
- assert_eq!(
- authority_error.to_string(),
- "draft validation failed: event contract registry version mismatch: expected 2, got 1"
- );
- assert_eq!(
- authority_error
- .source()
- .expect("draft validation source")
- .to_string(),
- "event contract registry version mismatch: expected 2, got 1"
- );
+ for (actual, expected_authority, expected_source) in cases {
+ let authority_error = RadrootsAuthorityError::DraftValidation(
+ radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch {
+ expected: 3,
+ actual,
+ },
+ );
+
+ assert_eq!(authority_error.to_string(), expected_authority);
+ assert_eq!(
+ authority_error
+ .source()
+ .expect("draft validation source")
+ .to_string(),
+ expected_source
+ );
+ }
}
}
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -18,7 +18,7 @@ use crate::{
};
use radroots_blossom::RadrootsBlossomBlobUrl;
-pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 2;
+pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsEventClass {
@@ -50,6 +50,7 @@ pub enum RadrootsNostrStandard {
Nip90,
Nip94,
Nip98,
+ Nip99,
Radroots,
}
@@ -86,8 +87,8 @@ pub enum RadrootsReducer {
FarmOpsProjection,
GroupProjection,
KnowledgeProjection,
- ListingInventoryAccounting,
- ListingProjection,
+ OperationalListingInventoryAccounting,
+ OperationalListingProjection,
MarketProjection,
OrderProjection,
ProfileProjection,
@@ -137,8 +138,8 @@ pub enum RadrootsTagSemantic {
Identifier,
Image,
Kind,
- ListingAddress,
- ListingSnapshot,
+ ClassifiedListingAddress,
+ OperationalListingSnapshot,
ListDescription,
Location,
Participant,
@@ -723,18 +724,137 @@ const TAG_STATUS: RadrootsTagContract = tag(
RadrootsTagValueType::Text,
false,
);
-const TAG_CATEGORY: RadrootsTagContract = tag(
- "category",
+const TAG_IMAGE: RadrootsTagContract = tag(
+ "image",
RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::Image,
+ RadrootsTagValueType::Url,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_FARM: RadrootsTagContract = tag(
+ "a",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::AddressableCoordinate,
+ RadrootsTagValueType::AddressableCoordinate,
+ true,
+);
+const TAG_OPERATIONAL_LISTING_PRODUCT_KEY: RadrootsTagContract = tag(
+ "key",
+ RadrootsTagCardinality::RequiredOne,
RadrootsTagSemantic::Category,
RadrootsTagValueType::Text,
false,
);
-const TAG_IMAGE: RadrootsTagContract = tag(
- "image",
+const TAG_OPERATIONAL_LISTING_TITLE: RadrootsTagContract = tag(
+ "title",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Title,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_CATEGORY: RadrootsTagContract = tag(
+ "category",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_PRIMARY_BIN: RadrootsTagContract = tag(
+ "radroots:primary_bin",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::OperationalListingSnapshot,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_BIN: RadrootsTagContract = tag(
+ "radroots:bin",
+ RadrootsTagCardinality::RequiredMany,
+ RadrootsTagSemantic::OperationalListingSnapshot,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_PRICE: RadrootsTagContract = tag(
+ "radroots:price",
+ RadrootsTagCardinality::RequiredMany,
+ RadrootsTagSemantic::Price,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_DISCOUNT: RadrootsTagContract = tag(
+ "radroots:discount",
RadrootsTagCardinality::OptionalMany,
- RadrootsTagSemantic::Image,
- RadrootsTagValueType::Url,
+ RadrootsTagSemantic::Price,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_RESOURCE_AREA: RadrootsTagContract = tag(
+ "radroots:resource_area",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::AddressableCoordinate,
+ RadrootsTagValueType::AddressableCoordinate,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_PLOT: RadrootsTagContract = tag(
+ "radroots:plot",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::AddressableCoordinate,
+ RadrootsTagValueType::AddressableCoordinate,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_INVENTORY: RadrootsTagContract = tag(
+ "inventory",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::OperationalListingSnapshot,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_AVAILABILITY_START: RadrootsTagContract = tag(
+ "radroots:availability_start",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Status,
+ RadrootsTagValueType::UnixTimestamp,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_EXPIRES_AT: RadrootsTagContract = tag(
+ "expires_at",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Status,
+ RadrootsTagValueType::UnixTimestamp,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_DELIVERY: RadrootsTagContract = tag(
+ "delivery",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Reference,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_PROCESS: RadrootsTagContract = tag(
+ "process",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_LOT: RadrootsTagContract = tag(
+ "lot",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Reference,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_PROFILE: RadrootsTagContract = tag(
+ "profile",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_OPERATIONAL_LISTING_YEAR: RadrootsTagContract = tag(
+ "year",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Category,
+ RadrootsTagValueType::Text,
false,
);
const TAG_CALENDAR_IMAGE: RadrootsTagContract = tag(
@@ -913,16 +1033,34 @@ const CALENDAR_RSVP_TAGS: &[RadrootsTagContract] = &[
TAG_CALENDAR_RSVP_AUTHOR,
];
const FARM_TAGS: &[RadrootsTagContract] = &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_IMAGE];
-const LISTING_TAGS: &[RadrootsTagContract] = &[
+const OPERATIONAL_LISTING_TAGS: &[RadrootsTagContract] = &[
TAG_D,
- TAG_TITLE,
+ TAG_P_REQUIRED,
+ TAG_OPERATIONAL_LISTING_FARM,
+ TAG_OPERATIONAL_LISTING_PRODUCT_KEY,
+ TAG_OPERATIONAL_LISTING_TITLE,
+ TAG_OPERATIONAL_LISTING_CATEGORY,
TAG_SUMMARY,
TAG_PUBLISHED_AT,
+ TAG_OPERATIONAL_LISTING_PROCESS,
+ TAG_OPERATIONAL_LISTING_LOT,
+ TAG_OPERATIONAL_LISTING_PROFILE,
+ TAG_OPERATIONAL_LISTING_YEAR,
TAG_LOCATION,
TAG_PRICE,
TAG_STATUS,
- TAG_CATEGORY,
TAG_IMAGE,
+ TAG_GEOHASH_OPTIONAL,
+ TAG_OPERATIONAL_LISTING_PRIMARY_BIN,
+ TAG_OPERATIONAL_LISTING_BIN,
+ TAG_OPERATIONAL_LISTING_PRICE,
+ TAG_OPERATIONAL_LISTING_DISCOUNT,
+ TAG_OPERATIONAL_LISTING_RESOURCE_AREA,
+ TAG_OPERATIONAL_LISTING_PLOT,
+ TAG_OPERATIONAL_LISTING_INVENTORY,
+ TAG_OPERATIONAL_LISTING_AVAILABILITY_START,
+ TAG_OPERATIONAL_LISTING_EXPIRES_AT,
+ TAG_OPERATIONAL_LISTING_DELIVERY,
];
const TRADE_MUTATION_TAGS: &[RadrootsTagContract] =
&[TAG_CONTRACT_REQUIRED, TAG_D, TAG_P_REQUIRED, TAG_E_MANY];
@@ -971,14 +1109,14 @@ const PROFILE_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::ProfileProjectio
const FARM_OPS_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::FarmOpsProjection];
const GROUP_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::GroupProjection];
const CALENDAR_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::CalendarProjection];
-const LISTING_REDUCERS: &[RadrootsReducer] = &[
- RadrootsReducer::ListingProjection,
+const OPERATIONAL_LISTING_REDUCERS: &[RadrootsReducer] = &[
+ RadrootsReducer::OperationalListingProjection,
RadrootsReducer::MarketProjection,
- RadrootsReducer::ListingInventoryAccounting,
+ RadrootsReducer::OperationalListingInventoryAccounting,
];
const TRADE_MUTATION_REDUCERS: &[RadrootsReducer] = &[
RadrootsReducer::TradeProjection,
- RadrootsReducer::ListingInventoryAccounting,
+ RadrootsReducer::OperationalListingInventoryAccounting,
];
const TRADE_VALIDATION_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::TradeValidation];
const RELAY_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::NostrRelayPolicyProjection];
@@ -1737,12 +1875,12 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[
["radroots.farm.workspace_manifest.v1"]
),
kind_contract!(
- KIND_LISTING,
- "KIND_LISTING",
- "Listing",
+ KIND_CLASSIFIED_LISTING,
+ "KIND_CLASSIFIED_LISTING",
+ "Classified Listing",
RadrootsEventClass::Addressable,
- RadrootsNostrStandard::Radroots,
- ["radroots.listing.published.v1"]
+ RadrootsNostrStandard::Nip99,
+ ["radroots.operational_listing.published.v1"]
),
kind_contract!(
KIND_KNOWLEDGE_SOURCE,
@@ -2945,17 +3083,17 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[
FARM_OPS_REDUCERS
),
event_contract!(
- "radroots.listing.published.v1",
- KIND_LISTING,
- "Listing",
- "RadrootsListing",
+ "radroots.operational_listing.published.v1",
+ KIND_CLASSIFIED_LISTING,
+ "Operational Listing",
+ "RadrootsOperationalListing",
RadrootsEventClass::Addressable,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Seller,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::Markdown,
RadrootsEventDiscriminator::KindOnly,
- LISTING_TAGS,
- LISTING_REDUCERS
+ OPERATIONAL_LISTING_TAGS,
+ OPERATIONAL_LISTING_REDUCERS
),
experimental_event_contract!(
"radroots.knowledge.source.v1",
@@ -3325,7 +3463,7 @@ pub fn kind_contract_family(contract: &RadrootsKindContract) -> Option<RadrootsC
| KIND_RESOURCE_HARVEST_CAP
| KIND_FARM_WORKSPACE_MANIFEST
| KIND_FARM_CRDT_CHANGE => RadrootsContractFamily::Farm,
- KIND_LISTING => RadrootsContractFamily::Market,
+ KIND_CLASSIFIED_LISTING => RadrootsContractFamily::Market,
KIND_TRADE_VALIDATION_RECEIPT
| KIND_TRADE_PROPOSAL
| KIND_TRADE_DECISION
@@ -3479,7 +3617,7 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> {
Some(RadrootsContractFamily::Knowledge)
} else if id.starts_with("radroots.list.") || id.starts_with("radroots.list_set.") {
Some(RadrootsContractFamily::List)
- } else if id.starts_with("radroots.listing.") {
+ } else if id.starts_with("radroots.operational_listing.") {
Some(RadrootsContractFamily::Market)
} else if id.starts_with("radroots.message.") {
Some(RadrootsContractFamily::Message)
@@ -4962,6 +5100,49 @@ mod tests {
}
#[test]
+ fn classified_listing_kind_and_operational_profile_are_distinct() {
+ let kind = kind_contract(KIND_CLASSIFIED_LISTING).expect("classified listing kind");
+ assert_eq!(kind.canonical_constant, "KIND_CLASSIFIED_LISTING");
+ assert_eq!(kind.name, "Classified Listing");
+ assert_eq!(kind.class, RadrootsEventClass::Addressable);
+ assert_eq!(kind.standard, RadrootsNostrStandard::Nip99);
+ assert_eq!(
+ kind.accepted_event_contracts,
+ &["radroots.operational_listing.published.v1"]
+ );
+
+ let profile = event_contract("radroots.operational_listing.published.v1")
+ .expect("operational listing profile");
+ assert_eq!(profile.name, "Operational Listing");
+ assert_eq!(profile.payload_type, "RadrootsOperationalListing");
+ assert_eq!(profile.content_schema, RadrootsContentSchema::Markdown);
+ assert_eq!(profile.discriminator, RadrootsEventDiscriminator::KindOnly);
+ assert!(event_contract("radroots.listing.published.v1").is_none());
+
+ let seller = "a".repeat(64);
+ let tags = vec![
+ owned_tag(&["d", "carrots"]),
+ owned_tag(&["p", seller.as_str()]),
+ owned_tag(&["a", format!("30340:{seller}:victoria-farm").as_str()]),
+ owned_tag(&["key", "carrots"]),
+ owned_tag(&["title", "Carrots"]),
+ owned_tag(&["category", "produce"]),
+ owned_tag(&["radroots:primary_bin", "field-bin"]),
+ owned_tag(&["radroots:bin", "field-bin", "1000", "g"]),
+ owned_tag(&["radroots:price", "field-bin", "3", "CAD", "1", "lb"]),
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CLASSIFIED_LISTING,
+ &tags,
+ "# Carrots",
+ "radroots.operational_listing.published.v1",
+ ),
+ Ok(())
+ );
+ }
+
+ #[test]
fn event_contract_lookup_supports_many_contracts_per_kind() {
let contracts = event_contracts_for_kind(KIND_LIST_SET_GENERIC).collect::<Vec<_>>();
assert_eq!(contracts.len(), 6);
@@ -5048,7 +5229,7 @@ mod tests {
Some(RadrootsContractFamily::List),
),
(
- "radroots.listing.test.v1",
+ "radroots.operational_listing.test.v1",
Some(RadrootsContractFamily::Market),
),
(
@@ -5082,7 +5263,7 @@ mod tests {
(KIND_LIST_SET_GENERIC, RadrootsContractFamily::List),
(KIND_CALENDAR_EVENT_RSVP, RadrootsContractFamily::Calendar),
(KIND_FARM_CRDT_CHANGE, RadrootsContractFamily::Farm),
- (KIND_LISTING, RadrootsContractFamily::Market),
+ (KIND_CLASSIFIED_LISTING, RadrootsContractFamily::Market),
(KIND_TRADE_CANCELLATION, RadrootsContractFamily::Trade),
(KIND_KNOWLEDGE_CLAIM, RadrootsContractFamily::Knowledge),
(KIND_JOB_FEEDBACK, RadrootsContractFamily::Job),
diff --git a/crates/event/src/dto.rs b/crates/event/src/dto.rs
@@ -82,7 +82,7 @@ mod tests {
assert!(export_names.contains("RadrootsNip01EventWireDto"));
assert!(export_names.contains("RadrootsSignedEventDto"));
assert!(export_names.contains("RadrootsVerifiedSignedEventDto"));
- assert!(export_names.contains("RadrootsListingImageSize"));
+ assert!(export_names.contains("RadrootsOperationalListingImageSize"));
}
#[test]
@@ -90,7 +90,7 @@ mod tests {
let registry = build_registry(dto_roots());
let summary = registry
- .struct_field_presence("RadrootsListingProduct", "summary")
+ .struct_field_presence("RadrootsOperationalListingProduct", "summary")
.expect("summary field exists");
assert!(!summary.required_on_deserialize);
assert!(summary.nullable);
@@ -121,6 +121,7 @@ mod tests {
"RadrootsOrderDiscountOffer",
"RadrootsOrderDiscountDecision",
"RadrootsListingCancel",
+ "RadrootsOperationalListingCancel",
"RadrootsOrderChange",
"RadrootsOrderResponse",
"RadrootsOrderRevision",
@@ -144,7 +145,7 @@ mod tests {
"RadrootsOrderDecision",
"RadrootsOrderDecisionOutcome",
"RadrootsOrderCancellation",
- "RadrootsListingParseError",
+ "RadrootsOperationalListingParseError",
] {
assert!(
rust_names.contains(source_root),
diff --git a/crates/event/src/generated/dto_roots.rs b/crates/event/src/generated/dto_roots.rs
@@ -85,24 +85,40 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip
::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsListEntry>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list_set::RadrootsListSet>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListing>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingAvailability>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingBin>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingDeliveryMethod>(
- ),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingImage>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingProduct>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingPublicLocation>(
- ),
- ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingStatus>(),
::dto_bindgen::export::RootDescriptor::new::<crate::message::RadrootsMessage>(),
::dto_bindgen::export::RootDescriptor::new::<crate::message::RadrootsMessageRecipient>(),
::dto_bindgen::export::RootDescriptor::new::<crate::message_file::RadrootsMessageFile>(),
::dto_bindgen::export::RootDescriptor::new::<
crate::message_file::RadrootsMessageFileDimensions,
>(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListing,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingAvailability,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingBin,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingDeliveryMethod,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingImage,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingParseError,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingProduct,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingPublicLocation,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::operational_listing::RadrootsOperationalListingStatus,
+ >(),
::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsCommercialDomain>(),
- ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsListingParseError>(),
::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderCancellation>(),
::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderDecision>(),
::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderDecisionOutcome>(),
@@ -155,7 +171,7 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip
>(),
::dto_bindgen::export::RootDescriptor::new::<crate::seal::RadrootsSeal>(),
::dto_bindgen::export::RootDescriptor::new::<
- crate::trade_validation::RadrootsTradeValidationListingError,
+ crate::trade_validation::RadrootsOperationalListingValidationError,
>(),
]
}
diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs
@@ -1,7 +1,7 @@
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
-use alloc::{string::String, string::ToString, vec::Vec};
+use alloc::{format, string::String, string::ToString, vec::Vec};
#[cfg(feature = "std")]
use std::{string::String, vec::Vec};
@@ -9,12 +9,15 @@ use std::{string::String, vec::Vec};
use core::{borrow::Borrow, fmt, ops::Deref, str::FromStr};
use url_nostd::Url;
+use crate::kinds::KIND_CLASSIFIED_LISTING;
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsIdParseError {
Empty,
InvalidFormat,
InvalidLength { expected: usize, actual: usize },
InvalidCharacter,
+ UnexpectedKind { expected: u32, actual: u32 },
TooLong { max: usize, actual: usize },
}
@@ -30,6 +33,12 @@ impl fmt::Display for RadrootsIdParseError {
)
}
Self::InvalidCharacter => write!(f, "identifier contains an invalid character"),
+ Self::UnexpectedKind { expected, actual } => {
+ write!(
+ f,
+ "identifier kind {actual} does not match required kind {expected}"
+ )
+ }
Self::TooLong { max, actual } => {
write!(f, "identifier length {actual} exceeds maximum length {max}")
}
@@ -185,7 +194,10 @@ validated_string_id!(
RadrootsAddressableCoordinate,
validate_addressable_coordinate
);
-validated_string_id!(RadrootsListingAddress, validate_addressable_coordinate);
+validated_string_id!(
+ RadrootsClassifiedListingAddress,
+ validate_classified_listing_address
+);
validated_string_id!(RadrootsOrderId, validate_commercial_id);
validated_string_id!(RadrootsOrderQuoteId, validate_commercial_id);
validated_string_id!(RadrootsInventoryBinId, validate_commercial_id);
@@ -284,6 +296,20 @@ fn validate_addressable_coordinate(value: &str) -> Result<String, RadrootsIdPars
Ok(value.to_string())
}
+fn validate_classified_listing_address(value: &str) -> Result<String, RadrootsIdParseError> {
+ let parts = parse_addressable_coordinate_parts(value)?;
+ if parts.kind != KIND_CLASSIFIED_LISTING {
+ return Err(RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: parts.kind,
+ });
+ }
+ Ok(format!(
+ "{}:{}:{}",
+ KIND_CLASSIFIED_LISTING, parts.pubkey, parts.d_tag
+ ))
+}
+
fn parse_addressable_coordinate_parts(
value: &str,
) -> Result<RadrootsAddressableCoordinateParts, RadrootsIdParseError> {
@@ -456,6 +482,10 @@ mod tests {
actual: 7,
},
RadrootsIdParseError::InvalidCharacter,
+ RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30023,
+ },
RadrootsIdParseError::TooLong {
max: 128,
actual: 129,
@@ -465,6 +495,14 @@ mod tests {
for error in errors {
assert!(!error.to_string().is_empty());
}
+ assert_eq!(
+ RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30023,
+ }
+ .to_string(),
+ "identifier kind 30023 does not match required kind 30402"
+ );
}
#[test]
@@ -528,13 +566,33 @@ mod tests {
addr
);
assert_eq!(
- RadrootsListingAddress::parse("30402:not_hex:listing-1").unwrap_err(),
+ RadrootsClassifiedListingAddress::parse("30402:not_hex:listing-1").unwrap_err(),
RadrootsIdParseError::InvalidLength {
expected: 64,
actual: 7
}
);
assert_eq!(
+ RadrootsClassifiedListingAddress::parse(format!("30023:{}:listing-1", hex_64('0')))
+ .unwrap_err(),
+ RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30023,
+ }
+ );
+ let canonical_pubkey = hex_64('a');
+ for noncanonical_kind in ["030402", "+30402"] {
+ assert_eq!(
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{noncanonical_kind}:{}:listing-1",
+ hex_64('A')
+ ))
+ .expect("classified listing coordinate")
+ .as_str(),
+ format!("30402:{canonical_pubkey}:listing-1")
+ );
+ }
+ assert_eq!(
RadrootsAddressableCoordinate::parse("30402").unwrap_err(),
RadrootsIdParseError::InvalidFormat
);
@@ -617,7 +675,7 @@ mod tests {
assert_identifier_impls!(RadrootsEventSignature, hex_128('c').as_str());
assert_identifier_impls!(RadrootsDTag, "listing-1");
assert_identifier_impls!(RadrootsAddressableCoordinate, addressable.as_str());
- assert_identifier_impls!(RadrootsListingAddress, addressable.as_str());
+ assert_identifier_impls!(RadrootsClassifiedListingAddress, addressable.as_str());
assert_identifier_impls!(RadrootsOrderId, "order-1");
assert_identifier_impls!(RadrootsOrderQuoteId, "quote-1");
assert_identifier_impls!(RadrootsInventoryBinId, "bin-1");
@@ -800,8 +858,8 @@ mod tests {
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
- struct MissingListingAddress {
- value: RadrootsListingAddress,
+ struct MissingClassifiedListingAddress {
+ value: RadrootsClassifiedListingAddress,
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
@@ -841,7 +899,10 @@ mod tests {
assert_eq!(missing_field_message::<MissingTradeCandidateId>(), missing);
assert_eq!(missing_field_message::<MissingTradeMutationId>(), missing);
assert_eq!(missing_field_message::<MissingDTag>(), missing);
- assert_eq!(missing_field_message::<MissingListingAddress>(), missing);
+ assert_eq!(
+ missing_field_message::<MissingClassifiedListingAddress>(),
+ missing
+ );
assert_eq!(
missing_field_message::<MissingAddressableCoordinate>(),
missing
@@ -852,10 +913,9 @@ mod tests {
let order: RadrootsOrderId =
serde_json::from_value(serde_json::json!("order-1")).expect("order from value");
- let listing: RadrootsListingAddress = serde_json::from_value(serde_json::json!(format!(
- "30402:{}:listing-1",
- hex_64('a')
- )))
+ let listing: RadrootsClassifiedListingAddress = serde_json::from_value(serde_json::json!(
+ format!("30402:{}:listing-1", hex_64('a'))
+ ))
.expect("listing from value");
let quote: RadrootsOrderQuoteId =
serde_json::from_value(serde_json::json!("quote-1")).expect("quote from value");
diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs
@@ -76,7 +76,7 @@ pub const KIND_ACCOUNT_CLAIM: u32 = 30380;
pub const KIND_VALIDATOR_SET: u32 = 30381;
pub const KIND_APP_DATA: u32 = 30078;
pub const KIND_FARM_WORKSPACE_MANIFEST: u32 = KIND_APP_DATA;
-pub const KIND_LISTING: u32 = 30402;
+pub const KIND_CLASSIFIED_LISTING: u32 = 30402;
pub const KIND_APPLICATION_HANDLER: u32 = 31990;
pub const KIND_GROUP_METADATA: u32 = 39000;
pub const KIND_GROUP_ADMINS: u32 = 39001;
@@ -103,7 +103,7 @@ pub const KIND_TRADE_SELLER_RESERVATION_ASSERTION: u32 = 3475;
pub const KIND_KNOWLEDGE_SOURCE: u32 = 30450;
pub const KIND_EVIDENCE_BOUNTY: u32 = 30451;
-pub const LISTING_EVENT_KINDS: [u32; 1] = [KIND_LISTING];
+pub const CLASSIFIED_LISTING_EVENT_KINDS: [u32; 1] = [KIND_CLASSIFIED_LISTING];
pub const WIKI_EVENT_KINDS: [u32; 3] = [
KIND_WIKI_MERGE_REQUEST,
@@ -176,7 +176,7 @@ pub const TRADE_MUTATION_EVENT_KINDS: [u32; 5] = [
pub const TRADE_VALIDATION_EVENT_KINDS: [u32; 1] = [KIND_TRADE_VALIDATION_RECEIPT];
pub const COMMERCIAL_EVENT_KINDS: [u32; 7] = [
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
KIND_TRADE_PROPOSAL,
KIND_TRADE_DECISION,
KIND_TRADE_REVISION_PROPOSAL,
@@ -196,7 +196,7 @@ pub const HOME_FEED_CANDIDATE_KINDS: [u32; 9] = [
KIND_REPOST,
KIND_GENERIC_REPOST,
KIND_ARTICLE,
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
KIND_CALENDAR_DATE_EVENT,
KIND_CALENDAR_TIME_EVENT,
KIND_FARM,
@@ -210,11 +210,15 @@ pub const EVENTS_CANDIDATE_KINDS: [u32; 4] = [
KIND_CALENDAR_EVENT_RSVP,
];
-pub const MARKET_CANDIDATE_KINDS: [u32; 3] = [KIND_LISTING, KIND_FARM, KIND_PUBLIC_FILE_METADATA];
+pub const MARKET_CANDIDATE_KINDS: [u32; 3] = [
+ KIND_CLASSIFIED_LISTING,
+ KIND_FARM,
+ KIND_PUBLIC_FILE_METADATA,
+];
pub const MAP_CANDIDATE_KINDS: [u32; 7] = [
KIND_FARM,
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
KIND_CALENDAR_DATE_EVENT,
KIND_CALENDAR_TIME_EVENT,
KIND_POST,
@@ -227,7 +231,7 @@ pub const PROFILE_PUBLIC_CONTENT_KINDS: [u32; 8] = [
KIND_REPOST,
KIND_GENERIC_REPOST,
KIND_ARTICLE,
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
KIND_CALENDAR_DATE_EVENT,
KIND_CALENDAR_TIME_EVENT,
KIND_PUBLIC_FILE_METADATA,
@@ -313,13 +317,8 @@ pub const PRODUCTION_SOCIAL_KINDS: [u32; 4] = [
];
#[inline]
-pub const fn is_listing_kind(kind: u32) -> bool {
- kind == KIND_LISTING
-}
-
-#[inline]
-pub const fn is_listing_event_kind(kind: u32) -> bool {
- is_listing_kind(kind)
+pub const fn is_classified_listing_kind(kind: u32) -> bool {
+ kind == KIND_CLASSIFIED_LISTING
}
#[inline]
@@ -440,7 +439,7 @@ pub const fn is_home_feed_candidate_kind(kind: u32) -> bool {
| KIND_REPOST
| KIND_GENERIC_REPOST
| KIND_ARTICLE
- | KIND_LISTING
+ | KIND_CLASSIFIED_LISTING
| KIND_CALENDAR_DATE_EVENT
| KIND_CALENDAR_TIME_EVENT
| KIND_FARM
@@ -461,7 +460,10 @@ pub const fn is_events_candidate_kind(kind: u32) -> bool {
#[inline]
pub const fn is_market_candidate_kind(kind: u32) -> bool {
- matches!(kind, KIND_LISTING | KIND_FARM | KIND_PUBLIC_FILE_METADATA)
+ matches!(
+ kind,
+ KIND_CLASSIFIED_LISTING | KIND_FARM | KIND_PUBLIC_FILE_METADATA
+ )
}
#[inline]
@@ -469,7 +471,7 @@ pub const fn is_map_candidate_kind(kind: u32) -> bool {
matches!(
kind,
KIND_FARM
- | KIND_LISTING
+ | KIND_CLASSIFIED_LISTING
| KIND_CALENDAR_DATE_EVENT
| KIND_CALENDAR_TIME_EVENT
| KIND_POST
@@ -486,7 +488,7 @@ pub const fn is_profile_public_content_kind(kind: u32) -> bool {
| KIND_REPOST
| KIND_GENERIC_REPOST
| KIND_ARTICLE
- | KIND_LISTING
+ | KIND_CLASSIFIED_LISTING
| KIND_CALENDAR_DATE_EVENT
| KIND_CALENDAR_TIME_EVENT
| KIND_PUBLIC_FILE_METADATA
@@ -575,7 +577,7 @@ pub const fn is_trade_validation_event_kind(kind: u32) -> bool {
#[inline]
pub const fn is_commercial_event_kind(kind: u32) -> bool {
- is_listing_event_kind(kind)
+ is_classified_listing_kind(kind)
|| is_trade_mutation_event_kind(kind)
|| is_trade_validation_event_kind(kind)
}
@@ -748,7 +750,7 @@ mod tests {
assert!(is_public_social_kind(KIND_CALENDAR));
assert!(is_public_social_kind(KIND_CALENDAR_EVENT_RSVP));
assert!(!is_public_social_kind(KIND_REPORT));
- assert!(!is_public_social_kind(KIND_LISTING));
+ assert!(!is_public_social_kind(KIND_CLASSIFIED_LISTING));
assert!(!is_public_social_kind(KIND_LIST_READ_WRITE_RELAYS));
assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE));
assert!(!is_public_social_kind(KIND_FARM_WORKSPACE_MANIFEST));
@@ -826,7 +828,7 @@ mod tests {
assert!(is_home_feed_candidate_kind(KIND_REPOST));
assert!(is_home_feed_candidate_kind(KIND_GENERIC_REPOST));
assert!(is_home_feed_candidate_kind(KIND_ARTICLE));
- assert!(is_home_feed_candidate_kind(KIND_LISTING));
+ assert!(is_home_feed_candidate_kind(KIND_CLASSIFIED_LISTING));
assert!(is_home_feed_candidate_kind(KIND_CALENDAR_DATE_EVENT));
assert!(is_home_feed_candidate_kind(KIND_CALENDAR_TIME_EVENT));
assert!(is_home_feed_candidate_kind(KIND_FARM));
@@ -843,13 +845,13 @@ mod tests {
assert!(!is_events_candidate_kind(KIND_POST));
assert!(!is_events_candidate_kind(KIND_FARM_CRDT_CHANGE));
- assert!(is_market_candidate_kind(KIND_LISTING));
+ assert!(is_market_candidate_kind(KIND_CLASSIFIED_LISTING));
assert!(is_market_candidate_kind(KIND_FARM));
assert!(is_market_candidate_kind(KIND_PUBLIC_FILE_METADATA));
assert!(!is_market_candidate_kind(KIND_REPORT));
assert!(is_map_candidate_kind(KIND_FARM));
- assert!(is_map_candidate_kind(KIND_LISTING));
+ assert!(is_map_candidate_kind(KIND_CLASSIFIED_LISTING));
assert!(is_map_candidate_kind(KIND_CALENDAR_DATE_EVENT));
assert!(is_map_candidate_kind(KIND_CALENDAR_TIME_EVENT));
assert!(is_map_candidate_kind(KIND_POST));
@@ -861,7 +863,7 @@ mod tests {
assert!(is_profile_public_content_kind(KIND_REPOST));
assert!(is_profile_public_content_kind(KIND_GENERIC_REPOST));
assert!(is_profile_public_content_kind(KIND_ARTICLE));
- assert!(is_profile_public_content_kind(KIND_LISTING));
+ assert!(is_profile_public_content_kind(KIND_CLASSIFIED_LISTING));
assert!(is_profile_public_content_kind(KIND_CALENDAR_DATE_EVENT));
assert!(is_profile_public_content_kind(KIND_CALENDAR_TIME_EVENT));
assert!(is_profile_public_content_kind(KIND_PUBLIC_FILE_METADATA));
@@ -911,7 +913,7 @@ mod tests {
#[test]
fn classifies_commercial_event_kinds() {
- assert_eq!(LISTING_EVENT_KINDS, [KIND_LISTING]);
+ assert_eq!(CLASSIFIED_LISTING_EVENT_KINDS, [KIND_CLASSIFIED_LISTING]);
assert_eq!(
ORDER_EVENT_KINDS,
[
@@ -937,7 +939,7 @@ mod tests {
assert_eq!(
COMMERCIAL_EVENT_KINDS,
[
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
KIND_TRADE_PROPOSAL,
KIND_TRADE_DECISION,
KIND_TRADE_REVISION_PROPOSAL,
@@ -947,8 +949,8 @@ mod tests {
]
);
- assert!(is_listing_event_kind(KIND_LISTING));
- assert!(!is_listing_event_kind(KIND_PROFILE));
+ assert!(is_classified_listing_kind(KIND_CLASSIFIED_LISTING));
+ assert!(!is_classified_listing_kind(KIND_PROFILE));
assert!(is_order_event_kind(KIND_ORDER_REQUEST));
assert!(is_order_event_kind(KIND_ORDER_DECISION));
@@ -989,7 +991,7 @@ mod tests {
assert!(!is_trade_validation_event_kind(6322));
assert!(!is_trade_validation_event_kind(3434));
- assert!(is_commercial_event_kind(KIND_LISTING));
+ assert!(is_commercial_event_kind(KIND_CLASSIFIED_LISTING));
assert!(is_commercial_event_kind(KIND_TRADE_PROPOSAL));
assert!(is_commercial_event_kind(KIND_TRADE_VALIDATION_RECEIPT));
assert!(!is_commercial_event_kind(KIND_ORDER_REQUEST));
diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs
@@ -43,11 +43,11 @@ pub mod kinds;
pub mod knowledge;
pub mod list;
pub mod list_set;
-pub mod listing;
pub mod location;
pub mod media;
pub mod message;
pub mod message_file;
+pub mod operational_listing;
pub mod order;
pub mod order_economics;
pub mod plot;
diff --git a/crates/event/src/listing.rs b/crates/event/src/listing.rs
@@ -1,269 +0,0 @@
-use radroots_core::{
- RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
-};
-
-use crate::farm::RadrootsFarmRef;
-use crate::ids::{RadrootsDTag, RadrootsInventoryBinId};
-use crate::plot::RadrootsPlotRef;
-use crate::resource_area::RadrootsResourceAreaRef;
-
-#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[cfg_attr(
- feature = "serde",
- serde(rename_all = "snake_case", tag = "kind", content = "amount")
-)]
-#[derive(Clone, Debug)]
-pub enum RadrootsListingAvailability {
- Window {
- #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
- start: Option<u64>,
- #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
- end: Option<u64>,
- },
- Status {
- status: RadrootsListingStatus,
- },
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[cfg_attr(
- feature = "serde",
- serde(rename_all = "snake_case", tag = "kind", content = "amount")
-)]
-#[derive(Clone, Debug)]
-pub enum RadrootsListingStatus {
- Active,
- Sold,
- Other { value: String },
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[cfg_attr(
- feature = "serde",
- serde(rename_all = "snake_case", tag = "kind", content = "amount")
-)]
-#[derive(Clone, Debug)]
-pub enum RadrootsListingDeliveryMethod {
- Pickup,
- LocalDelivery,
- Shipping,
- Other { method: String },
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListing {
- pub d_tag: RadrootsDTag,
- #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub published_at: Option<u64>,
- #[cfg_attr(any(feature = "serde", test), serde(default))]
- pub farm: RadrootsFarmRef,
- pub product: RadrootsListingProduct,
- pub primary_bin_id: RadrootsInventoryBinId,
- pub bins: Vec<RadrootsListingBin>,
- pub resource_area: Option<RadrootsResourceAreaRef>,
- pub plot: Option<RadrootsPlotRef>,
- pub discounts: Option<Vec<RadrootsCoreDiscount>>,
- pub inventory_available: Option<RadrootsCoreDecimal>,
- pub availability: Option<RadrootsListingAvailability>,
- pub delivery_method: Option<RadrootsListingDeliveryMethod>,
- pub location: Option<RadrootsListingPublicLocation>,
- pub images: Option<Vec<RadrootsListingImage>>,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingProduct {
- pub key: String,
- pub title: String,
- pub category: String,
- pub summary: Option<String>,
- pub process: Option<String>,
- pub lot: Option<String>,
- pub location: Option<String>,
- pub profile: Option<String>,
- pub year: Option<String>,
-}
-
-pub const RADROOTS_LISTING_PRODUCT_TAG_KEYS: [&str; 9] = [
- "key", "title", "category", "summary", "process", "lot", "location", "profile", "year",
-];
-
-pub struct RadrootsListingProductTagKeys;
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingBin {
- pub bin_id: RadrootsInventoryBinId,
- pub quantity: RadrootsCoreQuantity,
- pub price_per_canonical_unit: RadrootsCoreQuantityPrice,
- pub display_amount: Option<RadrootsCoreDecimal>,
- pub display_unit: Option<RadrootsCoreUnit>,
- pub display_label: Option<String>,
- pub display_price: Option<RadrootsCoreMoney>,
- pub display_price_unit: Option<RadrootsCoreUnit>,
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingPublicLocation {
- pub primary: String,
- pub city: Option<String>,
- pub region: Option<String>,
- pub country: Option<String>,
- pub geohash: String,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingImage {
- pub url: String,
- pub size: Option<RadrootsListingImageSize>,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingImageSize {
- pub w: u32,
- pub h: u32,
-}
-
-#[cfg(all(test, feature = "std"))]
-mod tests {
- use crate::farm::RadrootsFarmRef;
-
- #[test]
- fn defaults_listing_farm_ref_to_empty_values() {
- let farm_ref = RadrootsFarmRef::default();
- assert!(farm_ref.pubkey.is_empty());
- assert!(farm_ref.d_tag.is_empty());
- }
-
- #[test]
- fn listing_model_covers_published_metadata() {
- use crate::kinds::{KIND_LISTING, is_listing_kind};
-
- let listing = super::RadrootsListing {
- d_tag: "listing-draft".parse().unwrap(),
- published_at: Some(1_700_000_000),
- farm: RadrootsFarmRef::default(),
- product: super::RadrootsListingProduct {
- key: "lettuce".to_string(),
- title: "lettuce".to_string(),
- category: "produce".to_string(),
- summary: None,
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: "bin-1".parse().unwrap(),
- bins: vec![],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
- images: None,
- };
-
- assert_eq!(listing.published_at, Some(1_700_000_000));
- assert!(is_listing_kind(KIND_LISTING));
- }
-
- #[test]
- #[cfg(feature = "serde")]
- fn listing_deserializes_missing_farm_to_default_ref() {
- let listing = super::RadrootsListing {
- d_tag: "listing-draft".parse().unwrap(),
- published_at: Some(1_700_000_000),
- farm: RadrootsFarmRef {
- pubkey: "farm-pubkey".to_string(),
- d_tag: "farm-d-tag".to_string(),
- },
- product: super::RadrootsListingProduct {
- key: "lettuce".to_string(),
- title: "lettuce".to_string(),
- category: "produce".to_string(),
- summary: None,
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: "bin-1".parse().unwrap(),
- bins: vec![],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
- images: None,
- };
- let mut json = serde_json::to_value(&listing).unwrap();
- json.as_object_mut().unwrap().remove("farm");
-
- let parsed: super::RadrootsListing = serde_json::from_value(json).unwrap();
-
- assert!(parsed.farm.pubkey.is_empty());
- assert!(parsed.farm.d_tag.is_empty());
- }
-}
diff --git a/crates/event/src/operational_listing.rs b/crates/event/src/operational_listing.rs
@@ -0,0 +1,305 @@
+use radroots_core::{
+ RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+
+use crate::farm::RadrootsFarmRef;
+use crate::ids::{RadrootsDTag, RadrootsInventoryBinId};
+use crate::plot::RadrootsPlotRef;
+use crate::resource_area::RadrootsResourceAreaRef;
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsOperationalListingParseError {
+ InvalidKind(u32),
+ MissingTag(String),
+ InvalidTag(String),
+ InvalidNumber(String),
+ InvalidUnit,
+ InvalidCurrency,
+ InvalidJson(String),
+ InvalidDiscount(String),
+}
+
+impl core::fmt::Display for RadrootsOperationalListingParseError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::InvalidKind(kind) => write!(f, "invalid operational listing kind: {kind}"),
+ Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"),
+ Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"),
+ Self::InvalidNumber(field) => write!(f, "invalid number: {field}"),
+ Self::InvalidUnit => write!(f, "invalid unit"),
+ Self::InvalidCurrency => write!(f, "invalid currency"),
+ Self::InvalidJson(field) => write!(f, "invalid json: {field}"),
+ Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsOperationalListingParseError {}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(
+ feature = "serde",
+ serde(rename_all = "snake_case", tag = "kind", content = "amount")
+)]
+#[derive(Clone, Debug)]
+pub enum RadrootsOperationalListingAvailability {
+ Window {
+ #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
+ start: Option<u64>,
+ #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
+ end: Option<u64>,
+ },
+ Status {
+ status: RadrootsOperationalListingStatus,
+ },
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(
+ feature = "serde",
+ serde(rename_all = "snake_case", tag = "kind", content = "amount")
+)]
+#[derive(Clone, Debug)]
+pub enum RadrootsOperationalListingStatus {
+ Active,
+ Sold,
+ Other { value: String },
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(
+ feature = "serde",
+ serde(rename_all = "snake_case", tag = "kind", content = "amount")
+)]
+#[derive(Clone, Debug)]
+pub enum RadrootsOperationalListingDeliveryMethod {
+ Pickup,
+ LocalDelivery,
+ Shipping,
+ Other { method: String },
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListing {
+ pub d_tag: RadrootsDTag,
+ #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))]
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub published_at: Option<u64>,
+ #[cfg_attr(any(feature = "serde", test), serde(default))]
+ pub farm: RadrootsFarmRef,
+ pub product: RadrootsOperationalListingProduct,
+ pub primary_bin_id: RadrootsInventoryBinId,
+ pub bins: Vec<RadrootsOperationalListingBin>,
+ pub resource_area: Option<RadrootsResourceAreaRef>,
+ pub plot: Option<RadrootsPlotRef>,
+ pub discounts: Option<Vec<RadrootsCoreDiscount>>,
+ pub inventory_available: Option<RadrootsCoreDecimal>,
+ pub availability: Option<RadrootsOperationalListingAvailability>,
+ pub delivery_method: Option<RadrootsOperationalListingDeliveryMethod>,
+ pub location: Option<RadrootsOperationalListingPublicLocation>,
+ pub images: Option<Vec<RadrootsOperationalListingImage>>,
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingProduct {
+ pub key: String,
+ pub title: String,
+ pub category: String,
+ pub summary: Option<String>,
+ pub process: Option<String>,
+ pub lot: Option<String>,
+ pub location: Option<String>,
+ pub profile: Option<String>,
+ pub year: Option<String>,
+}
+
+pub const RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS: [&str; 9] = [
+ "key", "title", "category", "summary", "process", "lot", "location", "profile", "year",
+];
+
+pub struct RadrootsOperationalListingProductTagKeys;
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingBin {
+ pub bin_id: RadrootsInventoryBinId,
+ pub quantity: RadrootsCoreQuantity,
+ pub price_per_canonical_unit: RadrootsCoreQuantityPrice,
+ pub display_amount: Option<RadrootsCoreDecimal>,
+ pub display_unit: Option<RadrootsCoreUnit>,
+ pub display_label: Option<String>,
+ pub display_price: Option<RadrootsCoreMoney>,
+ pub display_price_unit: Option<RadrootsCoreUnit>,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingPublicLocation {
+ pub primary: String,
+ pub city: Option<String>,
+ pub region: Option<String>,
+ pub country: Option<String>,
+ pub geohash: String,
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingImage {
+ pub url: String,
+ pub size: Option<RadrootsOperationalListingImageSize>,
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingImageSize {
+ pub w: u32,
+ pub h: u32,
+}
+
+#[cfg(all(test, feature = "std"))]
+mod tests {
+ use crate::farm::RadrootsFarmRef;
+
+ #[test]
+ fn defaults_listing_farm_ref_to_empty_values() {
+ let farm_ref = RadrootsFarmRef::default();
+ assert!(farm_ref.pubkey.is_empty());
+ assert!(farm_ref.d_tag.is_empty());
+ }
+
+ #[test]
+ fn listing_model_covers_published_metadata() {
+ use crate::kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind};
+
+ let listing = super::RadrootsOperationalListing {
+ d_tag: "listing-draft".parse().unwrap(),
+ published_at: Some(1_700_000_000),
+ farm: RadrootsFarmRef::default(),
+ product: super::RadrootsOperationalListingProduct {
+ key: "lettuce".to_string(),
+ title: "lettuce".to_string(),
+ category: "produce".to_string(),
+ summary: None,
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().unwrap(),
+ bins: vec![],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ };
+
+ assert_eq!(listing.published_at, Some(1_700_000_000));
+ assert!(is_classified_listing_kind(KIND_CLASSIFIED_LISTING));
+ }
+
+ #[test]
+ #[cfg(feature = "serde")]
+ fn listing_deserializes_missing_farm_to_default_ref() {
+ let listing = super::RadrootsOperationalListing {
+ d_tag: "listing-draft".parse().unwrap(),
+ published_at: Some(1_700_000_000),
+ farm: RadrootsFarmRef {
+ pubkey: "farm-pubkey".to_string(),
+ d_tag: "farm-d-tag".to_string(),
+ },
+ product: super::RadrootsOperationalListingProduct {
+ key: "lettuce".to_string(),
+ title: "lettuce".to_string(),
+ category: "produce".to_string(),
+ summary: None,
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".parse().unwrap(),
+ bins: vec![],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ };
+ let mut json = serde_json::to_value(&listing).unwrap();
+ json.as_object_mut().unwrap().remove("farm");
+
+ let parsed: super::RadrootsOperationalListing = serde_json::from_value(json).unwrap();
+
+ assert!(parsed.farm.pubkey.is_empty());
+ assert!(parsed.farm.d_tag.is_empty());
+ }
+}
diff --git a/crates/event/src/order.rs b/crates/event/src/order.rs
@@ -9,53 +9,19 @@ use alloc::{
#[cfg(test)]
use crate::ids::RadrootsOrderQuoteId;
use crate::ids::{
- RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey,
+ RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey,
};
use crate::kinds::*;
+#[cfg(test)]
+use crate::operational_listing::RadrootsOperationalListingParseError;
pub use crate::order_economics::*;
#[cfg(test)]
-use crate::trade_validation::RadrootsTradeValidationListingError;
+use crate::trade_validation::RadrootsOperationalListingValidationError;
use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney};
pub const RADROOTS_COMMERCIAL_LISTING_DOMAIN: &str = "trade:listing";
pub const RADROOTS_ORDER_ENVELOPE_VERSION: u16 = 1;
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsListingParseError {
- InvalidKind(u32),
- MissingTag(String),
- InvalidTag(String),
- InvalidNumber(String),
- InvalidUnit,
- InvalidCurrency,
- InvalidJson(String),
- InvalidDiscount(String),
-}
-
-impl core::fmt::Display for RadrootsListingParseError {
- fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
- match self {
- Self::InvalidKind(kind) => write!(f, "invalid listing kind: {kind}"),
- Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"),
- Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"),
- Self::InvalidNumber(field) => write!(f, "invalid number: {field}"),
- Self::InvalidUnit => write!(f, "invalid unit"),
- Self::InvalidCurrency => write!(f, "invalid currency"),
- Self::InvalidJson(field) => write!(f, "invalid json: {field}"),
- Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"),
- }
- }
-}
-
-#[cfg(feature = "std")]
-impl std::error::Error for RadrootsListingParseError {}
-
impl RadrootsOrderEconomics {
pub fn canonicalize(&mut self) {
self.items
@@ -175,7 +141,7 @@ impl RadrootsOrderEconomics {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderRequest {
pub order_id: RadrootsOrderId,
- pub listing_addr: RadrootsListingAddress,
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub buyer_pubkey: RadrootsPublicKey,
pub seller_pubkey: RadrootsPublicKey,
pub items: Vec<RadrootsOrderItem>,
@@ -255,7 +221,7 @@ impl RadrootsOrderDecisionOutcome {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderDecision {
pub order_id: RadrootsOrderId,
- pub listing_addr: RadrootsListingAddress,
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub buyer_pubkey: RadrootsPublicKey,
pub seller_pubkey: RadrootsPublicKey,
pub decision: RadrootsOrderDecisionOutcome,
@@ -281,7 +247,7 @@ impl RadrootsOrderDecision {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOrderCancellation {
pub order_id: RadrootsOrderId,
- pub listing_addr: RadrootsListingAddress,
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub buyer_pubkey: RadrootsPublicKey,
pub seller_pubkey: RadrootsPublicKey,
pub reason: String,
@@ -810,7 +776,7 @@ mod tests {
pubkey('a')
}
- fn sample_listing_addr() -> RadrootsListingAddress {
+ fn sample_listing_addr() -> RadrootsClassifiedListingAddress {
format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey())
.parse()
.unwrap()
@@ -1071,23 +1037,29 @@ mod tests {
#[test]
fn listing_parse_error_json_preserves_external_tagged_shape() {
assert_eq!(
- serde_json::to_value(RadrootsListingParseError::InvalidKind(KIND_PROFILE)).unwrap(),
+ serde_json::to_value(RadrootsOperationalListingParseError::InvalidKind(
+ KIND_PROFILE
+ ))
+ .unwrap(),
serde_json::json!({ "InvalidKind": KIND_PROFILE })
);
assert_eq!(
- serde_json::to_value(RadrootsListingParseError::MissingTag("price".into())).unwrap(),
+ serde_json::to_value(RadrootsOperationalListingParseError::MissingTag(
+ "price".into()
+ ))
+ .unwrap(),
serde_json::json!({ "MissingTag": "price" })
);
assert_eq!(
- serde_json::to_value(RadrootsListingParseError::InvalidUnit).unwrap(),
+ serde_json::to_value(RadrootsOperationalListingParseError::InvalidUnit).unwrap(),
serde_json::json!("InvalidUnit")
);
assert_eq!(
- serde_json::from_value::<RadrootsListingParseError>(serde_json::json!({
+ serde_json::from_value::<RadrootsOperationalListingParseError>(serde_json::json!({
"InvalidJson": "bins"
}))
.unwrap(),
- RadrootsListingParseError::InvalidJson("bins".into())
+ RadrootsOperationalListingParseError::InvalidJson("bins".into())
);
}
@@ -1571,35 +1543,35 @@ mod tests {
#[test]
fn listing_parse_error_display_variants() {
assert_eq!(
- RadrootsListingParseError::InvalidKind(KIND_PROFILE).to_string(),
- "invalid listing kind: 0"
+ RadrootsOperationalListingParseError::InvalidKind(KIND_PROFILE).to_string(),
+ "invalid operational listing kind: 0"
);
assert_eq!(
- RadrootsListingParseError::MissingTag("price".into()).to_string(),
+ RadrootsOperationalListingParseError::MissingTag("price".into()).to_string(),
"missing required tag: price"
);
assert_eq!(
- RadrootsListingParseError::InvalidTag("farm".into()).to_string(),
+ RadrootsOperationalListingParseError::InvalidTag("farm".into()).to_string(),
"invalid tag: farm"
);
assert_eq!(
- RadrootsListingParseError::InvalidNumber("inventory".into()).to_string(),
+ RadrootsOperationalListingParseError::InvalidNumber("inventory".into()).to_string(),
"invalid number: inventory"
);
assert_eq!(
- RadrootsListingParseError::InvalidUnit.to_string(),
+ RadrootsOperationalListingParseError::InvalidUnit.to_string(),
"invalid unit"
);
assert_eq!(
- RadrootsListingParseError::InvalidCurrency.to_string(),
+ RadrootsOperationalListingParseError::InvalidCurrency.to_string(),
"invalid currency"
);
assert_eq!(
- RadrootsListingParseError::InvalidJson("bins".into()).to_string(),
+ RadrootsOperationalListingParseError::InvalidJson("bins".into()).to_string(),
"invalid json: bins"
);
assert_eq!(
- RadrootsListingParseError::InvalidDiscount("offer".into()).to_string(),
+ RadrootsOperationalListingParseError::InvalidDiscount("offer".into()).to_string(),
"invalid discount data for offer"
);
}
@@ -1607,96 +1579,97 @@ mod tests {
#[test]
fn listing_validation_error_display_variants() {
assert_eq!(
- (RadrootsTradeValidationListingError::InvalidKind { kind: KIND_PROFILE }).to_string(),
+ (RadrootsOperationalListingValidationError::InvalidKind { kind: KIND_PROFILE })
+ .to_string(),
"invalid listing kind: 0"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingListingId.to_string(),
+ RadrootsOperationalListingValidationError::MissingListingId.to_string(),
"missing listing id"
);
assert_eq!(
- RadrootsTradeValidationListingError::ListingEventNotFound {
+ RadrootsOperationalListingValidationError::ListingEventNotFound {
listing_addr: "listing-1".into(),
}
.to_string(),
"listing event not found: listing-1"
);
assert_eq!(
- RadrootsTradeValidationListingError::ListingEventFetchFailed {
+ RadrootsOperationalListingValidationError::ListingEventFetchFailed {
listing_addr: "listing-2".into(),
}
.to_string(),
"listing event fetch failed: listing-2"
);
assert_eq!(
- RadrootsTradeValidationListingError::ParseError {
- error: RadrootsListingParseError::InvalidJson("payload".into()),
+ RadrootsOperationalListingValidationError::ParseError {
+ error: RadrootsOperationalListingParseError::InvalidJson("payload".into()),
}
.to_string(),
"invalid listing data: invalid json: payload"
);
assert_eq!(
- RadrootsTradeValidationListingError::InvalidSeller.to_string(),
+ RadrootsOperationalListingValidationError::InvalidSeller.to_string(),
"listing author does not match farm pubkey"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingFarmProfile.to_string(),
+ RadrootsOperationalListingValidationError::MissingFarmProfile.to_string(),
"missing farm profile"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingFarmRecord.to_string(),
+ RadrootsOperationalListingValidationError::MissingFarmRecord.to_string(),
"missing farm record"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingTitle.to_string(),
+ RadrootsOperationalListingValidationError::MissingTitle.to_string(),
"missing listing title"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingDescription.to_string(),
+ RadrootsOperationalListingValidationError::MissingDescription.to_string(),
"missing listing description"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingProductType.to_string(),
+ RadrootsOperationalListingValidationError::MissingProductType.to_string(),
"missing listing product type"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingBins.to_string(),
+ RadrootsOperationalListingValidationError::MissingBins.to_string(),
"missing listing bins"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingPrimaryBin.to_string(),
+ RadrootsOperationalListingValidationError::MissingPrimaryBin.to_string(),
"missing primary listing bin"
);
assert_eq!(
- RadrootsTradeValidationListingError::InvalidBin.to_string(),
+ RadrootsOperationalListingValidationError::InvalidBin.to_string(),
"invalid listing bin"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingPrice.to_string(),
+ RadrootsOperationalListingValidationError::MissingPrice.to_string(),
"missing listing price"
);
assert_eq!(
- RadrootsTradeValidationListingError::InvalidPrice.to_string(),
+ RadrootsOperationalListingValidationError::InvalidPrice.to_string(),
"invalid listing price"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingInventory.to_string(),
+ RadrootsOperationalListingValidationError::MissingInventory.to_string(),
"missing listing inventory"
);
assert_eq!(
- RadrootsTradeValidationListingError::InvalidInventory.to_string(),
+ RadrootsOperationalListingValidationError::InvalidInventory.to_string(),
"invalid listing inventory"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingAvailability.to_string(),
+ RadrootsOperationalListingValidationError::MissingAvailability.to_string(),
"missing listing availability"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingLocation.to_string(),
+ RadrootsOperationalListingValidationError::MissingLocation.to_string(),
"missing listing location"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingDeliveryMethod.to_string(),
+ RadrootsOperationalListingValidationError::MissingDeliveryMethod.to_string(),
"missing listing delivery method"
);
}
diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs
@@ -12,7 +12,7 @@ use std::{string::String, vec::Vec};
use core::fmt;
use crate::ids::{
- RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsIdParseError,
+ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsIdParseError,
RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId,
RadrootsTradeMutationId,
};
@@ -272,7 +272,7 @@ impl RadrootsTradeCandidateTermsV1 {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct RadrootsTradeCandidateLineV1 {
pub line_id: RadrootsDTag,
- pub listing_addr: RadrootsAddressableCoordinate,
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub listing_event_id: RadrootsEventId,
pub listing_snapshot_sha256: String,
pub product_id: String,
@@ -1116,7 +1116,7 @@ mod tests {
farm_id: RadrootsDTag::parse("farm-1").unwrap(),
lines: vec![RadrootsTradeCandidateLineV1 {
line_id: RadrootsDTag::parse("line-1").unwrap(),
- listing_addr: RadrootsAddressableCoordinate::parse(format!(
+ listing_addr: RadrootsClassifiedListingAddress::parse(format!(
"30402:{}:listing-1",
hex_64('b')
))
@@ -1556,6 +1556,15 @@ mod tests {
}
#[test]
+ fn trade_candidate_deserialization_rejects_non_classified_listing_coordinates() {
+ let mut value = serde_json::to_value(candidate()).expect("candidate json");
+ value["lines"][0]["listing_addr"] =
+ serde_json::json!(format!("30023:{}:listing-1", hex_64('b')));
+
+ assert!(serde_json::from_value::<RadrootsTradeCandidateTermsV1>(value).is_err());
+ }
+
+ #[test]
#[cfg_attr(coverage_nightly, coverage(off))]
fn trade_decision_and_reservation_contracts_cover_all_paths() {
let assertion = reservation_assertion();
diff --git a/crates/event/src/trade_validation.rs b/crates/event/src/trade_validation.rs
@@ -3,7 +3,7 @@
#[cfg(not(feature = "std"))]
use alloc::string::String;
-use crate::order::RadrootsListingParseError;
+use crate::operational_listing::RadrootsOperationalListingParseError;
#[cfg_attr(
any(feature = "serde", test),
@@ -16,12 +16,20 @@ use crate::order::RadrootsListingParseError;
serde(rename_all = "snake_case", tag = "kind", content = "amount")
)]
#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsTradeValidationListingError {
- InvalidKind { kind: u32 },
+pub enum RadrootsOperationalListingValidationError {
+ InvalidKind {
+ kind: u32,
+ },
MissingListingId,
- ListingEventNotFound { listing_addr: String },
- ListingEventFetchFailed { listing_addr: String },
- ParseError { error: RadrootsListingParseError },
+ ListingEventNotFound {
+ listing_addr: String,
+ },
+ ListingEventFetchFailed {
+ listing_addr: String,
+ },
+ ParseError {
+ error: RadrootsOperationalListingParseError,
+ },
InvalidSeller,
MissingFarmProfile,
MissingFarmRecord,
@@ -43,7 +51,7 @@ pub enum RadrootsTradeValidationListingError {
MissingDeliveryMethod,
}
-impl core::fmt::Display for RadrootsTradeValidationListingError {
+impl core::fmt::Display for RadrootsOperationalListingValidationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidKind { kind } => write!(f, "invalid listing kind: {kind}"),
@@ -79,7 +87,7 @@ impl core::fmt::Display for RadrootsTradeValidationListingError {
}
#[cfg(feature = "std")]
-impl std::error::Error for RadrootsTradeValidationListingError {}
+impl std::error::Error for RadrootsOperationalListingValidationError {}
#[cfg(test)]
mod tests {
@@ -88,19 +96,19 @@ mod tests {
#[test]
fn listing_validation_error_display_covers_location_variants() {
assert_eq!(
- RadrootsTradeValidationListingError::MissingLocation.to_string(),
+ RadrootsOperationalListingValidationError::MissingLocation.to_string(),
"missing listing location"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingLocationLocality.to_string(),
+ RadrootsOperationalListingValidationError::MissingLocationLocality.to_string(),
"missing listing location locality"
);
assert_eq!(
- RadrootsTradeValidationListingError::MissingLocationGeohash.to_string(),
+ RadrootsOperationalListingValidationError::MissingLocationGeohash.to_string(),
"missing listing location geohash"
);
assert_eq!(
- RadrootsTradeValidationListingError::InvalidLocationGeohash.to_string(),
+ RadrootsOperationalListingValidationError::InvalidLocationGeohash.to_string(),
"invalid listing location geohash"
);
}
diff --git a/crates/event_codec/src/farm/list_sets.rs b/crates/event_codec/src/farm/list_sets.rs
@@ -8,10 +8,10 @@ use alloc::{
vec::Vec,
};
-use radroots_event::kinds::KIND_LISTING;
+use radroots_event::kinds::KIND_CLASSIFIED_LISTING;
use radroots_event::list::RadrootsListEntry;
use radroots_event::list_set::RadrootsListSet;
-use radroots_event::listing::RadrootsListing;
+use radroots_event::operational_listing::RadrootsOperationalListing;
use radroots_event::plot::RadrootsPlot;
use crate::d_tag::validate_d_tag;
@@ -130,7 +130,7 @@ where
})
}
-pub fn farm_listings_list_set<I, S>(
+pub fn farm_operational_listings_list_set<I, S>(
farm_id: &str,
farm_pubkey: &str,
listing_ids: I,
@@ -147,7 +147,7 @@ where
}
validate_d_tag(listing_id, "listing_id")?;
let mut address = String::new();
- address.push_str(&KIND_LISTING.to_string());
+ address.push_str(&KIND_CLASSIFIED_LISTING.to_string());
address.push(':');
address.push_str(farm_pubkey);
address.push(':');
@@ -167,15 +167,15 @@ where
})
}
-pub fn farm_listings_list_set_from_listings<'a, I>(
+pub fn farm_operational_listings_list_set_from_listings<'a, I>(
farm_id: &str,
farm_pubkey: &str,
listings: I,
) -> Result<RadrootsListSet, EventEncodeError>
where
- I: IntoIterator<Item = &'a RadrootsListing>,
+ I: IntoIterator<Item = &'a RadrootsOperationalListing>,
{
- farm_listings_list_set(
+ farm_operational_listings_list_set(
farm_id,
farm_pubkey,
listings.into_iter().map(|listing| listing.d_tag.as_str()),
@@ -267,19 +267,21 @@ mod tests {
farm_plots_list_set(farm_id, farm_pubkey, ["invalid"]).expect_err("invalid plot_id");
assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
- let listings = farm_listings_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
- .expect("listings");
+ let listings =
+ farm_operational_listings_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect("listings");
assert_eq!(listings.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:listings");
assert_eq!(listings.entries[0].tag, "a");
- let err = farm_listings_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
- .expect_err("invalid farm id");
+ let err =
+ farm_operational_listings_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect_err("invalid farm id");
assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
- let err = farm_listings_list_set(farm_id, farm_pubkey, ["invalid"])
+ let err = farm_operational_listings_list_set(farm_id, farm_pubkey, ["invalid"])
.expect_err("invalid listing_id");
assert!(matches!(err, EventEncodeError::InvalidField("listing_id")));
- let err =
- farm_listings_list_set(farm_id, farm_pubkey, [" "]).expect_err("empty listing_id");
+ let err = farm_operational_listings_list_set(farm_id, farm_pubkey, [" "])
+ .expect_err("empty listing_id");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("listing_id")
diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs
@@ -11,7 +11,7 @@ mod tests {
use crate::farm::decode::{farm_from_event, parsed_from_event};
use crate::farm::encode::{farm_build_tags, farm_ref_tags};
use crate::farm::list_sets::{
- farm_listings_list_set_from_listings, farm_members_list_set,
+ farm_members_list_set, farm_operational_listings_list_set_from_listings,
farm_plots_list_set_from_plots, member_of_farms_list_set,
};
use radroots_core::{
@@ -22,7 +22,10 @@ mod tests {
use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId};
#[cfg(feature = "serde_json")]
use radroots_event::kinds::KIND_FARM;
- use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct};
+ use radroots_event::operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingBin,
+ RadrootsOperationalListingProduct,
+ };
use radroots_event::plot::RadrootsPlot;
#[cfg(feature = "serde_json")]
@@ -475,14 +478,14 @@ mod tests {
#[test]
fn farm_listings_list_set_uses_listing_addresses() {
- let listings = vec![RadrootsListing {
+ let listings = vec![RadrootsOperationalListing {
d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
published_at: None,
farm: RadrootsFarmRef {
pubkey: "farm_pubkey".to_string(),
d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "coffee".to_string(),
title: "Coffee".to_string(),
category: "coffee".to_string(),
@@ -494,7 +497,7 @@ mod tests {
year: None,
},
primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: bin_id("bin-1"),
quantity: RadrootsCoreQuantity::new(
RadrootsCoreDecimal::from(1u32),
@@ -526,7 +529,7 @@ mod tests {
images: None,
}];
- let listings_list = farm_listings_list_set_from_listings(
+ let listings_list = farm_operational_listings_list_set_from_listings(
"AAAAAAAAAAAAAAAAAAAAAA",
"farm_pubkey",
&listings,
diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs
@@ -50,7 +50,7 @@ pub mod seal;
pub mod list;
pub mod list_set;
-pub mod listing;
+pub mod operational_listing;
pub mod order;
pub mod trade;
diff --git a/crates/event_codec/src/listing/decode.rs b/crates/event_codec/src/listing/decode.rs
@@ -1,848 +0,0 @@
-#[cfg(not(feature = "std"))]
-use alloc::{
- string::{String, ToString},
- vec::Vec,
-};
-
-use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
- RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
-};
-use radroots_event::{
- RadrootsEventEnvelope,
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId},
- kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_listing_kind},
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
- RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus,
- },
- location::is_public_geohash5,
- order::RadrootsListingParseError,
- plot::RadrootsPlotRef,
- resource_area::RadrootsResourceAreaRef,
- tags::{TAG_D, TAG_PUBLISHED_AT},
-};
-
-use crate::d_tag::validate_d_tag_tag;
-use crate::error::EventParseError;
-use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
-
-const EXPECTED_LISTING_KINDS: &str = "30402";
-const TAG_A: &str = "a";
-const TAG_P: &str = "p";
-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";
-
-#[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>,
- region: Option<String>,
- country: Option<String>,
-}
-
-fn parse_decimal(
- value: &str,
- field: &'static str,
-) -> Result<RadrootsCoreDecimal, ListingDecodeError> {
- value
- .parse::<RadrootsCoreDecimal>()
- .map_err(|_| ListingDecodeError::InvalidNumber(field))
-}
-
-fn reject_private_listing_location_content(content: &str) -> Result<(), ListingDecodeError> {
- 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(ListingDecodeError::InvalidJson("location"));
- }
- }
- }
- Ok(())
-}
-
-fn parse_currency(
- value: &str,
- field: &'static str,
-) -> Result<RadrootsCoreCurrency, ListingDecodeError> {
- let upper = value.trim().to_ascii_uppercase();
- RadrootsCoreCurrency::from_str_upper(&upper)
- .map_err(|_| ListingDecodeError::InvalidCurrency(field))
-}
-
-fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, ListingDecodeError> {
- value
- .parse::<RadrootsCoreUnit>()
- .map_err(|_| ListingDecodeError::InvalidUnit(field))
-}
-
-fn parse_u64_tag_value(
- value: Option<&String>,
- field: &'static str,
-) -> Result<u64, ListingDecodeError> {
- value
- .ok_or(ListingDecodeError::InvalidTag(field))?
- .parse::<u64>()
- .map_err(|_| ListingDecodeError::InvalidNumber(field))
-}
-
-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(ListingDecodeError::MissingTag(TAG_D))?;
- let value = tag
- .get(1)
- .map(|value| value.to_string())
- .ok_or(ListingDecodeError::InvalidTag(TAG_D))?;
- if value.trim().is_empty() {
- return Err(ListingDecodeError::InvalidTag(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, ListingDecodeError> {
- for tag in tags
- .iter()
- .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
- {
- let value = tag
- .get(1)
- .map(|value| value.to_string())
- .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(ListingDecodeError::InvalidTag(TAG_A))?;
- if kind != KIND_FARM {
- continue;
- }
- let pubkey = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_A))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_A))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingDecodeError::InvalidTag(TAG_A));
- }
- validate_d_tag_tag(&d_tag, TAG_A).map_err(|_| ListingDecodeError::InvalidTag(TAG_A))?;
- return Ok(RadrootsFarmRef { pubkey, d_tag });
- }
- Err(ListingDecodeError::MissingTag(TAG_A))
-}
-
-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(ListingDecodeError::MissingTag(TAG_P))?;
- let value = tag
- .get(1)
- .map(|value| value.to_string())
- .ok_or(ListingDecodeError::InvalidTag(TAG_P))?;
- if value.trim().is_empty() {
- return Err(ListingDecodeError::InvalidTag(TAG_P));
- }
- Ok(value)
-}
-
-fn parse_resource_area(
- tags: &[Vec<String>],
-) -> Result<Option<RadrootsResourceAreaRef>, ListingDecodeError> {
- let tag = tags
- .iter()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
- let Some(tag) = tag else {
- return Ok(None);
- };
- let value = tag
- .get(1)
- .map(|value| value.to_string())
- .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(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
- if kind != KIND_RESOURCE_AREA {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
- }
- let pubkey = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingDecodeError::InvalidTag(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>, ListingDecodeError> {
- let tag = tags
- .iter()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
- let Some(tag) = tag else {
- return Ok(None);
- };
- let value = tag
- .get(1)
- .map(|value| value.to_string())
- .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(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
- if kind != KIND_PLOT {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
- }
- let pubkey = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
- .to_string();
- let d_tag = parts
- .next()
- .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
- .to_string();
- if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
- return Err(ListingDecodeError::InvalidTag(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 }))
-}
-
-pub fn listing_from_event(
- kind: u32,
- tags: &[Vec<String>],
- content: &str,
-) -> Result<RadrootsListing, EventParseError> {
- if !is_listing_kind(kind) {
- return Err(EventParseError::InvalidKind {
- expected: EXPECTED_LISTING_KINDS,
- got: kind,
- });
- }
- 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, 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)?;
- let farm_pubkey = parse_farm_pubkey(tags)?;
- let resource_area = parse_resource_area(tags)?;
- let plot = parse_plot_ref(tags)?;
-
- 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;
- }
- match tag[0].as_str() {
- "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
- .get(1)
- .and_then(|value| clean_value(value))
- .ok_or(ListingDecodeError::InvalidTag(TAG_LOCATION))?;
- let mut parsed = ListingLocationDraft {
- primary,
- city: None,
- region: None,
- country: None,
- };
- if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
- parsed.city = Some(city);
- }
- if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) {
- parsed.region = Some(region);
- }
- if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) {
- parsed.country = Some(country);
- }
- location = Some(parsed);
- } 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 => {}
- TAG_RADROOTS_PRIMARY_BIN => {
- let value = tag
- .get(1)
- .and_then(|value| clean_value(value))
- .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
- if let Some(existing) = primary_bin_id.as_ref() {
- if existing != &value {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
- }
- } else {
- primary_bin_id = Some(value);
- }
- }
- TAG_RADROOTS_BIN => {
- if tag.len() < 4 || tag.len() > 7 {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
- }
- let bin_id = tag
- .get(1)
- .and_then(|value| clean_value(value))
- .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(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
- }
- let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
- if bin.quantity.is_some() {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
- }
- 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, TAG_RADROOTS_BIN)?;
- 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(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
- }
- _ => {}
- }
- }
- TAG_RADROOTS_PRICE => {
- if tag.len() < 6 || tag.len() > 8 {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
- }
- let bin_id = tag
- .get(1)
- .and_then(|value| clean_value(value))
- .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)?;
- let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?;
- 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(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(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
- }
- bin.price_per_canonical_unit = Some(price_per_canonical_unit);
-
- match tag.as_slice() {
- [_, _, _, _, _, _, _] => {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
- }
- [_, _, _, _, _, _, 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, TAG_RADROOTS_PRICE)?;
- 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(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(ListingDecodeError::InvalidTag(TAG_GEOHASH))?;
- if !is_public_geohash5(&value) {
- return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH));
- }
- let value = value.to_ascii_lowercase();
- if geohash.as_ref().is_some_and(|existing| existing != &value) {
- return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH));
- }
- geohash = Some(value);
- }
- 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(ListingDecodeError::InvalidTag(TAG_INVENTORY))?;
- inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
- }
- TAG_RADROOTS_AVAILABILITY_START => {
- availability_start = Some(parse_u64_tag_value(
- tag.get(1),
- TAG_RADROOTS_AVAILABILITY_START,
- )?);
- }
- TAG_EXPIRES_AT => {
- availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?);
- }
- TAG_STATUS => {
- let status = tag
- .get(1)
- .and_then(|value| clean_value(value))
- .unwrap_or_default();
- availability_status = Some(parse_status(&status));
- }
- TAG_DELIVERY => {
- let method = tag
- .get(1)
- .and_then(|value| clean_value(value))
- .unwrap_or_default();
- delivery_method = Some(match method.as_str() {
- "pickup" => RadrootsListingDeliveryMethod::Pickup,
- "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
- "shipping" => RadrootsListingDeliveryMethod::Shipping,
- "other" => RadrootsListingDeliveryMethod::Other {
- method: tag
- .get(2)
- .and_then(|value| clean_value(value))
- .unwrap_or_default(),
- },
- other => RadrootsListingDeliveryMethod::Other {
- method: other.to_string(),
- },
- });
- }
- TAG_IMAGE => {
- let url = tag
- .get(1)
- .ok_or(ListingDecodeError::InvalidTag(TAG_IMAGE))?;
- if url.trim().is_empty() {
- continue;
- }
- images.push(RadrootsListingImage {
- url: url.to_string(),
- size: tag.get(2).and_then(|value| parse_image_size(value)),
- });
- }
- _ => {}
- }
- }
-
- 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(|location| {
- let geohash = geohash.ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?;
- Ok::<RadrootsListingPublicLocation, ListingDecodeError>(RadrootsListingPublicLocation {
- primary: location.primary,
- city: location.city,
- region: location.region,
- country: location.country,
- geohash,
- })
- })
- .transpose()?;
-
- if farm_pubkey != farm_ref.pubkey {
- return Err(ListingDecodeError::InvalidTag(TAG_P));
- }
-
- let primary_bin_id =
- 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(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
- }
-
- let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?;
- let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
- .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
-
- 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)
- },
- })
-}
-
-pub fn data_from_event(
- id: String,
- author: String,
- published_at: u64,
- kind: u32,
- content: String,
- tags: Vec<Vec<String>>,
-) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> {
- let listing = listing_from_event(kind, &tags, &content)?;
- Ok(RadrootsParsedData::new(
- id,
- author,
- published_at,
- kind,
- listing,
- ))
-}
-
-pub fn parsed_from_event(
- id: String,
- author: String,
- published_at: u64,
- kind: u32,
- content: String,
- tags: Vec<Vec<String>>,
- sig: String,
-) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> {
- let data = data_from_event(
- id.clone(),
- author.clone(),
- published_at,
- kind,
- content.clone(),
- tags.clone(),
- )?;
- RadrootsParsedEvent::from_parts(
- id,
- author,
- published_at,
- kind,
- content,
- tags,
- sig,
- data.data,
- )
-}
-
-pub fn data_from_nostr_event(
- event: &RadrootsEventEnvelope,
-) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> {
- data_from_event(
- event.id_str().to_string(),
- event.author_str().to_string(),
- event.created_at_u64(),
- event.kind_u32(),
- event.content().to_string(),
- event.tags_as_vec(),
- )
-}
-
-pub fn parsed_from_nostr_event(
- event: &RadrootsEventEnvelope,
-) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> {
- parsed_from_event(
- event.id_str().to_string(),
- event.author_str().to_string(),
- event.created_at_u64(),
- event.kind_u32(),
- event.content().to_string(),
- event.tags_as_vec(),
- event.sig_str().to_string(),
- )
-}
-
-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(value) = value.and_then(|value| clean_value(value))
- {
- *target = value;
- }
-}
-
-fn set_optional(target: &mut Option<String>, value: Option<&String>) {
- if target.is_none()
- && let Some(value) = value.and_then(|value| clean_value(value))
- {
- *target = Some(value);
- }
-}
-
-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, 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)]
-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(position) = bins.iter().position(|bin| bin.bin_id == bin_id) {
- return &mut bins[position];
- }
- bins.push(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,
- });
- *order_index += 1;
- let index = bins.len() - 1;
- &mut bins[index]
-}
-
-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(ListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?;
- let price = draft
- .price_per_canonical_unit
- .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRICE))?;
- if quantity.unit != price.quantity.unit {
- return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE));
- }
- bins.push(RadrootsListingBin {
- bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
- .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?,
- 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,
- });
- }
- Ok(bins)
-}
diff --git a/crates/event_codec/src/listing/encode.rs b/crates/event_codec/src/listing/encode.rs
@@ -1,163 +0,0 @@
-#[cfg(all(not(feature = "std"), test, feature = "serde_json"))]
-use alloc::vec;
-#[cfg(all(not(feature = "std"), feature = "serde_json"))]
-use alloc::{format, string::ToString};
-#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
-
-#[cfg(feature = "serde_json")]
-use radroots_event::kinds::{KIND_LISTING, is_listing_kind};
-use radroots_event::listing::RadrootsListing;
-
-use crate::error::EventEncodeError;
-use crate::listing::tags::listing_tags;
-#[cfg(feature = "serde_json")]
-use crate::listing::tags::listing_tags_full;
-#[cfg(feature = "serde_json")]
-use radroots_event::wire::RadrootsNip01EventWireParts;
-
-#[cfg(feature = "serde_json")]
-const DEFAULT_KIND: u32 = KIND_LISTING;
-
-pub fn listing_build_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> {
- listing_tags(listing)
-}
-
-#[cfg(feature = "serde_json")]
-pub fn to_wire_parts(
- listing: &RadrootsListing,
-) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
- to_wire_parts_with_kind(listing, DEFAULT_KIND)
-}
-
-#[cfg(feature = "serde_json")]
-pub fn to_wire_parts_with_kind(
- listing: &RadrootsListing,
- kind: u32,
-) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
- if !is_listing_kind(kind) {
- return Err(EventEncodeError::InvalidKind(kind));
- }
- let tags = listing_tags_full(listing)?;
- let content = listing_markdown_content(listing);
- Ok(RadrootsNip01EventWireParts {
- kind,
- content,
- tags,
- })
-}
-
-#[cfg(feature = "serde_json")]
-pub fn to_json_wire_parts_with_kind(
- listing: &RadrootsListing,
- kind: u32,
-) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
- if !is_listing_kind(kind) {
- return Err(EventEncodeError::InvalidKind(kind));
- }
- let tags = listing_tags_full(listing)?;
- let content = serde_json::to_string(listing).map_err(|_| EventEncodeError::Json)?;
- Ok(RadrootsNip01EventWireParts {
- kind,
- content,
- tags,
- })
-}
-
-#[cfg(feature = "serde_json")]
-fn listing_markdown_content(listing: &RadrootsListing) -> String {
- let title = listing.product.title.trim();
- let summary = listing
- .product
- .summary
- .as_deref()
- .map(str::trim)
- .filter(|value| !value.is_empty());
-
- match (title.is_empty(), summary) {
- (false, Some(summary)) => format!("# {title}\n\n{summary}"),
- (false, None) => format!("# {title}"),
- (true, Some(summary)) => summary.to_string(),
- (true, None) => String::new(),
- }
-}
-
-#[cfg(all(test, feature = "serde_json"))]
-mod tests {
- use super::*;
- use core::str::FromStr;
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::{
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId},
- listing::{RadrootsListingBin, RadrootsListingProduct},
- };
-
- fn decimal(value: &str) -> RadrootsCoreDecimal {
- RadrootsCoreDecimal::from_str(value).expect("decimal")
- }
-
- fn listing_with(title: &str, summary: Option<&str>) -> RadrootsListing {
- RadrootsListing {
- d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: "a".repeat(64),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
- },
- product: RadrootsListingProduct {
- key: "coffee".to_string(),
- title: title.to_string(),
- category: "produce".to_string(),
- summary: summary.map(ToOwned::to_owned),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
- bins: vec![RadrootsListingBin {
- bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
- quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
- ),
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
- images: None,
- }
- }
-
- #[test]
- fn listing_markdown_content_covers_title_summary_combinations() {
- assert_eq!(
- listing_markdown_content(&listing_with("Coffee", Some("Washed"))),
- "# Coffee\n\nWashed"
- );
- assert_eq!(
- listing_markdown_content(&listing_with("Coffee", None)),
- "# Coffee"
- );
- assert_eq!(
- listing_markdown_content(&listing_with(" ", Some("Washed"))),
- "Washed"
- );
- assert_eq!(listing_markdown_content(&listing_with(" ", None)), "");
- }
-}
diff --git a/crates/event_codec/src/listing/tags.rs b/crates/event_codec/src/listing/tags.rs
@@ -1,1322 +0,0 @@
-#![forbid(unsafe_code)]
-
-#[cfg(not(feature = "std"))]
-use alloc::{
- format,
- string::{String, ToString},
- vec,
- vec::Vec,
-};
-
-#[cfg(any(feature = "serde_json", test))]
-use radroots_core::RadrootsCoreDiscount;
-use radroots_core::RadrootsCoreMoney;
-use radroots_event::farm::RadrootsFarmRef;
-use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
-use radroots_event::listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingPublicLocation,
- RadrootsListingStatus,
-};
-use radroots_event::location::{has_textual_locality, is_public_geohash5};
-use radroots_event::plot::RadrootsPlotRef;
-use radroots_event::resource_area::RadrootsResourceAreaRef;
-use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT};
-
-use crate::d_tag::validate_d_tag;
-use crate::error::EventEncodeError;
-
-const TAG_PRICE: &str = "price";
-const TAG_RADROOTS_BIN: &str = "radroots:bin";
-const TAG_RADROOTS_PRICE: &str = "radroots:price";
-#[cfg(feature = "serde_json")]
-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_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";
-
-#[derive(Clone, Copy, Debug)]
-pub struct ListingTagOptions {
- pub include_geohash: bool,
- pub include_inventory: bool,
- pub include_availability: bool,
- pub include_delivery: bool,
-}
-
-impl Default for ListingTagOptions {
- fn default() -> Self {
- Self {
- include_geohash: true,
- include_inventory: false,
- include_availability: false,
- include_delivery: false,
- }
- }
-}
-
-impl ListingTagOptions {
- pub fn with_trade_fields() -> Self {
- Self {
- include_inventory: true,
- include_availability: true,
- include_delivery: true,
- ..Self::default()
- }
- }
-}
-
-pub fn listing_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> {
- listing_tags_with_options(listing, ListingTagOptions::default())
-}
-
-pub fn listing_tags_full(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> {
- listing_tags_with_options(listing, ListingTagOptions::with_trade_fields())
-}
-
-pub fn listing_tags_with_options(
- listing: &RadrootsListing,
- options: ListingTagOptions,
-) -> Result<Vec<Vec<String>>, EventEncodeError> {
- let d_tag = listing.d_tag.as_str();
- validate_d_tag(d_tag, "d")?;
- if listing.bins.is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("bins"));
- }
-
- let mut tags: Vec<Vec<String>> = Vec::new();
- tags.push(vec![TAG_D.to_string(), d_tag.to_string()]);
- push_farm_tags(&mut tags, &listing.farm)?;
- if let Some(resource_area) = listing.resource_area.as_ref() {
- tags.push(tag_listing_resource_area(resource_area)?);
- }
- if let Some(plot) = listing.plot.as_ref() {
- tags.push(tag_listing_plot(plot)?);
- }
-
- let product = &listing.product;
- push_tag_value(&mut tags, "key", &product.key);
- push_tag_value(&mut tags, "title", &product.title);
- push_tag_value(&mut tags, "category", &product.category);
- if let Some(summary) = product.summary.as_deref() {
- push_tag_value(&mut tags, "summary", summary);
- }
- if let Some(published_at) = listing.published_at {
- tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]);
- }
- if let Some(process) = product.process.as_deref() {
- push_tag_value(&mut tags, "process", process);
- }
- if let Some(lot) = product.lot.as_deref() {
- push_tag_value(&mut tags, "lot", lot);
- }
- if let Some(location) = product.location.as_deref() {
- push_tag_value(&mut tags, "location", location);
- }
- if let Some(profile) = product.profile.as_deref() {
- push_tag_value(&mut tags, "profile", profile);
- }
- if let Some(year) = product.year.as_deref() {
- push_tag_value(&mut tags, "year", year);
- }
-
- tags.push(vec![
- TAG_RADROOTS_PRIMARY_BIN.to_string(),
- listing.primary_bin_id.to_string(),
- ]);
-
- let mut bins: Vec<&RadrootsListingBin> = listing.bins.iter().collect();
- if let Some(pos) = bins
- .iter()
- .position(|bin| bin.bin_id == listing.primary_bin_id)
- {
- let primary = bins.remove(pos);
- bins.insert(0, primary);
- }
-
- for bin in bins {
- let price_tag = tag_listing_price(bin)?;
- let bin_tag = tag_listing_bin(bin)?;
- tags.push(bin_tag);
- tags.push(price_tag);
- if bin.bin_id == listing.primary_bin_id {
- let total = bin_total_price(bin)?;
- tags.push(tag_listing_price_generic(&total));
- }
- }
-
- #[cfg(feature = "serde_json")]
- if let Some(discounts) = &listing.discounts {
- for discount in discounts {
- let payload = discount_tag_payload(discount)?;
- tags.push(vec![TAG_RADROOTS_DISCOUNT.to_string(), payload]);
- }
- }
- #[cfg(not(feature = "serde_json"))]
- if listing.discounts.as_ref().is_some() {
- return Err(EventEncodeError::Json);
- }
-
- if options.include_inventory
- && let Some(inventory) = &listing.inventory_available
- {
- tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]);
- }
-
- if options.include_availability
- && let Some(availability) = &listing.availability
- {
- match availability {
- RadrootsListingAvailability::Status { status } => {
- tags.push(vec![
- TAG_STATUS.to_string(),
- status_as_str(status).to_string(),
- ]);
- }
- RadrootsListingAvailability::Window { start, end } => {
- if let Some(start) = start {
- tags.push(vec![
- TAG_RADROOTS_AVAILABILITY_START.to_string(),
- start.to_string(),
- ]);
- }
- if let Some(end) = end {
- tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]);
- }
- }
- }
- }
-
- if options.include_delivery
- && let Some(method) = &listing.delivery_method
- {
- let mut tag = Vec::with_capacity(3);
- tag.push(TAG_DELIVERY.to_string());
- match method {
- 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 {
- let primary = clean_value(&location.primary)
- .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?;
- if !has_textual_locality(
- &location.primary,
- location.city.as_deref(),
- location.region.as_deref(),
- location.country.as_deref(),
- ) {
- return Err(EventEncodeError::EmptyRequiredField("location.locality"));
- }
- let mut tag = Vec::with_capacity(5);
- tag.push(TAG_LOCATION.to_string());
- tag.push(primary);
- if let Some(city) = location.city.as_deref().and_then(clean_value) {
- tag.push(city);
- }
- if let Some(region) = location.region.as_deref().and_then(clean_value) {
- tag.push(region);
- }
- if let Some(country) = location.country.as_deref().and_then(clean_value) {
- tag.push(country);
- }
- tags.push(tag);
- if options.include_geohash {
- push_location_geotag(&mut tags, location)?;
- }
- }
-
- if let Some(images) = &listing.images {
- for image in images {
- if let Some(tag) = tag_listing_image(image) {
- tags.push(tag);
- }
- }
- }
-
- Ok(tags)
-}
-
-fn push_farm_tags(
- tags: &mut Vec<Vec<String>>,
- farm: &RadrootsFarmRef,
-) -> Result<(), EventEncodeError> {
- if farm.pubkey.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("farm.pubkey"));
- }
- if farm.d_tag.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("farm.d_tag"));
- }
- validate_d_tag(&farm.d_tag, "farm.d_tag")?;
- let mut address = String::new();
- address.push_str(&KIND_FARM.to_string());
- address.push(':');
- address.push_str(&farm.pubkey);
- address.push(':');
- address.push_str(&farm.d_tag);
- tags.push(vec![TAG_P.to_string(), farm.pubkey.clone()]);
- tags.push(vec![TAG_A.to_string(), address]);
- Ok(())
-}
-
-fn tag_listing_resource_area(
- area: &RadrootsResourceAreaRef,
-) -> Result<Vec<String>, EventEncodeError> {
- if area.pubkey.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey"));
- }
- if area.d_tag.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag"));
- }
- validate_d_tag(&area.d_tag, "resource_area.d_tag")?;
- let mut address = String::new();
- address.push_str(&KIND_RESOURCE_AREA.to_string());
- address.push(':');
- address.push_str(&area.pubkey);
- address.push(':');
- address.push_str(&area.d_tag);
- Ok(vec![TAG_RADROOTS_RESOURCE_AREA.to_string(), address])
-}
-
-fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeError> {
- if plot.pubkey.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("plot.pubkey"));
- }
- if plot.d_tag.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("plot.d_tag"));
- }
- validate_d_tag(&plot.d_tag, "plot.d_tag")?;
- let mut address = String::new();
- address.push_str(&KIND_PLOT.to_string());
- address.push(':');
- address.push_str(&plot.pubkey);
- address.push(':');
- address.push_str(&plot.d_tag);
- Ok(vec![TAG_RADROOTS_PLOT.to_string(), address])
-}
-
-fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> {
- let unit = bin.quantity.unit;
- if unit != unit.canonical_unit() {
- return Err(EventEncodeError::EmptyRequiredField("bin.quantity"));
- }
- let mut tag = Vec::with_capacity(7);
- tag.push(TAG_RADROOTS_BIN.to_string());
- tag.push(bin.bin_id.to_string());
- tag.push(bin.quantity.amount.to_string());
- tag.push(unit.code().to_string());
- if let Some(amount) = bin.display_amount.as_ref() {
- let Some(unit) = bin.display_unit else {
- return Err(EventEncodeError::EmptyRequiredField("bin.display_unit"));
- };
- tag.push(amount.to_string());
- tag.push(unit.code().to_string());
- if let Some(label) = bin
- .display_label
- .as_deref()
- .and_then(clean_value)
- .or_else(|| bin.quantity.label.as_deref().and_then(clean_value))
- {
- tag.push(label);
- }
- } else if bin.display_unit.is_some() {
- return Err(EventEncodeError::EmptyRequiredField("bin.display_amount"));
- }
- Ok(tag)
-}
-
-fn tag_listing_price(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> {
- let price = &bin.price_per_canonical_unit;
- if !price.is_price_per_canonical_unit() {
- return Err(EventEncodeError::EmptyRequiredField(
- "bin.price_per_canonical_unit",
- ));
- }
- let mut tag = Vec::with_capacity(8);
- tag.push(TAG_RADROOTS_PRICE.to_string());
- tag.push(bin.bin_id.to_string());
- tag.push(price.amount.amount.to_string());
- tag.push(price.amount.currency.as_str().to_string());
- tag.push(price.quantity.amount.to_string());
- tag.push(price.quantity.unit.code().to_string());
- match (&bin.display_price, bin.display_price_unit) {
- (Some(price_display), Some(unit)) => {
- if price_display.currency != price.amount.currency {
- return Err(EventEncodeError::EmptyRequiredField("bin.display_price"));
- }
- tag.push(price_display.amount.to_string());
- tag.push(unit.code().to_string());
- }
- (None, None) => {}
- (None, Some(_)) => return Err(EventEncodeError::EmptyRequiredField("bin.display_price")),
- (Some(_), None) => {
- return Err(EventEncodeError::EmptyRequiredField(
- "bin.display_price_unit",
- ));
- }
- }
- Ok(tag)
-}
-
-fn bin_total_price(bin: &RadrootsListingBin) -> Result<RadrootsCoreMoney, EventEncodeError> {
- bin.price_per_canonical_unit
- .try_cost_for_quantity_in(&bin.quantity)
- .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit"))
-}
-
-fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> {
- let mut tag = Vec::with_capacity(4);
- tag.push(TAG_PRICE.to_string());
- tag.push(price.amount.to_string());
- tag.push(price.currency.as_str().to_string());
- tag
-}
-
-fn tag_listing_image(image: &RadrootsListingImage) -> Option<Vec<String>> {
- let url = clean_value(&image.url)?;
- let mut tag = Vec::with_capacity(3);
- tag.push(TAG_IMAGE.to_string());
- tag.push(url);
- if let Some(size) = &image.size {
- tag.push(format!("{}x{}", size.w, size.h));
- }
- Some(tag)
-}
-
-fn push_location_geotag(
- tags: &mut Vec<Vec<String>>,
- location: &RadrootsListingPublicLocation,
-) -> Result<(), EventEncodeError> {
- let geohash = clean_value(&location.geohash)
- .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?;
- if !is_public_geohash5(&geohash) {
- return Err(EventEncodeError::InvalidField("location.geohash"));
- }
- tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]);
- Ok(())
-}
-
-fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
- let _ = clean_value(value).map(|cleaned| tags.push(vec![key.to_string(), cleaned]));
-}
-
-fn clean_value(value: &str) -> Option<String> {
- let trimmed = value.trim();
- (!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("null")).then(|| trimmed.to_string())
-}
-
-#[cfg(any(feature = "serde_json", test))]
-fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> {
- #[cfg(feature = "serde_json")]
- {
- serde_json::to_string(discount).map_err(|_| EventEncodeError::Json)
- }
- #[cfg(not(feature = "serde_json"))]
- {
- let _ = discount;
- Err(EventEncodeError::Json)
- }
-}
-
-fn status_as_str(status: &RadrootsListingStatus) -> &str {
- match status {
- RadrootsListingStatus::Active => "active",
- RadrootsListingStatus::Sold => "sold",
- RadrootsListingStatus::Other { value } => value.as_str(),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use core::str::FromStr;
-
- use crate::test_fixtures::{
- CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX,
- };
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountScope,
- RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId};
- use radroots_event::listing::{
- RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingStatus,
- };
-
- const TEST_NPUB: &str = FIXTURE_ALICE_NPUB;
- const TEST_PUBKEY_HEX: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
- const TEST_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
- const TEST_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
-
- fn cdn_url(path: &str) -> String {
- format!("{CDN_PRIMARY_HTTPS}/{path}")
- }
-
- fn decimal(value: &str) -> RadrootsCoreDecimal {
- RadrootsCoreDecimal::from_str(value).expect("valid decimal")
- }
-
- fn d_tag(raw: &str) -> RadrootsDTag {
- raw.parse().unwrap()
- }
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- raw.parse().unwrap()
- }
-
- fn base_product() -> RadrootsListingProduct {
- RadrootsListingProduct {
- key: "coffee".to_string(),
- title: "Coffee".to_string(),
- category: "agri".to_string(),
- summary: Some("summary".to_string()),
- process: Some("washed".to_string()),
- lot: Some("lot-1".to_string()),
- location: Some("null".to_string()),
- profile: Some("null".to_string()),
- year: Some("2024".to_string()),
- }
- }
-
- fn base_bin() -> RadrootsListingBin {
- RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG)
- .with_label("bag"),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("0.01"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
- ),
- display_amount: Some(decimal("1")),
- display_unit: Some(RadrootsCoreUnit::MassKg),
- display_label: Some("kilobag".to_string()),
- display_price: Some(RadrootsCoreMoney::new(
- decimal("10"),
- RadrootsCoreCurrency::USD,
- )),
- display_price_unit: Some(RadrootsCoreUnit::MassKg),
- }
- }
-
- fn base_listing() -> RadrootsListing {
- RadrootsListing {
- d_tag: d_tag(TEST_D_TAG),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: TEST_FARM_D_TAG.to_string(),
- },
- product: base_product(),
- primary_bin_id: bin_id("bin-1"),
- bins: vec![base_bin()],
- resource_area: Some(RadrootsResourceAreaRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
- }),
- plot: Some(RadrootsPlotRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
- }),
- discounts: None,
- inventory_available: Some(decimal("2")),
- availability: Some(RadrootsListingAvailability::Window {
- start: Some(10),
- end: Some(20),
- }),
- delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: Some("San Martin".to_string()),
- country: Some("PE".to_string()),
- geohash: "9q8yy".to_string(),
- }),
- images: Some(vec![
- RadrootsListingImage {
- url: cdn_url("a.jpg"),
- size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
- },
- RadrootsListingImage {
- url: " ".to_string(),
- size: None,
- },
- ]),
- }
- }
-
- fn find_tag<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a Vec<String>> {
- tags.iter()
- .find(|tag| tag.first().map(|v| v.as_str()) == Some(key))
- }
-
- #[test]
- fn options_defaults_and_trade_fields() {
- let defaults = ListingTagOptions::default();
- assert!(defaults.include_geohash);
- assert!(!defaults.include_inventory);
- assert!(!defaults.include_availability);
- assert!(!defaults.include_delivery);
-
- let trade = ListingTagOptions::with_trade_fields();
- assert!(trade.include_geohash);
- assert!(trade.include_inventory);
- assert!(trade.include_availability);
- assert!(trade.include_delivery);
- }
-
- #[test]
- fn clean_value_and_push_tag_value_cover_null_paths() {
- assert_eq!(clean_value(" value "), Some("value".to_string()));
- assert_eq!(clean_value(""), None);
- assert_eq!(clean_value(" null "), None);
-
- let mut tags = Vec::new();
- push_tag_value(&mut tags, "k", "value");
- push_tag_value(&mut tags, "k", "null");
- push_tag_value(&mut tags, "k", "");
- assert_eq!(tags.len(), 1);
- assert_eq!(tags[0], vec!["k".to_string(), "value".to_string()]);
- }
-
- #[test]
- fn location_geotag_accepts_public_geohash5() {
- let mut tags = Vec::new();
- let location = RadrootsListingPublicLocation {
- primary: "Test".to_string(),
- city: Some("Town".to_string()),
- region: None,
- country: None,
- geohash: "9Q8YY".to_string(),
- };
- push_location_geotag(&mut tags, &location).expect("public geohash");
-
- assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]);
- assert!(find_tag(&tags, "l").is_none());
- assert!(find_tag(&tags, "L").is_none());
- }
-
- #[test]
- fn location_geotag_rejects_blank_geohash() {
- let mut tags = Vec::new();
- let location = RadrootsListingPublicLocation {
- primary: "Test".to_string(),
- city: Some("Town".to_string()),
- region: None,
- country: None,
- geohash: " ".to_string(),
- };
-
- assert!(matches!(
- push_location_geotag(&mut tags, &location),
- Err(EventEncodeError::EmptyRequiredField("location.geohash"))
- ));
- assert!(tags.is_empty());
- }
-
- #[test]
- fn location_geotag_rejects_non_public_geohash() {
- let mut tags = Vec::new();
- let location = RadrootsListingPublicLocation {
- primary: "Test".to_string(),
- city: Some("Town".to_string()),
- region: None,
- country: None,
- geohash: "9q8yyz".to_string(),
- };
-
- assert!(matches!(
- push_location_geotag(&mut tags, &location),
- Err(EventEncodeError::InvalidField("location.geohash"))
- ));
- assert!(tags.is_empty());
- }
-
- #[test]
- fn image_and_status_helpers_cover_variants() {
- let with_size = tag_listing_image(&RadrootsListingImage {
- url: format!(" {CDN_PRIMARY_HTTPS}/a.jpg "),
- size: Some(RadrootsListingImageSize { w: 10, h: 20 }),
- })
- .expect("image tag");
- assert_eq!(with_size[0], "image");
- assert_eq!(with_size[2], "10x20");
-
- let without_size = tag_listing_image(&RadrootsListingImage {
- url: cdn_url("b.jpg"),
- size: None,
- })
- .expect("image tag");
- assert_eq!(without_size.len(), 2);
-
- assert!(
- tag_listing_image(&RadrootsListingImage {
- url: "null".to_string(),
- size: None,
- })
- .is_none()
- );
-
- assert_eq!(status_as_str(&RadrootsListingStatus::Active), "active");
- assert_eq!(status_as_str(&RadrootsListingStatus::Sold), "sold");
- assert_eq!(
- status_as_str(&RadrootsListingStatus::Other {
- value: "paused".to_string()
- }),
- "paused"
- );
- }
-
- #[test]
- fn discount_payload_without_serde_json_errors() {
- let discount = RadrootsCoreDiscount {
- scope: RadrootsCoreDiscountScope::Bin,
- threshold: RadrootsCoreDiscountThreshold::BinCount {
- bin_id: "bin-1".to_string(),
- min: 2,
- },
- value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
- decimal("1"),
- RadrootsCoreCurrency::USD,
- )),
- };
- #[cfg(feature = "serde_json")]
- {
- let payload = discount_tag_payload(&discount).expect("serde_json payload");
- assert!(payload.contains("\"scope\":\"bin\""));
- }
- #[cfg(not(feature = "serde_json"))]
- {
- let _err = discount_tag_payload(&discount).expect_err("missing serde_json");
- }
- }
-
- #[test]
- fn farm_and_reference_tag_helpers_cover_errors_and_success() {
- let mut tags = Vec::new();
- push_farm_tags(
- &mut tags,
- &RadrootsFarmRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: TEST_FARM_D_TAG.to_string(),
- },
- )
- .expect("farm tags");
- assert!(find_tag(&tags, "p").is_some());
- assert!(find_tag(&tags, "a").is_some());
-
- let err = push_farm_tags(
- &mut Vec::new(),
- &RadrootsFarmRef {
- pubkey: "".to_string(),
- d_tag: TEST_FARM_D_TAG.to_string(),
- },
- )
- .expect_err("empty farm pubkey");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("farm.pubkey")
- ));
-
- let err = push_farm_tags(
- &mut Vec::new(),
- &RadrootsFarmRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "".to_string(),
- },
- )
- .expect_err("empty farm d_tag");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("farm.d_tag")
- ));
-
- let err = push_farm_tags(
- &mut Vec::new(),
- &RadrootsFarmRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "farm:invalid".to_string(),
- },
- )
- .expect_err("invalid farm d_tag");
- assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
-
- let area = tag_listing_resource_area(&RadrootsResourceAreaRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
- })
- .expect("resource area");
- assert_eq!(area[0], "radroots:resource_area");
-
- let plot = tag_listing_plot(&RadrootsPlotRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
- })
- .expect("plot");
- assert_eq!(plot[0], "radroots:plot");
- }
-
- #[test]
- fn bin_tag_and_price_helpers_cover_error_and_success_paths() {
- let bin = base_bin();
- let bin_tag = tag_listing_bin(&bin).expect("bin tag");
- assert_eq!(bin_tag[0], "radroots:bin");
- let price_tag = tag_listing_price(&bin).expect("price tag");
- assert_eq!(price_tag[0], "radroots:price");
- let total = bin_total_price(&bin).expect("total price");
- let generic_tag = tag_listing_price_generic(&total);
- assert_eq!(generic_tag[0], "price");
-
- let mut non_canonical = base_bin();
- non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
- let err = tag_listing_bin(&non_canonical).expect_err("non canonical quantity");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.quantity")
- ));
-
- let mut missing_display_amount = base_bin();
- missing_display_amount.display_amount = None;
- let err = tag_listing_bin(&missing_display_amount).expect_err("missing display amount");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.display_amount")
- ));
-
- let mut missing_display_unit = base_bin();
- missing_display_unit.display_unit = None;
- let err = tag_listing_bin(&missing_display_unit).expect_err("missing display unit");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.display_unit")
- ));
-
- let mut no_display_bin = base_bin();
- no_display_bin.display_amount = None;
- no_display_bin.display_unit = None;
- no_display_bin.display_label = None;
- let no_display_tag = tag_listing_bin(&no_display_bin).expect("bin tag without display");
- assert_eq!(no_display_tag.len(), 4);
-
- let mut invalid_unit_price = base_bin();
- invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG),
- );
- let err = tag_listing_price(&invalid_unit_price).expect_err("not unit price");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
- ));
-
- let mut mismatch_display_currency = base_bin();
- mismatch_display_currency.display_price = Some(RadrootsCoreMoney::new(
- decimal("10"),
- RadrootsCoreCurrency::EUR,
- ));
- let err = tag_listing_price(&mismatch_display_currency).expect_err("currency mismatch");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.display_price")
- ));
-
- let mut missing_display_price = base_bin();
- missing_display_price.display_price = None;
- let err = tag_listing_price(&missing_display_price).expect_err("missing display price");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.display_price")
- ));
-
- let mut missing_display_price_unit = base_bin();
- missing_display_price_unit.display_price_unit = None;
- let err = tag_listing_price(&missing_display_price_unit).expect_err("missing unit");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.display_price_unit")
- ));
-
- let mut no_display_price = base_bin();
- no_display_price.display_price = None;
- no_display_price.display_price_unit = None;
- let tag = tag_listing_price(&no_display_price).expect("price tag without display fields");
- let full_tag = tag_listing_price(&base_bin()).expect("price tag with display fields");
- assert_eq!(&tag[..6], &full_tag[..6]);
- assert_eq!(tag.len(), 6);
-
- let mut invalid_cost = base_bin();
- invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each),
- );
- let err = bin_total_price(&invalid_cost).expect_err("invalid cost conversion");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
- ));
- }
-
- #[test]
- fn listing_tags_propagate_bin_and_resource_area_validation_errors() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG),
- );
- let err = listing_tags_with_options(&listing, ListingTagOptions::default())
- .expect_err("non unit price should fail listing tags");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
- ));
-
- let mut listing = base_listing();
- listing.discounts = None;
- listing.bins[0].quantity =
- RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
- listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
- );
- let err = listing_tags_with_options(&listing, ListingTagOptions::default())
- .expect_err("non-canonical quantity should fail listing tags");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.quantity")
- ));
-
- let mut listing = base_listing();
- listing.discounts = None;
- listing.bins[0].quantity =
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each);
- listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
- );
- let err = listing_tags_with_options(&listing, ListingTagOptions::default())
- .expect_err("invalid total conversion should fail listing tags");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
- ));
-
- let mut listing = base_listing();
- listing.discounts = None;
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "invalid".to_string(),
- });
- let err = listing_tags_with_options(&listing, ListingTagOptions::default())
- .expect_err("invalid resource area d_tag should fail listing tags");
- assert!(matches!(
- err,
- EventEncodeError::InvalidField("resource_area.d_tag")
- ));
- }
-
- #[test]
- fn listing_tags_required_errors_and_success_paths() {
- let mut listing_with_discount = base_listing();
- listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount {
- scope: RadrootsCoreDiscountScope::Bin,
- threshold: RadrootsCoreDiscountThreshold::BinCount {
- bin_id: "bin-1".to_string(),
- min: 2,
- },
- value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
- decimal("1"),
- RadrootsCoreCurrency::USD,
- )),
- }]);
- #[cfg(feature = "serde_json")]
- {
- let tags =
- listing_tags_with_options(&listing_with_discount, ListingTagOptions::default())
- .expect("discount serialization works");
- assert!(find_tag(&tags, "radroots:discount").is_some());
- }
- #[cfg(not(feature = "serde_json"))]
- {
- let _err =
- listing_tags_with_options(&listing_with_discount, ListingTagOptions::default())
- .expect_err("discount serialization requires serde_json");
- }
-
- let mut listing = base_listing();
- listing.discounts = None;
- let tags = listing_tags_with_options(&listing, ListingTagOptions::with_trade_fields())
- .expect("listing tags");
- assert!(find_tag(&tags, "d").is_some());
- assert!(find_tag(&tags, "p").is_some());
- assert!(find_tag(&tags, "a").is_some());
- assert!(find_tag(&tags, "radroots:primary_bin").is_some());
- assert!(find_tag(&tags, "radroots:bin").is_some());
- assert!(find_tag(&tags, "radroots:price").is_some());
- assert!(find_tag(&tags, "price").is_some());
- assert!(find_tag(&tags, "inventory").is_some());
- assert!(find_tag(&tags, "radroots:availability_start").is_some());
- assert!(find_tag(&tags, "expires_at").is_some());
- assert!(find_tag(&tags, "delivery").is_some());
- assert!(find_tag(&tags, "location").is_some());
- assert!(find_tag(&tags, "image").is_some());
-
- let mut status_listing = base_listing();
- status_listing.discounts = None;
- status_listing.availability = Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Other {
- value: "paused".to_string(),
- },
- });
- let status_tags = listing_tags_full(&status_listing).expect("status tags");
- assert!(status_tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("status")
- && tag.get(1).map(|v| v.as_str()) == Some("paused")
- }));
-
- for method in [
- RadrootsListingDeliveryMethod::Pickup,
- RadrootsListingDeliveryMethod::LocalDelivery,
- RadrootsListingDeliveryMethod::Shipping,
- RadrootsListingDeliveryMethod::Other {
- method: "scheduled".to_string(),
- },
- ] {
- let mut delivery_listing = base_listing();
- delivery_listing.discounts = None;
- delivery_listing.delivery_method = Some(method);
- let method_tags = listing_tags_full(&delivery_listing).expect("delivery tags");
- assert!(find_tag(&method_tags, "delivery").is_some());
- }
-
- let mut no_availability = base_listing();
- no_availability.discounts = None;
- no_availability.availability = None;
- let no_availability_tags = listing_tags_with_options(
- &no_availability,
- ListingTagOptions {
- include_availability: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("availability option without value");
- assert!(find_tag(&no_availability_tags, "status").is_none());
- assert!(find_tag(&no_availability_tags, "radroots:availability_start").is_none());
- assert!(find_tag(&no_availability_tags, "expires_at").is_none());
-
- let mut empty_window_availability = base_listing();
- empty_window_availability.discounts = None;
- empty_window_availability.availability = Some(RadrootsListingAvailability::Window {
- start: None,
- end: None,
- });
- let empty_window_tags = listing_tags_with_options(
- &empty_window_availability,
- ListingTagOptions {
- include_availability: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("availability window without bounds");
- assert!(find_tag(&empty_window_tags, "radroots:availability_start").is_none());
- assert!(find_tag(&empty_window_tags, "expires_at").is_none());
-
- let mut no_delivery = base_listing();
- no_delivery.discounts = None;
- no_delivery.delivery_method = None;
- let no_delivery_tags = listing_tags_with_options(
- &no_delivery,
- ListingTagOptions {
- include_delivery: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("delivery option without value");
- assert!(find_tag(&no_delivery_tags, "delivery").is_none());
-
- let mut no_inventory = base_listing();
- no_inventory.discounts = None;
- no_inventory.inventory_available = None;
- let no_inventory_tags = listing_tags_with_options(
- &no_inventory,
- ListingTagOptions {
- include_inventory: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("inventory option without value");
- assert!(find_tag(&no_inventory_tags, "inventory").is_none());
-
- let mut no_geo = base_listing();
- no_geo.discounts = None;
- let no_geo_tags = listing_tags_with_options(
- &no_geo,
- ListingTagOptions {
- include_geohash: false,
- ..ListingTagOptions::default()
- },
- )
- .expect("location without geotags");
- assert!(find_tag(&no_geo_tags, "location").is_some());
- assert!(find_tag(&no_geo_tags, "g").is_none());
- assert!(find_tag(&no_geo_tags, "l").is_none());
-
- let geohash_only_tags = listing_tags_with_options(
- &no_geo,
- ListingTagOptions {
- include_geohash: true,
- ..ListingTagOptions::default()
- },
- )
- .expect("location with geohash only");
- assert!(find_tag(&geohash_only_tags, "g").is_some());
- assert!(find_tag(&geohash_only_tags, "l").is_none());
- }
-
- #[test]
- fn listing_tags_required_field_errors() {
- let mut listing = base_listing();
-
- listing.d_tag = d_tag("listing:invalid");
- let err = listing_tags(&listing).expect_err("invalid d");
- assert!(matches!(err, EventEncodeError::InvalidField("d")));
-
- listing = base_listing();
- listing.bins.clear();
- let err = listing_tags(&listing).expect_err("missing bins");
- assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
-
- listing = base_listing();
- listing.farm.pubkey = "".to_string();
- let err = listing_tags(&listing).expect_err("missing farm pubkey");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("farm.pubkey")
- ));
-
- listing = base_listing();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
- });
- let err = listing_tags(&listing).expect_err("missing resource_area pubkey");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("resource_area.pubkey")
- ));
-
- listing = base_listing();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "".to_string(),
- });
- let err = listing_tags(&listing).expect_err("missing resource_area d_tag");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("resource_area.d_tag")
- ));
-
- listing = base_listing();
- listing.plot = Some(RadrootsPlotRef {
- pubkey: "".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
- });
- let err = listing_tags(&listing).expect_err("missing plot pubkey");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("plot.pubkey")
- ));
-
- listing = base_listing();
- listing.plot = Some(RadrootsPlotRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "".to_string(),
- });
- let err = listing_tags(&listing).expect_err("missing plot d_tag");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("plot.d_tag")
- ));
-
- listing = base_listing();
- listing.plot = Some(RadrootsPlotRef {
- pubkey: TEST_PUBKEY_HEX.to_string(),
- d_tag: "plot:invalid".to_string(),
- });
- let err = listing_tags(&listing).expect_err("invalid plot d_tag");
- assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
- }
-
- #[test]
- fn listing_tags_omit_optional_refs_and_product_fields_when_absent() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.resource_area = None;
- listing.plot = None;
- listing.product.summary = None;
- listing.product.process = None;
- listing.product.lot = None;
- listing.product.location = None;
- listing.product.profile = None;
- listing.product.year = None;
- listing.location = None;
- listing.images = None;
-
- let tags = listing_tags(&listing).expect("listing tags without optional fields");
- assert!(find_tag(&tags, "d").is_some());
- assert!(find_tag(&tags, "p").is_some());
- assert!(find_tag(&tags, "a").is_some());
- assert!(find_tag(&tags, "radroots:resource_area").is_none());
- assert!(find_tag(&tags, "radroots:plot").is_none());
- assert!(find_tag(&tags, "summary").is_none());
- assert!(find_tag(&tags, "process").is_none());
- assert!(find_tag(&tags, "lot").is_none());
- assert!(find_tag(&tags, "location").is_none());
- assert!(find_tag(&tags, "profile").is_none());
- assert!(find_tag(&tags, "year").is_none());
- assert!(find_tag(&tags, "image").is_none());
- }
-
- #[test]
- fn listing_tags_location_and_product_cleaning_paths() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.product.location = Some(" null ".to_string());
- listing.product.profile = Some(" ".to_string());
- listing.location = None;
- listing.images = Some(vec![RadrootsListingImage {
- url: "null".to_string(),
- size: None,
- }]);
- let tags = listing_tags_full(&listing).expect("cleaning path tags");
- assert!(find_tag(&tags, "location").is_none());
- assert!(find_tag(&tags, "profile").is_none());
- assert!(find_tag(&tags, "image").is_none());
- }
-
- #[test]
- fn listing_tags_preserve_location_fields_when_present() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.images = None;
- listing.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: Some("San Martin".to_string()),
- country: Some("PE".to_string()),
- geohash: "6gkzw".to_string(),
- });
- let tags = listing_tags_full(&listing).expect("location tags");
- let location = find_tag(&tags, "location").expect("location tag");
- assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
- assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba"));
- assert_eq!(location.get(3).map(|v| v.as_str()), Some("San Martin"));
- assert_eq!(location.get(4).map(|v| v.as_str()), Some("PE"));
- assert!(find_tag(&tags, "image").is_none());
- }
-
- #[test]
- fn listing_tags_reorders_primary_bin_and_falls_back_to_quantity_label() {
- let mut listing = base_listing();
- listing.discounts = None;
-
- let mut primary = base_bin();
- primary.bin_id = bin_id("bin-1");
- primary.display_label = None;
- primary.quantity = primary.quantity.clone().with_label("fallback-label");
-
- let mut secondary = base_bin();
- secondary.bin_id = bin_id("bin-2");
- listing.primary_bin_id = bin_id("bin-1");
- listing.bins = vec![secondary, primary];
-
- let tags = listing_tags(&listing).expect("listing tags");
- let first_bin = tags
- .iter()
- .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin"))
- .expect("first bin tag");
- assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-1"));
- assert_eq!(first_bin.get(6).map(|v| v.as_str()), Some("fallback-label"));
-
- let mut listing_without_primary_match = base_listing();
- listing_without_primary_match.discounts = None;
- let mut first = base_bin();
- first.bin_id = bin_id("bin-2");
- first.display_label = None;
- first.quantity = RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG);
- let mut second = base_bin();
- second.bin_id = bin_id("bin-1");
- listing_without_primary_match.primary_bin_id = bin_id("bin-missing");
- listing_without_primary_match.bins = vec![first, second];
-
- let tags = listing_tags(&listing_without_primary_match).expect("listing tags");
- let first_bin = tags
- .iter()
- .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin"))
- .expect("first bin tag");
- assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-2"));
- assert_eq!(first_bin.len(), 6);
- }
-
- #[test]
- fn listing_tags_location_handles_partial_optional_components() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some(" ".to_string()),
- region: Some("San Martin".to_string()),
- country: Some(" ".to_string()),
- geohash: "9q8yy".to_string(),
- });
-
- let tags = listing_tags(&listing).expect("listing tags");
- let location = find_tag(&tags, "location").expect("location tag");
- assert_eq!(location.first().map(|v| v.as_str()), Some("location"));
- assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
- assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin"));
- assert_eq!(location.len(), 3);
-
- listing.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: Some(" ".to_string()),
- country: Some("PE".to_string()),
- geohash: "9q8yy".to_string(),
- });
- let tags = listing_tags(&listing).expect("listing tags");
- let location = find_tag(&tags, "location").expect("location tag");
- assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
- assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba"));
- assert_eq!(location.get(3).map(|v| v.as_str()), Some("PE"));
- assert_eq!(location.len(), 4);
- }
-
- #[test]
- fn listing_tags_supports_npub_farm_pubkey() {
- let mut listing = base_listing();
- listing.discounts = None;
- listing.farm.pubkey = TEST_NPUB.to_string();
- let tags = listing_tags(&listing).expect("npub farm tags");
- assert!(tags.iter().any(|tag| {
- tag.first().map(|v| v.as_str()) == Some("p")
- && tag.get(1).map(|v| v.as_str()) == Some(TEST_NPUB)
- }));
- }
-}
diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs
@@ -300,6 +300,7 @@ fn standard_label(value: RadrootsNostrStandard) -> &'static str {
RadrootsNostrStandard::Nip90 => "nip90",
RadrootsNostrStandard::Nip94 => "nip94",
RadrootsNostrStandard::Nip98 => "nip98",
+ RadrootsNostrStandard::Nip99 => "nip99",
RadrootsNostrStandard::Radroots => "radroots",
}
}
@@ -376,8 +377,8 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Identifier => "identifier",
RadrootsTagSemantic::Image => "image",
RadrootsTagSemantic::Kind => "kind",
- RadrootsTagSemantic::ListingAddress => "listing_address",
- RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
+ RadrootsTagSemantic::ClassifiedListingAddress => "listing_address",
+ RadrootsTagSemantic::OperationalListingSnapshot => "listing_snapshot",
RadrootsTagSemantic::ListDescription => "list_description",
RadrootsTagSemantic::Location => "location",
RadrootsTagSemantic::Participant => "participant",
@@ -434,8 +435,10 @@ fn reducer_label(value: RadrootsReducer) -> &'static str {
RadrootsReducer::FarmOpsProjection => "farm_ops_projection",
RadrootsReducer::GroupProjection => "group_projection",
RadrootsReducer::KnowledgeProjection => "knowledge_projection",
- RadrootsReducer::ListingInventoryAccounting => "listing_inventory_accounting",
- RadrootsReducer::ListingProjection => "listing_projection",
+ RadrootsReducer::OperationalListingInventoryAccounting => {
+ "operational_listing_inventory_accounting"
+ }
+ RadrootsReducer::OperationalListingProjection => "operational_listing_projection",
RadrootsReducer::MarketProjection => "market_projection",
RadrootsReducer::OrderProjection => "order_projection",
RadrootsReducer::ProfileProjection => "profile_projection",
@@ -445,3 +448,26 @@ fn reducer_label(value: RadrootsReducer) -> &'static str {
RadrootsReducer::TradeValidation => "trade_validation",
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{reducer_label, standard_label};
+ use radroots_event::contract::{RadrootsNostrStandard, RadrootsReducer};
+
+ #[test]
+ fn classified_listing_standard_label_is_nip99() {
+ assert_eq!(standard_label(RadrootsNostrStandard::Nip99), "nip99");
+ }
+
+ #[test]
+ fn operational_listing_reducer_labels_are_unambiguous() {
+ assert_eq!(
+ reducer_label(RadrootsReducer::OperationalListingProjection),
+ "operational_listing_projection"
+ );
+ assert_eq!(
+ reducer_label(RadrootsReducer::OperationalListingInventoryAccounting),
+ "operational_listing_inventory_accounting"
+ );
+ }
+}
diff --git a/crates/event_codec/src/operational_listing/decode.rs b/crates/event_codec/src/operational_listing/decode.rs
@@ -0,0 +1,894 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
+ RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_event::{
+ RadrootsEventEnvelope,
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_classified_listing_kind},
+ location::is_public_geohash5,
+ operational_listing::RadrootsOperationalListingParseError,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingImage, RadrootsOperationalListingImageSize,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingStatus,
+ },
+ plot::RadrootsPlotRef,
+ resource_area::RadrootsResourceAreaRef,
+ tags::{TAG_D, TAG_PUBLISHED_AT},
+};
+
+use crate::d_tag::validate_d_tag_tag;
+use crate::error::EventParseError;
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+
+const EXPECTED_CLASSIFIED_LISTING_KINDS: &str = "30402";
+const TAG_A: &str = "a";
+const TAG_P: &str = "p";
+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";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum OperationalListingDecodeError {
+ 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 OperationalListingDecodeError {
+ fn into_listing_parse_error(self) -> RadrootsOperationalListingParseError {
+ match self {
+ Self::MissingTag(field) => {
+ RadrootsOperationalListingParseError::MissingTag(field.to_string())
+ }
+ Self::InvalidTag(field) => {
+ RadrootsOperationalListingParseError::InvalidTag(field.to_string())
+ }
+ Self::InvalidNumber(field) => {
+ RadrootsOperationalListingParseError::InvalidNumber(field.to_string())
+ }
+ Self::InvalidUnit(_) => RadrootsOperationalListingParseError::InvalidUnit,
+ Self::InvalidCurrency(_) => RadrootsOperationalListingParseError::InvalidCurrency,
+ Self::InvalidJson(field) => {
+ RadrootsOperationalListingParseError::InvalidJson(field.to_string())
+ }
+ #[cfg(feature = "serde_json")]
+ Self::InvalidDiscount(field) => {
+ RadrootsOperationalListingParseError::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 OperationalListingLocationDraft {
+ primary: String,
+ city: Option<String>,
+ region: Option<String>,
+ country: Option<String>,
+}
+
+fn parse_decimal(
+ value: &str,
+ field: &'static str,
+) -> Result<RadrootsCoreDecimal, OperationalListingDecodeError> {
+ value
+ .parse::<RadrootsCoreDecimal>()
+ .map_err(|_| OperationalListingDecodeError::InvalidNumber(field))
+}
+
+fn reject_private_listing_location_content(
+ content: &str,
+) -> Result<(), OperationalListingDecodeError> {
+ 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(OperationalListingDecodeError::InvalidJson("location"));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn parse_currency(
+ value: &str,
+ field: &'static str,
+) -> Result<RadrootsCoreCurrency, OperationalListingDecodeError> {
+ let upper = value.trim().to_ascii_uppercase();
+ RadrootsCoreCurrency::from_str_upper(&upper)
+ .map_err(|_| OperationalListingDecodeError::InvalidCurrency(field))
+}
+
+fn parse_unit(
+ value: &str,
+ field: &'static str,
+) -> Result<RadrootsCoreUnit, OperationalListingDecodeError> {
+ value
+ .parse::<RadrootsCoreUnit>()
+ .map_err(|_| OperationalListingDecodeError::InvalidUnit(field))
+}
+
+fn parse_u64_tag_value(
+ value: Option<&String>,
+ field: &'static str,
+) -> Result<u64, OperationalListingDecodeError> {
+ value
+ .ok_or(OperationalListingDecodeError::InvalidTag(field))?
+ .parse::<u64>()
+ .map_err(|_| OperationalListingDecodeError::InvalidNumber(field))
+}
+
+fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
+ .ok_or(OperationalListingDecodeError::MissingTag(TAG_D))?;
+ let value = tag
+ .get(1)
+ .map(|value| value.to_string())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_D))?;
+ if value.trim().is_empty() {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_D));
+ }
+ validate_d_tag_tag(&value, TAG_D)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?;
+ Ok(value)
+}
+
+fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, OperationalListingDecodeError> {
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
+ {
+ let value = tag
+ .get(1)
+ .map(|value| value.to_string())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?;
+ let mut parts = value.splitn(3, ':');
+ let kind = parts
+ .next()
+ .and_then(|raw| raw.parse::<u32>().ok())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?;
+ if kind != KIND_FARM {
+ continue;
+ }
+ let pubkey = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?
+ .to_string();
+ let d_tag = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?
+ .to_string();
+ if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_A));
+ }
+ validate_d_tag_tag(&d_tag, TAG_A)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_A))?;
+ return Ok(RadrootsFarmRef { pubkey, d_tag });
+ }
+ Err(OperationalListingDecodeError::MissingTag(TAG_A))
+}
+
+fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P))
+ .ok_or(OperationalListingDecodeError::MissingTag(TAG_P))?;
+ let value = tag
+ .get(1)
+ .map(|value| value.to_string())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_P))?;
+ if value.trim().is_empty() {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_P));
+ }
+ Ok(value)
+}
+
+fn parse_resource_area(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsResourceAreaRef>, OperationalListingDecodeError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
+ let Some(tag) = tag else {
+ return Ok(None);
+ };
+ let value = tag.get(1).map(|value| value.to_string()).ok_or(
+ OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA),
+ )?;
+ let mut parts = value.splitn(3, ':');
+ let kind = parts.next().and_then(|raw| raw.parse::<u32>().ok()).ok_or(
+ OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA),
+ )?;
+ if kind != KIND_RESOURCE_AREA {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_RESOURCE_AREA,
+ ));
+ }
+ let pubkey = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_RESOURCE_AREA,
+ ))?
+ .to_string();
+ let d_tag = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_RESOURCE_AREA,
+ ))?
+ .to_string();
+ if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_RESOURCE_AREA,
+ ));
+ }
+ validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
+ Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag }))
+}
+
+fn parse_plot_ref(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsPlotRef>, OperationalListingDecodeError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
+ let Some(tag) = tag else {
+ return Ok(None);
+ };
+ let value = tag
+ .get(1)
+ .map(|value| value.to_string())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
+ let mut parts = value.splitn(3, ':');
+ let kind = parts
+ .next()
+ .and_then(|raw| raw.parse::<u32>().ok())
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
+ if kind != KIND_PLOT {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
+ }
+ let pubkey = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
+ .to_string();
+ let d_tag = parts
+ .next()
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
+ .to_string();
+ if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
+ }
+ validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
+ Ok(Some(RadrootsPlotRef { pubkey, d_tag }))
+}
+
+pub fn operational_listing_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsOperationalListing, EventParseError> {
+ if !is_classified_listing_kind(kind) {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_CLASSIFIED_LISTING_KINDS,
+ got: kind,
+ });
+ }
+ decode_operational_listing_from_event_parts(tags, content)
+ .map_err(OperationalListingDecodeError::into_event_parse_error)
+}
+
+pub fn operational_listing_from_event_parts(
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> {
+ decode_operational_listing_from_event_parts(tags, content)
+ .map_err(OperationalListingDecodeError::into_listing_parse_error)
+}
+
+pub fn operational_listing_from_nostr_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> {
+ if !is_classified_listing_kind(event.kind_u32()) {
+ return Err(RadrootsOperationalListingParseError::InvalidKind(
+ event.kind_u32(),
+ ));
+ }
+ operational_listing_from_event_parts(&event.tags_as_vec(), event.content())
+}
+
+fn decode_operational_listing_from_event_parts(
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsOperationalListing, OperationalListingDecodeError> {
+ 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)?;
+
+ let mut product = RadrootsOperationalListingProduct {
+ 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<OperationalListingLocationDraft> = None;
+ let mut inventory_available: Option<RadrootsCoreDecimal> = None;
+ let mut availability_status: Option<RadrootsOperationalListingStatus> = None;
+ let mut availability_start: Option<u64> = None;
+ let mut availability_end: Option<u64> = None;
+ let mut delivery_method: Option<RadrootsOperationalListingDeliveryMethod> = None;
+ let mut images: Vec<RadrootsOperationalListingImage> = Vec::new();
+ let mut geohash: Option<String> = None;
+ let mut published_at: Option<u64> = None;
+
+ for tag in tags {
+ if tag.is_empty() {
+ continue;
+ }
+ match tag[0].as_str() {
+ "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
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_LOCATION))?;
+ let mut parsed = OperationalListingLocationDraft {
+ primary,
+ city: None,
+ region: None,
+ country: None,
+ };
+ if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
+ parsed.city = Some(city);
+ }
+ if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) {
+ parsed.region = Some(region);
+ }
+ if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) {
+ parsed.country = Some(country);
+ }
+ location = Some(parsed);
+ } 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 => {}
+ TAG_RADROOTS_PRIMARY_BIN => {
+ let value = tag.get(1).and_then(|value| clean_value(value)).ok_or(
+ OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN),
+ )?;
+ if let Some(existing) = primary_bin_id.as_ref() {
+ if existing != &value {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRIMARY_BIN,
+ ));
+ }
+ } else {
+ primary_bin_id = Some(value);
+ }
+ }
+ TAG_RADROOTS_BIN => {
+ if tag.len() < 4 || tag.len() > 7 {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ let bin_id = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(OperationalListingDecodeError::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(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
+ if bin.quantity.is_some() {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ 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, TAG_RADROOTS_BIN)?;
+ 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(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
+ }
+ _ => {}
+ }
+ }
+ TAG_RADROOTS_PRICE => {
+ if tag.len() < 6 || tag.len() > 8 {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRICE,
+ ));
+ }
+ let bin_id = tag.get(1).and_then(|value| clean_value(value)).ok_or(
+ OperationalListingDecodeError::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)?;
+ let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?;
+ 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(OperationalListingDecodeError::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(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRICE,
+ ));
+ }
+ bin.price_per_canonical_unit = Some(price_per_canonical_unit);
+
+ match tag.as_slice() {
+ [_, _, _, _, _, _, _] => {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRICE,
+ ));
+ }
+ [_, _, _, _, _, _, 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, TAG_RADROOTS_PRICE)?;
+ 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(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_DISCOUNT,
+ ))?;
+ discounts.push(parse_discount(payload)?);
+ }
+ TAG_GEOHASH => {
+ let value = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?;
+ if !is_public_geohash5(&value) {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH));
+ }
+ let value = value.to_ascii_lowercase();
+ if geohash.as_ref().is_some_and(|existing| existing != &value) {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH));
+ }
+ geohash = Some(value);
+ }
+ TAG_DD => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD)),
+ TAG_DD_LAT => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LAT)),
+ TAG_DD_LON => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LON)),
+ TAG_LABEL => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL)),
+ TAG_LABEL_NS => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL_NS)),
+ TAG_INVENTORY => {
+ let value = tag
+ .get(1)
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_INVENTORY))?;
+ inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
+ }
+ TAG_RADROOTS_AVAILABILITY_START => {
+ availability_start = Some(parse_u64_tag_value(
+ tag.get(1),
+ TAG_RADROOTS_AVAILABILITY_START,
+ )?);
+ }
+ TAG_EXPIRES_AT => {
+ availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?);
+ }
+ TAG_STATUS => {
+ let status = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default();
+ availability_status = Some(parse_status(&status));
+ }
+ TAG_DELIVERY => {
+ let method = tag
+ .get(1)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default();
+ delivery_method = Some(match method.as_str() {
+ "pickup" => RadrootsOperationalListingDeliveryMethod::Pickup,
+ "local_delivery" => RadrootsOperationalListingDeliveryMethod::LocalDelivery,
+ "shipping" => RadrootsOperationalListingDeliveryMethod::Shipping,
+ "other" => RadrootsOperationalListingDeliveryMethod::Other {
+ method: tag
+ .get(2)
+ .and_then(|value| clean_value(value))
+ .unwrap_or_default(),
+ },
+ other => RadrootsOperationalListingDeliveryMethod::Other {
+ method: other.to_string(),
+ },
+ });
+ }
+ TAG_IMAGE => {
+ let url = tag
+ .get(1)
+ .ok_or(OperationalListingDecodeError::InvalidTag(TAG_IMAGE))?;
+ if url.trim().is_empty() {
+ continue;
+ }
+ images.push(RadrootsOperationalListingImage {
+ url: url.to_string(),
+ size: tag.get(2).and_then(|value| parse_image_size(value)),
+ });
+ }
+ _ => {}
+ }
+ }
+
+ let availability = match availability_status {
+ Some(status) => Some(RadrootsOperationalListingAvailability::Status { status }),
+ None => match (availability_start, availability_end) {
+ (None, None) => None,
+ (start, end) => Some(RadrootsOperationalListingAvailability::Window { start, end }),
+ },
+ };
+
+ let location = location
+ .map(|location| {
+ let geohash = geohash.ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?;
+ Ok::<RadrootsOperationalListingPublicLocation, OperationalListingDecodeError>(
+ RadrootsOperationalListingPublicLocation {
+ primary: location.primary,
+ city: location.city,
+ region: location.region,
+ country: location.country,
+ geohash,
+ },
+ )
+ })
+ .transpose()?;
+
+ if farm_pubkey != farm_ref.pubkey {
+ return Err(OperationalListingDecodeError::InvalidTag(TAG_P));
+ }
+
+ let primary_bin_id = primary_bin_id.ok_or(OperationalListingDecodeError::MissingTag(
+ TAG_RADROOTS_PRIMARY_BIN,
+ ))?;
+ let bins = build_bins(bin_drafts)?;
+ if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRIMARY_BIN,
+ ));
+ }
+
+ let d_tag = RadrootsDTag::parse(&d_tag)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?;
+ let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
+
+ Ok(RadrootsOperationalListing {
+ 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)
+ },
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u64,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsOperationalListing>, EventParseError> {
+ let listing = operational_listing_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ listing,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u64,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsOperationalListing>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ RadrootsParsedEvent::from_parts(
+ id,
+ author,
+ published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ data.data,
+ )
+}
+
+pub fn data_from_nostr_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsParsedData<RadrootsOperationalListing>, EventParseError> {
+ data_from_event(
+ event.id_str().to_string(),
+ event.author_str().to_string(),
+ event.created_at_u64(),
+ event.kind_u32(),
+ event.content().to_string(),
+ event.tags_as_vec(),
+ )
+}
+
+pub fn parsed_from_nostr_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsParsedEvent<RadrootsOperationalListing>, EventParseError> {
+ parsed_from_event(
+ event.id_str().to_string(),
+ event.author_str().to_string(),
+ event.created_at_u64(),
+ event.kind_u32(),
+ event.content().to_string(),
+ event.tags_as_vec(),
+ event.sig_str().to_string(),
+ )
+}
+
+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(value) = value.and_then(|value| clean_value(value))
+ {
+ *target = value;
+ }
+}
+
+fn set_optional(target: &mut Option<String>, value: Option<&String>) {
+ if target.is_none()
+ && let Some(value) = value.and_then(|value| clean_value(value))
+ {
+ *target = Some(value);
+ }
+}
+
+fn parse_status(value: &str) -> RadrootsOperationalListingStatus {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "active" => RadrootsOperationalListingStatus::Active,
+ "sold" => RadrootsOperationalListingStatus::Sold,
+ other => RadrootsOperationalListingStatus::Other {
+ value: other.to_string(),
+ },
+ }
+}
+
+fn parse_image_size(value: &str) -> Option<RadrootsOperationalListingImageSize> {
+ let (w_raw, h_raw) = value.split_once('x')?;
+ let w = w_raw.parse::<u32>().ok()?;
+ let h = h_raw.parse::<u32>().ok()?;
+ Some(RadrootsOperationalListingImageSize { w, h })
+}
+
+fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, OperationalListingDecodeError> {
+ #[cfg(feature = "serde_json")]
+ {
+ serde_json::from_str(payload)
+ .map_err(|_| OperationalListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT))
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _ = payload;
+ Err(OperationalListingDecodeError::InvalidJson("discount"))
+ }
+}
+
+#[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(position) = bins.iter().position(|bin| bin.bin_id == bin_id) {
+ return &mut bins[position];
+ }
+ bins.push(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,
+ });
+ *order_index += 1;
+ let index = bins.len() - 1;
+ &mut bins[index]
+}
+
+fn build_bins(
+ mut drafts: Vec<BinDraft>,
+) -> Result<Vec<RadrootsOperationalListingBin>, OperationalListingDecodeError> {
+ 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(OperationalListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?;
+ let price =
+ draft
+ .price_per_canonical_unit
+ .ok_or(OperationalListingDecodeError::MissingTag(
+ TAG_RADROOTS_PRICE,
+ ))?;
+ if quantity.unit != price.quantity.unit {
+ return Err(OperationalListingDecodeError::InvalidTag(
+ TAG_RADROOTS_PRICE,
+ ));
+ }
+ bins.push(RadrootsOperationalListingBin {
+ bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
+ .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?,
+ 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,
+ });
+ }
+ Ok(bins)
+}
diff --git a/crates/event_codec/src/operational_listing/encode.rs b/crates/event_codec/src/operational_listing/encode.rs
@@ -0,0 +1,151 @@
+#[cfg(all(not(feature = "std"), test, feature = "serde_json"))]
+use alloc::vec;
+#[cfg(all(not(feature = "std"), feature = "serde_json"))]
+use alloc::{format, string::ToString};
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+#[cfg(feature = "serde_json")]
+use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind};
+use radroots_event::operational_listing::RadrootsOperationalListing;
+
+use crate::error::EventEncodeError;
+use crate::operational_listing::tags::operational_listing_tags;
+#[cfg(feature = "serde_json")]
+use crate::operational_listing::tags::operational_listing_tags_full;
+#[cfg(feature = "serde_json")]
+use radroots_event::wire::RadrootsNip01EventWireParts;
+
+#[cfg(feature = "serde_json")]
+const DEFAULT_KIND: u32 = KIND_CLASSIFIED_LISTING;
+
+pub fn operational_listing_build_tags(
+ listing: &RadrootsOperationalListing,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ operational_listing_tags(listing)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts(
+ listing: &RadrootsOperationalListing,
+) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
+ to_wire_parts_with_kind(listing, DEFAULT_KIND)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts_with_kind(
+ listing: &RadrootsOperationalListing,
+ kind: u32,
+) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
+ if !is_classified_listing_kind(kind) {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = operational_listing_tags_full(listing)?;
+ let content = operational_listing_markdown_content(listing);
+ Ok(RadrootsNip01EventWireParts {
+ kind,
+ content,
+ tags,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+fn operational_listing_markdown_content(listing: &RadrootsOperationalListing) -> String {
+ let title = listing.product.title.trim();
+ let summary = listing
+ .product
+ .summary
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+
+ match (title.is_empty(), summary) {
+ (false, Some(summary)) => format!("# {title}\n\n{summary}"),
+ (false, None) => format!("# {title}"),
+ (true, Some(summary)) => summary.to_string(),
+ (true, None) => String::new(),
+ }
+}
+
+#[cfg(all(test, feature = "serde_json"))]
+mod tests {
+ use super::*;
+ use core::str::FromStr;
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_event::{
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ operational_listing::{RadrootsOperationalListingBin, RadrootsOperationalListingProduct},
+ };
+
+ fn decimal(value: &str) -> RadrootsCoreDecimal {
+ RadrootsCoreDecimal::from_str(value).expect("decimal")
+ }
+
+ fn listing_with(title: &str, summary: Option<&str>) -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
+ d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "a".repeat(64),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "coffee".to_string(),
+ title: title.to_string(),
+ category: "produce".to_string(),
+ summary: summary.map(ToOwned::to_owned),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ ),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ }
+ }
+
+ #[test]
+ fn operational_listing_markdown_content_covers_title_summary_combinations() {
+ assert_eq!(
+ operational_listing_markdown_content(&listing_with("Coffee", Some("Washed"))),
+ "# Coffee\n\nWashed"
+ );
+ assert_eq!(
+ operational_listing_markdown_content(&listing_with("Coffee", None)),
+ "# Coffee"
+ );
+ assert_eq!(
+ operational_listing_markdown_content(&listing_with(" ", Some("Washed"))),
+ "Washed"
+ );
+ assert_eq!(
+ operational_listing_markdown_content(&listing_with(" ", None)),
+ ""
+ );
+ }
+}
diff --git a/crates/event_codec/src/listing/mod.rs b/crates/event_codec/src/operational_listing/mod.rs
diff --git a/crates/event_codec/src/operational_listing/tags.rs b/crates/event_codec/src/operational_listing/tags.rs
@@ -0,0 +1,1363 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
+
+#[cfg(any(feature = "serde_json", test))]
+use radroots_core::RadrootsCoreDiscount;
+use radroots_core::RadrootsCoreMoney;
+use radroots_event::farm::RadrootsFarmRef;
+use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
+use radroots_event::location::{has_textual_locality, is_public_geohash5};
+use radroots_event::operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingImage, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingStatus,
+};
+use radroots_event::plot::RadrootsPlotRef;
+use radroots_event::resource_area::RadrootsResourceAreaRef;
+use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT};
+
+use crate::d_tag::validate_d_tag;
+use crate::error::EventEncodeError;
+
+const TAG_PRICE: &str = "price";
+const TAG_RADROOTS_BIN: &str = "radroots:bin";
+const TAG_RADROOTS_PRICE: &str = "radroots:price";
+#[cfg(feature = "serde_json")]
+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_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";
+
+#[derive(Clone, Copy, Debug)]
+pub struct OperationalListingTagOptions {
+ pub include_geohash: bool,
+ pub include_inventory: bool,
+ pub include_availability: bool,
+ pub include_delivery: bool,
+}
+
+impl Default for OperationalListingTagOptions {
+ fn default() -> Self {
+ Self {
+ include_geohash: true,
+ include_inventory: false,
+ include_availability: false,
+ include_delivery: false,
+ }
+ }
+}
+
+impl OperationalListingTagOptions {
+ pub fn with_trade_fields() -> Self {
+ Self {
+ include_inventory: true,
+ include_availability: true,
+ include_delivery: true,
+ ..Self::default()
+ }
+ }
+}
+
+pub fn operational_listing_tags(
+ listing: &RadrootsOperationalListing,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ operational_listing_tags_with_options(listing, OperationalListingTagOptions::default())
+}
+
+pub fn operational_listing_tags_full(
+ listing: &RadrootsOperationalListing,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ operational_listing_tags_with_options(
+ listing,
+ OperationalListingTagOptions::with_trade_fields(),
+ )
+}
+
+pub fn operational_listing_tags_with_options(
+ listing: &RadrootsOperationalListing,
+ options: OperationalListingTagOptions,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ let d_tag = listing.d_tag.as_str();
+ validate_d_tag(d_tag, "d")?;
+ if listing.bins.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("bins"));
+ }
+
+ let mut tags: Vec<Vec<String>> = Vec::new();
+ tags.push(vec![TAG_D.to_string(), d_tag.to_string()]);
+ push_farm_tags(&mut tags, &listing.farm)?;
+ if let Some(resource_area) = listing.resource_area.as_ref() {
+ tags.push(tag_listing_resource_area(resource_area)?);
+ }
+ if let Some(plot) = listing.plot.as_ref() {
+ tags.push(tag_listing_plot(plot)?);
+ }
+
+ let product = &listing.product;
+ push_tag_value(&mut tags, "key", &product.key);
+ push_tag_value(&mut tags, "title", &product.title);
+ push_tag_value(&mut tags, "category", &product.category);
+ if let Some(summary) = product.summary.as_deref() {
+ push_tag_value(&mut tags, "summary", summary);
+ }
+ if let Some(published_at) = listing.published_at {
+ tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]);
+ }
+ if let Some(process) = product.process.as_deref() {
+ push_tag_value(&mut tags, "process", process);
+ }
+ if let Some(lot) = product.lot.as_deref() {
+ push_tag_value(&mut tags, "lot", lot);
+ }
+ if let Some(location) = product.location.as_deref() {
+ push_tag_value(&mut tags, "location", location);
+ }
+ if let Some(profile) = product.profile.as_deref() {
+ push_tag_value(&mut tags, "profile", profile);
+ }
+ if let Some(year) = product.year.as_deref() {
+ push_tag_value(&mut tags, "year", year);
+ }
+
+ tags.push(vec![
+ TAG_RADROOTS_PRIMARY_BIN.to_string(),
+ listing.primary_bin_id.to_string(),
+ ]);
+
+ let mut bins: Vec<&RadrootsOperationalListingBin> = listing.bins.iter().collect();
+ if let Some(pos) = bins
+ .iter()
+ .position(|bin| bin.bin_id == listing.primary_bin_id)
+ {
+ let primary = bins.remove(pos);
+ bins.insert(0, primary);
+ }
+
+ for bin in bins {
+ let price_tag = tag_listing_price(bin)?;
+ let bin_tag = tag_listing_bin(bin)?;
+ tags.push(bin_tag);
+ tags.push(price_tag);
+ if bin.bin_id == listing.primary_bin_id {
+ let total = bin_total_price(bin)?;
+ tags.push(tag_listing_price_generic(&total));
+ }
+ }
+
+ #[cfg(feature = "serde_json")]
+ if let Some(discounts) = &listing.discounts {
+ for discount in discounts {
+ let payload = discount_tag_payload(discount)?;
+ tags.push(vec![TAG_RADROOTS_DISCOUNT.to_string(), payload]);
+ }
+ }
+ #[cfg(not(feature = "serde_json"))]
+ if listing.discounts.as_ref().is_some() {
+ return Err(EventEncodeError::Json);
+ }
+
+ if options.include_inventory
+ && let Some(inventory) = &listing.inventory_available
+ {
+ tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]);
+ }
+
+ if options.include_availability
+ && let Some(availability) = &listing.availability
+ {
+ match availability {
+ RadrootsOperationalListingAvailability::Status { status } => {
+ tags.push(vec![
+ TAG_STATUS.to_string(),
+ status_as_str(status).to_string(),
+ ]);
+ }
+ RadrootsOperationalListingAvailability::Window { start, end } => {
+ if let Some(start) = start {
+ tags.push(vec![
+ TAG_RADROOTS_AVAILABILITY_START.to_string(),
+ start.to_string(),
+ ]);
+ }
+ if let Some(end) = end {
+ tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]);
+ }
+ }
+ }
+ }
+
+ if options.include_delivery
+ && let Some(method) = &listing.delivery_method
+ {
+ let mut tag = Vec::with_capacity(3);
+ tag.push(TAG_DELIVERY.to_string());
+ match method {
+ RadrootsOperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()),
+ RadrootsOperationalListingDeliveryMethod::LocalDelivery => {
+ tag.push("local_delivery".into())
+ }
+ RadrootsOperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()),
+ RadrootsOperationalListingDeliveryMethod::Other { method } => {
+ tag.push("other".into());
+ tag.push(method.clone());
+ }
+ }
+ tags.push(tag);
+ }
+
+ if let Some(location) = &listing.location {
+ let primary = clean_value(&location.primary)
+ .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?;
+ if !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ ) {
+ return Err(EventEncodeError::EmptyRequiredField("location.locality"));
+ }
+ let mut tag = Vec::with_capacity(5);
+ tag.push(TAG_LOCATION.to_string());
+ tag.push(primary);
+ if let Some(city) = location.city.as_deref().and_then(clean_value) {
+ tag.push(city);
+ }
+ if let Some(region) = location.region.as_deref().and_then(clean_value) {
+ tag.push(region);
+ }
+ if let Some(country) = location.country.as_deref().and_then(clean_value) {
+ tag.push(country);
+ }
+ tags.push(tag);
+ if options.include_geohash {
+ push_location_geotag(&mut tags, location)?;
+ }
+ }
+
+ if let Some(images) = &listing.images {
+ for image in images {
+ if let Some(tag) = tag_listing_image(image) {
+ tags.push(tag);
+ }
+ }
+ }
+
+ Ok(tags)
+}
+
+fn push_farm_tags(
+ tags: &mut Vec<Vec<String>>,
+ farm: &RadrootsFarmRef,
+) -> Result<(), EventEncodeError> {
+ if farm.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.pubkey"));
+ }
+ if farm.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.d_tag"));
+ }
+ validate_d_tag(&farm.d_tag, "farm.d_tag")?;
+ let mut address = String::new();
+ address.push_str(&KIND_FARM.to_string());
+ address.push(':');
+ address.push_str(&farm.pubkey);
+ address.push(':');
+ address.push_str(&farm.d_tag);
+ tags.push(vec![TAG_P.to_string(), farm.pubkey.clone()]);
+ tags.push(vec![TAG_A.to_string(), address]);
+ Ok(())
+}
+
+fn tag_listing_resource_area(
+ area: &RadrootsResourceAreaRef,
+) -> Result<Vec<String>, EventEncodeError> {
+ if area.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey"));
+ }
+ if area.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag"));
+ }
+ validate_d_tag(&area.d_tag, "resource_area.d_tag")?;
+ let mut address = String::new();
+ address.push_str(&KIND_RESOURCE_AREA.to_string());
+ address.push(':');
+ address.push_str(&area.pubkey);
+ address.push(':');
+ address.push_str(&area.d_tag);
+ Ok(vec![TAG_RADROOTS_RESOURCE_AREA.to_string(), address])
+}
+
+fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeError> {
+ if plot.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("plot.pubkey"));
+ }
+ if plot.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("plot.d_tag"));
+ }
+ validate_d_tag(&plot.d_tag, "plot.d_tag")?;
+ let mut address = String::new();
+ address.push_str(&KIND_PLOT.to_string());
+ address.push(':');
+ address.push_str(&plot.pubkey);
+ address.push(':');
+ address.push_str(&plot.d_tag);
+ Ok(vec![TAG_RADROOTS_PLOT.to_string(), address])
+}
+
+fn tag_listing_bin(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, EventEncodeError> {
+ let unit = bin.quantity.unit;
+ if unit != unit.canonical_unit() {
+ return Err(EventEncodeError::EmptyRequiredField("bin.quantity"));
+ }
+ let mut tag = Vec::with_capacity(7);
+ tag.push(TAG_RADROOTS_BIN.to_string());
+ tag.push(bin.bin_id.to_string());
+ tag.push(bin.quantity.amount.to_string());
+ tag.push(unit.code().to_string());
+ if let Some(amount) = bin.display_amount.as_ref() {
+ let Some(unit) = bin.display_unit else {
+ return Err(EventEncodeError::EmptyRequiredField("bin.display_unit"));
+ };
+ tag.push(amount.to_string());
+ tag.push(unit.code().to_string());
+ if let Some(label) = bin
+ .display_label
+ .as_deref()
+ .and_then(clean_value)
+ .or_else(|| bin.quantity.label.as_deref().and_then(clean_value))
+ {
+ tag.push(label);
+ }
+ } else if bin.display_unit.is_some() {
+ return Err(EventEncodeError::EmptyRequiredField("bin.display_amount"));
+ }
+ Ok(tag)
+}
+
+fn tag_listing_price(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, EventEncodeError> {
+ let price = &bin.price_per_canonical_unit;
+ if !price.is_price_per_canonical_unit() {
+ return Err(EventEncodeError::EmptyRequiredField(
+ "bin.price_per_canonical_unit",
+ ));
+ }
+ let mut tag = Vec::with_capacity(8);
+ tag.push(TAG_RADROOTS_PRICE.to_string());
+ tag.push(bin.bin_id.to_string());
+ tag.push(price.amount.amount.to_string());
+ tag.push(price.amount.currency.as_str().to_string());
+ tag.push(price.quantity.amount.to_string());
+ tag.push(price.quantity.unit.code().to_string());
+ match (&bin.display_price, bin.display_price_unit) {
+ (Some(price_display), Some(unit)) => {
+ if price_display.currency != price.amount.currency {
+ return Err(EventEncodeError::EmptyRequiredField("bin.display_price"));
+ }
+ tag.push(price_display.amount.to_string());
+ tag.push(unit.code().to_string());
+ }
+ (None, None) => {}
+ (None, Some(_)) => return Err(EventEncodeError::EmptyRequiredField("bin.display_price")),
+ (Some(_), None) => {
+ return Err(EventEncodeError::EmptyRequiredField(
+ "bin.display_price_unit",
+ ));
+ }
+ }
+ Ok(tag)
+}
+
+fn bin_total_price(
+ bin: &RadrootsOperationalListingBin,
+) -> Result<RadrootsCoreMoney, EventEncodeError> {
+ bin.price_per_canonical_unit
+ .try_cost_for_quantity_in(&bin.quantity)
+ .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit"))
+}
+
+fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> {
+ let mut tag = Vec::with_capacity(4);
+ tag.push(TAG_PRICE.to_string());
+ tag.push(price.amount.to_string());
+ tag.push(price.currency.as_str().to_string());
+ tag
+}
+
+fn tag_listing_image(image: &RadrootsOperationalListingImage) -> Option<Vec<String>> {
+ let url = clean_value(&image.url)?;
+ let mut tag = Vec::with_capacity(3);
+ tag.push(TAG_IMAGE.to_string());
+ tag.push(url);
+ if let Some(size) = &image.size {
+ tag.push(format!("{}x{}", size.w, size.h));
+ }
+ Some(tag)
+}
+
+fn push_location_geotag(
+ tags: &mut Vec<Vec<String>>,
+ location: &RadrootsOperationalListingPublicLocation,
+) -> Result<(), EventEncodeError> {
+ let geohash = clean_value(&location.geohash)
+ .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?;
+ if !is_public_geohash5(&geohash) {
+ return Err(EventEncodeError::InvalidField("location.geohash"));
+ }
+ tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]);
+ Ok(())
+}
+
+fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
+ let _ = clean_value(value).map(|cleaned| tags.push(vec![key.to_string(), cleaned]));
+}
+
+fn clean_value(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ (!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("null")).then(|| trimmed.to_string())
+}
+
+#[cfg(any(feature = "serde_json", test))]
+fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> {
+ #[cfg(feature = "serde_json")]
+ {
+ serde_json::to_string(discount).map_err(|_| EventEncodeError::Json)
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _ = discount;
+ Err(EventEncodeError::Json)
+ }
+}
+
+fn status_as_str(status: &RadrootsOperationalListingStatus) -> &str {
+ match status {
+ RadrootsOperationalListingStatus::Active => "active",
+ RadrootsOperationalListingStatus::Sold => "sold",
+ RadrootsOperationalListingStatus::Other { value } => value.as_str(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use core::str::FromStr;
+
+ use crate::test_fixtures::{
+ CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ };
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountScope,
+ RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId};
+ use radroots_event::operational_listing::{
+ RadrootsOperationalListingImageSize, RadrootsOperationalListingProduct,
+ RadrootsOperationalListingStatus,
+ };
+
+ const TEST_NPUB: &str = FIXTURE_ALICE_NPUB;
+ const TEST_PUBKEY_HEX: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+ const TEST_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
+ const TEST_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+
+ fn cdn_url(path: &str) -> String {
+ format!("{CDN_PRIMARY_HTTPS}/{path}")
+ }
+
+ fn decimal(value: &str) -> RadrootsCoreDecimal {
+ RadrootsCoreDecimal::from_str(value).expect("valid decimal")
+ }
+
+ fn d_tag(raw: &str) -> RadrootsDTag {
+ raw.parse().unwrap()
+ }
+
+ fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ raw.parse().unwrap()
+ }
+
+ fn base_product() -> RadrootsOperationalListingProduct {
+ RadrootsOperationalListingProduct {
+ key: "coffee".to_string(),
+ title: "Coffee".to_string(),
+ category: "agri".to_string(),
+ summary: Some("summary".to_string()),
+ process: Some("washed".to_string()),
+ lot: Some("lot-1".to_string()),
+ location: Some("null".to_string()),
+ profile: Some("null".to_string()),
+ year: Some("2024".to_string()),
+ }
+ }
+
+ fn base_bin() -> RadrootsOperationalListingBin {
+ RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG)
+ .with_label("bag"),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("0.01"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ ),
+ display_amount: Some(decimal("1")),
+ display_unit: Some(RadrootsCoreUnit::MassKg),
+ display_label: Some("kilobag".to_string()),
+ display_price: Some(RadrootsCoreMoney::new(
+ decimal("10"),
+ RadrootsCoreCurrency::USD,
+ )),
+ display_price_unit: Some(RadrootsCoreUnit::MassKg),
+ }
+ }
+
+ fn base_listing() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
+ d_tag: d_tag(TEST_D_TAG),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: TEST_FARM_D_TAG.to_string(),
+ },
+ product: base_product(),
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![base_bin()],
+ resource_area: Some(RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ }),
+ plot: Some(RadrootsPlotRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ }),
+ discounts: None,
+ inventory_available: Some(decimal("2")),
+ availability: Some(RadrootsOperationalListingAvailability::Window {
+ start: Some(10),
+ end: Some(20),
+ }),
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
+ location: Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "9q8yy".to_string(),
+ }),
+ images: Some(vec![
+ RadrootsOperationalListingImage {
+ url: cdn_url("a.jpg"),
+ size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }),
+ },
+ RadrootsOperationalListingImage {
+ url: " ".to_string(),
+ size: None,
+ },
+ ]),
+ }
+ }
+
+ fn find_tag<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a Vec<String>> {
+ tags.iter()
+ .find(|tag| tag.first().map(|v| v.as_str()) == Some(key))
+ }
+
+ #[test]
+ fn options_defaults_and_trade_fields() {
+ let defaults = OperationalListingTagOptions::default();
+ assert!(defaults.include_geohash);
+ assert!(!defaults.include_inventory);
+ assert!(!defaults.include_availability);
+ assert!(!defaults.include_delivery);
+
+ let trade = OperationalListingTagOptions::with_trade_fields();
+ assert!(trade.include_geohash);
+ assert!(trade.include_inventory);
+ assert!(trade.include_availability);
+ assert!(trade.include_delivery);
+ }
+
+ #[test]
+ fn clean_value_and_push_tag_value_cover_null_paths() {
+ assert_eq!(clean_value(" value "), Some("value".to_string()));
+ assert_eq!(clean_value(""), None);
+ assert_eq!(clean_value(" null "), None);
+
+ let mut tags = Vec::new();
+ push_tag_value(&mut tags, "k", "value");
+ push_tag_value(&mut tags, "k", "null");
+ push_tag_value(&mut tags, "k", "");
+ assert_eq!(tags.len(), 1);
+ assert_eq!(tags[0], vec!["k".to_string(), "value".to_string()]);
+ }
+
+ #[test]
+ fn location_geotag_accepts_public_geohash5() {
+ let mut tags = Vec::new();
+ let location = RadrootsOperationalListingPublicLocation {
+ primary: "Test".to_string(),
+ city: Some("Town".to_string()),
+ region: None,
+ country: None,
+ geohash: "9Q8YY".to_string(),
+ };
+ push_location_geotag(&mut tags, &location).expect("public geohash");
+
+ assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]);
+ assert!(find_tag(&tags, "l").is_none());
+ assert!(find_tag(&tags, "L").is_none());
+ }
+
+ #[test]
+ fn location_geotag_rejects_blank_geohash() {
+ let mut tags = Vec::new();
+ let location = RadrootsOperationalListingPublicLocation {
+ primary: "Test".to_string(),
+ city: Some("Town".to_string()),
+ region: None,
+ country: None,
+ geohash: " ".to_string(),
+ };
+
+ assert!(matches!(
+ push_location_geotag(&mut tags, &location),
+ Err(EventEncodeError::EmptyRequiredField("location.geohash"))
+ ));
+ assert!(tags.is_empty());
+ }
+
+ #[test]
+ fn location_geotag_rejects_non_public_geohash() {
+ let mut tags = Vec::new();
+ let location = RadrootsOperationalListingPublicLocation {
+ primary: "Test".to_string(),
+ city: Some("Town".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yyz".to_string(),
+ };
+
+ assert!(matches!(
+ push_location_geotag(&mut tags, &location),
+ Err(EventEncodeError::InvalidField("location.geohash"))
+ ));
+ assert!(tags.is_empty());
+ }
+
+ #[test]
+ fn image_and_status_helpers_cover_variants() {
+ let with_size = tag_listing_image(&RadrootsOperationalListingImage {
+ url: format!(" {CDN_PRIMARY_HTTPS}/a.jpg "),
+ size: Some(RadrootsOperationalListingImageSize { w: 10, h: 20 }),
+ })
+ .expect("image tag");
+ assert_eq!(with_size[0], "image");
+ assert_eq!(with_size[2], "10x20");
+
+ let without_size = tag_listing_image(&RadrootsOperationalListingImage {
+ url: cdn_url("b.jpg"),
+ size: None,
+ })
+ .expect("image tag");
+ assert_eq!(without_size.len(), 2);
+
+ assert!(
+ tag_listing_image(&RadrootsOperationalListingImage {
+ url: "null".to_string(),
+ size: None,
+ })
+ .is_none()
+ );
+
+ assert_eq!(
+ status_as_str(&RadrootsOperationalListingStatus::Active),
+ "active"
+ );
+ assert_eq!(
+ status_as_str(&RadrootsOperationalListingStatus::Sold),
+ "sold"
+ );
+ assert_eq!(
+ status_as_str(&RadrootsOperationalListingStatus::Other {
+ value: "paused".to_string()
+ }),
+ "paused"
+ );
+ }
+
+ #[test]
+ fn discount_payload_without_serde_json_errors() {
+ let discount = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 2,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
+ decimal("1"),
+ RadrootsCoreCurrency::USD,
+ )),
+ };
+ #[cfg(feature = "serde_json")]
+ {
+ let payload = discount_tag_payload(&discount).expect("serde_json payload");
+ assert!(payload.contains("\"scope\":\"bin\""));
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _err = discount_tag_payload(&discount).expect_err("missing serde_json");
+ }
+ }
+
+ #[test]
+ fn farm_and_reference_tag_helpers_cover_errors_and_success() {
+ let mut tags = Vec::new();
+ push_farm_tags(
+ &mut tags,
+ &RadrootsFarmRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: TEST_FARM_D_TAG.to_string(),
+ },
+ )
+ .expect("farm tags");
+ assert!(find_tag(&tags, "p").is_some());
+ assert!(find_tag(&tags, "a").is_some());
+
+ let err = push_farm_tags(
+ &mut Vec::new(),
+ &RadrootsFarmRef {
+ pubkey: "".to_string(),
+ d_tag: TEST_FARM_D_TAG.to_string(),
+ },
+ )
+ .expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = push_farm_tags(
+ &mut Vec::new(),
+ &RadrootsFarmRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "".to_string(),
+ },
+ )
+ .expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let err = push_farm_tags(
+ &mut Vec::new(),
+ &RadrootsFarmRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "farm:invalid".to_string(),
+ },
+ )
+ .expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+
+ let area = tag_listing_resource_area(&RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ })
+ .expect("resource area");
+ assert_eq!(area[0], "radroots:resource_area");
+
+ let plot = tag_listing_plot(&RadrootsPlotRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ })
+ .expect("plot");
+ assert_eq!(plot[0], "radroots:plot");
+ }
+
+ #[test]
+ fn bin_tag_and_price_helpers_cover_error_and_success_paths() {
+ let bin = base_bin();
+ let bin_tag = tag_listing_bin(&bin).expect("bin tag");
+ assert_eq!(bin_tag[0], "radroots:bin");
+ let price_tag = tag_listing_price(&bin).expect("price tag");
+ assert_eq!(price_tag[0], "radroots:price");
+ let total = bin_total_price(&bin).expect("total price");
+ let generic_tag = tag_listing_price_generic(&total);
+ assert_eq!(generic_tag[0], "price");
+
+ let mut non_canonical = base_bin();
+ non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
+ let err = tag_listing_bin(&non_canonical).expect_err("non canonical quantity");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.quantity")
+ ));
+
+ let mut missing_display_amount = base_bin();
+ missing_display_amount.display_amount = None;
+ let err = tag_listing_bin(&missing_display_amount).expect_err("missing display amount");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_amount")
+ ));
+
+ let mut missing_display_unit = base_bin();
+ missing_display_unit.display_unit = None;
+ let err = tag_listing_bin(&missing_display_unit).expect_err("missing display unit");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_unit")
+ ));
+
+ let mut no_display_bin = base_bin();
+ no_display_bin.display_amount = None;
+ no_display_bin.display_unit = None;
+ no_display_bin.display_label = None;
+ let no_display_tag = tag_listing_bin(&no_display_bin).expect("bin tag without display");
+ assert_eq!(no_display_tag.len(), 4);
+
+ let mut invalid_unit_price = base_bin();
+ invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG),
+ );
+ let err = tag_listing_price(&invalid_unit_price).expect_err("not unit price");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut mismatch_display_currency = base_bin();
+ mismatch_display_currency.display_price = Some(RadrootsCoreMoney::new(
+ decimal("10"),
+ RadrootsCoreCurrency::EUR,
+ ));
+ let err = tag_listing_price(&mismatch_display_currency).expect_err("currency mismatch");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price")
+ ));
+
+ let mut missing_display_price = base_bin();
+ missing_display_price.display_price = None;
+ let err = tag_listing_price(&missing_display_price).expect_err("missing display price");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price")
+ ));
+
+ let mut missing_display_price_unit = base_bin();
+ missing_display_price_unit.display_price_unit = None;
+ let err = tag_listing_price(&missing_display_price_unit).expect_err("missing unit");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price_unit")
+ ));
+
+ let mut no_display_price = base_bin();
+ no_display_price.display_price = None;
+ no_display_price.display_price_unit = None;
+ let tag = tag_listing_price(&no_display_price).expect("price tag without display fields");
+ let full_tag = tag_listing_price(&base_bin()).expect("price tag with display fields");
+ assert_eq!(&tag[..6], &full_tag[..6]);
+ assert_eq!(tag.len(), 6);
+
+ let mut invalid_cost = base_bin();
+ invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each),
+ );
+ let err = bin_total_price(&invalid_cost).expect_err("invalid cost conversion");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+ }
+
+ #[test]
+ fn operational_listing_tags_propagate_bin_and_resource_area_validation_errors() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG),
+ );
+ let err = operational_listing_tags_with_options(
+ &listing,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("non unit price should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].quantity =
+ RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ );
+ let err = operational_listing_tags_with_options(
+ &listing,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("non-canonical quantity should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.quantity")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].quantity =
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each);
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ );
+ let err = operational_listing_tags_with_options(
+ &listing,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("invalid total conversion should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "invalid".to_string(),
+ });
+ let err = operational_listing_tags_with_options(
+ &listing,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("invalid resource area d_tag should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+ }
+
+ #[test]
+ fn operational_listing_tags_required_errors_and_success_paths() {
+ let mut listing_with_discount = base_listing();
+ listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 2,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
+ decimal("1"),
+ RadrootsCoreCurrency::USD,
+ )),
+ }]);
+ #[cfg(feature = "serde_json")]
+ {
+ let tags = operational_listing_tags_with_options(
+ &listing_with_discount,
+ OperationalListingTagOptions::default(),
+ )
+ .expect("discount serialization works");
+ assert!(find_tag(&tags, "radroots:discount").is_some());
+ }
+ #[cfg(not(feature = "serde_json"))]
+ {
+ let _err = operational_listing_tags_with_options(
+ &listing_with_discount,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("discount serialization requires serde_json");
+ }
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ let tags = operational_listing_tags_with_options(
+ &listing,
+ OperationalListingTagOptions::with_trade_fields(),
+ )
+ .expect("listing tags");
+ assert!(find_tag(&tags, "d").is_some());
+ assert!(find_tag(&tags, "p").is_some());
+ assert!(find_tag(&tags, "a").is_some());
+ assert!(find_tag(&tags, "radroots:primary_bin").is_some());
+ assert!(find_tag(&tags, "radroots:bin").is_some());
+ assert!(find_tag(&tags, "radroots:price").is_some());
+ assert!(find_tag(&tags, "price").is_some());
+ assert!(find_tag(&tags, "inventory").is_some());
+ assert!(find_tag(&tags, "radroots:availability_start").is_some());
+ assert!(find_tag(&tags, "expires_at").is_some());
+ assert!(find_tag(&tags, "delivery").is_some());
+ assert!(find_tag(&tags, "location").is_some());
+ assert!(find_tag(&tags, "image").is_some());
+
+ let mut status_listing = base_listing();
+ status_listing.discounts = None;
+ status_listing.availability = Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Other {
+ value: "paused".to_string(),
+ },
+ });
+ let status_tags = operational_listing_tags_full(&status_listing).expect("status tags");
+ assert!(status_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("status")
+ && tag.get(1).map(|v| v.as_str()) == Some("paused")
+ }));
+
+ for method in [
+ RadrootsOperationalListingDeliveryMethod::Pickup,
+ RadrootsOperationalListingDeliveryMethod::LocalDelivery,
+ RadrootsOperationalListingDeliveryMethod::Shipping,
+ RadrootsOperationalListingDeliveryMethod::Other {
+ method: "scheduled".to_string(),
+ },
+ ] {
+ let mut delivery_listing = base_listing();
+ delivery_listing.discounts = None;
+ delivery_listing.delivery_method = Some(method);
+ let method_tags =
+ operational_listing_tags_full(&delivery_listing).expect("delivery tags");
+ assert!(find_tag(&method_tags, "delivery").is_some());
+ }
+
+ let mut no_availability = base_listing();
+ no_availability.discounts = None;
+ no_availability.availability = None;
+ let no_availability_tags = operational_listing_tags_with_options(
+ &no_availability,
+ OperationalListingTagOptions {
+ include_availability: true,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("availability option without value");
+ assert!(find_tag(&no_availability_tags, "status").is_none());
+ assert!(find_tag(&no_availability_tags, "radroots:availability_start").is_none());
+ assert!(find_tag(&no_availability_tags, "expires_at").is_none());
+
+ let mut empty_window_availability = base_listing();
+ empty_window_availability.discounts = None;
+ empty_window_availability.availability =
+ Some(RadrootsOperationalListingAvailability::Window {
+ start: None,
+ end: None,
+ });
+ let empty_window_tags = operational_listing_tags_with_options(
+ &empty_window_availability,
+ OperationalListingTagOptions {
+ include_availability: true,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("availability window without bounds");
+ assert!(find_tag(&empty_window_tags, "radroots:availability_start").is_none());
+ assert!(find_tag(&empty_window_tags, "expires_at").is_none());
+
+ let mut no_delivery = base_listing();
+ no_delivery.discounts = None;
+ no_delivery.delivery_method = None;
+ let no_delivery_tags = operational_listing_tags_with_options(
+ &no_delivery,
+ OperationalListingTagOptions {
+ include_delivery: true,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("delivery option without value");
+ assert!(find_tag(&no_delivery_tags, "delivery").is_none());
+
+ let mut no_inventory = base_listing();
+ no_inventory.discounts = None;
+ no_inventory.inventory_available = None;
+ let no_inventory_tags = operational_listing_tags_with_options(
+ &no_inventory,
+ OperationalListingTagOptions {
+ include_inventory: true,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("inventory option without value");
+ assert!(find_tag(&no_inventory_tags, "inventory").is_none());
+
+ let mut no_geo = base_listing();
+ no_geo.discounts = None;
+ let no_geo_tags = operational_listing_tags_with_options(
+ &no_geo,
+ OperationalListingTagOptions {
+ include_geohash: false,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("location without geotags");
+ assert!(find_tag(&no_geo_tags, "location").is_some());
+ assert!(find_tag(&no_geo_tags, "g").is_none());
+ assert!(find_tag(&no_geo_tags, "l").is_none());
+
+ let geohash_only_tags = operational_listing_tags_with_options(
+ &no_geo,
+ OperationalListingTagOptions {
+ include_geohash: true,
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .expect("location with geohash only");
+ assert!(find_tag(&geohash_only_tags, "g").is_some());
+ assert!(find_tag(&geohash_only_tags, "l").is_none());
+ }
+
+ #[test]
+ fn operational_listing_tags_required_field_errors() {
+ let mut listing = base_listing();
+
+ listing.d_tag = d_tag("listing:invalid");
+ let err = operational_listing_tags(&listing).expect_err("invalid d");
+ assert!(matches!(err, EventEncodeError::InvalidField("d")));
+
+ listing = base_listing();
+ listing.bins.clear();
+ let err = operational_listing_tags(&listing).expect_err("missing bins");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
+
+ listing = base_listing();
+ listing.farm.pubkey = "".to_string();
+ let err = operational_listing_tags(&listing).expect_err("missing farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ listing = base_listing();
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: "".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ });
+ let err = operational_listing_tags(&listing).expect_err("missing resource_area pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.pubkey")
+ ));
+
+ listing = base_listing();
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "".to_string(),
+ });
+ let err = operational_listing_tags(&listing).expect_err("missing resource_area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.d_tag")
+ ));
+
+ listing = base_listing();
+ listing.plot = Some(RadrootsPlotRef {
+ pubkey: "".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ });
+ let err = operational_listing_tags(&listing).expect_err("missing plot pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.pubkey")
+ ));
+
+ listing = base_listing();
+ listing.plot = Some(RadrootsPlotRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "".to_string(),
+ });
+ let err = operational_listing_tags(&listing).expect_err("missing plot d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.d_tag")
+ ));
+
+ listing = base_listing();
+ listing.plot = Some(RadrootsPlotRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "plot:invalid".to_string(),
+ });
+ let err = operational_listing_tags(&listing).expect_err("invalid plot d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
+ }
+
+ #[test]
+ fn operational_listing_tags_omit_optional_refs_and_product_fields_when_absent() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.resource_area = None;
+ listing.plot = None;
+ listing.product.summary = None;
+ listing.product.process = None;
+ listing.product.lot = None;
+ listing.product.location = None;
+ listing.product.profile = None;
+ listing.product.year = None;
+ listing.location = None;
+ listing.images = None;
+
+ let tags =
+ operational_listing_tags(&listing).expect("listing tags without optional fields");
+ assert!(find_tag(&tags, "d").is_some());
+ assert!(find_tag(&tags, "p").is_some());
+ assert!(find_tag(&tags, "a").is_some());
+ assert!(find_tag(&tags, "radroots:resource_area").is_none());
+ assert!(find_tag(&tags, "radroots:plot").is_none());
+ assert!(find_tag(&tags, "summary").is_none());
+ assert!(find_tag(&tags, "process").is_none());
+ assert!(find_tag(&tags, "lot").is_none());
+ assert!(find_tag(&tags, "location").is_none());
+ assert!(find_tag(&tags, "profile").is_none());
+ assert!(find_tag(&tags, "year").is_none());
+ assert!(find_tag(&tags, "image").is_none());
+ }
+
+ #[test]
+ fn operational_listing_tags_location_and_product_cleaning_paths() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.product.location = Some(" null ".to_string());
+ listing.product.profile = Some(" ".to_string());
+ listing.location = None;
+ listing.images = Some(vec![RadrootsOperationalListingImage {
+ url: "null".to_string(),
+ size: None,
+ }]);
+ let tags = operational_listing_tags_full(&listing).expect("cleaning path tags");
+ assert!(find_tag(&tags, "location").is_none());
+ assert!(find_tag(&tags, "profile").is_none());
+ assert!(find_tag(&tags, "image").is_none());
+ }
+
+ #[test]
+ fn operational_listing_tags_preserve_location_fields_when_present() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.images = None;
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "6gkzw".to_string(),
+ });
+ let tags = operational_listing_tags_full(&listing).expect("location tags");
+ let location = find_tag(&tags, "location").expect("location tag");
+ assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
+ assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba"));
+ assert_eq!(location.get(3).map(|v| v.as_str()), Some("San Martin"));
+ assert_eq!(location.get(4).map(|v| v.as_str()), Some("PE"));
+ assert!(find_tag(&tags, "image").is_none());
+ }
+
+ #[test]
+ fn operational_listing_tags_reorders_primary_bin_and_falls_back_to_quantity_label() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+
+ let mut primary = base_bin();
+ primary.bin_id = bin_id("bin-1");
+ primary.display_label = None;
+ primary.quantity = primary.quantity.clone().with_label("fallback-label");
+
+ let mut secondary = base_bin();
+ secondary.bin_id = bin_id("bin-2");
+ listing.primary_bin_id = bin_id("bin-1");
+ listing.bins = vec![secondary, primary];
+
+ let tags = operational_listing_tags(&listing).expect("listing tags");
+ let first_bin = tags
+ .iter()
+ .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin"))
+ .expect("first bin tag");
+ assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-1"));
+ assert_eq!(first_bin.get(6).map(|v| v.as_str()), Some("fallback-label"));
+
+ let mut listing_without_primary_match = base_listing();
+ listing_without_primary_match.discounts = None;
+ let mut first = base_bin();
+ first.bin_id = bin_id("bin-2");
+ first.display_label = None;
+ first.quantity = RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG);
+ let mut second = base_bin();
+ second.bin_id = bin_id("bin-1");
+ listing_without_primary_match.primary_bin_id = bin_id("bin-missing");
+ listing_without_primary_match.bins = vec![first, second];
+
+ let tags = operational_listing_tags(&listing_without_primary_match).expect("listing tags");
+ let first_bin = tags
+ .iter()
+ .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin"))
+ .expect("first bin tag");
+ assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-2"));
+ assert_eq!(first_bin.len(), 6);
+ }
+
+ #[test]
+ fn operational_listing_tags_location_handles_partial_optional_components() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some(" ".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some(" ".to_string()),
+ geohash: "9q8yy".to_string(),
+ });
+
+ let tags = operational_listing_tags(&listing).expect("listing tags");
+ let location = find_tag(&tags, "location").expect("location tag");
+ assert_eq!(location.first().map(|v| v.as_str()), Some("location"));
+ assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
+ assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin"));
+ assert_eq!(location.len(), 3);
+
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some(" ".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "9q8yy".to_string(),
+ });
+ let tags = operational_listing_tags(&listing).expect("listing tags");
+ let location = find_tag(&tags, "location").expect("location tag");
+ assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba"));
+ assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba"));
+ assert_eq!(location.get(3).map(|v| v.as_str()), Some("PE"));
+ assert_eq!(location.len(), 4);
+ }
+
+ #[test]
+ fn operational_listing_tags_supports_npub_farm_pubkey() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.farm.pubkey = TEST_NPUB.to_string();
+ let tags = operational_listing_tags(&listing).expect("npub farm tags");
+ assert!(tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("p")
+ && tag.get(1).map(|v| v.as_str()) == Some(TEST_NPUB)
+ }));
+ }
+}
diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs
@@ -4,7 +4,9 @@ use alloc::{string::String, vec::Vec};
#[cfg(feature = "serde_json")]
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventPtr,
- ids::{RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsPublicKey},
+ ids::{
+ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey,
+ },
kinds::is_order_event_kind,
order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderEnvelope,
@@ -124,7 +126,7 @@ pub fn order_envelope_from_event<T: DeserializeOwned>(
if envelope.listing_addr != listing_addr {
return Err(RadrootsOrderEnvelopeParseError::ListingAddrTagMismatch);
}
- RadrootsListingAddress::parse(&envelope.listing_addr)
+ RadrootsClassifiedListingAddress::parse(&envelope.listing_addr)
.map_err(RadrootsOrderEnvelopeParseError::InvalidListingAddr)?;
let tag_order_id = required_order_tag_value(&event_tags, TAG_D)?;
@@ -354,8 +356,8 @@ mod tests {
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr,
ids::{
- RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
- RadrootsOrderQuoteId, RadrootsPublicKey,
+ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId,
+ RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey,
},
kinds::{KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST},
order::{
@@ -391,7 +393,7 @@ mod tests {
seller_pubkey().into_string()
}
- fn listing_addr() -> RadrootsListingAddress {
+ fn listing_addr() -> RadrootsClassifiedListingAddress {
format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey_wire())
.parse()
.unwrap()
@@ -564,7 +566,7 @@ mod tests {
#[test]
fn listing_address_roundtrips() {
let raw = format!("30402:{}:listing-1", seller_pubkey_wire());
- let addr = RadrootsListingAddress::parse(&raw).expect("parse listing address");
+ let addr = RadrootsClassifiedListingAddress::parse(&raw).expect("parse listing address");
assert_eq!(addr.as_str(), raw);
}
@@ -752,7 +754,7 @@ mod tests {
RadrootsOrderPayloadError::MissingItems,
);
let invalid_listing_addr = RadrootsOrderEnvelopeParseError::InvalidListingAddr(
- RadrootsListingAddress::parse("not-a-listing-address").unwrap_err(),
+ RadrootsClassifiedListingAddress::parse("not-a-listing-address").unwrap_err(),
);
let errors = [
RadrootsOrderEnvelopeParseError::InvalidKind(3431),
diff --git a/crates/event_codec/src/order/tags.rs b/crates/event_codec/src/order/tags.rs
@@ -254,7 +254,7 @@ mod tests {
};
use radroots_event::{
RadrootsEventPtr,
- kinds::KIND_LISTING,
+ kinds::KIND_CLASSIFIED_LISTING,
tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT},
};
@@ -264,7 +264,7 @@ mod tests {
#[test]
fn order_envelope_tags_build_expected_tags() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let tags = order_envelope_tags(
"pubkey",
listing_addr.as_str(),
@@ -284,7 +284,7 @@ mod tests {
#[test]
fn order_envelope_tags_include_listing_event_pointer_and_chain_refs() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let listing_event_id = event_id('a');
let tags = order_envelope_tags(
"buyer",
@@ -318,7 +318,7 @@ mod tests {
#[test]
fn order_envelope_tags_support_listing_event_without_relay() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let listing_event_id = event_id('b');
let tags = order_envelope_tags(
"buyer",
@@ -345,7 +345,7 @@ mod tests {
#[test]
fn order_envelope_tags_accept_str_listing_address() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let tags = order_envelope_tags(
"buyer",
listing_addr.as_str(),
@@ -369,7 +369,7 @@ mod tests {
#[test]
fn order_envelope_tags_accept_str_listing_address_with_listing_event_only() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let listing_event_id = event_id('c');
let tags = order_envelope_tags(
"buyer",
@@ -395,7 +395,7 @@ mod tests {
#[test]
fn order_envelope_tags_reject_empty_required_fields() {
- let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
+ let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg");
let err = order_envelope_tags(" ", listing_addr.as_str(), None::<&str>, None, None, None)
.expect_err("blank recipient");
diff --git a/crates/event_codec/src/tag_builders.rs b/crates/event_codec/src/tag_builders.rs
@@ -15,10 +15,10 @@ use radroots_event::{
document::RadrootsDocument, farm::RadrootsFarm, follow::RadrootsFollow,
geochat::RadrootsGeoChat, gift_wrap::RadrootsGiftWrap, job_feedback::RadrootsJobFeedback,
job_request::RadrootsJobRequest, job_result::RadrootsJobResult, list::RadrootsList,
- list_set::RadrootsListSet, listing::RadrootsListing, message::RadrootsMessage,
- message_file::RadrootsMessageFile, plot::RadrootsPlot, reaction::RadrootsReaction,
- resource_area::RadrootsResourceArea, resource_cap::RadrootsResourceHarvestCap,
- seal::RadrootsSeal,
+ list_set::RadrootsListSet, message::RadrootsMessage, message_file::RadrootsMessageFile,
+ operational_listing::RadrootsOperationalListing, plot::RadrootsPlot,
+ reaction::RadrootsReaction, resource_area::RadrootsResourceArea,
+ resource_cap::RadrootsResourceHarvestCap, seal::RadrootsSeal,
};
use crate::app_data::encode::app_data_build_tags;
@@ -44,9 +44,9 @@ use crate::knowledge::encode::{
};
use crate::list::encode::list_build_tags;
use crate::list_set::encode::list_set_build_tags;
-use crate::listing::tags::listing_tags;
use crate::message::encode::message_build_tags;
use crate::message_file::encode::message_file_build_tags;
+use crate::operational_listing::tags::operational_listing_tags;
use crate::plot::encode::plot_build_tags;
use crate::reaction::encode::reaction_build_tags;
use crate::resource_area::encode::resource_area_build_tags;
@@ -58,11 +58,11 @@ pub trait RadrootsEventTagBuilder {
fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error>;
}
-impl RadrootsEventTagBuilder for RadrootsListing {
+impl RadrootsEventTagBuilder for RadrootsOperationalListing {
type Error = EventEncodeError;
fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
- listing_tags(self)
+ operational_listing_tags(self)
}
}
diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs
@@ -215,8 +215,8 @@ mod tests {
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
ids::{
- RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId,
- RadrootsPublicKey, RadrootsTradeId,
+ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId,
+ RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId,
},
trade::{
RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION,
@@ -274,7 +274,7 @@ mod tests {
farm_id: RadrootsDTag::parse("farm-1").unwrap(),
lines: vec![RadrootsTradeCandidateLineV1 {
line_id: RadrootsDTag::parse("line-1").unwrap(),
- listing_addr: RadrootsAddressableCoordinate::parse(format!(
+ listing_addr: RadrootsClassifiedListingAddress::parse(format!(
"30402:{}:listing-1",
hex_64('b')
))
diff --git a/crates/event_codec/tests/domain_encode_non_serde.rs b/crates/event_codec/tests/domain_encode_non_serde.rs
@@ -13,9 +13,10 @@ use radroots_event::{
farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef},
gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon},
ids::{RadrootsDTag, RadrootsInventoryBinId},
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
},
plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef},
resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef},
@@ -26,10 +27,13 @@ use radroots_event_codec::coop::list_sets::{coop_members_farms_list_set, coop_me
use radroots_event_codec::document::encode::document_build_tags;
use radroots_event_codec::error::EventEncodeError;
use radroots_event_codec::farm::encode::{farm_build_tags, farm_ref_tags};
-use radroots_event_codec::farm::list_sets::{farm_listings_list_set, farm_members_list_set};
-use radroots_event_codec::listing::encode::listing_build_tags;
-use radroots_event_codec::listing::tags::{
- ListingTagOptions, listing_tags_full, listing_tags_with_options,
+use radroots_event_codec::farm::list_sets::{
+ farm_members_list_set, farm_operational_listings_list_set,
+};
+use radroots_event_codec::operational_listing::encode::operational_listing_build_tags;
+use radroots_event_codec::operational_listing::tags::{
+ OperationalListingTagOptions, operational_listing_tags_full,
+ operational_listing_tags_with_options,
};
use radroots_event_codec::plot::encode::{plot_address, plot_build_tags};
use radroots_event_codec::resource_area::encode::{
@@ -155,7 +159,7 @@ fn sample_plot() -> RadrootsPlot {
}
}
-fn sample_listing() -> RadrootsListing {
+fn sample_listing() -> RadrootsOperationalListing {
let quantity =
RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
@@ -163,14 +167,14 @@ fn sample_listing() -> RadrootsListing {
quantity.clone(),
);
- RadrootsListing {
+ RadrootsOperationalListing {
d_tag: listing_d_tag(VALID_DOC_D_TAG),
published_at: None,
farm: RadrootsFarmRef {
pubkey: VALID_PUBKEY.to_string(),
d_tag: VALID_FARM_D_TAG.to_string(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "nutmeg".to_string(),
title: "Nutmeg".to_string(),
category: "spice".to_string(),
@@ -182,7 +186,7 @@ fn sample_listing() -> RadrootsListing {
year: None,
},
primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: bin_id("bin-1"),
quantity,
price_per_canonical_unit,
@@ -196,11 +200,11 @@ fn sample_listing() -> RadrootsListing {
plot: None,
discounts: None,
inventory_available: Some(decimal("12")),
- availability: Some(RadrootsListingAvailability::Window {
+ availability: Some(RadrootsOperationalListingAvailability::Window {
start: Some(1),
end: Some(2),
}),
- delivery_method: Some(RadrootsListingDeliveryMethod::Shipping),
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Shipping),
location: None,
images: None,
}
@@ -528,14 +532,14 @@ fn farm_encode_and_list_set_paths() {
EventEncodeError::EmptyRequiredField("entry.values")
));
- let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, [" "])
+ let err = farm_operational_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, [" "])
.expect_err("empty listing id");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("listing_id")
));
- let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, ["invalid"])
+ let err = farm_operational_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, ["invalid"])
.expect_err("invalid listing id");
assert!(matches!(err, EventEncodeError::InvalidField("listing_id")));
}
@@ -633,13 +637,13 @@ fn plot_encode_paths() {
#[test]
fn listing_encode_paths() {
let listing = sample_listing();
- let tags = listing_build_tags(&listing).expect("listing tags");
+ let tags = operational_listing_build_tags(&listing).expect("listing tags");
assert!(
tags.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
);
- let full_tags = listing_tags_full(&listing).expect("listing full tags");
+ let full_tags = operational_listing_tags_full(&listing).expect("listing full tags");
assert!(full_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("inventory")
&& tag.get(1).map(|v| v.as_str()) == Some("12")
@@ -657,9 +661,10 @@ fn listing_encode_paths() {
&& tag.get(1).map(|v| v.as_str()) == Some("shipping")
}));
- let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields;
- let option_tags =
- listing_tags_with_options(&listing, with_trade_fields()).expect("listing option tags");
+ let with_trade_fields: fn() -> OperationalListingTagOptions =
+ OperationalListingTagOptions::with_trade_fields;
+ let option_tags = operational_listing_tags_with_options(&listing, with_trade_fields())
+ .expect("listing option tags");
assert!(option_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("inventory")
&& tag.get(1).map(|v| v.as_str()) == Some("12")
@@ -673,27 +678,29 @@ fn listing_encode_paths() {
listing_with_display_fallback.bins[0].display_amount = Some(decimal("1"));
listing_with_display_fallback.bins[0].display_unit = Some(RadrootsCoreUnit::Each);
listing_with_display_fallback.bins[0].display_label = None;
- let display_tags =
- listing_tags_with_options(&listing_with_display_fallback, ListingTagOptions::default())
- .expect("listing tags with display fallback");
+ let display_tags = operational_listing_tags_with_options(
+ &listing_with_display_fallback,
+ OperationalListingTagOptions::default(),
+ )
+ .expect("listing tags with display fallback");
assert!(display_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("radroots:bin")
&& tag.last().map(|v| v.as_str()) == Some("fallback-label")
}));
let mut listing_with_geohash = sample_listing();
- listing_with_geohash.location = Some(RadrootsListingPublicLocation {
+ listing_with_geohash.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Origin".to_string(),
city: Some("Town".to_string()),
region: Some("Region".to_string()),
country: Some("PE".to_string()),
geohash: "6gkzw".to_string(),
});
- let decoded_tags = listing_tags_with_options(
+ let decoded_tags = operational_listing_tags_with_options(
&listing_with_geohash,
- ListingTagOptions {
+ OperationalListingTagOptions {
include_geohash: false,
- ..ListingTagOptions::default()
+ ..OperationalListingTagOptions::default()
},
)
.expect("listing tags without geohash tag");
@@ -704,32 +711,36 @@ fn listing_encode_paths() {
);
let mut listing_with_shared_geohash = sample_listing();
- listing_with_shared_geohash.location = Some(RadrootsListingPublicLocation {
+ listing_with_shared_geohash.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Origin".to_string(),
city: Some("Town".to_string()),
region: Some("Region".to_string()),
country: Some("PE".to_string()),
geohash: "6gkzw".to_string(),
});
- let shared_geohash_tags =
- listing_tags_with_options(&listing_with_shared_geohash, ListingTagOptions::default())
- .expect("listing tags with shared geohash");
+ let shared_geohash_tags = operational_listing_tags_with_options(
+ &listing_with_shared_geohash,
+ OperationalListingTagOptions::default(),
+ )
+ .expect("listing tags with shared geohash");
assert!(shared_geohash_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("g")
&& tag.get(1).map(|v| v.as_str()) == Some("6gkzw")
}));
let mut listing_without_coordinates = sample_listing();
- listing_without_coordinates.location = Some(RadrootsListingPublicLocation {
+ listing_without_coordinates.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Origin".to_string(),
city: Some("Town".to_string()),
region: Some("Region".to_string()),
country: Some("PE".to_string()),
geohash: "6gkzw".to_string(),
});
- let no_coordinates_tags =
- listing_tags_with_options(&listing_without_coordinates, ListingTagOptions::default())
- .expect("listing tags without coordinates");
+ let no_coordinates_tags = operational_listing_tags_with_options(
+ &listing_without_coordinates,
+ OperationalListingTagOptions::default(),
+ )
+ .expect("listing tags without coordinates");
assert!(
!no_coordinates_tags
.iter()
@@ -746,16 +757,18 @@ fn listing_encode_paths() {
listing_with_blank_optionals.product.location = Some(" ".to_string());
listing_with_blank_optionals.product.profile = Some("null".to_string());
listing_with_blank_optionals.product.year = Some(" ".to_string());
- listing_with_blank_optionals.location = Some(RadrootsListingPublicLocation {
+ listing_with_blank_optionals.location = Some(RadrootsOperationalListingPublicLocation {
primary: " ".to_string(),
city: Some(" ".to_string()),
region: Some("null".to_string()),
country: Some(" ".to_string()),
geohash: "6gkzw".to_string(),
});
- let blank_optional_tags =
- listing_tags_with_options(&listing_with_blank_optionals, ListingTagOptions::default())
- .expect_err("blank public location is invalid");
+ let blank_optional_tags = operational_listing_tags_with_options(
+ &listing_with_blank_optionals,
+ OperationalListingTagOptions::default(),
+ )
+ .expect_err("blank public location is invalid");
assert!(matches!(
blank_optional_tags,
EventEncodeError::EmptyRequiredField("location.primary")
@@ -766,9 +779,9 @@ fn listing_encode_paths() {
listing_with_blank_product_optionals.product.location = Some(" ".to_string());
listing_with_blank_product_optionals.product.profile = Some("null".to_string());
listing_with_blank_product_optionals.product.year = Some(" ".to_string());
- let blank_optional_tags = listing_tags_with_options(
+ let blank_optional_tags = operational_listing_tags_with_options(
&listing_with_blank_product_optionals,
- ListingTagOptions::default(),
+ OperationalListingTagOptions::default(),
)
.expect("listing tags with blank product optional values");
assert!(
@@ -783,18 +796,18 @@ fn listing_encode_paths() {
);
let mut listing_without_geohash = sample_listing();
- listing_without_geohash.location = Some(RadrootsListingPublicLocation {
+ listing_without_geohash.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Origin".to_string(),
city: Some("Town".to_string()),
region: Some("Region".to_string()),
country: Some("PE".to_string()),
geohash: "6gkzw".to_string(),
});
- let without_geohash_tags = listing_tags_with_options(
+ let without_geohash_tags = operational_listing_tags_with_options(
&listing_without_geohash,
- ListingTagOptions {
+ OperationalListingTagOptions {
include_geohash: false,
- ..ListingTagOptions::default()
+ ..OperationalListingTagOptions::default()
},
)
.expect("listing tags without geohash");
@@ -807,7 +820,7 @@ fn listing_encode_paths() {
let mut listing_without_availability = sample_listing();
listing_without_availability.availability = None;
let no_availability_tags =
- listing_tags_with_options(&listing_without_availability, with_trade_fields())
+ operational_listing_tags_with_options(&listing_without_availability, with_trade_fields())
.expect("listing tags without availability");
assert!(
!no_availability_tags
@@ -816,8 +829,8 @@ fn listing_encode_paths() {
);
let mut listing_pickup = sample_listing();
- listing_pickup.delivery_method = Some(RadrootsListingDeliveryMethod::Pickup);
- let pickup_tags = listing_tags_with_options(&listing_pickup, with_trade_fields())
+ listing_pickup.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::Pickup);
+ let pickup_tags = operational_listing_tags_with_options(&listing_pickup, with_trade_fields())
.expect("listing tags with pickup delivery");
assert!(pickup_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("delivery")
@@ -825,8 +838,8 @@ fn listing_encode_paths() {
}));
let mut listing_local = sample_listing();
- listing_local.delivery_method = Some(RadrootsListingDeliveryMethod::LocalDelivery);
- let local_tags = listing_tags_with_options(&listing_local, with_trade_fields())
+ listing_local.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::LocalDelivery);
+ let local_tags = operational_listing_tags_with_options(&listing_local, with_trade_fields())
.expect("listing tags with local delivery");
assert!(local_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("delivery")
@@ -834,11 +847,12 @@ fn listing_encode_paths() {
}));
let mut listing_other_delivery = sample_listing();
- listing_other_delivery.delivery_method = Some(RadrootsListingDeliveryMethod::Other {
- method: "courier".to_string(),
- });
+ listing_other_delivery.delivery_method =
+ Some(RadrootsOperationalListingDeliveryMethod::Other {
+ method: "courier".to_string(),
+ });
let other_delivery_tags =
- listing_tags_with_options(&listing_other_delivery, with_trade_fields())
+ operational_listing_tags_with_options(&listing_other_delivery, with_trade_fields())
.expect("listing tags with other delivery");
assert!(other_delivery_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("delivery")
@@ -849,8 +863,9 @@ fn listing_encode_paths() {
let mut invalid = sample_listing();
invalid.bins[0].display_price = None;
invalid.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each);
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("missing display price");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("missing display price");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.display_price")
@@ -862,8 +877,9 @@ fn listing_encode_paths() {
RadrootsCoreCurrency::USD,
));
invalid.bins[0].display_price_unit = None;
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("missing display price unit");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("missing display price unit");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.display_price_unit")
@@ -875,9 +891,11 @@ fn listing_encode_paths() {
RadrootsCoreCurrency::USD,
));
listing_with_display_price.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each);
- let display_price_tags =
- listing_tags_with_options(&listing_with_display_price, ListingTagOptions::default())
- .expect("listing tags with display price");
+ let display_price_tags = operational_listing_tags_with_options(
+ &listing_with_display_price,
+ OperationalListingTagOptions::default(),
+ )
+ .expect("listing tags with display price");
assert!(display_price_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("radroots:price")
&& tag.get(6).map(|v| v.as_str()) == Some("10")
@@ -889,8 +907,9 @@ fn listing_encode_paths() {
decimal("10"),
RadrootsCoreCurrency::EUR,
));
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("display price currency mismatch");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("display price currency mismatch");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.display_price")
@@ -899,8 +918,9 @@ fn listing_encode_paths() {
let mut invalid = sample_listing();
invalid.bins[0].display_amount = None;
invalid.bins[0].display_unit = Some(RadrootsCoreUnit::Each);
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("missing display amount");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("missing display amount");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.display_amount")
@@ -912,8 +932,9 @@ fn listing_encode_paths() {
RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
);
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("non-canonical bin quantity");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("non-canonical bin quantity");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.quantity")
@@ -924,8 +945,9 @@ fn listing_encode_paths() {
RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::Each),
);
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("price must be per canonical unit");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("price must be per canonical unit");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
@@ -936,8 +958,9 @@ fn listing_encode_paths() {
RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
);
- let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
- .expect_err("non-convertible bin total price");
+ let err =
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("non-convertible bin total price");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
@@ -945,7 +968,7 @@ fn listing_encode_paths() {
let mut invalid = sample_listing();
invalid.farm.d_tag = " ".to_string();
- let err = listing_build_tags(&invalid).expect_err("empty listing farm d_tag");
+ let err = operational_listing_build_tags(&invalid).expect_err("empty listing farm d_tag");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("farm.d_tag")
@@ -954,7 +977,8 @@ fn listing_encode_paths() {
let mut invalid = sample_listing();
invalid.bins.clear();
let err =
- listing_tags_with_options(&invalid, ListingTagOptions::default()).expect_err("empty bins");
+ operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default())
+ .expect_err("empty bins");
assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
}
diff --git a/crates/event_codec/tests/fixtures/listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/listing_parse_event.v1.json
@@ -1,215 +0,0 @@
-{
- "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/fixtures/operational_listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json
@@ -0,0 +1,215 @@
+{
+ "suite": "operational_listing",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "operational_listing_tags_override_json_content_001",
+ "kind": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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": "operational_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
@@ -1,1524 +0,0 @@
-#![cfg(feature = "serde_json")]
-
-use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
- RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
- RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
-};
-use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId},
- kinds::{KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA},
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- 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, 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,
-};
-use radroots_event_codec::listing::tags::{
- ListingTagOptions, listing_tags_full, listing_tags_with_options,
-};
-use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr};
-
-use serde_json::Value;
-
-const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
-const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
-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()
-}
-
-fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- raw.parse().unwrap()
-}
-
-fn sample_listing_tags() -> Vec<Vec<String>> {
- listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap()
-}
-
-fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) {
- tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name));
-}
-
-fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) {
- let tag = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(name))
- .expect("tag");
- *tag = replacement.into_iter().map(str::to_string).collect();
-}
-
-fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: String) -> RadrootsEventEnvelope {
- RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: EVENT_ID.to_string(),
- author: AUTHOR.to_string(),
- created_at: 7,
- kind,
- tags,
- content,
- sig: EVENT_SIG.to_string(),
- })
- .unwrap()
-}
-
-fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) {
- match listing_from_event(KIND_LISTING, &tags, "# Widget") {
- Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected),
- other => panic!("expected missing tag {expected}: {other:?}"),
- }
-}
-
-fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) {
- match listing_from_event(KIND_LISTING, &tags, "# Widget") {
- Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected),
- other => panic!("expected invalid tag {expected}: {other:?}"),
- }
-}
-
-fn sample_listing(d_tag: &str) -> RadrootsListing {
- let quantity =
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
- let price = RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
- quantity.clone(),
- );
-
- RadrootsListing {
- d_tag: listing_d_tag(d_tag),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: "farm_pubkey".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- },
- product: RadrootsListingProduct {
- key: "sku".to_string(),
- title: "Widget".to_string(),
- category: "Tools".to_string(),
- summary: None,
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity,
- price_per_canonical_unit: price,
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
- images: None,
- }
-}
-
-#[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();
- let display_qty = RadrootsCoreDecimal::from_str("1").unwrap();
- let display_price = RadrootsCoreDecimal::from_str("10").unwrap();
-
- RadrootsListing {
- d_tag: listing_d_tag(d_tag),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: "farm_pubkey".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- },
- product: RadrootsListingProduct {
- key: "sku".to_string(),
- title: "Widget".to_string(),
- category: "Tools".to_string(),
- summary: Some("Compact widget".to_string()),
- process: Some("milled".to_string()),
- lot: Some("lot-1".to_string()),
- location: Some("Warehouse".to_string()),
- profile: Some("standard".to_string()),
- year: Some("2024".to_string()),
- },
- primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
- ),
- display_amount: Some(display_qty),
- display_unit: Some(RadrootsCoreUnit::MassKg),
- display_label: Some("bag".to_string()),
- display_price: Some(RadrootsCoreMoney::new(
- display_price,
- RadrootsCoreCurrency::USD,
- )),
- display_price_unit: Some(RadrootsCoreUnit::MassKg),
- }],
- resource_area: None,
- plot: None,
- discounts: Some(vec![RadrootsCoreDiscount {
- scope: RadrootsCoreDiscountScope::Bin,
- threshold: RadrootsCoreDiscountThreshold::BinCount {
- bin_id: "bin-1".to_string(),
- min: 5,
- },
- value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from_str("2").unwrap(),
- RadrootsCoreCurrency::USD,
- )),
- }]),
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: Some("San Martin".to_string()),
- country: Some("PE".to_string()),
- geohash: "9q8yy".to_string(),
- }),
- images: Some(vec![RadrootsListingImage {
- url: "http://example.com/widget.jpg".to_string(),
- size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
- }]),
- }
-}
-
-#[test]
-fn listing_build_tags_requires_d_tag() {
- assert!(RadrootsDTag::parse("").is_err());
-}
-
-#[test]
-fn listing_build_tags_rejects_invalid_d_tag() {
- let listing = sample_listing("invalid:tag");
- let err = listing_build_tags(&listing).unwrap_err();
- assert!(matches!(err, EventEncodeError::InvalidField("d")));
-}
-
-#[test]
-fn listing_roundtrip_from_event() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- let parts = to_wire_parts(&listing).unwrap();
-
- assert_eq!(parts.content, "# Widget");
-
- let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.d_tag, listing.d_tag);
- assert_eq!(decoded.product.key, listing.product.key);
- assert_eq!(decoded.product.title, listing.product.title);
- assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
- assert_eq!(decoded.bins.len(), listing.bins.len());
-}
-
-#[test]
-fn listing_json_wire_parts_with_kind_serializes_listing_object() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- let parts = to_json_wire_parts_with_kind(&listing, KIND_LISTING).unwrap();
-
- assert_eq!(parts.kind, KIND_LISTING);
- let decoded: RadrootsListing = serde_json::from_str(&parts.content).unwrap();
- assert_eq!(decoded.d_tag, listing.d_tag);
- assert_eq!(decoded.product.title, listing.product.title);
- assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
-}
-
-#[test]
-fn listing_json_wire_parts_with_kind_rejects_wrong_kind() {
- let err = to_json_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST)
- .unwrap_err();
- assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
-}
-
-#[test]
-fn listing_json_wire_parts_with_kind_reports_tag_errors() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "resource_pubkey".to_string(),
- d_tag: "bad d".to_string(),
- });
-
- let err = to_json_wire_parts_with_kind(&listing, KIND_LISTING).unwrap_err();
- assert!(matches!(
- err,
- EventEncodeError::InvalidField("resource_area.d_tag")
- ));
-}
-
-#[test]
-fn listing_from_event_reconstructs_from_tags_with_markdown_content() {
- let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA");
- let tags = listing_build_tags(&listing).unwrap();
-
- let decoded = listing_from_event(KIND_LISTING, &tags, "### Markdown listing").unwrap();
- assert_eq!(decoded.d_tag, listing.d_tag);
- assert_eq!(decoded.product.summary, listing.product.summary);
- assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
- assert_eq!(
- decoded
- .location
- .as_ref()
- .map(|location| location.primary.as_str()),
- Some("Moyobamba")
- );
-}
-
-#[test]
-fn listing_from_event_rejects_invalid_d_tag() {
- let mut tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
- let d_tag = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
- .expect("d tag");
- d_tag[1] = "invalid:tag".to_string();
-
- let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag(TAG_D)));
-}
-
-#[test]
-fn listing_from_event_rejects_wrong_kind() {
- let tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
-
- let err = listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "30402",
- got: KIND_POST
- }
- ));
-}
-
-#[test]
-fn listing_from_event_covers_reference_tag_error_paths() {
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, TAG_D);
- assert_missing_tag(tags, TAG_D);
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, TAG_D, vec![TAG_D]);
- assert_invalid_tag(tags, TAG_D);
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]);
- assert_invalid_tag(tags, TAG_D);
-
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, "a");
- assert_missing_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "30340"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]);
- assert_invalid_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]);
- assert_missing_tag(tags, "a");
-
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, "p");
- assert_missing_tag(tags, "p");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "p", vec!["p"]);
- assert_invalid_tag(tags, "p");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "p", vec!["p", " "]);
- assert_invalid_tag(tags, "p");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]);
- assert_invalid_tag(tags, "p");
-}
-
-#[test]
-fn listing_from_event_covers_resource_and_plot_reference_paths() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: "resource_pubkey".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(),
- });
- listing.plot = Some(RadrootsPlotRef {
- pubkey: "plot_pubkey".to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
- });
- let tags = listing_build_tags(&listing).unwrap();
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(
- decoded
- .resource_area
- .as_ref()
- .map(|area| area.d_tag.as_str()),
- Some("AAAAAAAAAAAAAAAAAAAABQ")
- );
- assert_eq!(
- decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()),
- Some("AAAAAAAAAAAAAAAAAAAAAw")
- );
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["radroots:resource_area".to_string()]);
- assert_invalid_tag(tags, "radroots:resource_area");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:resource_area".to_string(),
- format!("{KIND_FARM}:resource_pubkey:resource-area-1"),
- ]);
- assert_invalid_tag(tags, "radroots:resource_area");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:resource_area".to_string(),
- format!("{KIND_RESOURCE_AREA}::resource-area-1"),
- ]);
- assert_invalid_tag(tags, "radroots:resource_area");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:resource_area".to_string(),
- format!("{KIND_RESOURCE_AREA}:resource_pubkey:"),
- ]);
- assert_invalid_tag(tags, "radroots:resource_area");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:resource_area".to_string(),
- format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"),
- ]);
- assert_invalid_tag(tags, "radroots:resource_area");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["radroots:plot".to_string()]);
- assert_invalid_tag(tags, "radroots:plot");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:plot".to_string(),
- format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"),
- ]);
- assert_invalid_tag(tags, "radroots:plot");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:plot".to_string(),
- format!("{KIND_PLOT}:plot_pubkey:"),
- ]);
- assert_invalid_tag(tags, "radroots:plot");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:plot".to_string(),
- format!("{KIND_PLOT}:plot_pubkey:bad d"),
- ]);
- assert_invalid_tag(tags, "radroots:plot");
-}
-
-#[test]
-fn listing_from_event_covers_bin_and_price_error_paths() {
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, "radroots:primary_bin");
- assert_missing_tag(tags, "radroots:primary_bin");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:primary_bin".to_string(),
- "bin-1".to_string(),
- ]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(decoded.primary_bin_id.as_str(), "bin-1");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:primary_bin".to_string(),
- "bin-2".to_string(),
- ]);
- assert_invalid_tag(tags, "radroots:primary_bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:primary_bin",
- vec!["radroots:primary_bin", "bin-2"],
- );
- assert_invalid_tag(tags, "radroots:primary_bin");
-
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, "radroots:bin");
- assert_missing_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- remove_tags(&mut tags, "radroots:price");
- assert_missing_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]);
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:bin",
- vec!["radroots:bin", "bin-1", "1", "kg"],
- );
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:bin",
- vec!["radroots:bin", "bin-1", "1", "not-a-unit"],
- );
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:bin",
- vec!["radroots:bin", "bin-1", "1", "each", "1"],
- );
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:bin",
- vec![
- "radroots:bin",
- "bin-1",
- "1",
- "each",
- "1",
- "each",
- "label",
- "extra",
- ],
- );
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:bin",
- vec!["radroots:bin", "bin-1", "1", "each", "1", "each"],
- );
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(
- decoded.bins[0].display_amount,
- Some(RadrootsCoreDecimal::from(1u32))
- );
- assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each));
- assert_eq!(decoded.bins[0].display_label, None);
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:bin".to_string(),
- "bin-1".to_string(),
- "1".to_string(),
- "each".to_string(),
- ]);
- assert_invalid_tag(tags, "radroots:bin");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]);
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:price",
- vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"],
- );
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:price",
- vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"],
- );
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:price",
- vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"],
- );
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:price",
- vec![
- "radroots:price",
- "bin-1",
- "10",
- "USD",
- "1",
- "each",
- "10",
- "each",
- "extra",
- ],
- );
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:price".to_string(),
- "bin-1".to_string(),
- "10".to_string(),
- "USD".to_string(),
- "1".to_string(),
- "each".to_string(),
- ]);
- assert_invalid_tag(tags, "radroots:price");
-
- let mut tags = sample_listing_tags();
- replace_first_tag(
- &mut tags,
- "radroots:price",
- vec!["radroots:price", "bin-1", "10", "USD", "1", "g"],
- );
- assert_invalid_tag(tags, "radroots:price");
-}
-
-#[test]
-fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
- for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] {
- let mut tags = sample_listing_tags();
- tags.push(vec![expected.to_string(), "synthetic".to_string()]);
- assert_invalid_tag(tags, expected);
- }
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
- assert!(decoded.location.is_none());
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
- tags.push(vec![
- "location".to_string(),
- "Peru".to_string(),
- "Moyobamba".to_string(),
- "San Martin".to_string(),
- "PE".to_string(),
- ]);
- tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
- assert_eq!(
- decoded.location.as_ref().map(|location| {
- (
- location.primary.as_str(),
- location.city.as_deref(),
- location.geohash.as_str(),
- )
- }),
- Some(("Peru", Some("Moyobamba"), "6gkzw"))
- );
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "location".to_string(),
- " ".to_string(),
- "Moyobamba".to_string(),
- ]);
- assert_invalid_tag(tags, "location");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "location".to_string(),
- "Farm stand".to_string(),
- " ".to_string(),
- "null".to_string(),
- " ".to_string(),
- ]);
- tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(
- decoded.location.as_ref().map(|location| {
- (
- location.primary.as_str(),
- location.city.as_deref(),
- location.region.as_deref(),
- location.country.as_deref(),
- location.geohash.as_str(),
- )
- }),
- Some(("Farm stand", None, None, None, "9q8yy"))
- );
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["g".to_string(), "9q8ya".to_string()]);
- assert_invalid_tag(tags, "g");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
- tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
- assert_invalid_tag(tags, "g");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["inventory".to_string()]);
- assert_invalid_tag(tags, "inventory");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["inventory".to_string(), "bad".to_string()]);
- assert_invalid_tag(tags, "inventory");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["inventory".to_string(), "12.5".to_string()]);
- tags.push(vec![
- "radroots:availability_start".to_string(),
- "1730".to_string(),
- ]);
- tags.push(vec!["expires_at".to_string(), "1740".to_string()]);
- tags.push(vec!["delivery".to_string(), "pickup".to_string()]);
- tags.push(vec!["image".to_string(), " ".to_string()]);
- tags.push(vec![
- "image".to_string(),
- "https://example.test/a.jpg".to_string(),
- ]);
- tags.push(vec![
- "image".to_string(),
- "https://example.test/b.jpg".to_string(),
- "bad-size".to_string(),
- ]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- let Some(RadrootsListingAvailability::Window { start, end }) = decoded.availability else {
- panic!("expected availability window");
- };
- assert_eq!(start, Some(1730));
- assert_eq!(end, Some(1740));
- assert!(matches!(
- decoded.delivery_method,
- Some(RadrootsListingDeliveryMethod::Pickup)
- ));
- assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2));
- assert!(decoded.images.as_ref().unwrap()[1].size.is_none());
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert!(matches!(
- decoded.delivery_method,
- Some(RadrootsListingDeliveryMethod::LocalDelivery)
- ));
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["delivery".to_string(), "shipping".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert!(matches!(
- decoded.delivery_method,
- Some(RadrootsListingDeliveryMethod::Shipping)
- ));
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "delivery".to_string(),
- "other".to_string(),
- "bike courier".to_string(),
- ]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else {
- panic!("expected other delivery method");
- };
- assert_eq!(method, "bike courier");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["delivery".to_string(), "drone".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else {
- panic!("expected fallback delivery method");
- };
- assert_eq!(method, "drone");
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["status".to_string(), "active".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert!(matches!(
- decoded.availability,
- Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active
- })
- ));
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["status".to_string(), "sold".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert!(matches!(
- decoded.availability,
- Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Sold
- })
- ));
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["status".to_string(), "paused".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- let Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Other { value },
- }) = decoded.availability
- else {
- panic!("expected other availability status");
- };
- assert_eq!(value, "paused");
-}
-
-#[test]
-fn listing_from_event_rejects_private_location_content_edges() {
- let tags = sample_listing_tags();
- for content in [
- "# Widget",
- "{not-json",
- r#"{"name":"Widget"}"#,
- r#"{"location":{"public_label":"Farm shelf"}}"#,
- ] {
- let decoded = listing_from_event(KIND_LISTING, &tags, content).unwrap();
- assert_eq!(decoded.product.key, "sku");
- }
-
- for key in [
- "lat",
- "lng",
- "lon",
- "point",
- "polygon",
- "coordinates",
- "accuracy",
- "altitude",
- "label",
- "tag_0",
- "gcs",
- ] {
- let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#);
- let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err();
- assert!(matches!(err, EventParseError::InvalidJson("content")));
- }
-}
-
-#[test]
-fn listing_from_event_covers_remaining_edge_paths() {
- let mut tags = sample_listing_tags();
- tags.insert(0, Vec::new());
- tags.push(vec!["location".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(decoded.product.location, None);
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:plot".to_string(),
- format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"),
- ]);
- assert_invalid_tag(tags, "radroots:plot");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:primary_bin".to_string(),
- "bin-2".to_string(),
- ]);
- assert_invalid_tag(tags, "radroots:primary_bin");
-
- let mut tags = sample_listing_tags();
- let primary_position = tags
- .iter()
- .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin"))
- .expect("primary bin tag");
- tags.insert(
- primary_position + 1,
- vec!["radroots:primary_bin".to_string(), "bin-2".to_string()],
- );
- assert_invalid_tag(tags, "radroots:primary_bin");
-
- let mut tags = sample_listing_tags();
- tags.insert(0, vec!["key".to_string(), " ".to_string()]);
- tags.push(vec!["key".to_string(), "ignored".to_string()]);
- tags.insert(0, vec!["summary".to_string(), " ".to_string()]);
- tags.push(vec!["summary".to_string(), "first summary".to_string()]);
- tags.push(vec!["summary".to_string(), "ignored summary".to_string()]);
- tags.push(vec!["process".to_string(), "null".to_string()]);
- tags.push(vec!["lot".to_string(), " null ".to_string()]);
- tags.push(vec!["profile".to_string(), "null".to_string()]);
- tags.push(vec!["year".to_string(), "null".to_string()]);
- let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
- assert_eq!(decoded.product.key, "sku");
- assert_eq!(decoded.product.summary.as_deref(), Some("first summary"));
- assert_eq!(decoded.product.process, None);
- assert_eq!(decoded.product.lot, None);
- assert_eq!(decoded.product.profile, None);
- assert_eq!(decoded.product.year, None);
-
- let mut tags = sample_listing_tags();
- tags.push(vec!["radroots:availability_start".to_string()]);
- assert_invalid_tag(tags, "radroots:availability_start");
-
- let mut tags = sample_listing_tags();
- tags.push(vec![
- "radroots:availability_start".to_string(),
- "bad".to_string(),
- ]);
- assert_invalid_tag(tags, "radroots:availability_start");
-}
-
-#[test]
-fn listing_parsed_wrappers_preserve_event_metadata() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
- let parts = to_wire_parts(&listing).unwrap();
- let data = data_from_event(
- EVENT_ID.to_string(),
- AUTHOR.to_string(),
- 7,
- parts.kind,
- parts.content.clone(),
- parts.tags.clone(),
- )
- .unwrap();
- assert_eq!(data.id, EVENT_ID);
- assert_eq!(data.author, AUTHOR);
- assert_eq!(data.published_at, 7);
- assert_eq!(data.kind, KIND_LISTING);
- assert_eq!(data.data.d_tag, listing.d_tag);
-
- let parsed = parsed_from_event(
- EVENT_ID.to_string(),
- AUTHOR.to_string(),
- 7,
- parts.kind,
- parts.content.clone(),
- parts.tags.clone(),
- EVENT_SIG.to_string(),
- )
- .unwrap();
- assert_eq!(parsed.event.id_str(), EVENT_ID);
- assert_eq!(parsed.event.author_str(), AUTHOR);
- assert_eq!(parsed.event.created_at_u64(), 7);
- assert_eq!(parsed.event.sig_str(), EVENT_SIG);
- assert_eq!(parsed.data.data.d_tag, listing.d_tag);
-
- let event = event_envelope(parts.kind, parts.tags, parts.content);
- let data = data_from_nostr_event(&event).unwrap();
- assert_eq!(data.data.d_tag, listing.d_tag);
- let parsed = parsed_from_nostr_event(&event).unwrap();
- assert_eq!(parsed.event.sig_str(), EVENT_SIG);
- assert_eq!(parsed.data.data.d_tag, listing.d_tag);
-
- let err = parsed_from_event(
- EVENT_ID.to_string(),
- AUTHOR.to_string(),
- 7,
- KIND_POST,
- event.content().to_string(),
- event.tags_as_vec(),
- EVENT_SIG.to_string(),
- )
- .unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "30402",
- got: KIND_POST
- }
- ));
-}
-
-#[test]
-fn retired_listing_kind_is_rejected() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
- listing.published_at = Some(1_781_895_600);
-
- assert!(matches!(
- to_wire_parts_with_kind(&listing, 30403),
- Err(EventEncodeError::InvalidKind(30403))
- ));
-}
-
-#[test]
-fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- listing.published_at = Some(1_781_895_600);
- let parts = to_wire_parts_with_kind(&listing, KIND_LISTING).unwrap();
- assert!(parts.tags.iter().any(|tag| {
- tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)
- && tag.get(1).map(|value| value.as_str()) == Some("1781895600")
- }));
-
- let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.published_at, Some(1_781_895_600));
-
- let mut tags = parts.tags;
- 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_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT)));
-}
-
-#[test]
-fn to_wire_parts_rejects_non_listing_kind() {
- let err =
- to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err();
- assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
-}
-
-#[test]
-fn listing_build_tags_includes_listing_fields() {
- let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
- let tags = listing_build_tags(&listing).unwrap();
-
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some(TAG_D)
- && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("p")
- && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("a")
- && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("title")
- && t.get(1).map(|s| s.as_str()) == Some("Widget")
- }));
-
- let primary_tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:primary_bin"))
- .expect("primary bin tag");
- assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
-
- let bin_tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:bin"))
- .expect("bin tag");
- assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
- assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000"));
- assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g"));
- assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1"));
- assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg"));
- assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag"));
-
- let price_tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:price"))
- .expect("radroots price tag");
- assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
- assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01"));
- assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD"));
- assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1"));
- assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g"));
- assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10"));
- assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg"));
-
- let generic_price_tag = tags
- .iter()
- .find(|t| {
- t.first().map(|s| s.as_str()) == Some("price")
- && t.get(1).map(|s| s.as_str()) == Some("10")
- })
- .expect("generic price tag");
- assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD"));
-
- let discount_tag = tags
- .iter()
- .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:discount"))
- .expect("discount tag");
- assert!(
- discount_tag
- .get(1)
- .map(|s| s.contains("\"scope\":\"bin\""))
- .unwrap_or(false)
- );
-
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("location")
- && t.get(1).map(|s| s.as_str()) == Some("Moyobamba")
- }));
-
- let g_tags: Vec<&Vec<String>> = tags
- .iter()
- .filter(|t| t.first().map(|s| s.as_str()) == Some("g"))
- .collect();
- assert_eq!(g_tags.len(), 1);
- assert_eq!(g_tags[0][1].len(), 5);
- assert!(
- !tags
- .iter()
- .any(|t| t.first().map(|s| s.as_str()) == Some("L"))
- );
- assert!(
- !tags
- .iter()
- .any(|t| t.first().map(|s| s.as_str()) == Some("l"))
- );
-
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("image")
- && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg")
- && t.get(2).map(|s| s.as_str()) == Some("1200x800")
- }));
-}
-
-#[test]
-fn listing_tags_full_uses_single_generic_price_for_primary_bin() {
- let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw");
- listing.bins.push(RadrootsListingBin {
- bin_id: bin_id("bin-2"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from_str("500").unwrap(),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from_str("0.02").unwrap(),
- RadrootsCoreCurrency::USD,
- ),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
- ),
- display_amount: Some(RadrootsCoreDecimal::from(500u32)),
- display_unit: Some(RadrootsCoreUnit::MassG),
- display_label: Some("sample".to_string()),
- display_price: Some(RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from_str("10").unwrap(),
- RadrootsCoreCurrency::USD,
- )),
- display_price_unit: Some(RadrootsCoreUnit::MassG),
- });
-
- let tags = listing_tags_full(&listing).unwrap();
- let generic_price_tags: Vec<&Vec<String>> = tags
- .iter()
- .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price"))
- .collect();
- assert_eq!(generic_price_tags.len(), 1);
- assert_eq!(
- generic_price_tags[0].get(1).map(|value| value.as_str()),
- Some("10")
- );
- assert_eq!(
- generic_price_tags[0].get(2).map(|value| value.as_str()),
- Some("USD")
- );
-}
-
-#[test]
-fn listing_tags_full_includes_trade_fields() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap();
- let inventory_value = inventory.to_string();
- listing.inventory_available = Some(inventory);
- listing.availability = Some(RadrootsListingAvailability::Window {
- start: Some(1730000000),
- end: Some(1731000000),
- });
- listing.delivery_method = Some(RadrootsListingDeliveryMethod::Shipping);
-
- let tags = listing_tags_full(&listing).unwrap();
-
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("inventory")
- && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str())
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("radroots:availability_start")
- && t.get(1).map(|s| s.as_str()) == Some("1730000000")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("expires_at")
- && t.get(1).map(|s| s.as_str()) == Some("1731000000")
- }));
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("delivery")
- && t.get(1).map(|s| s.as_str()) == Some("shipping")
- }));
-}
-
-#[test]
-fn listing_tags_full_includes_status_tag() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- listing.availability = Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
- });
-
- let tags = listing_tags_full(&listing).unwrap();
-
- assert!(tags.iter().any(|t| {
- t.first().map(|s| s.as_str()) == Some("status")
- && t.get(1).map(|s| s.as_str()) == Some("active")
- }));
-}
-
-#[test]
-fn listing_build_tags_ignores_null_strings() {
- let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
- listing.product.summary = Some("null".to_string());
- listing.product.process = Some("null".to_string());
- listing.product.lot = Some("null".to_string());
- listing.product.location = Some("null".to_string());
- listing.product.profile = Some("null".to_string());
- listing.product.year = Some("null".to_string());
- listing.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("null".to_string()),
- region: Some("San Martin".to_string()),
- country: Some("null".to_string()),
- geohash: "9q8yy".to_string(),
- });
- listing.images = Some(vec![RadrootsListingImage {
- url: "null".to_string(),
- size: None,
- }]);
-
- let tags = listing_build_tags(&listing).unwrap();
- assert!(
- !tags
- .iter()
- .any(|tag| tag.iter().any(|value| value == "null"))
- );
-}
-
-#[test]
-fn listing_build_tags_rejects_location_without_public_locality() {
- let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- listing.location = Some(RadrootsListingPublicLocation {
- primary: "Farm stand".to_string(),
- city: Some("null".to_string()),
- region: None,
- country: None,
- geohash: "9q8yy".to_string(),
- });
-
- assert!(matches!(
- listing_build_tags(&listing),
- Err(EventEncodeError::EmptyRequiredField("location.locality"))
- ));
-}
-
-#[test]
-fn listing_tags_with_options_cover_location_fallback_paths() {
- let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
- geohash_only.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: None,
- country: None,
- geohash: "6gkzw".to_string(),
- });
- let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap();
- assert!(
- tags.iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
- );
- assert!(
- !tags
- .iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("l"))
- );
-
- let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
- no_coordinates.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: None,
- country: None,
- geohash: "9q8yy".to_string(),
- });
- let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap();
- assert!(
- !tags
- .iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("L"))
- );
- assert!(
- tags.iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
- );
-
- let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
- without_geohash.location = Some(RadrootsListingPublicLocation {
- primary: "Moyobamba".to_string(),
- city: Some("Moyobamba".to_string()),
- region: None,
- country: None,
- geohash: "9q8yy".to_string(),
- });
- let tags = listing_tags_with_options(
- &without_geohash,
- ListingTagOptions {
- ..ListingTagOptions::default()
- },
- )
- .unwrap();
- assert!(
- tags.iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
- );
- assert!(
- !tags
- .iter()
- .any(|tag| tag.first().map(|value| value.as_str()) == Some("L"))
- );
-}
diff --git a/crates/event_codec/tests/operational_listing.rs b/crates/event_codec/tests/operational_listing.rs
@@ -0,0 +1,1530 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
+ RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
+ RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_event::{
+ RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ kinds::{KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA},
+ operational_listing::RadrootsOperationalListingParseError,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingImage, RadrootsOperationalListingImageSize,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingStatus,
+ },
+ plot::RadrootsPlotRef,
+ resource_area::RadrootsResourceAreaRef,
+ tags::{TAG_D, TAG_PUBLISHED_AT},
+};
+use radroots_event_codec::error::{EventEncodeError, EventParseError};
+use radroots_event_codec::operational_listing::decode::{
+ data_from_event, data_from_nostr_event, operational_listing_from_event,
+ operational_listing_from_event_parts, operational_listing_from_nostr_event, parsed_from_event,
+ parsed_from_nostr_event,
+};
+use radroots_event_codec::operational_listing::encode::{
+ operational_listing_build_tags, to_wire_parts, to_wire_parts_with_kind,
+};
+use radroots_event_codec::operational_listing::tags::{
+ OperationalListingTagOptions, operational_listing_tags_full,
+ operational_listing_tags_with_options,
+};
+use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr};
+
+use serde_json::Value;
+
+const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const EVENT_SIG: &str = concat!(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+);
+const PACKAGED_PARSE_VECTORS: &str =
+ include_str!("fixtures/operational_listing_parse_event.v1.json");
+const WORKSPACE_PARSE_VECTOR_PATH: &str =
+ "../../contracts/conformance/vectors/operational_listing/parse_event.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+
+fn listing_d_tag(raw: &str) -> RadrootsDTag {
+ raw.parse().unwrap()
+}
+
+fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ raw.parse().unwrap()
+}
+
+fn sample_operational_listing_tags() -> Vec<Vec<String>> {
+ operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap()
+}
+
+fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) {
+ tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name));
+}
+
+fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) {
+ let tag = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(name))
+ .expect("tag");
+ *tag = replacement.into_iter().map(str::to_string).collect();
+}
+
+fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: String) -> RadrootsEventEnvelope {
+ RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: EVENT_ID.to_string(),
+ author: AUTHOR.to_string(),
+ created_at: 7,
+ kind,
+ tags,
+ content,
+ sig: EVENT_SIG.to_string(),
+ })
+ .unwrap()
+}
+
+fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) {
+ match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") {
+ Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected),
+ other => panic!("expected missing tag {expected}: {other:?}"),
+ }
+}
+
+fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) {
+ match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") {
+ Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected),
+ other => panic!("expected invalid tag {expected}: {other:?}"),
+ }
+}
+
+fn sample_listing(d_tag: &str) -> RadrootsOperationalListing {
+ let quantity =
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
+ let price = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
+ quantity.clone(),
+ );
+
+ RadrootsOperationalListing {
+ d_tag: listing_d_tag(d_tag),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "sku".to_string(),
+ title: "Widget".to_string(),
+ category: "Tools".to_string(),
+ summary: None,
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity,
+ price_per_canonical_unit: price,
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ }
+}
+
+#[test]
+fn operational_listing_from_event_parts_preserves_listing_error_taxonomy() {
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, TAG_D);
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::MissingTag(TAG_D.to_string())
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]);
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidTag(TAG_D.to_string())
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "bad", "each"],
+ );
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidNumber("radroots:bin".to_string())
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "bad"],
+ );
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidUnit
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"],
+ );
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidCurrency
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["radroots:discount".to_string(), "{".to_string()]);
+ assert_eq!(
+ operational_listing_from_event_parts(&tags, "").unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidDiscount("radroots:discount".to_string())
+ );
+
+ assert_eq!(
+ operational_listing_from_event_parts(
+ &sample_operational_listing_tags(),
+ r#"{"location":{"coordinates":[1,2]}}"#,
+ )
+ .unwrap_err(),
+ RadrootsOperationalListingParseError::InvalidJson("location".to_string())
+ );
+}
+
+#[test]
+fn operational_listing_from_event_parts_does_not_allow_json_content_to_override_tags() {
+ let tags = sample_operational_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 =
+ operational_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");
+ 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") {
+ "operational_listing.parse_event.valid" => {
+ let listing = operational_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}"
+ );
+ }
+ "operational_listing.parse_event.invalid" => {
+ let error = operational_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) -> RadrootsOperationalListing {
+ let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap();
+ let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap();
+ let display_qty = RadrootsCoreDecimal::from_str("1").unwrap();
+ let display_price = RadrootsCoreDecimal::from_str("10").unwrap();
+
+ RadrootsOperationalListing {
+ d_tag: listing_d_tag(d_tag),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "sku".to_string(),
+ title: "Widget".to_string(),
+ category: "Tools".to_string(),
+ summary: Some("Compact widget".to_string()),
+ process: Some("milled".to_string()),
+ lot: Some("lot-1".to_string()),
+ location: Some("Warehouse".to_string()),
+ profile: Some("standard".to_string()),
+ year: Some("2024".to_string()),
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
+ ),
+ display_amount: Some(display_qty),
+ display_unit: Some(RadrootsCoreUnit::MassKg),
+ display_label: Some("bag".to_string()),
+ display_price: Some(RadrootsCoreMoney::new(
+ display_price,
+ RadrootsCoreCurrency::USD,
+ )),
+ display_price_unit: Some(RadrootsCoreUnit::MassKg),
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: Some(vec![RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 5,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from_str("2").unwrap(),
+ RadrootsCoreCurrency::USD,
+ )),
+ }]),
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("PE".to_string()),
+ geohash: "9q8yy".to_string(),
+ }),
+ images: Some(vec![RadrootsOperationalListingImage {
+ url: "http://example.com/widget.jpg".to_string(),
+ size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }),
+ }]),
+ }
+}
+
+#[test]
+fn operational_listing_build_tags_requires_d_tag() {
+ assert!(RadrootsDTag::parse("").is_err());
+}
+
+#[test]
+fn operational_listing_build_tags_rejects_invalid_d_tag() {
+ let listing = sample_listing("invalid:tag");
+ let err = operational_listing_build_tags(&listing).unwrap_err();
+ assert!(matches!(err, EventEncodeError::InvalidField("d")));
+}
+
+#[test]
+fn listing_roundtrip_from_event() {
+ let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ let parts = to_wire_parts(&listing).unwrap();
+
+ assert_eq!(parts.content, "# Widget");
+
+ let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.d_tag, listing.d_tag);
+ assert_eq!(decoded.product.key, listing.product.key);
+ assert_eq!(decoded.product.title, listing.product.title);
+ assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
+ assert_eq!(decoded.bins.len(), listing.bins.len());
+}
+
+#[test]
+fn operational_listing_from_event_reconstructs_from_tags_with_markdown_content() {
+ let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA");
+ let tags = operational_listing_build_tags(&listing).unwrap();
+
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "### Markdown listing")
+ .unwrap();
+ assert_eq!(decoded.d_tag, listing.d_tag);
+ assert_eq!(decoded.product.summary, listing.product.summary);
+ assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
+ assert_eq!(
+ decoded
+ .location
+ .as_ref()
+ .map(|location| location.primary.as_str()),
+ Some("Moyobamba")
+ );
+}
+
+#[test]
+fn operational_listing_from_event_rejects_invalid_d_tag() {
+ let mut tags =
+ operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
+ let d_tag = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
+ .expect("d tag");
+ d_tag[1] = "invalid:tag".to_string();
+
+ let err =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_D)));
+}
+
+#[test]
+fn operational_listing_from_event_rejects_wrong_kind() {
+ let tags = operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
+
+ let err = operational_listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "30402",
+ got: KIND_POST
+ }
+ ));
+}
+
+#[test]
+fn operational_listing_from_event_covers_reference_tag_error_paths() {
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, TAG_D);
+ assert_missing_tag(tags, TAG_D);
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, TAG_D, vec![TAG_D]);
+ assert_invalid_tag(tags, TAG_D);
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]);
+ assert_invalid_tag(tags, TAG_D);
+
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, "a");
+ assert_missing_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "30340"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]);
+ assert_invalid_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]);
+ assert_missing_tag(tags, "a");
+
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, "p");
+ assert_missing_tag(tags, "p");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "p", vec!["p"]);
+ assert_invalid_tag(tags, "p");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "p", vec!["p", " "]);
+ assert_invalid_tag(tags, "p");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]);
+ assert_invalid_tag(tags, "p");
+}
+
+#[test]
+fn operational_listing_from_event_covers_resource_and_plot_reference_paths() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: "resource_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(),
+ });
+ listing.plot = Some(RadrootsPlotRef {
+ pubkey: "plot_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ });
+ let tags = operational_listing_build_tags(&listing).unwrap();
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(
+ decoded
+ .resource_area
+ .as_ref()
+ .map(|area| area.d_tag.as_str()),
+ Some("AAAAAAAAAAAAAAAAAAAABQ")
+ );
+ assert_eq!(
+ decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()),
+ Some("AAAAAAAAAAAAAAAAAAAAAw")
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["radroots:resource_area".to_string()]);
+ assert_invalid_tag(tags, "radroots:resource_area");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:resource_area".to_string(),
+ format!("{KIND_FARM}:resource_pubkey:resource-area-1"),
+ ]);
+ assert_invalid_tag(tags, "radroots:resource_area");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:resource_area".to_string(),
+ format!("{KIND_RESOURCE_AREA}::resource-area-1"),
+ ]);
+ assert_invalid_tag(tags, "radroots:resource_area");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:resource_area".to_string(),
+ format!("{KIND_RESOURCE_AREA}:resource_pubkey:"),
+ ]);
+ assert_invalid_tag(tags, "radroots:resource_area");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:resource_area".to_string(),
+ format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"),
+ ]);
+ assert_invalid_tag(tags, "radroots:resource_area");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["radroots:plot".to_string()]);
+ assert_invalid_tag(tags, "radroots:plot");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:plot".to_string(),
+ format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"),
+ ]);
+ assert_invalid_tag(tags, "radroots:plot");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:plot".to_string(),
+ format!("{KIND_PLOT}:plot_pubkey:"),
+ ]);
+ assert_invalid_tag(tags, "radroots:plot");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:plot".to_string(),
+ format!("{KIND_PLOT}:plot_pubkey:bad d"),
+ ]);
+ assert_invalid_tag(tags, "radroots:plot");
+}
+
+#[test]
+fn operational_listing_from_event_covers_bin_and_price_error_paths() {
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, "radroots:primary_bin");
+ assert_missing_tag(tags, "radroots:primary_bin");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:primary_bin".to_string(),
+ "bin-1".to_string(),
+ ]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(decoded.primary_bin_id.as_str(), "bin-1");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:primary_bin".to_string(),
+ "bin-2".to_string(),
+ ]);
+ assert_invalid_tag(tags, "radroots:primary_bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:primary_bin",
+ vec!["radroots:primary_bin", "bin-2"],
+ );
+ assert_invalid_tag(tags, "radroots:primary_bin");
+
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, "radroots:bin");
+ assert_missing_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ remove_tags(&mut tags, "radroots:price");
+ assert_missing_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]);
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "kg"],
+ );
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "not-a-unit"],
+ );
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "each", "1"],
+ );
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec![
+ "radroots:bin",
+ "bin-1",
+ "1",
+ "each",
+ "1",
+ "each",
+ "label",
+ "extra",
+ ],
+ );
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:bin",
+ vec!["radroots:bin", "bin-1", "1", "each", "1", "each"],
+ );
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(
+ decoded.bins[0].display_amount,
+ Some(RadrootsCoreDecimal::from(1u32))
+ );
+ assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each));
+ assert_eq!(decoded.bins[0].display_label, None);
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:bin".to_string(),
+ "bin-1".to_string(),
+ "1".to_string(),
+ "each".to_string(),
+ ]);
+ assert_invalid_tag(tags, "radroots:bin");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]);
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"],
+ );
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"],
+ );
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"],
+ );
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec![
+ "radroots:price",
+ "bin-1",
+ "10",
+ "USD",
+ "1",
+ "each",
+ "10",
+ "each",
+ "extra",
+ ],
+ );
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:price".to_string(),
+ "bin-1".to_string(),
+ "10".to_string(),
+ "USD".to_string(),
+ "1".to_string(),
+ "each".to_string(),
+ ]);
+ assert_invalid_tag(tags, "radroots:price");
+
+ let mut tags = sample_operational_listing_tags();
+ replace_first_tag(
+ &mut tags,
+ "radroots:price",
+ vec!["radroots:price", "bin-1", "10", "USD", "1", "g"],
+ );
+ assert_invalid_tag(tags, "radroots:price");
+}
+
+#[test]
+fn operational_listing_from_event_covers_trade_location_delivery_and_image_paths() {
+ for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] {
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![expected.to_string(), "synthetic".to_string()]);
+ assert_invalid_tag(tags, expected);
+ }
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
+ assert!(decoded.location.is_none());
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
+ tags.push(vec![
+ "location".to_string(),
+ "Peru".to_string(),
+ "Moyobamba".to_string(),
+ "San Martin".to_string(),
+ "PE".to_string(),
+ ]);
+ tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
+ assert_eq!(
+ decoded.location.as_ref().map(|location| {
+ (
+ location.primary.as_str(),
+ location.city.as_deref(),
+ location.geohash.as_str(),
+ )
+ }),
+ Some(("Peru", Some("Moyobamba"), "6gkzw"))
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "location".to_string(),
+ " ".to_string(),
+ "Moyobamba".to_string(),
+ ]);
+ assert_invalid_tag(tags, "location");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "location".to_string(),
+ "Farm stand".to_string(),
+ " ".to_string(),
+ "null".to_string(),
+ " ".to_string(),
+ ]);
+ tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(
+ decoded.location.as_ref().map(|location| {
+ (
+ location.primary.as_str(),
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ location.geohash.as_str(),
+ )
+ }),
+ Some(("Farm stand", None, None, None, "9q8yy"))
+ );
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["g".to_string(), "9q8ya".to_string()]);
+ assert_invalid_tag(tags, "g");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["g".to_string(), "9q8yy".to_string()]);
+ tags.push(vec!["g".to_string(), "6gkzw".to_string()]);
+ assert_invalid_tag(tags, "g");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["inventory".to_string()]);
+ assert_invalid_tag(tags, "inventory");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["inventory".to_string(), "bad".to_string()]);
+ assert_invalid_tag(tags, "inventory");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["inventory".to_string(), "12.5".to_string()]);
+ tags.push(vec![
+ "radroots:availability_start".to_string(),
+ "1730".to_string(),
+ ]);
+ tags.push(vec!["expires_at".to_string(), "1740".to_string()]);
+ tags.push(vec!["delivery".to_string(), "pickup".to_string()]);
+ tags.push(vec!["image".to_string(), " ".to_string()]);
+ tags.push(vec![
+ "image".to_string(),
+ "https://example.test/a.jpg".to_string(),
+ ]);
+ tags.push(vec![
+ "image".to_string(),
+ "https://example.test/b.jpg".to_string(),
+ "bad-size".to_string(),
+ ]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ let Some(RadrootsOperationalListingAvailability::Window { start, end }) = decoded.availability
+ else {
+ panic!("expected availability window");
+ };
+ assert_eq!(start, Some(1730));
+ assert_eq!(end, Some(1740));
+ assert!(matches!(
+ decoded.delivery_method,
+ Some(RadrootsOperationalListingDeliveryMethod::Pickup)
+ ));
+ assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2));
+ assert!(decoded.images.as_ref().unwrap()[1].size.is_none());
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert!(matches!(
+ decoded.delivery_method,
+ Some(RadrootsOperationalListingDeliveryMethod::LocalDelivery)
+ ));
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["delivery".to_string(), "shipping".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert!(matches!(
+ decoded.delivery_method,
+ Some(RadrootsOperationalListingDeliveryMethod::Shipping)
+ ));
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "delivery".to_string(),
+ "other".to_string(),
+ "bike courier".to_string(),
+ ]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ let Some(RadrootsOperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method
+ else {
+ panic!("expected other delivery method");
+ };
+ assert_eq!(method, "bike courier");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["delivery".to_string(), "drone".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ let Some(RadrootsOperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method
+ else {
+ panic!("expected fallback delivery method");
+ };
+ assert_eq!(method, "drone");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["status".to_string(), "active".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert!(matches!(
+ decoded.availability,
+ Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active
+ })
+ ));
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["status".to_string(), "sold".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert!(matches!(
+ decoded.availability,
+ Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Sold
+ })
+ ));
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["status".to_string(), "paused".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ let Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Other { value },
+ }) = decoded.availability
+ else {
+ panic!("expected other availability status");
+ };
+ assert_eq!(value, "paused");
+}
+
+#[test]
+fn operational_listing_from_event_rejects_private_location_content_edges() {
+ let tags = sample_operational_listing_tags();
+ for content in [
+ "# Widget",
+ "{not-json",
+ r#"{"name":"Widget"}"#,
+ r#"{"location":{"public_label":"Farm shelf"}}"#,
+ ] {
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, content).unwrap();
+ assert_eq!(decoded.product.key, "sku");
+ }
+
+ for key in [
+ "lat",
+ "lng",
+ "lon",
+ "point",
+ "polygon",
+ "coordinates",
+ "accuracy",
+ "altitude",
+ "label",
+ "tag_0",
+ "gcs",
+ ] {
+ let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#);
+ let err =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, &content).unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidJson("content")));
+ }
+}
+
+#[test]
+fn operational_listing_from_event_covers_remaining_edge_paths() {
+ let mut tags = sample_operational_listing_tags();
+ tags.insert(0, Vec::new());
+ tags.push(vec!["location".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(decoded.product.location, None);
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:plot".to_string(),
+ format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"),
+ ]);
+ assert_invalid_tag(tags, "radroots:plot");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:primary_bin".to_string(),
+ "bin-2".to_string(),
+ ]);
+ assert_invalid_tag(tags, "radroots:primary_bin");
+
+ let mut tags = sample_operational_listing_tags();
+ let primary_position = tags
+ .iter()
+ .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin"))
+ .expect("primary bin tag");
+ tags.insert(
+ primary_position + 1,
+ vec!["radroots:primary_bin".to_string(), "bin-2".to_string()],
+ );
+ assert_invalid_tag(tags, "radroots:primary_bin");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.insert(0, vec!["key".to_string(), " ".to_string()]);
+ tags.push(vec!["key".to_string(), "ignored".to_string()]);
+ tags.insert(0, vec!["summary".to_string(), " ".to_string()]);
+ tags.push(vec!["summary".to_string(), "first summary".to_string()]);
+ tags.push(vec!["summary".to_string(), "ignored summary".to_string()]);
+ tags.push(vec!["process".to_string(), "null".to_string()]);
+ tags.push(vec!["lot".to_string(), " null ".to_string()]);
+ tags.push(vec!["profile".to_string(), "null".to_string()]);
+ tags.push(vec!["year".to_string(), "null".to_string()]);
+ let decoded =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap();
+ assert_eq!(decoded.product.key, "sku");
+ assert_eq!(decoded.product.summary.as_deref(), Some("first summary"));
+ assert_eq!(decoded.product.process, None);
+ assert_eq!(decoded.product.lot, None);
+ assert_eq!(decoded.product.profile, None);
+ assert_eq!(decoded.product.year, None);
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec!["radroots:availability_start".to_string()]);
+ assert_invalid_tag(tags, "radroots:availability_start");
+
+ let mut tags = sample_operational_listing_tags();
+ tags.push(vec![
+ "radroots:availability_start".to_string(),
+ "bad".to_string(),
+ ]);
+ assert_invalid_tag(tags, "radroots:availability_start");
+}
+
+#[test]
+fn listing_parsed_wrappers_preserve_event_metadata() {
+ let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ let parts = to_wire_parts(&listing).unwrap();
+ let data = data_from_event(
+ EVENT_ID.to_string(),
+ AUTHOR.to_string(),
+ 7,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(data.id, EVENT_ID);
+ assert_eq!(data.author, AUTHOR);
+ assert_eq!(data.published_at, 7);
+ assert_eq!(data.kind, KIND_CLASSIFIED_LISTING);
+ assert_eq!(data.data.d_tag, listing.d_tag);
+
+ let parsed = parsed_from_event(
+ EVENT_ID.to_string(),
+ AUTHOR.to_string(),
+ 7,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ EVENT_SIG.to_string(),
+ )
+ .unwrap();
+ assert_eq!(parsed.event.id_str(), EVENT_ID);
+ assert_eq!(parsed.event.author_str(), AUTHOR);
+ assert_eq!(parsed.event.created_at_u64(), 7);
+ assert_eq!(parsed.event.sig_str(), EVENT_SIG);
+ assert_eq!(parsed.data.data.d_tag, listing.d_tag);
+
+ let event = event_envelope(parts.kind, parts.tags, parts.content);
+ let data = data_from_nostr_event(&event).unwrap();
+ assert_eq!(data.data.d_tag, listing.d_tag);
+ let parsed = parsed_from_nostr_event(&event).unwrap();
+ assert_eq!(parsed.event.sig_str(), EVENT_SIG);
+ assert_eq!(parsed.data.data.d_tag, listing.d_tag);
+
+ let err = parsed_from_event(
+ EVENT_ID.to_string(),
+ AUTHOR.to_string(),
+ 7,
+ KIND_POST,
+ event.content().to_string(),
+ event.tags_as_vec(),
+ EVENT_SIG.to_string(),
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "30402",
+ got: KIND_POST
+ }
+ ));
+}
+
+#[test]
+fn retired_listing_kind_is_rejected() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ listing.published_at = Some(1_781_895_600);
+
+ assert!(matches!(
+ to_wire_parts_with_kind(&listing, 30403),
+ Err(EventEncodeError::InvalidKind(30403))
+ ));
+}
+
+#[test]
+fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.published_at = Some(1_781_895_600);
+ let parts = to_wire_parts_with_kind(&listing, KIND_CLASSIFIED_LISTING).unwrap();
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)
+ && tag.get(1).map(|value| value.as_str()) == Some("1781895600")
+ }));
+
+ let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.published_at, Some(1_781_895_600));
+
+ let mut tags = parts.tags;
+ 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 =
+ operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT)));
+}
+
+#[test]
+fn to_wire_parts_rejects_non_listing_kind() {
+ let err =
+ to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err();
+ assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
+}
+
+#[test]
+fn operational_listing_build_tags_includes_listing_fields() {
+ let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
+ let tags = operational_listing_build_tags(&listing).unwrap();
+
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some(TAG_D)
+ && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("p")
+ && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("a")
+ && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("title")
+ && t.get(1).map(|s| s.as_str()) == Some("Widget")
+ }));
+
+ let primary_tag = tags
+ .iter()
+ .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:primary_bin"))
+ .expect("primary bin tag");
+ assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
+
+ let bin_tag = tags
+ .iter()
+ .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:bin"))
+ .expect("bin tag");
+ assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
+ assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000"));
+ assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g"));
+ assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1"));
+ assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg"));
+ assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag"));
+
+ let price_tag = tags
+ .iter()
+ .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:price"))
+ .expect("radroots price tag");
+ assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
+ assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01"));
+ assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD"));
+ assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1"));
+ assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g"));
+ assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10"));
+ assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg"));
+
+ let generic_price_tag = tags
+ .iter()
+ .find(|t| {
+ t.first().map(|s| s.as_str()) == Some("price")
+ && t.get(1).map(|s| s.as_str()) == Some("10")
+ })
+ .expect("generic price tag");
+ assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD"));
+
+ let discount_tag = tags
+ .iter()
+ .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:discount"))
+ .expect("discount tag");
+ assert!(
+ discount_tag
+ .get(1)
+ .map(|s| s.contains("\"scope\":\"bin\""))
+ .unwrap_or(false)
+ );
+
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("location")
+ && t.get(1).map(|s| s.as_str()) == Some("Moyobamba")
+ }));
+
+ let g_tags: Vec<&Vec<String>> = tags
+ .iter()
+ .filter(|t| t.first().map(|s| s.as_str()) == Some("g"))
+ .collect();
+ assert_eq!(g_tags.len(), 1);
+ assert_eq!(g_tags[0][1].len(), 5);
+ assert!(
+ !tags
+ .iter()
+ .any(|t| t.first().map(|s| s.as_str()) == Some("L"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|t| t.first().map(|s| s.as_str()) == Some("l"))
+ );
+
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("image")
+ && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg")
+ && t.get(2).map(|s| s.as_str()) == Some("1200x800")
+ }));
+}
+
+#[test]
+fn operational_listing_tags_full_uses_single_generic_price_for_primary_bin() {
+ let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw");
+ listing.bins.push(RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-2"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from_str("500").unwrap(),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from_str("0.02").unwrap(),
+ RadrootsCoreCurrency::USD,
+ ),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
+ ),
+ display_amount: Some(RadrootsCoreDecimal::from(500u32)),
+ display_unit: Some(RadrootsCoreUnit::MassG),
+ display_label: Some("sample".to_string()),
+ display_price: Some(RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from_str("10").unwrap(),
+ RadrootsCoreCurrency::USD,
+ )),
+ display_price_unit: Some(RadrootsCoreUnit::MassG),
+ });
+
+ let tags = operational_listing_tags_full(&listing).unwrap();
+ let generic_price_tags: Vec<&Vec<String>> = tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price"))
+ .collect();
+ assert_eq!(generic_price_tags.len(), 1);
+ assert_eq!(
+ generic_price_tags[0].get(1).map(|value| value.as_str()),
+ Some("10")
+ );
+ assert_eq!(
+ generic_price_tags[0].get(2).map(|value| value.as_str()),
+ Some("USD")
+ );
+}
+
+#[test]
+fn operational_listing_tags_full_includes_trade_fields() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap();
+ let inventory_value = inventory.to_string();
+ listing.inventory_available = Some(inventory);
+ listing.availability = Some(RadrootsOperationalListingAvailability::Window {
+ start: Some(1730000000),
+ end: Some(1731000000),
+ });
+ listing.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::Shipping);
+
+ let tags = operational_listing_tags_full(&listing).unwrap();
+
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("inventory")
+ && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str())
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("radroots:availability_start")
+ && t.get(1).map(|s| s.as_str()) == Some("1730000000")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("expires_at")
+ && t.get(1).map(|s| s.as_str()) == Some("1731000000")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("delivery")
+ && t.get(1).map(|s| s.as_str()) == Some("shipping")
+ }));
+}
+
+#[test]
+fn operational_listing_tags_full_includes_status_tag() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.availability = Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
+ });
+
+ let tags = operational_listing_tags_full(&listing).unwrap();
+
+ assert!(tags.iter().any(|t| {
+ t.first().map(|s| s.as_str()) == Some("status")
+ && t.get(1).map(|s| s.as_str()) == Some("active")
+ }));
+}
+
+#[test]
+fn operational_listing_build_tags_ignores_null_strings() {
+ let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.product.summary = Some("null".to_string());
+ listing.product.process = Some("null".to_string());
+ listing.product.lot = Some("null".to_string());
+ listing.product.location = Some("null".to_string());
+ listing.product.profile = Some("null".to_string());
+ listing.product.year = Some("null".to_string());
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("null".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("null".to_string()),
+ geohash: "9q8yy".to_string(),
+ });
+ listing.images = Some(vec![RadrootsOperationalListingImage {
+ url: "null".to_string(),
+ size: None,
+ }]);
+
+ let tags = operational_listing_build_tags(&listing).unwrap();
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.iter().any(|value| value == "null"))
+ );
+}
+
+#[test]
+fn operational_listing_build_tags_rejects_location_without_public_locality() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Farm stand".to_string(),
+ city: Some("null".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yy".to_string(),
+ });
+
+ assert!(matches!(
+ operational_listing_build_tags(&listing),
+ Err(EventEncodeError::EmptyRequiredField("location.locality"))
+ ));
+}
+
+#[test]
+fn operational_listing_tags_with_options_cover_location_fallback_paths() {
+ let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ geohash_only.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: None,
+ country: None,
+ geohash: "6gkzw".to_string(),
+ });
+ let tags = operational_listing_tags_with_options(
+ &geohash_only,
+ OperationalListingTagOptions::default(),
+ )
+ .unwrap();
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("l"))
+ );
+
+ let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ no_coordinates.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yy".to_string(),
+ });
+ let tags = operational_listing_tags_with_options(
+ &no_coordinates,
+ OperationalListingTagOptions::default(),
+ )
+ .unwrap();
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("L"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
+ );
+
+ let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
+ without_geohash.location = Some(RadrootsOperationalListingPublicLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: None,
+ country: None,
+ geohash: "9q8yy".to_string(),
+ });
+ let tags = operational_listing_tags_with_options(
+ &without_geohash,
+ OperationalListingTagOptions {
+ ..OperationalListingTagOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("g"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some("L"))
+ );
+}
diff --git a/crates/event_codec/tests/social_events.rs b/crates/event_codec/tests/social_events.rs
@@ -7,10 +7,10 @@ use radroots_event::{
file_metadata::RadrootsFileMetadata,
group::{RadrootsGroupEditableMetadata, RadrootsGroupMetadata},
kinds::{
- KIND_ARTICLE, KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_GROUP_METADATA, KIND_HTTP_AUTH,
- KIND_LISTING, KIND_POST, KIND_PUBLIC_FILE_METADATA, KIND_RELAY_AUTH, KIND_REPORT,
- is_home_feed_candidate_kind, is_market_candidate_kind, is_private_farm_ops_kind,
- is_public_social_kind,
+ KIND_ARTICLE, KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_FARM_CRDT_CHANGE,
+ KIND_GROUP_METADATA, KIND_HTTP_AUTH, KIND_POST, KIND_PUBLIC_FILE_METADATA, KIND_RELAY_AUTH,
+ KIND_REPORT, is_home_feed_candidate_kind, is_market_candidate_kind,
+ is_private_farm_ops_kind, is_public_social_kind,
},
social::RadrootsSocialMediaDimensions,
};
@@ -25,7 +25,7 @@ use radroots_event_codec::{
decode::file_metadata_from_event, encode::to_wire_parts as public_file_to_wire_parts,
},
group::{decode::group_metadata_from_event, encode::group_metadata_to_wire_parts},
- listing::decode::listing_from_event,
+ operational_listing::decode::operational_listing_from_event,
post::decode::post_from_event,
};
@@ -88,7 +88,7 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() {
})
));
assert!(matches!(
- listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"),
+ operational_listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"),
Err(EventParseError::InvalidKind {
expected: "30402",
got: KIND_FARM_CRDT_CHANGE
@@ -98,8 +98,8 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() {
assert!(is_public_social_kind(KIND_ARTICLE));
assert!(is_public_social_kind(KIND_PUBLIC_FILE_METADATA));
assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE));
- assert!(!is_public_social_kind(KIND_LISTING));
- assert!(is_home_feed_candidate_kind(KIND_LISTING));
+ assert!(!is_public_social_kind(KIND_CLASSIFIED_LISTING));
+ assert!(is_home_feed_candidate_kind(KIND_CLASSIFIED_LISTING));
assert!(is_home_feed_candidate_kind(KIND_FARM));
assert!(is_home_feed_candidate_kind(KIND_PUBLIC_FILE_METADATA));
assert!(!is_home_feed_candidate_kind(30403));
@@ -107,7 +107,7 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() {
assert!(!is_home_feed_candidate_kind(KIND_FARM_CRDT_CHANGE));
assert!(!is_home_feed_candidate_kind(KIND_RELAY_AUTH));
assert!(!is_home_feed_candidate_kind(KIND_HTTP_AUTH));
- assert!(is_market_candidate_kind(KIND_LISTING));
+ assert!(is_market_candidate_kind(KIND_CLASSIFIED_LISTING));
assert!(!is_market_candidate_kind(30403));
}
diff --git a/crates/event_codec/tests/structured_encode_default.rs b/crates/event_codec/tests/structured_encode_default.rs
@@ -16,7 +16,9 @@ use radroots_event::kinds::{
KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP,
};
use radroots_event::list_set::RadrootsListSet;
-use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct};
+use radroots_event::operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingBin, RadrootsOperationalListingProduct,
+};
use radroots_event::plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef};
use radroots_event::resource_area::{
RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef,
@@ -40,9 +42,9 @@ use radroots_event_codec::farm::encode::{
to_wire_parts_with_kind as farm_to_wire_parts_with_kind,
};
use radroots_event_codec::farm::list_sets::{
- farm_listings_list_set, farm_listings_list_set_from_listings, farm_members_list_set,
- farm_owners_list_set, farm_plots_list_set, farm_plots_list_set_from_plots,
- farm_workers_list_set, member_of_farms_list_set,
+ farm_members_list_set, farm_operational_listings_list_set,
+ farm_operational_listings_list_set_from_listings, farm_owners_list_set, farm_plots_list_set,
+ farm_plots_list_set_from_plots, farm_workers_list_set, member_of_farms_list_set,
};
use radroots_event_codec::plot::encode::{
plot_address, plot_build_tags, to_wire_parts as plot_to_wire_parts,
@@ -107,21 +109,21 @@ fn sample_gcs() -> RadrootsGcsLocation {
}
}
-fn sample_listing(d_tag: &str) -> RadrootsListing {
+fn sample_listing(d_tag: &str) -> RadrootsOperationalListing {
let quantity =
RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
let price = RadrootsCoreQuantityPrice::new(
RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
quantity.clone(),
);
- RadrootsListing {
+ RadrootsOperationalListing {
d_tag: listing_d_tag(d_tag),
published_at: None,
farm: RadrootsFarmRef {
pubkey: TEST_PUBKEY_HEX.to_string(),
d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "sku".to_string(),
title: "Widget".to_string(),
category: "Tools".to_string(),
@@ -133,7 +135,7 @@ fn sample_listing(d_tag: &str) -> RadrootsListing {
year: None,
},
primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: bin_id("bin-1"),
quantity,
price_per_canonical_unit: price,
@@ -877,11 +879,12 @@ fn structured_list_sets_cover_success_and_error_paths() {
assert_eq!(plots.entries.len(), 1);
let listings =
- farm_listings_list_set(farm_id, TEST_PUBKEY_HEX, ["AAAAAAAAAAAAAAAAAAAAAg"]).unwrap();
+ farm_operational_listings_list_set(farm_id, TEST_PUBKEY_HEX, ["AAAAAAAAAAAAAAAAAAAAAg"])
+ .unwrap();
assert_eq!(listings.d_tag, format!("farm:{farm_id}:listings"));
assert_eq!(listings.entries.len(), 1);
- let listings_from = farm_listings_list_set_from_listings(
+ let listings_from = farm_operational_listings_list_set_from_listings(
farm_id,
TEST_PUBKEY_HEX,
[sample_listing("AAAAAAAAAAAAAAAAAAAAAg")].iter(),
@@ -921,7 +924,7 @@ fn structured_list_sets_cover_success_and_error_paths() {
err,
EventEncodeError::EmptyRequiredField("entry.values")
));
- let err = farm_listings_list_set(farm_id, TEST_PUBKEY_HEX, [" "]).unwrap_err();
+ let err = farm_operational_listings_list_set(farm_id, TEST_PUBKEY_HEX, [" "]).unwrap_err();
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("listing_id")
diff --git a/crates/event_codec/tests/tag_builders.rs b/crates/event_codec/tests/tag_builders.rs
@@ -26,13 +26,14 @@ use radroots_event::kinds::{
};
use radroots_event::list::{RadrootsList, RadrootsListEntry};
use radroots_event::list_set::RadrootsListSet;
-use radroots_event::listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingImage,
- RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingPublicLocation,
- RadrootsListingStatus,
-};
use radroots_event::message::{RadrootsMessage, RadrootsMessageRecipient};
use radroots_event::message_file::RadrootsMessageFile;
+use radroots_event::operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingImage,
+ RadrootsOperationalListingImageSize, RadrootsOperationalListingProduct,
+ RadrootsOperationalListingPublicLocation, RadrootsOperationalListingStatus,
+};
use radroots_event::plot::{RadrootsPlot, RadrootsPlotRef};
use radroots_event::reaction::RadrootsReaction;
use radroots_event::resource_area::{
@@ -43,8 +44,10 @@ use radroots_event::seal::RadrootsSeal;
use radroots_event::social::RadrootsSocialTarget;
use radroots_event_codec::error::EventEncodeError;
use radroots_event_codec::job::encode::JobEncodeError;
-use radroots_event_codec::listing::encode::listing_build_tags;
-use radroots_event_codec::listing::tags::{ListingTagOptions, listing_tags_with_options};
+use radroots_event_codec::operational_listing::encode::operational_listing_build_tags;
+use radroots_event_codec::operational_listing::tags::{
+ OperationalListingTagOptions, operational_listing_tags_with_options,
+};
use radroots_event_codec::tag_builders::RadrootsEventTagBuilder;
use test_fixtures::{
CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX, RELAY_PRIMARY_WSS,
@@ -109,7 +112,7 @@ fn sample_gcs() -> RadrootsGcsLocation {
}
}
-fn sample_listing() -> RadrootsListing {
+fn sample_listing() -> RadrootsOperationalListing {
let quantity =
RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
let price = RadrootsCoreQuantityPrice::new(
@@ -117,14 +120,14 @@ fn sample_listing() -> RadrootsListing {
quantity.clone(),
);
- RadrootsListing {
+ RadrootsOperationalListing {
d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
published_at: None,
farm: RadrootsFarmRef {
pubkey: TEST_NPUB.to_string(),
d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "sku".to_string(),
title: "Widget".to_string(),
category: "Tools".to_string(),
@@ -136,7 +139,7 @@ fn sample_listing() -> RadrootsListing {
year: None,
},
primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: bin_id("bin-1"),
quantity,
price_per_canonical_unit: price,
@@ -161,7 +164,7 @@ fn sample_listing() -> RadrootsListing {
fn event_tag_builder_impls_build_tags_for_all_supported_types() {
let listing = sample_listing();
assert!(!listing.build_tags().unwrap().is_empty());
- assert!(!listing_build_tags(&listing).unwrap().is_empty());
+ assert!(!operational_listing_build_tags(&listing).unwrap().is_empty());
let app_data = RadrootsAppData {
d_tag: "radroots.app".to_string(),
@@ -450,35 +453,39 @@ fn listing_and_message_builders_cover_optional_shapes() {
listing.product.location = Some("Moyobamba".to_string());
listing.product.profile = Some("fruity".to_string());
listing.product.year = Some("2024".to_string());
- listing.location = Some(RadrootsListingPublicLocation {
+ listing.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: Some("San Martin".to_string()),
country: Some("PE".to_string()),
geohash: "9q8yy".to_string(),
});
- listing.images = Some(vec![RadrootsListingImage {
+ listing.images = Some(vec![RadrootsOperationalListingImage {
url: cdn_url("a.jpg"),
- size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
+ size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }),
}]);
- assert!(!listing_build_tags(&listing).unwrap().is_empty());
+ assert!(!operational_listing_build_tags(&listing).unwrap().is_empty());
let mut listing_with_trade = listing.clone();
listing_with_trade.inventory_available = Some(RadrootsCoreDecimal::from(12u32));
- let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields;
+ let with_trade_fields: fn() -> OperationalListingTagOptions =
+ OperationalListingTagOptions::with_trade_fields;
let trade_options = with_trade_fields();
- listing_with_trade.availability = Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
+ listing_with_trade.availability = Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
});
- let listing_tags_full_fn: fn(&RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> =
- radroots_event_codec::listing::tags::listing_tags_full;
- let full_tags = listing_tags_full_fn(&listing_with_trade).unwrap();
+ let operational_listing_tags_full_fn: fn(
+ &RadrootsOperationalListing,
+ ) -> Result<Vec<Vec<String>>, EventEncodeError> =
+ radroots_event_codec::operational_listing::tags::operational_listing_tags_full;
+ let full_tags = operational_listing_tags_full_fn(&listing_with_trade).unwrap();
assert!(full_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("inventory")
&& tag.get(1).map(|v| v.as_str()) == Some("12")
}));
- let trade_tags = listing_tags_with_options(&listing_with_trade, trade_options).unwrap();
+ let trade_tags =
+ operational_listing_tags_with_options(&listing_with_trade, trade_options).unwrap();
assert!(trade_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("inventory")
&& tag.get(1).map(|v| v.as_str()) == Some("12")
@@ -489,62 +496,70 @@ fn listing_and_message_builders_cover_optional_shapes() {
}));
let mut listing_status_sold = listing_with_trade.clone();
- listing_status_sold.availability = Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Sold,
+ listing_status_sold.availability = Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Sold,
});
- let sold_tags = listing_tags_with_options(&listing_status_sold, trade_options).unwrap();
+ let sold_tags =
+ operational_listing_tags_with_options(&listing_status_sold, trade_options).unwrap();
assert!(sold_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("status")
&& tag.get(1).map(|v| v.as_str()) == Some("sold")
}));
let mut listing_status_other = listing_with_trade.clone();
- listing_status_other.availability = Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Other {
+ listing_status_other.availability = Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Other {
value: "paused".to_string(),
},
});
- let other_tags = listing_tags_with_options(&listing_status_other, trade_options).unwrap();
+ let other_tags =
+ operational_listing_tags_with_options(&listing_status_other, trade_options).unwrap();
assert!(other_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("status")
&& tag.get(1).map(|v| v.as_str()) == Some("paused")
}));
let mut listing_geohash_only = listing_with_trade.clone();
- listing_geohash_only.location = Some(RadrootsListingPublicLocation {
+ listing_geohash_only.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: None,
country: None,
geohash: "6gkzw".to_string(),
});
- let geohash_tags =
- listing_tags_with_options(&listing_geohash_only, ListingTagOptions::default()).unwrap();
+ let geohash_tags = operational_listing_tags_with_options(
+ &listing_geohash_only,
+ OperationalListingTagOptions::default(),
+ )
+ .unwrap();
assert!(geohash_tags.iter().any(|tag| {
tag.first().map(|v| v.as_str()) == Some("g")
&& tag.get(1).map(|v| v.as_str()) == Some("6gkzw")
}));
let mut listing_no_coordinates = listing_with_trade.clone();
- listing_no_coordinates.location = Some(RadrootsListingPublicLocation {
+ listing_no_coordinates.location = Some(RadrootsOperationalListingPublicLocation {
primary: "Moyobamba".to_string(),
city: Some("Moyobamba".to_string()),
region: None,
country: None,
geohash: "9q8yy".to_string(),
});
- let no_coordinates_tags =
- listing_tags_with_options(&listing_no_coordinates, ListingTagOptions::default()).unwrap();
+ let no_coordinates_tags = operational_listing_tags_with_options(
+ &listing_no_coordinates,
+ OperationalListingTagOptions::default(),
+ )
+ .unwrap();
assert!(
!no_coordinates_tags
.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
);
- let without_private_location_tags = listing_tags_with_options(
+ let without_private_location_tags = operational_listing_tags_with_options(
&listing_with_trade,
- ListingTagOptions {
- ..ListingTagOptions::default()
+ OperationalListingTagOptions {
+ ..OperationalListingTagOptions::default()
},
)
.unwrap();
@@ -555,7 +570,7 @@ fn listing_and_message_builders_cover_optional_shapes() {
);
let mut listing_with_empty_primary_location = listing_with_trade.clone();
- listing_with_empty_primary_location.location = Some(RadrootsListingPublicLocation {
+ listing_with_empty_primary_location.location = Some(RadrootsOperationalListingPublicLocation {
primary: " null ".to_string(),
city: Some("Moyobamba".to_string()),
region: None,
@@ -563,7 +578,7 @@ fn listing_and_message_builders_cover_optional_shapes() {
geohash: "9q8yy".to_string(),
});
assert!(matches!(
- listing_tags_with_options(&listing_with_empty_primary_location, trade_options),
+ operational_listing_tags_with_options(&listing_with_empty_primary_location, trade_options),
Err(EventEncodeError::EmptyRequiredField("location.primary"))
));
@@ -581,8 +596,9 @@ fn listing_and_message_builders_cover_optional_shapes() {
}]);
#[cfg(feature = "serde_json")]
{
- let tags = listing_tags_with_options(&listing_with_discount_payload, trade_options)
- .expect("discount serialization works");
+ let tags =
+ operational_listing_tags_with_options(&listing_with_discount_payload, trade_options)
+ .expect("discount serialization works");
assert!(
tags.iter()
.any(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:discount"))
@@ -590,8 +606,9 @@ fn listing_and_message_builders_cover_optional_shapes() {
}
#[cfg(not(feature = "serde_json"))]
{
- let err = listing_tags_with_options(&listing_with_discount_payload, trade_options)
- .expect_err("discounts require serde_json in non-serde lane");
+ let err =
+ operational_listing_tags_with_options(&listing_with_discount_payload, trade_options)
+ .expect_err("discounts require serde_json in non-serde lane");
assert!(matches!(err, EventEncodeError::Json));
}
@@ -634,17 +651,17 @@ fn listing_and_message_builders_cover_optional_shapes() {
fn listing_builder_rejects_required_field_errors() {
let mut listing = sample_listing();
listing.d_tag = d_tag("listing:invalid");
- let err = listing_build_tags(&listing).expect_err("invalid listing d_tag");
+ let err = operational_listing_build_tags(&listing).expect_err("invalid listing d_tag");
assert!(matches!(err, EventEncodeError::InvalidField("d")));
let mut listing = sample_listing();
listing.bins.clear();
- let err = listing_build_tags(&listing).expect_err("empty bins");
+ let err = operational_listing_build_tags(&listing).expect_err("empty bins");
assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
let mut listing = sample_listing();
listing.farm.pubkey = " ".to_string();
- let err = listing_build_tags(&listing).expect_err("empty farm pubkey");
+ let err = operational_listing_build_tags(&listing).expect_err("empty farm pubkey");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("farm.pubkey")
@@ -652,7 +669,7 @@ fn listing_builder_rejects_required_field_errors() {
let mut listing = sample_listing();
listing.farm.d_tag = " ".to_string();
- let err = listing_build_tags(&listing).expect_err("empty farm d_tag");
+ let err = operational_listing_build_tags(&listing).expect_err("empty farm d_tag");
assert!(matches!(
err,
EventEncodeError::EmptyRequiredField("farm.d_tag")
diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs
@@ -479,8 +479,8 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Identifier => "identifier",
RadrootsTagSemantic::Image => "image",
RadrootsTagSemantic::Kind => "kind",
- RadrootsTagSemantic::ListingAddress => "listing_address",
- RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
+ RadrootsTagSemantic::ClassifiedListingAddress => "listing_address",
+ RadrootsTagSemantic::OperationalListingSnapshot => "listing_snapshot",
RadrootsTagSemantic::ListDescription => "list_description",
RadrootsTagSemantic::Location => "location",
RadrootsTagSemantic::Participant => "participant",
@@ -751,8 +751,14 @@ mod tests {
(RadrootsTagSemantic::Identifier, "identifier"),
(RadrootsTagSemantic::Image, "image"),
(RadrootsTagSemantic::Kind, "kind"),
- (RadrootsTagSemantic::ListingAddress, "listing_address"),
- (RadrootsTagSemantic::ListingSnapshot, "listing_snapshot"),
+ (
+ RadrootsTagSemantic::ClassifiedListingAddress,
+ "listing_address",
+ ),
+ (
+ RadrootsTagSemantic::OperationalListingSnapshot,
+ "listing_snapshot",
+ ),
(RadrootsTagSemantic::ListDescription, "list_description"),
(RadrootsTagSemantic::Location, "location"),
(RadrootsTagSemantic::Participant, "participant"),
diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs
@@ -1642,8 +1642,8 @@ mod tests {
use nostr::EventBuilder;
use radroots_event::draft::RadrootsSignedEvent;
use radroots_event::event_head::event_head_candidate_for_event;
- use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId};
- use radroots_event::kinds::{KIND_GEOCHAT, KIND_LISTING, KIND_POST, KIND_PROFILE};
+ use radroots_event::ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId};
+ use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT, KIND_POST, KIND_PROFILE};
use radroots_event::trade::{
RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1,
@@ -1726,8 +1726,8 @@ mod tests {
farm_id: RadrootsDTag::parse("farm-1").expect("farm id"),
lines: vec![RadrootsTradeCandidateLineV1 {
line_id: RadrootsDTag::parse("line-1").expect("line id"),
- listing_addr: RadrootsAddressableCoordinate::parse(format!(
- "{KIND_LISTING}:{}:listing-1",
+ listing_addr: RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{}:listing-1",
FIXTURE_ALICE_PUBLIC_KEY_HEX
))
.expect("listing address"),
@@ -1941,7 +1941,7 @@ mod tests {
serde_json::to_string(&wire).expect("raw json")
}
- fn listing_tags(d_tag: &str) -> Vec<Vec<String>> {
+ fn operational_listing_tags(d_tag: &str) -> Vec<Vec<String>> {
vec![vec!["d".to_owned(), d_tag.to_owned()]]
}
@@ -2754,7 +2754,7 @@ mod tests {
#[tokio::test]
async fn malformed_addressable_heads_are_not_projected() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = signed_event(KIND_LISTING, 16, Vec::new(), "{}");
+ let event = signed_event(KIND_CLASSIFIED_LISTING, 16, Vec::new(), "{}");
let receipt = store
.ingest_event(RadrootsEventIngest::new(event.clone(), 2_270))
@@ -2781,13 +2781,23 @@ mod tests {
#[tokio::test]
async fn id_mismatch_addressable_raw_json_does_not_update_heads() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let original = signed_event(KIND_LISTING, 17, listing_tags("listing-1"), "{}");
+ let original = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 17,
+ operational_listing_tags("listing-1"),
+ "{}",
+ );
let first = store
.ingest_event(RadrootsEventIngest::new(original.clone(), 2_300))
.await
.expect("first");
let coordinate = head_coordinate_for_event(&original);
- let invalid = signed_event(KIND_LISTING, 18, listing_tags("listing-1"), "{}");
+ let invalid = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 18,
+ operational_listing_tags("listing-1"),
+ "{}",
+ );
let raw_json = tampered_content_raw_json(&invalid, "{\"tampered\":true}");
let error = RadrootsEventIngest::from_raw_json(raw_json, 2_400).expect_err("id mismatch");
let head = store
@@ -2804,16 +2814,21 @@ mod tests {
#[tokio::test]
async fn signature_invalid_addressable_events_do_not_update_heads() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let original = signed_event(KIND_LISTING, 19, listing_tags("listing-2"), "{}");
+ let original = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 19,
+ operational_listing_tags("listing-2"),
+ "{}",
+ );
store
.ingest_event(RadrootsEventIngest::new(original.clone(), 2_500))
.await
.expect("first");
let coordinate = head_coordinate_for_event(&original);
let invalid = tamper_signature(&signed_event(
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
20,
- listing_tags("listing-2"),
+ operational_listing_tags("listing-2"),
"{}",
));
@@ -2842,16 +2857,21 @@ mod tests {
#[tokio::test]
async fn duplicate_invalid_addressable_events_do_not_update_heads() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let original = signed_event(KIND_LISTING, 21, listing_tags("listing-3"), "{}");
+ let original = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 21,
+ operational_listing_tags("listing-3"),
+ "{}",
+ );
store
.ingest_event(RadrootsEventIngest::new(original.clone(), 2_700))
.await
.expect("original");
let coordinate = head_coordinate_for_event(&original);
let invalid = tamper_signature(&signed_event(
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
22,
- listing_tags("listing-3"),
+ operational_listing_tags("listing-3"),
"{}",
));
@@ -2886,7 +2906,12 @@ mod tests {
#[tokio::test]
async fn duplicate_verified_addressable_events_preserve_heads() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = signed_event(KIND_LISTING, 23, listing_tags("listing-4"), "{}");
+ let event = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 23,
+ operational_listing_tags("listing-4"),
+ "{}",
+ );
let coordinate = head_coordinate_for_event(&event);
let first = store
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -3251,10 +3251,10 @@ fn u32_from_i64(field: &'static str, value: i64) -> Result<u32, RadrootsOutboxEr
mod tests {
use super::*;
use radroots_event::ids::{
- RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId,
+ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId,
RadrootsPublicKey, RadrootsTradeId,
};
- use radroots_event::kinds::{KIND_GEOCHAT, KIND_LISTING};
+ use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT};
use radroots_event::trade::{
RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION,
RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1,
@@ -3312,8 +3312,8 @@ mod tests {
farm_id: RadrootsDTag::parse("farm-1").expect("farm id"),
lines: vec![RadrootsTradeCandidateLineV1 {
line_id: RadrootsDTag::parse("line-1").expect("line id"),
- listing_addr: RadrootsAddressableCoordinate::parse(format!(
- "{KIND_LISTING}:{}:listing-1",
+ listing_addr: RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{}:listing-1",
FIXTURE_ALICE_PUBLIC_KEY_HEX
))
.expect("listing address"),
diff --git a/crates/protocol_contract_v1/src/lib.rs b/crates/protocol_contract_v1/src/lib.rs
@@ -293,10 +293,10 @@ pub const PROTOCOL_EVENT_CATALOG_V1: &[ProtocolEventDescriptorV1] = &[
purpose: "immutable one-validator set artifact signed by network authority",
},
ProtocolEventDescriptorV1 {
- name: "listing",
+ name: "classified_listing",
kind: 30402,
event_class: ProtocolEventClassV1::Addressable,
- purpose: "public listing aggregate and revision",
+ purpose: "NIP-99 classified listing",
},
];
@@ -769,6 +769,24 @@ mod tests {
}
#[test]
+ fn classified_listing_catalog_entry_is_exact_nip_99_contract() {
+ let classified_listing = PROTOCOL_EVENT_CATALOG_V1
+ .iter()
+ .find(|event| event.kind == 30402)
+ .expect("kind 30402 catalog entry");
+
+ assert_eq!(
+ *classified_listing,
+ ProtocolEventDescriptorV1 {
+ name: "classified_listing",
+ kind: 30402,
+ event_class: ProtocolEventClassV1::Addressable,
+ purpose: "NIP-99 classified listing",
+ }
+ );
+ }
+
+ #[test]
fn validation_reports_transport_catalog_errors() {
let local = TRANSPORT_CAPABILITY_CATALOG_V1[0];
let nostr = TRANSPORT_CAPABILITY_CATALOG_V1[1];
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -22,14 +22,16 @@ use radroots_event::event_head::{
};
use radroots_event::ids::RadrootsEventId;
use radroots_event::kinds::{
- KIND_CALENDAR, KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_PROFILE, is_nip51_list_set_kind,
+ KIND_CALENDAR, KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_PLOT, KIND_PROFILE,
+ is_nip51_list_set_kind,
};
-use radroots_event::listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingStatus,
+use radroots_event::operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingStatus,
};
use radroots_event_codec::farm::decode as farm_decode;
use radroots_event_codec::list_set::decode as list_set_decode;
-use radroots_event_codec::listing::decode as listing_decode;
+use radroots_event_codec::operational_listing::decode as listing_decode;
use radroots_event_codec::plot::decode as plot_decode;
use radroots_event_codec::profile::decode as profile_decode;
use radroots_replica_schema::ReplicaSchemaError;
@@ -206,7 +208,7 @@ fn ingest_event_inner(
KIND_PROFILE => ingest_profile_event(exec, event),
KIND_FARM => ingest_farm_event(exec, event, factory),
KIND_PLOT => ingest_plot_event(exec, event, factory),
- KIND_LISTING => ingest_listing_event(exec, event),
+ KIND_CLASSIFIED_LISTING => ingest_listing_event(exec, event),
kind if is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR => {
ingest_list_set_event(exec, event)
}
@@ -473,7 +475,7 @@ fn ingest_listing_event(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> {
- let listing = listing_decode::listing_from_event(
+ let listing = listing_decode::operational_listing_from_event(
event.kind_u32(),
&event.tags_as_vec(),
event.content(),
@@ -561,7 +563,10 @@ fn ingest_list_set_event(
))
}
-fn listing_event_addr(event: &RadrootsEventEnvelope, listing: &RadrootsListing) -> String {
+fn listing_event_addr(
+ event: &RadrootsEventEnvelope,
+ listing: &RadrootsOperationalListing,
+) -> String {
format!(
"{}:{}:{}",
event.kind_u32(),
@@ -570,17 +575,17 @@ fn listing_event_addr(event: &RadrootsEventEnvelope, listing: &RadrootsListing)
)
}
-fn listing_is_orderable(listing: &RadrootsListing) -> bool {
+fn listing_is_orderable(listing: &RadrootsOperationalListing) -> bool {
match listing.availability.as_ref() {
- Some(RadrootsListingAvailability::Status { status }) => {
- matches!(status, RadrootsListingStatus::Active)
+ Some(RadrootsOperationalListingAvailability::Status { status }) => {
+ matches!(status, RadrootsOperationalListingStatus::Active)
}
- Some(RadrootsListingAvailability::Window { .. }) | None => true,
+ Some(RadrootsOperationalListingAvailability::Window { .. }) | None => true,
}
}
fn trade_product_fields_from_listing(
- listing: &RadrootsListing,
+ listing: &RadrootsOperationalListing,
listing_addr: &str,
) -> Result<ITradeProductFields, RadrootsReplicaEventsError> {
let bin = primary_listing_bin(listing)?;
@@ -656,7 +661,7 @@ fn trade_product_fields_from_listing(
}
fn trade_product_notes_from_listing(
- listing: &RadrootsListing,
+ listing: &RadrootsOperationalListing,
) -> Result<Option<String>, RadrootsReplicaEventsError> {
let Some(discounts) = listing
.discounts
@@ -674,8 +679,8 @@ fn trade_product_notes_from_listing(
}
fn primary_listing_bin(
- listing: &RadrootsListing,
-) -> Result<&RadrootsListingBin, RadrootsReplicaEventsError> {
+ listing: &RadrootsOperationalListing,
+) -> Result<&RadrootsOperationalListingBin, RadrootsReplicaEventsError> {
listing
.bins
.iter()
@@ -1542,7 +1547,7 @@ mod tests {
use radroots_event::kinds::{KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC};
use radroots_event::list::RadrootsListEntry;
use radroots_event::list_set::RadrootsListSet;
- use radroots_event::listing::RadrootsListingProduct;
+ use radroots_event::operational_listing::RadrootsOperationalListingProduct;
use radroots_event::plot::{RadrootsPlot, RadrootsPlotLocation};
use radroots_event::profile::{
RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfileType, radroots_profile_type_tag_value,
@@ -1917,7 +1922,7 @@ mod tests {
id,
author,
u64::from(created_at),
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
vec![
vec!["d".to_string(), d_tag.to_string()],
vec![
@@ -1968,15 +1973,15 @@ mod tests {
"USD".parse().expect("currency")
}
- fn listing_model() -> RadrootsListing {
- RadrootsListing {
+ fn listing_model() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
d_tag: "AAAAAAAAAAAAAAAAAAAAAA".parse().expect("d tag"),
published_at: Some(1),
farm: RadrootsFarmRef {
pubkey: "c".repeat(64),
d_tag: "AAAAAAAAAAAAAAAAAAAAAZ".to_string(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "pasture-eggs".to_string(),
title: "Pasture Eggs".to_string(),
category: "eggs".to_string(),
@@ -1988,7 +1993,7 @@ mod tests {
year: Some("2026".to_string()),
},
primary_bin_id: "bin-a".parse().expect("primary bin id"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: "bin-a".parse().expect("bin id"),
quantity: RadrootsCoreQuantity::new(listing_decimal("12"), RadrootsCoreUnit::Each)
.with_label("unit label"),
@@ -2006,8 +2011,8 @@ mod tests {
plot: None,
discounts: Some(Vec::new()),
inventory_available: Some(listing_decimal("5")),
- availability: Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
+ availability: Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
}),
delivery_method: None,
location: None,
@@ -2624,7 +2629,10 @@ mod tests {
let seller_pubkey = "c".repeat(64);
let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
- let listing_addr = format!("{}:{}:{}", KIND_LISTING, seller_pubkey, listing_d_tag);
+ let listing_addr = format!(
+ "{}:{}:{}",
+ KIND_CLASSIFIED_LISTING, seller_pubkey, listing_d_tag
+ );
let mut active = listing_event(
500,
@@ -2750,7 +2758,7 @@ mod tests {
&exec,
&INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs {
on: NostrEventHeadQueryBindValues::Key {
- key: event_head_key(KIND_LISTING, &seller_pubkey, listing_d_tag),
+ key: event_head_key(KIND_CLASSIFIED_LISTING, &seller_pubkey, listing_d_tag),
},
}),
)
@@ -2789,7 +2797,10 @@ mod tests {
let seller_pubkey = "c".repeat(64);
let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAg";
- let listing_addr = format!("{}:{}:{}", KIND_LISTING, seller_pubkey, listing_d_tag);
+ let listing_addr = format!(
+ "{}:{}:{}",
+ KIND_CLASSIFIED_LISTING, seller_pubkey, listing_d_tag
+ );
let mut active = listing_event(
600,
diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs
@@ -3,9 +3,9 @@ use dto_bindgen_core::{
StructDef, TargetFieldNames, TargetOverride, TypeDef, TypeRef, WireFieldNames,
};
-use crate::listing::{
- model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal},
- validation::RadrootsTradeListing,
+use crate::operational_listing::{
+ model::{RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal},
+ validation::RadrootsOperationalListingTradeProjection,
};
use crate::workflow::{
RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1,
@@ -15,9 +15,9 @@ use crate::workflow::{
pub fn dto_roots() -> [RootDescriptor; 12] {
[
- RootDescriptor::new::<RadrootsTradeListing>(),
- RootDescriptor::new::<RadrootsTradeListingSubtotal>(),
- RootDescriptor::new::<RadrootsTradeListingTotal>(),
+ RootDescriptor::new::<RadrootsOperationalListingTradeProjection>(),
+ RootDescriptor::new::<RadrootsOperationalListingSubtotal>(),
+ RootDescriptor::new::<RadrootsOperationalListingTotal>(),
RootDescriptor::new::<RadrootsTradeNegotiationStateV1>(),
RootDescriptor::new::<RadrootsTradeAgreementStateV1>(),
RootDescriptor::new::<RadrootsTradeEvidenceStateV1>(),
@@ -30,145 +30,149 @@ pub fn dto_roots() -> [RootDescriptor; 12] {
]
}
-impl Dto for RadrootsTradeListing {
+impl Dto for RadrootsOperationalListingTradeProjection {
fn describe(ctx: &mut DescribeCtx) -> TypeRef {
let def = StructDef::new(
- "RadrootsTradeListing",
- "RadrootsTradeListing",
- span("crates/trade/src/listing/validation.rs", 25),
+ "RadrootsOperationalListingTradeProjection",
+ "RadrootsOperationalListingTradeProjection",
+ span("crates/trade/src/operational_listing/validation.rs", 25),
)
.with_field(field(
"listing_id",
"listing_id",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
26,
))
.with_field(field(
"listing_addr",
"listing_addr",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
27,
))
.with_field(field(
"seller_pubkey",
"seller_pubkey",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
28,
))
.with_field(field(
"title",
"title",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
29,
))
.with_field(field(
"description",
"description",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
30,
))
.with_field(field(
"product_type",
"product_type",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
31,
))
.with_field(field(
"primary_bin_id",
"primary_bin_id",
String::describe(ctx),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
32,
))
.with_field(field(
"bin_quantity",
"bin_quantity",
ts_ref("RadrootsCoreQuantity"),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
33,
))
.with_field(field(
"unit",
"unit",
ts_ref("RadrootsCoreUnit"),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
34,
))
.with_field(field(
"unit_price",
"unit_price",
ts_ref("RadrootsCoreMoney"),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
35,
))
.with_field(field(
"inventory_available",
"inventory_available",
ts_ref("RadrootsCoreDecimal"),
- "crates/trade/src/listing/validation.rs",
+ "crates/trade/src/operational_listing/validation.rs",
36,
))
.with_field(field(
"availability",
"availability",
- ts_ref("RadrootsListingAvailability"),
- "crates/trade/src/listing/validation.rs",
+ ts_ref("RadrootsOperationalListingAvailability"),
+ "crates/trade/src/operational_listing/validation.rs",
37,
))
.with_field(field(
"location",
"location",
- ts_ref("RadrootsListingPublicLocation"),
- "crates/trade/src/listing/validation.rs",
+ ts_ref("RadrootsOperationalListingPublicLocation"),
+ "crates/trade/src/operational_listing/validation.rs",
38,
))
.with_field(field(
"delivery_method",
"delivery_method",
- ts_ref("RadrootsListingDeliveryMethod"),
- "crates/trade/src/listing/validation.rs",
+ ts_ref("RadrootsOperationalListingDeliveryMethod"),
+ "crates/trade/src/operational_listing/validation.rs",
39,
))
.with_field(field(
"listing",
"listing",
- ts_ref("RadrootsListing"),
- "crates/trade/src/listing/validation.rs",
+ ts_ref("RadrootsOperationalListing"),
+ "crates/trade/src/operational_listing/validation.rs",
40,
));
- register(ctx, "RadrootsTradeListing", TypeDef::Struct(def))
+ register(
+ ctx,
+ "RadrootsOperationalListingTradeProjection",
+ TypeDef::Struct(def),
+ )
}
}
-impl Dto for RadrootsTradeListingSubtotal {
+impl Dto for RadrootsOperationalListingSubtotal {
fn describe(ctx: &mut DescribeCtx) -> TypeRef {
- trade_listing_total_like(
+ operational_listing_total_like(
ctx,
- "RadrootsTradeListingSubtotal",
- "crates/trade/src/listing/model.rs",
+ "RadrootsOperationalListingSubtotal",
+ "crates/trade/src/operational_listing/model.rs",
3,
)
}
}
-impl Dto for RadrootsTradeListingTotal {
+impl Dto for RadrootsOperationalListingTotal {
fn describe(ctx: &mut DescribeCtx) -> TypeRef {
- trade_listing_total_like(
+ operational_listing_total_like(
ctx,
- "RadrootsTradeListingTotal",
- "crates/trade/src/listing/model.rs",
+ "RadrootsOperationalListingTotal",
+ "crates/trade/src/operational_listing/model.rs",
12,
)
}
}
-fn trade_listing_total_like(
+fn operational_listing_total_like(
ctx: &mut DescribeCtx,
rust_ident: &str,
file: &str,
@@ -240,9 +244,9 @@ mod tests {
use super::dto_roots;
const TRADE_SOURCE_ROOTS: &[&str] = &[
- "RadrootsTradeListing",
- "RadrootsTradeListingSubtotal",
- "RadrootsTradeListingTotal",
+ "RadrootsOperationalListingTradeProjection",
+ "RadrootsOperationalListingSubtotal",
+ "RadrootsOperationalListingTotal",
"RadrootsTradeNegotiationStateV1",
"RadrootsTradeAgreementStateV1",
"RadrootsTradeEvidenceStateV1",
@@ -276,8 +280,8 @@ mod tests {
#[test]
fn trade_source_fields_use_package_aliases_for_import_boundaries() {
let registry = build_registry(dto_roots());
- let listing = find_struct(®istry, "RadrootsTradeListing");
- let subtotal = find_struct(®istry, "RadrootsTradeListingSubtotal");
+ let listing = find_struct(®istry, "RadrootsOperationalListingTradeProjection");
+ let subtotal = find_struct(®istry, "RadrootsOperationalListingSubtotal");
assert_eq!(
typescript_override_target(field_ty(listing, "inventory_available")),
@@ -285,7 +289,7 @@ mod tests {
);
assert_eq!(
typescript_override_target(field_ty(listing, "listing")),
- Some("RadrootsListing")
+ Some("RadrootsOperationalListing")
);
assert_eq!(
typescript_override_target(field_ty(subtotal, "price_currency")),
diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs
@@ -3,7 +3,7 @@
use core::str::FromStr;
use radroots_event::ids::{
- RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId,
+ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsOrderId,
RadrootsPublicKey,
};
@@ -65,7 +65,7 @@ impl FromStr for RadrootsTradeId {
pub struct RadrootsTradeLocator {
pub trade_id: RadrootsTradeId,
pub root_event_id: Option<RadrootsEventId>,
- pub listing_addr: Option<RadrootsListingAddress>,
+ pub listing_addr: Option<RadrootsClassifiedListingAddress>,
pub buyer_pubkey: Option<RadrootsPublicKey>,
pub seller_pubkey: Option<RadrootsPublicKey>,
}
@@ -94,7 +94,7 @@ impl RadrootsTradeLocator {
self
}
- pub fn with_listing_addr(mut self, listing_addr: RadrootsListingAddress) -> Self {
+ pub fn with_listing_addr(mut self, listing_addr: RadrootsClassifiedListingAddress) -> Self {
self.listing_addr = Some(listing_addr);
self
}
@@ -117,7 +117,7 @@ impl RadrootsTradeLocator {
pub struct RadrootsTradeLocatorCandidate {
pub trade_id: RadrootsTradeId,
pub root_event_id: RadrootsEventId,
- pub listing_addr: RadrootsListingAddress,
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub buyer_pubkey: RadrootsPublicKey,
pub seller_pubkey: RadrootsPublicKey,
}
@@ -137,7 +137,7 @@ impl RadrootsTradeLocatorCandidate {
#[cfg(test)]
mod tests {
use super::*;
- use radroots_event::kinds::KIND_LISTING;
+ use radroots_event::kinds::KIND_CLASSIFIED_LISTING;
const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -154,9 +154,11 @@ mod tests {
RadrootsPublicKey::parse(raw).expect("public key")
}
- fn listing_addr() -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("listing address")
+ fn listing_addr() -> RadrootsClassifiedListingAddress {
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address")
}
#[test]
diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs
@@ -6,7 +6,7 @@ extern crate alloc;
#[cfg(feature = "dto-bindgen")]
pub mod dto;
pub mod identity;
-pub mod listing;
+pub mod operational_listing;
pub mod prelude;
#[cfg(feature = "serde_json")]
pub mod validation_receipt;
diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs
@@ -1,481 +0,0 @@
-//! Canonicalization for Radroots Listing v1 drafts.
-
-#![forbid(unsafe_code)]
-
-#[cfg(not(feature = "std"))]
-use alloc::{format, string::ToString, vec::Vec};
-
-#[cfg(feature = "std")]
-use std::{string::ToString, vec::Vec};
-
-use radroots_authority::RadrootsActorContext;
-use radroots_event::{
- contract::RadrootsActorRole,
- ids::{
- RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey,
- },
- kinds::KIND_LISTING,
- listing::RadrootsListing,
-};
-use thiserror::Error;
-
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsListingEditDocumentV1 {
- pub listing: RadrootsListing,
-}
-
-impl RadrootsListingEditDocumentV1 {
- pub fn new(listing: RadrootsListing) -> Self {
- Self { listing }
- }
-}
-
-#[cfg_attr(feature = "serde", derive(serde::Serialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsCanonicalListingEdit {
- listing: RadrootsListing,
- seller_pubkey: RadrootsPublicKey,
- public_listing_addr: RadrootsListingAddress,
-}
-
-impl RadrootsCanonicalListingEdit {
- pub fn new(
- mut listing: RadrootsListing,
- seller_pubkey: RadrootsPublicKey,
- ) -> Result<Self, RadrootsListingEditError> {
- let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str())
- .map_err(RadrootsListingEditError::InvalidFarmPubkey)?;
- if farm_pubkey != seller_pubkey {
- return Err(RadrootsListingEditError::FarmPubkeyMismatch {
- expected_pubkey: seller_pubkey,
- actual_pubkey: farm_pubkey,
- });
- }
- listing.farm.pubkey = farm_pubkey.as_str().to_string();
- validate_listing_bins(&listing)?;
-
- let public_listing_addr =
- listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str());
-
- Ok(Self {
- listing,
- seller_pubkey,
- public_listing_addr,
- })
- }
-
- pub fn listing(&self) -> &RadrootsListing {
- &self.listing
- }
-
- pub fn seller_pubkey(&self) -> &RadrootsPublicKey {
- &self.seller_pubkey
- }
-
- pub fn public_listing_addr(&self) -> &RadrootsListingAddress {
- &self.public_listing_addr
- }
-}
-
-#[derive(Clone, Debug, Error, PartialEq, Eq)]
-pub enum RadrootsListingEditError {
- #[error("invalid listing edit farm pubkey: {0}")]
- InvalidFarmPubkey(RadrootsIdParseError),
- #[error("invalid listing edit address: {0}")]
- InvalidListingAddress(RadrootsIdParseError),
- #[error("listing edit actor does not satisfy required role {required_role:?}")]
- ActorRoleUnsatisfied { required_role: RadrootsActorRole },
- #[error("listing edit farm pubkey does not match seller")]
- FarmPubkeyMismatch {
- expected_pubkey: RadrootsPublicKey,
- actual_pubkey: RadrootsPublicKey,
- },
- #[error("listing edit primary bin is missing")]
- MissingPrimaryBin {
- primary_bin_id: RadrootsInventoryBinId,
- },
- #[error("listing edit contains duplicate bin ID")]
- DuplicateBinId { bin_id: RadrootsInventoryBinId },
-}
-
-fn validate_listing_bins(listing: &RadrootsListing) -> Result<(), RadrootsListingEditError> {
- let primary_bin_id = listing.primary_bin_id.clone();
- let mut seen_bin_ids = Vec::new();
- let mut primary_bin_found = false;
- for bin in &listing.bins {
- if seen_bin_ids
- .iter()
- .any(|seen_bin_id| seen_bin_id == &bin.bin_id)
- {
- return Err(RadrootsListingEditError::DuplicateBinId {
- bin_id: bin.bin_id.clone(),
- });
- }
- if bin.bin_id == primary_bin_id {
- primary_bin_found = true;
- }
- seen_bin_ids.push(bin.bin_id.clone());
- }
-
- if !primary_bin_found {
- return Err(RadrootsListingEditError::MissingPrimaryBin { primary_bin_id });
- }
- Ok(())
-}
-
-fn listing_addr(
- kind: u32,
- seller_pubkey: &RadrootsPublicKey,
- d_tag: &str,
-) -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str()))
- .expect("typed listing identity must form a listing address")
-}
-
-pub fn canonicalize_listing_edit(
- actor: &RadrootsActorContext,
- mut document: RadrootsListingEditDocumentV1,
-) -> Result<RadrootsCanonicalListingEdit, RadrootsListingEditError> {
- if !actor.satisfies(RadrootsActorRole::Seller) {
- return Err(RadrootsListingEditError::ActorRoleUnsatisfied {
- required_role: RadrootsActorRole::Seller,
- });
- }
-
- let seller_pubkey = actor.pubkey().clone();
- let farm_pubkey = document.listing.farm.pubkey.as_str();
- if farm_pubkey.is_empty() {
- document.listing.farm.pubkey = seller_pubkey.as_str().to_string();
- }
-
- RadrootsCanonicalListingEdit::new(document.listing, seller_pubkey)
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_authority::RadrootsActorContext;
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::{
- contract::RadrootsActorRole,
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey},
- kinds::KIND_LISTING,
- listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct},
- };
-
- use super::{
- RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingEditError,
- canonicalize_listing_edit,
- };
-
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
-
- fn d_tag(raw: &str) -> RadrootsDTag {
- RadrootsDTag::parse(raw).expect("d tag")
- }
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
- }
-
- fn listing() -> RadrootsListing {
- RadrootsListing {
- d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: SELLER.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- },
- product: RadrootsListingProduct {
- key: "coffee".to_string(),
- title: "Coffee".to_string(),
- category: "coffee".to_string(),
- summary: Some("Single origin coffee".to_string()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1000u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice {
- amount: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(20u32),
- RadrootsCoreCurrency::USD,
- ),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- },
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
- images: None,
- }
- }
-
- fn seller_actor() -> RadrootsActorContext {
- RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor")
- }
-
- fn buyer_actor() -> RadrootsActorContext {
- RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor")
- }
-
- #[test]
- fn draft_document_wraps_listing() {
- let document = RadrootsListingEditDocumentV1::new(listing());
-
- assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
- assert_eq!(document.listing.product.title, "Coffee");
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn draft_document_deserializes_as_untrusted_input() {
- let json = serde_json::to_string(&RadrootsListingEditDocumentV1::new(listing()))
- .expect("serialize document");
-
- let document: RadrootsListingEditDocumentV1 =
- serde_json::from_str(&json).expect("deserialize document");
- let canonical =
- canonicalize_listing_edit(&seller_actor(), document).expect("canonical draft");
-
- assert_eq!(canonical.seller_pubkey().as_str(), SELLER);
- assert_eq!(canonical.listing().product.title, "Coffee");
- }
-
- #[test]
- fn canonical_draft_carries_seller_listing_and_addresses() {
- let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller");
- let listing = listing();
-
- let canonical =
- RadrootsCanonicalListingEdit::new(listing, seller_pubkey.clone()).expect("canonical");
-
- assert_eq!(canonical.seller_pubkey(), &seller_pubkey);
- assert_eq!(
- canonical.public_listing_addr().as_str(),
- format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
- );
- assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
- }
-
- #[test]
- fn listing_edit_error_variants_are_precise() {
- assert!(matches!(
- RadrootsListingEditError::InvalidFarmPubkey(
- RadrootsPublicKey::parse("bad").unwrap_err()
- ),
- RadrootsListingEditError::InvalidFarmPubkey(_)
- ));
- assert!(matches!(
- RadrootsListingEditError::InvalidListingAddress(
- RadrootsListingAddress::parse("bad").unwrap_err()
- ),
- RadrootsListingEditError::InvalidListingAddress(_)
- ));
- }
-
- #[test]
- fn canonicalize_listing_edit_fills_missing_farm_pubkey_and_derives_address() {
- let mut listing = listing();
- listing.farm.pubkey.clear();
- let document = RadrootsListingEditDocumentV1::new(listing);
-
- let canonical =
- canonicalize_listing_edit(&seller_actor(), document).expect("canonical draft");
-
- assert_eq!(canonical.seller_pubkey().as_str(), SELLER);
- assert_eq!(
- canonical.public_listing_addr().as_str(),
- format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
- );
- assert_eq!(canonical.listing().farm.pubkey, SELLER);
- }
-
- #[test]
- fn canonicalize_listing_edit_rejects_non_seller_actor() {
- let document = RadrootsListingEditDocumentV1::new(listing());
-
- let error = canonicalize_listing_edit(&buyer_actor(), document).unwrap_err();
-
- assert_eq!(
- error,
- RadrootsListingEditError::ActorRoleUnsatisfied {
- required_role: RadrootsActorRole::Seller
- }
- );
- }
-
- #[test]
- fn canonicalize_listing_edit_rejects_mismatched_farm_pubkey() {
- let mut listing = listing();
- listing.farm.pubkey = OTHER.to_string();
- let document = RadrootsListingEditDocumentV1::new(listing);
-
- let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err();
-
- assert!(matches!(
- error,
- RadrootsListingEditError::FarmPubkeyMismatch { .. }
- ));
- }
-
- #[test]
- fn canonicalize_listing_edit_rejects_invalid_farm_pubkey() {
- let mut listing = listing();
- listing.farm.pubkey = "bad".to_string();
- let document = RadrootsListingEditDocumentV1::new(listing);
-
- let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err();
-
- assert!(matches!(
- error,
- RadrootsListingEditError::InvalidFarmPubkey(_)
- ));
- }
-
- #[test]
- fn canonical_draft_new_rejects_mismatched_farm_pubkey() {
- let mut listing = listing();
- listing.farm.pubkey = OTHER.to_string();
-
- let error = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .unwrap_err();
-
- assert!(matches!(
- error,
- RadrootsListingEditError::FarmPubkeyMismatch { .. }
- ));
- }
-
- #[test]
- fn canonical_draft_new_rejects_invalid_farm_pubkey() {
- let mut listing = listing();
- listing.farm.pubkey = "bad".to_string();
-
- let error = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .unwrap_err();
-
- assert!(matches!(
- error,
- RadrootsListingEditError::InvalidFarmPubkey(_)
- ));
- }
-
- #[test]
- fn canonical_draft_new_rejects_empty_farm_pubkey() {
- let mut listing = listing();
- listing.farm.pubkey.clear();
-
- let error = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .unwrap_err();
-
- assert!(matches!(
- error,
- RadrootsListingEditError::InvalidFarmPubkey(_)
- ));
- }
-
- #[test]
- fn canonicalize_listing_edit_rejects_missing_primary_bin() {
- let mut listing = listing();
- listing.primary_bin_id = bin_id("bin-2");
- let document = RadrootsListingEditDocumentV1::new(listing);
-
- let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err();
-
- assert_eq!(
- error,
- RadrootsListingEditError::MissingPrimaryBin {
- primary_bin_id: bin_id("bin-2")
- }
- );
- }
-
- #[test]
- fn canonical_draft_new_rejects_missing_primary_bin() {
- let mut listing = listing();
- listing.primary_bin_id = bin_id("bin-2");
-
- let error = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .unwrap_err();
-
- assert_eq!(
- error,
- RadrootsListingEditError::MissingPrimaryBin {
- primary_bin_id: bin_id("bin-2")
- }
- );
- }
-
- #[test]
- fn canonicalize_listing_edit_rejects_duplicate_bin_ids() {
- let mut listing = listing();
- listing.bins.push(listing.bins[0].clone());
- let document = RadrootsListingEditDocumentV1::new(listing);
-
- let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err();
-
- assert_eq!(
- error,
- RadrootsListingEditError::DuplicateBinId {
- bin_id: bin_id("bin-1")
- }
- );
- }
-
- #[test]
- fn canonical_draft_new_rejects_duplicate_bin_ids() {
- let mut listing = listing();
- listing.bins.push(listing.bins[0].clone());
-
- let error = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .unwrap_err();
-
- assert_eq!(
- error,
- RadrootsListingEditError::DuplicateBinId {
- bin_id: bin_id("bin-1")
- }
- );
- }
-}
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -1,290 +0,0 @@
-pub mod draft;
-pub mod model;
-pub mod mutation;
-pub mod price_ext;
-pub mod validation;
-
-use radroots_event::{
- RadrootsEventEnvelope,
- ids::{
- RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError,
- RadrootsListingAddress, RadrootsPublicKey,
- },
- kinds::is_listing_kind,
- listing::RadrootsListing,
-};
-use radroots_event_codec::listing::decode::listing_from_nostr_event;
-use thiserror::Error;
-
-pub use self::draft::{
- RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingEditError,
- canonicalize_listing_edit,
-};
-#[cfg(feature = "serde_json")]
-pub use self::mutation::build_listing_mutation_draft;
-pub use self::mutation::{
- RadrootsListingLifecycleState, RadrootsListingMutation, RadrootsListingMutationError,
-};
-pub use radroots_event::order::RadrootsListingParseError as ListingParseError;
-
-#[derive(Clone, Debug, Error, PartialEq, Eq)]
-pub enum RadrootsListingAddressError {
- #[error("invalid listing address: {0}")]
- InvalidAddress(RadrootsIdParseError),
- #[error("listing address must reference a listing kind")]
- InvalidKind { actual: u32 },
-}
-
-#[derive(Clone, Debug, Error, PartialEq, Eq)]
-pub enum RadrootsPublicListingAddressError {
- #[error("invalid listing address: {0}")]
- InvalidAddress(RadrootsIdParseError),
- #[error("listing address must reference a listing kind")]
- InvalidListingKind { actual: u32 },
- #[error("listing address must reference a public NIP-99 listing")]
- InvalidKind { actual: u32 },
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingAddressParts {
- pub address: RadrootsListingAddress,
- pub kind: u32,
- pub seller_pubkey: RadrootsPublicKey,
- pub listing_id: RadrootsDTag,
-}
-
-impl RadrootsListingAddressParts {
- pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsListingAddressError> {
- parse_listing_address(value)
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsPublicListingAddress {
- pub address: RadrootsListingAddress,
- pub kind: u32,
- pub seller_pubkey: RadrootsPublicKey,
- pub listing_id: RadrootsDTag,
-}
-
-impl RadrootsPublicListingAddress {
- pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsPublicListingAddressError> {
- parse_public_listing_address(value)
- }
-}
-
-pub fn parse_listing_address(
- value: impl AsRef<str>,
-) -> Result<RadrootsListingAddressParts, RadrootsListingAddressError> {
- let value = value.as_ref();
- let address = RadrootsListingAddress::parse(value)
- .map_err(RadrootsListingAddressError::InvalidAddress)?;
- let parts = RadrootsAddressableCoordinateParts::parse(address.as_str())
- .expect("typed listing address must contain valid coordinate parts");
- ensure_listing_kind(parts.kind)?;
- Ok(RadrootsListingAddressParts {
- address,
- kind: parts.kind,
- seller_pubkey: parts.pubkey,
- listing_id: parts.d_tag,
- })
-}
-
-pub fn parse_public_listing_address(
- value: impl AsRef<str>,
-) -> Result<RadrootsPublicListingAddress, RadrootsPublicListingAddressError> {
- let parts = parse_listing_address(value).map_err(|error| match error {
- RadrootsListingAddressError::InvalidAddress(error) => {
- RadrootsPublicListingAddressError::InvalidAddress(error)
- }
- RadrootsListingAddressError::InvalidKind { actual } => {
- RadrootsPublicListingAddressError::InvalidListingKind { actual }
- }
- })?;
- Ok(RadrootsPublicListingAddress {
- address: parts.address,
- kind: parts.kind,
- seller_pubkey: parts.seller_pubkey,
- listing_id: parts.listing_id,
- })
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> {
- if !is_listing_kind(kind) {
- return Err(RadrootsListingAddressError::InvalidKind { actual: kind });
- }
- Ok(())
-}
-
-pub fn parse_listing_event(
- event: &RadrootsEventEnvelope,
-) -> Result<RadrootsListing, ListingParseError> {
- listing_from_nostr_event(event)
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- RadrootsListingAddressError, RadrootsListingAddressParts, RadrootsPublicListingAddress,
- RadrootsPublicListingAddressError, parse_listing_address, parse_listing_event,
- parse_public_listing_address,
- };
- use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
- ids::RadrootsListingAddress,
- kinds::{KIND_LISTING, KIND_PROFILE},
- order::RadrootsListingParseError,
- };
-
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
-
- fn listing_event() -> RadrootsEventEnvelope {
- RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: "9".repeat(64),
- author: SELLER.to_string(),
- created_at: 1,
- kind: KIND_LISTING,
- tags: vec![
- vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
- vec!["p".into(), SELLER.into()],
- vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec!["summary".into(), "Single origin".into()],
- vec!["radroots:primary_bin".into(), "bin-1".into()],
- vec![
- "radroots:bin".into(),
- "bin-1".into(),
- "1000".into(),
- "g".into(),
- ],
- vec![
- "radroots:price".into(),
- "bin-1".into(),
- "20".into(),
- "USD".into(),
- "1".into(),
- "g".into(),
- ],
- ],
- content: String::new(),
- sig: "f".repeat(128),
- })
- .expect("listing event")
- }
-
- #[test]
- fn parse_listing_event_rejects_non_listing_kind() {
- let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: "8".repeat(64),
- author: SELLER.to_string(),
- created_at: 1,
- kind: KIND_PROFILE,
- tags: vec![],
- content: String::new(),
- sig: "f".repeat(128),
- })
- .expect("profile event");
-
- assert!(matches!(
- parse_listing_event(&event),
- Err(RadrootsListingParseError::InvalidKind(KIND_PROFILE))
- ));
- }
-
- #[test]
- fn parse_listing_event_accepts_listing_kind() {
- let listing = parse_listing_event(&listing_event()).expect("listing");
-
- assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
- assert_eq!(listing.farm.pubkey, SELLER);
- assert_eq!(listing.primary_bin_id.as_str(), "bin-1");
- }
-
- #[test]
- fn listing_address_associated_parsers_delegate_to_public_parsers() {
- let raw = format!("{KIND_LISTING}:{SELLER}:listing-1");
-
- let listing = RadrootsListingAddressParts::parse(raw.clone()).expect("listing address");
- let public = RadrootsPublicListingAddress::parse(&raw).expect("public address");
- let typed =
- parse_public_listing_address(RadrootsListingAddress::parse(&raw).expect("typed addr"))
- .expect("typed public address");
-
- assert_eq!(listing.address.as_str(), raw);
- assert_eq!(public.address.as_str(), raw);
- assert_eq!(typed.address.as_str(), raw);
- assert_eq!(listing.seller_pubkey.as_str(), SELLER);
- assert_eq!(public.seller_pubkey.as_str(), SELLER);
- assert_eq!(typed.seller_pubkey.as_str(), SELLER);
- }
-
- #[test]
- fn parse_public_listing_address_accepts_public_listing_kind() {
- let raw = format!("{KIND_LISTING}:{SELLER}:listing-1");
- let parsed = parse_public_listing_address(&raw).expect("public listing address");
-
- assert_eq!(parsed.address.as_str(), raw);
- assert_eq!(parsed.kind, KIND_LISTING);
- assert_eq!(parsed.seller_pubkey.as_str(), SELLER);
- assert_eq!(parsed.listing_id.as_str(), "listing-1");
- }
-
- #[test]
- fn parse_listing_address_rejects_retired_listing_kind() {
- let raw = format!("30403:{SELLER}:listing-1");
-
- assert!(matches!(
- parse_listing_address(&raw),
- Err(RadrootsListingAddressError::InvalidKind { actual: 30403 })
- ));
- assert!(matches!(
- parse_public_listing_address(&raw),
- Err(RadrootsPublicListingAddressError::InvalidListingKind { actual: 30403 })
- ));
- }
-
- #[test]
- fn parse_public_listing_address_maps_invalid_listing_addresses() {
- assert!(matches!(
- parse_public_listing_address("not-an-address"),
- Err(RadrootsPublicListingAddressError::InvalidAddress(_))
- ));
-
- let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
- assert!(matches!(
- parse_public_listing_address(&raw),
- Err(RadrootsPublicListingAddressError::InvalidListingKind {
- actual: KIND_PROFILE
- })
- ));
-
- let typed = RadrootsListingAddress::parse(&raw).expect("typed profile address");
- assert!(matches!(
- parse_public_listing_address(typed.clone()),
- Err(RadrootsPublicListingAddressError::InvalidListingKind {
- actual: KIND_PROFILE
- })
- ));
- assert!(matches!(
- parse_public_listing_address(&typed),
- Err(RadrootsPublicListingAddressError::InvalidListingKind {
- actual: KIND_PROFILE
- })
- ));
- }
-
- #[test]
- fn parse_listing_address_rejects_non_listing_kind() {
- let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
-
- assert!(matches!(
- parse_listing_address(&raw),
- Err(RadrootsListingAddressError::InvalidKind {
- actual: KIND_PROFILE
- })
- ));
- }
-}
diff --git a/crates/trade/src/listing/model.rs b/crates/trade/src/listing/model.rs
@@ -1,17 +0,0 @@
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeListingSubtotal {
- pub price_amount: radroots_core::RadrootsCoreMoney,
- pub price_currency: radroots_core::RadrootsCoreCurrency,
- pub quantity_amount: radroots_core::RadrootsCoreDecimal,
- pub quantity_unit: radroots_core::RadrootsCoreUnit,
-}
-
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeListingTotal {
- pub price_amount: radroots_core::RadrootsCoreMoney,
- pub price_currency: radroots_core::RadrootsCoreCurrency,
- pub quantity_amount: radroots_core::RadrootsCoreDecimal,
- pub quantity_unit: radroots_core::RadrootsCoreUnit,
-}
diff --git a/crates/trade/src/listing/mutation.rs b/crates/trade/src/listing/mutation.rs
@@ -1,453 +0,0 @@
-//! Mutation draft preparation for Radroots Listing v1.
-
-#![forbid(unsafe_code)]
-
-#[cfg(all(feature = "serde_json", not(feature = "std")))]
-use alloc::string::{String, ToString};
-
-#[cfg(all(feature = "serde_json", feature = "std"))]
-use std::string::{String, ToString};
-
-use radroots_event::ids::RadrootsListingAddress;
-#[cfg(feature = "serde_json")]
-use radroots_event::{
- draft::{RadrootsDraftError, RadrootsEventDraft},
- kinds::KIND_LISTING,
-};
-#[cfg(feature = "serde_json")]
-use radroots_event_codec::listing::encode::to_json_wire_parts_with_kind;
-use thiserror::Error;
-
-use crate::listing::draft::RadrootsCanonicalListingEdit;
-
-/// Listing v1 mutation intent for draft preparation only.
-///
-/// Publish and update target the public listing event, while local-only draft
-/// persistence and archive are intentionally unsupported as wire events.
-#[derive(Clone, Debug)]
-pub enum RadrootsListingMutation {
- Publish {
- draft: RadrootsCanonicalListingEdit,
- },
- Update {
- draft: RadrootsCanonicalListingEdit,
- },
- SaveDraft {
- draft: RadrootsCanonicalListingEdit,
- },
- Archive {
- listing_addr: RadrootsListingAddress,
- },
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum RadrootsListingLifecycleState {
- Draft,
- Published,
-}
-
-#[derive(Clone, Debug, Error, PartialEq, Eq)]
-pub enum RadrootsListingMutationError {
- #[error("listing mutation is not supported")]
- UnsupportedMutation,
- #[cfg(feature = "serde_json")]
- #[error("failed to encode listing mutation: {0}")]
- EncodeListing(String),
- #[cfg(feature = "serde_json")]
- #[error("failed to build listing mutation draft: {0}")]
- FrozenDraft(RadrootsDraftError),
-}
-
-const LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.listing.published.v1";
-
-impl RadrootsListingMutation {
- pub fn publish(draft: RadrootsCanonicalListingEdit) -> Self {
- Self::Publish { draft }
- }
-
- pub fn update(draft: RadrootsCanonicalListingEdit) -> Self {
- Self::Update { draft }
- }
-
- pub fn save_draft(draft: RadrootsCanonicalListingEdit) -> Self {
- Self::SaveDraft { draft }
- }
-
- pub fn archive(listing_addr: RadrootsListingAddress) -> Self {
- Self::Archive { listing_addr }
- }
-
- pub fn lifecycle_state(
- &self,
- ) -> Result<RadrootsListingLifecycleState, RadrootsListingMutationError> {
- match self {
- Self::Publish { .. } | Self::Update { .. } => {
- Ok(RadrootsListingLifecycleState::Published)
- }
- Self::SaveDraft { .. } => Ok(RadrootsListingLifecycleState::Draft),
- Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
- }
- }
-
- pub fn canonical_draft(
- &self,
- ) -> Result<&RadrootsCanonicalListingEdit, RadrootsListingMutationError> {
- match self {
- Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => {
- Ok(draft)
- }
- Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
- }
- }
-
- pub fn listing_addr(&self) -> Result<&RadrootsListingAddress, RadrootsListingMutationError> {
- match self {
- Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()),
- Self::SaveDraft { draft } => Ok(draft.public_listing_addr()),
- Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
- }
- }
-}
-
-#[cfg(feature = "serde_json")]
-pub fn build_listing_mutation_draft(
- mutation: &RadrootsListingMutation,
- created_at: u64,
-) -> Result<RadrootsEventDraft, RadrootsListingMutationError> {
- let (draft, kind, contract_id) = match mutation {
- RadrootsListingMutation::Publish { draft } | RadrootsListingMutation::Update { draft } => {
- (draft, KIND_LISTING, LISTING_PUBLISHED_CONTRACT_ID)
- }
- RadrootsListingMutation::SaveDraft { .. } | RadrootsListingMutation::Archive { .. } => {
- return Err(RadrootsListingMutationError::UnsupportedMutation);
- }
- };
- let parts = to_json_wire_parts_with_kind(draft.listing(), kind)
- .map_err(|error| RadrootsListingMutationError::EncodeListing(error.to_string()))?;
- RadrootsEventDraft::new(
- contract_id,
- parts.kind,
- created_at,
- parts.tags,
- parts.content,
- draft.seller_pubkey().as_str(),
- )
- .map_err(RadrootsListingMutationError::FrozenDraft)
-}
-
-#[cfg(test)]
-mod tests {
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey},
- kinds::KIND_LISTING,
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
- RadrootsListingStatus,
- },
- resource_area::RadrootsResourceAreaRef,
- };
-
- use crate::listing::draft::RadrootsCanonicalListingEdit;
- use crate::listing::validation::validate_listing_event;
-
- use super::{
- LISTING_PUBLISHED_CONTRACT_ID, RadrootsListingLifecycleState, RadrootsListingMutation,
- RadrootsListingMutationError, build_listing_mutation_draft,
- };
-
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
-
- fn d_tag(raw: &str) -> RadrootsDTag {
- RadrootsDTag::parse(raw).expect("d tag")
- }
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
- }
-
- fn listing() -> RadrootsListing {
- RadrootsListing {
- d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: SELLER.to_string(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
- },
- product: RadrootsListingProduct {
- key: "coffee".to_string(),
- title: "Coffee".to_string(),
- category: "coffee".to_string(),
- summary: Some("Single origin coffee".to_string()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1000u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice {
- amount: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(20u32),
- RadrootsCoreCurrency::USD,
- ),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- },
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
- availability: Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
- }),
- delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingPublicLocation {
- primary: "Farm".to_string(),
- city: Some("Town".to_string()),
- region: Some("Region".to_string()),
- country: Some("US".to_string()),
- geohash: "9q8yy".to_string(),
- }),
- images: None,
- }
- }
-
- fn canonical_draft() -> RadrootsCanonicalListingEdit {
- RadrootsCanonicalListingEdit::new(
- listing(),
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .expect("canonical listing edit")
- }
-
- #[test]
- fn supported_mutations_report_lifecycle_states() {
- assert_eq!(
- RadrootsListingMutation::publish(canonical_draft())
- .lifecycle_state()
- .expect("state"),
- RadrootsListingLifecycleState::Published
- );
- assert_eq!(
- RadrootsListingMutation::update(canonical_draft())
- .lifecycle_state()
- .expect("state"),
- RadrootsListingLifecycleState::Published
- );
- assert_eq!(
- RadrootsListingMutation::save_draft(canonical_draft())
- .lifecycle_state()
- .expect("state"),
- RadrootsListingLifecycleState::Draft
- );
- }
-
- #[test]
- fn supported_mutations_expose_canonical_drafts() {
- let publish = RadrootsListingMutation::publish(canonical_draft());
- let update = RadrootsListingMutation::update(canonical_draft());
- let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
-
- assert_eq!(
- publish
- .canonical_draft()
- .expect("draft")
- .seller_pubkey()
- .as_str(),
- SELLER
- );
- assert_eq!(
- update
- .canonical_draft()
- .expect("draft")
- .seller_pubkey()
- .as_str(),
- SELLER
- );
- assert_eq!(
- save_draft
- .canonical_draft()
- .expect("draft")
- .seller_pubkey()
- .as_str(),
- SELLER
- );
- assert_eq!(
- publish
- .canonical_draft()
- .expect("draft")
- .listing()
- .d_tag
- .as_str(),
- "AAAAAAAAAAAAAAAAAAAAAg"
- );
- }
-
- #[test]
- fn supported_mutations_report_listing_addresses() {
- let publish = RadrootsListingMutation::publish(canonical_draft());
- let update = RadrootsListingMutation::update(canonical_draft());
- let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
-
- assert_eq!(
- publish.listing_addr().expect("address").as_str(),
- format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
- );
- assert_eq!(
- update.listing_addr().expect("address").as_str(),
- format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
- );
- assert_eq!(
- save_draft.listing_addr().expect("address").as_str(),
- format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
- );
- }
-
- #[test]
- fn archive_is_explicitly_unsupported() {
- let archive = RadrootsListingMutation::archive(
- RadrootsListingAddress::parse(format!(
- "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
- ))
- .expect("listing address"),
- );
-
- assert_eq!(
- archive.lifecycle_state().unwrap_err(),
- RadrootsListingMutationError::UnsupportedMutation
- );
- assert_eq!(
- archive.canonical_draft().unwrap_err(),
- RadrootsListingMutationError::UnsupportedMutation
- );
- assert_eq!(
- archive.listing_addr().unwrap_err(),
- RadrootsListingMutationError::UnsupportedMutation
- );
- }
-
- #[test]
- fn build_listing_mutation_draft_maps_publish_and_update_to_published_listing() {
- let publish = RadrootsListingMutation::publish(canonical_draft());
- let update = RadrootsListingMutation::update(canonical_draft());
-
- let publish_draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
- let update_draft = build_listing_mutation_draft(&update, 1_700_000_000).expect("draft");
-
- assert_eq!(publish_draft.kind_u32(), KIND_LISTING);
- assert_eq!(publish_draft.contract_id(), LISTING_PUBLISHED_CONTRACT_ID);
- assert_eq!(publish_draft.expected_pubkey_str(), SELLER);
- assert_eq!(publish_draft.created_at_u64(), 1_700_000_000);
- let published_content: RadrootsListing =
- serde_json::from_str(publish_draft.content()).expect("listing json");
- assert_eq!(published_content.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
- assert_eq!(update_draft.kind_u32(), KIND_LISTING);
- assert_eq!(update_draft.contract_id(), LISTING_PUBLISHED_CONTRACT_ID);
- assert_eq!(update_draft.expected_pubkey_str(), SELLER);
- }
-
- #[test]
- fn build_listing_mutation_draft_rejects_save_draft() {
- let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
-
- assert_eq!(
- build_listing_mutation_draft(&save_draft, 1_700_000_000).unwrap_err(),
- RadrootsListingMutationError::UnsupportedMutation
- );
- }
-
- #[test]
- fn build_listing_mutation_draft_rejects_archive() {
- let archive = RadrootsListingMutation::archive(
- RadrootsListingAddress::parse(format!(
- "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
- ))
- .expect("listing address"),
- );
-
- assert_eq!(
- build_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(),
- RadrootsListingMutationError::UnsupportedMutation
- );
- }
-
- #[test]
- fn build_listing_mutation_draft_reports_encode_errors() {
- let mut listing = listing();
- listing.resource_area = Some(RadrootsResourceAreaRef {
- pubkey: SELLER.to_string(),
- d_tag: "bad d tag".to_string(),
- });
- let draft = RadrootsCanonicalListingEdit::new(
- listing,
- RadrootsPublicKey::parse(SELLER).expect("seller"),
- )
- .expect("canonical listing edit");
- let publish = RadrootsListingMutation::publish(draft);
-
- let err = build_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err();
-
- assert!(matches!(
- err,
- RadrootsListingMutationError::EncodeListing(_)
- ));
- }
-
- #[test]
- fn build_listing_mutation_draft_event_id_is_stable_for_fixed_input() {
- let publish = RadrootsListingMutation::publish(canonical_draft());
-
- let first = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
- let second = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
-
- assert_eq!(
- first.expected_event_id_str(),
- second.expected_event_id_str()
- );
- assert_eq!(first.expected_event_id_str().len(), 64);
- assert_eq!(first.tags_as_vec(), second.tags_as_vec());
- assert_eq!(first.content(), second.content());
- }
-
- #[test]
- fn build_listing_mutation_draft_output_validates_as_trade_listing() {
- let publish = RadrootsListingMutation::publish(canonical_draft());
- let draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
-
- let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: draft.expected_event_id_str().to_owned(),
- author: draft.expected_pubkey_str().to_owned(),
- created_at: draft.created_at_u64(),
- kind: draft.kind_u32(),
- tags: draft.tags_as_vec(),
- content: draft.content().to_owned(),
- sig: "f".repeat(128),
- })
- .expect("listing event");
- let validated = validate_listing_event(&event).expect("validated listing");
-
- assert_eq!(validated.seller_pubkey, SELLER);
- assert!(validated.listing_addr.contains(&format!(":{SELLER}:")));
- }
-}
diff --git a/crates/trade/src/listing/price_ext.rs b/crates/trade/src/listing/price_ext.rs
@@ -1,215 +0,0 @@
-use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal};
-use radroots_core::{
- RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError,
- RadrootsCoreQuantityPriceOps,
-};
-use radroots_event::listing::RadrootsListingBin;
-
-pub trait BinPricingExt {
- fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal;
- fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal;
-}
-
-pub trait BinPricingTryExt {
- fn try_subtotal_for_count(
- &self,
- bin_count: u32,
- ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError>;
- fn try_total_for_count(
- &self,
- bin_count: u32,
- ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError>;
-}
-
-#[inline]
-fn effective_quantity(bin: &RadrootsListingBin, bin_count: u32) -> RadrootsCoreQuantity {
- let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count);
- RadrootsCoreQuantity::new(amount, bin.quantity.unit)
-}
-
-impl BinPricingExt for RadrootsListingBin {
- fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal {
- let effective_qty = effective_quantity(self, bin_count);
- let money = self
- .price_per_canonical_unit
- .cost_for_rounded(&effective_qty);
- let currency = money.currency;
-
- RadrootsTradeListingSubtotal {
- price_amount: money,
- price_currency: currency,
- quantity_amount: effective_qty.amount,
- quantity_unit: effective_qty.unit,
- }
- }
-
- fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal {
- let sub = self.subtotal_for_count(bin_count);
- RadrootsTradeListingTotal {
- price_amount: sub.price_amount,
- price_currency: sub.price_currency,
- quantity_amount: sub.quantity_amount,
- quantity_unit: sub.quantity_unit,
- }
- }
-}
-
-impl BinPricingTryExt for RadrootsListingBin {
- fn try_subtotal_for_count(
- &self,
- bin_count: u32,
- ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError> {
- let effective_qty = effective_quantity(self, bin_count);
- let money = self
- .price_per_canonical_unit
- .try_cost_for_rounded(&effective_qty)?;
- let currency = money.currency;
-
- Ok(RadrootsTradeListingSubtotal {
- price_amount: money,
- price_currency: currency,
- quantity_amount: effective_qty.amount,
- quantity_unit: effective_qty.unit,
- })
- }
-
- fn try_total_for_count(
- &self,
- bin_count: u32,
- ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError> {
- let sub = self.try_subtotal_for_count(bin_count)?;
- Ok(RadrootsTradeListingTotal {
- price_amount: sub.price_amount,
- price_currency: sub.price_currency,
- quantity_amount: sub.quantity_amount,
- quantity_unit: sub.quantity_unit,
- })
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{BinPricingExt, BinPricingTryExt};
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit,
- };
- use radroots_event::ids::RadrootsInventoryBinId;
- use radroots_event::listing::RadrootsListingBin;
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
- }
-
- fn valid_bin() -> RadrootsListingBin {
- RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(2u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
- ),
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }
- }
-
- #[test]
- fn try_subtotal_for_rejects_unit_mismatch() {
- let bin = RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each),
- ),
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- };
-
- let err = bin.try_subtotal_for_count(1).unwrap_err();
- assert_eq!(
- err,
- RadrootsCoreQuantityPriceError::UnitMismatch {
- have: RadrootsCoreUnit::MassG,
- want: RadrootsCoreUnit::Each,
- }
- );
- }
-
- #[test]
- fn subtotal_and_total_for_count_follow_effective_quantity() {
- let bin = valid_bin();
- let subtotal = bin.subtotal_for_count(3);
- let total = bin.total_for_count(3);
-
- assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32));
- assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG);
- assert_eq!(
- subtotal.price_amount.amount,
- RadrootsCoreDecimal::from(30u32)
- );
- assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD);
-
- assert_eq!(total.quantity_amount, subtotal.quantity_amount);
- assert_eq!(total.quantity_unit, subtotal.quantity_unit);
- assert_eq!(total.price_amount, subtotal.price_amount);
- assert_eq!(total.price_currency, subtotal.price_currency);
- }
-
- #[test]
- fn try_subtotal_and_try_total_match_non_fallible_paths() {
- let bin = valid_bin();
- let subtotal = bin.try_subtotal_for_count(4).expect("subtotal");
- let total = bin.try_total_for_count(4).expect("total");
-
- assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32));
- assert_eq!(
- subtotal.price_amount.amount,
- RadrootsCoreDecimal::from(40u32)
- );
- assert_eq!(total.quantity_amount, subtotal.quantity_amount);
- assert_eq!(total.price_amount, subtotal.price_amount);
- }
-
- #[test]
- fn try_total_for_count_propagates_subtotal_errors() {
- let bin = RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
- RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
- RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each),
- ),
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- };
-
- let err = bin.try_total_for_count(1).unwrap_err();
- assert_eq!(
- err,
- RadrootsCoreQuantityPriceError::UnitMismatch {
- have: RadrootsCoreUnit::MassG,
- want: RadrootsCoreUnit::Each,
- }
- );
- }
-}
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -1,706 +0,0 @@
-#![forbid(unsafe_code)]
-
-#[cfg(not(feature = "std"))]
-use alloc::{format, string::String};
-
-use radroots_core::{
- RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit,
-};
-use radroots_event::{
- RadrootsEventEnvelope,
- ids::RadrootsListingAddress,
- kinds::is_listing_kind,
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
- RadrootsListingPublicLocation,
- },
- location::{has_textual_locality, is_public_geohash5},
- trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError,
-};
-
-use radroots_event_codec::listing::decode::listing_from_nostr_event;
-
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug)]
-pub struct RadrootsTradeListing {
- pub listing_id: String,
- pub listing_addr: String,
- pub seller_pubkey: String,
- pub title: String,
- pub description: String,
- pub product_type: String,
- pub primary_bin_id: String,
- pub bin_quantity: RadrootsCoreQuantity,
- pub unit: RadrootsCoreUnit,
- pub unit_price: RadrootsCoreMoney,
- pub inventory_available: RadrootsCoreDecimal,
- pub availability: RadrootsListingAvailability,
- pub location: RadrootsListingPublicLocation,
- pub delivery_method: RadrootsListingDeliveryMethod,
- pub listing: RadrootsListing,
-}
-
-pub fn validate_listing_event(
- event: &RadrootsEventEnvelope,
-) -> Result<RadrootsTradeListing, TradeListingValidationError> {
- if !is_listing_kind(event.kind_u32()) {
- return Err(TradeListingValidationError::InvalidKind {
- kind: event.kind_u32(),
- });
- }
-
- let listing = listing_from_nostr_event(event)
- .map_err(|error| TradeListingValidationError::ParseError { error })?;
- let listing_id = listing.d_tag.trim().to_string();
-
- let seller_pubkey = event.author_str().to_owned();
- if listing.farm.pubkey != seller_pubkey {
- return Err(TradeListingValidationError::InvalidSeller);
- }
- let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id);
- let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw)
- .expect("validated listing identity must form a listing address")
- .into_string();
-
- let title = listing.product.title.trim().to_string();
- if title.is_empty() {
- return Err(TradeListingValidationError::MissingTitle);
- }
-
- let description = listing
- .product
- .summary
- .as_ref()
- .map(|s| s.trim().to_string())
- .unwrap_or_default();
- if description.is_empty() {
- return Err(TradeListingValidationError::MissingDescription);
- }
-
- let product_type = if !listing.product.category.trim().is_empty() {
- listing.product.category.trim().to_string()
- } else {
- listing.product.key.trim().to_string()
- };
- if product_type.is_empty() {
- return Err(TradeListingValidationError::MissingProductType);
- }
-
- if listing.bins.is_empty() {
- return Err(TradeListingValidationError::MissingBins);
- }
- let primary_bin_id = listing.primary_bin_id.trim().to_string();
- let primary_bin = listing
- .bins
- .iter()
- .find(|bin| bin.bin_id == primary_bin_id)
- .ok_or(TradeListingValidationError::MissingPrimaryBin)?;
-
- if primary_bin.quantity.amount.is_sign_negative() {
- return Err(TradeListingValidationError::InvalidBin);
- }
- if !primary_bin.quantity.is_canonical() {
- return Err(TradeListingValidationError::InvalidBin);
- }
- if !primary_bin
- .price_per_canonical_unit
- .is_price_per_canonical_unit()
- {
- return Err(TradeListingValidationError::InvalidPrice);
- }
- if primary_bin
- .price_per_canonical_unit
- .amount
- .amount
- .is_sign_negative()
- {
- return Err(TradeListingValidationError::InvalidPrice);
- }
- if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit {
- return Err(TradeListingValidationError::InvalidPrice);
- }
-
- let inventory_available = listing
- .inventory_available
- .ok_or(TradeListingValidationError::MissingInventory)?;
- if inventory_available.is_sign_negative() {
- return Err(TradeListingValidationError::InvalidInventory);
- }
-
- let availability = listing
- .availability
- .clone()
- .ok_or(TradeListingValidationError::MissingAvailability)?;
- let location = listing
- .location
- .clone()
- .ok_or(TradeListingValidationError::MissingLocation)?;
- if !has_textual_locality(
- &location.primary,
- location.city.as_deref(),
- location.region.as_deref(),
- location.country.as_deref(),
- ) {
- return Err(TradeListingValidationError::MissingLocationLocality);
- }
- validate_listing_location_geohash(&location.geohash)?;
- let delivery_method = listing
- .delivery_method
- .clone()
- .ok_or(TradeListingValidationError::MissingDeliveryMethod)?;
-
- Ok(RadrootsTradeListing {
- listing_id,
- listing_addr,
- seller_pubkey,
- title,
- description,
- product_type,
- primary_bin_id: primary_bin_id.clone(),
- bin_quantity: primary_bin.quantity.clone(),
- unit: primary_bin.quantity.unit,
- unit_price: primary_bin.price_per_canonical_unit.amount.clone(),
- inventory_available,
- availability,
- location,
- delivery_method,
- listing,
- })
-}
-
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn validate_listing_location_geohash(geohash: &str) -> Result<(), TradeListingValidationError> {
- if geohash.trim().is_empty() {
- return Err(TradeListingValidationError::MissingLocationGeohash);
- }
- if !is_public_geohash5(geohash) {
- return Err(TradeListingValidationError::InvalidLocationGeohash);
- }
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::{TradeListingValidationError, validate_listing_event};
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::{
- RadrootsEventEnvelope,
- farm::RadrootsFarmRef,
- ids::{RadrootsDTag, RadrootsInventoryBinId},
- kinds::KIND_LISTING,
- listing::{
- RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
- RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation,
- },
- };
-
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
-
- fn d_tag(raw: &str) -> RadrootsDTag {
- RadrootsDTag::parse(raw).expect("d tag")
- }
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
- }
-
- fn base_listing() -> RadrootsListing {
- RadrootsListing {
- d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: SELLER.into(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
- },
- product: RadrootsListingProduct {
- key: "coffee".into(),
- title: "Coffee".into(),
- category: "coffee".into(),
- summary: Some("Single origin coffee".into()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: bin_id("bin-1"),
- bins: vec![RadrootsListingBin {
- bin_id: bin_id("bin-1"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1000u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice {
- amount: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(20u32),
- RadrootsCoreCurrency::USD,
- ),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- },
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
- availability: Some(RadrootsListingAvailability::Status {
- status: radroots_event::listing::RadrootsListingStatus::Active,
- }),
- delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingPublicLocation {
- primary: "Farm".into(),
- city: Some("Town".into()),
- region: Some("Region".into()),
- country: Some("US".into()),
- geohash: "9q8yy".into(),
- }),
- images: None,
- }
- }
-
- fn base_event(listing: &RadrootsListing) -> RadrootsEventEnvelope {
- let mut tags = 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),
- ],
- 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(),
- ],
- ];
- 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(
- author: &str,
- kind: u32,
- tags: Vec<Vec<String>>,
- content: String,
- ) -> RadrootsEventEnvelope {
- RadrootsEventEnvelope::new(radroots_event::RadrootsEventEnvelopeParts {
- id: "9".repeat(64),
- author: author.to_string(),
- created_at: 0,
- kind,
- tags,
- content,
- sig: "f".repeat(128),
- })
- .expect("event")
- }
-
- fn assert_validation_err(listing: RadrootsListing, expected: TradeListingValidationError) {
- let event = base_event(&listing);
- let err = validate_listing_event(&event).unwrap_err();
- assert_eq!(format!("{err}"), format!("{expected}"));
- }
-
- #[test]
- fn validate_listing_ok() {
- let listing = base_listing();
- let event = base_event(&listing);
- assert!(validate_listing_event(&event).is_ok());
- }
-
- #[test]
- fn validate_listing_rejects_retired_kind() {
- let listing = base_listing();
- let event = event_with_parts(
- SELLER,
- 30403,
- base_event(&listing).tags_as_vec(),
- String::new(),
- );
- let err = validate_listing_event(&event).unwrap_err();
- assert_eq!(
- err,
- TradeListingValidationError::InvalidKind { kind: 30403 }
- );
- }
-
- #[test]
- fn validate_listing_rejects_missing_d_tag() {
- 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::ListingParseError::MissingTag("d".to_string())
- }
- );
- }
-
- #[test]
- fn validate_listing_rejects_invalid_currency() {
- let event = event_with_parts(
- SELLER,
- KIND_LISTING,
- vec![
- vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
- vec!["p".into(), SELLER.into()],
- vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
- vec!["key".into(), "coffee".into()],
- vec!["title".into(), "Coffee".into()],
- vec!["category".into(), "coffee".into()],
- vec!["summary".into(), "Single origin".into()],
- vec![
- "quantity".into(),
- "1".into(),
- "lb".into(),
- "bag".into(),
- "5".into(),
- ],
- vec![
- "price".into(),
- "20".into(),
- "US".into(),
- "1".into(),
- "lb".into(),
- ],
- vec![
- "location".into(),
- "Farm".into(),
- "Town".into(),
- "Region".into(),
- ],
- vec!["status".into(), "active".into()],
- vec!["delivery".into(), "pickup".into()],
- ],
- String::new(),
- );
- let err = validate_listing_event(&event).unwrap_err();
- assert!(format!("{err:?}").starts_with("ParseError"));
- }
-
- #[test]
- fn validate_listing_rejects_mismatched_seller() {
- let listing = base_listing();
- let event = event_with_parts(
- OTHER_SELLER,
- KIND_LISTING,
- base_event(&listing).tags_as_vec(),
- String::new(),
- );
- let err = validate_listing_event(&event).unwrap_err();
- assert_eq!(err, TradeListingValidationError::InvalidSeller);
- }
-
- #[test]
- fn validate_listing_rejects_missing_inventory() {
- let mut listing = base_listing();
- listing.inventory_available = None;
- let event = base_event(&listing);
- let err = validate_listing_event(&event).unwrap_err();
- assert_eq!(err, TradeListingValidationError::MissingInventory);
- }
-
- #[test]
- fn validate_listing_rejects_invalid_kind() {
- let listing = base_listing();
- 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 });
- }
-
- #[test]
- fn validate_listing_rejects_missing_title() {
- let mut listing = base_listing();
- listing.product.title = " ".into();
- assert_validation_err(listing, TradeListingValidationError::MissingTitle);
- }
-
- #[test]
- fn validate_listing_rejects_missing_description() {
- let mut listing = base_listing();
- listing.product.summary = Some(" ".into());
- assert_validation_err(listing, TradeListingValidationError::MissingDescription);
- }
-
- #[test]
- fn validate_listing_rejects_missing_product_type() {
- let mut listing = base_listing();
- listing.product.category = " ".into();
- listing.product.key = " ".into();
- assert_validation_err(listing, TradeListingValidationError::MissingProductType);
- }
-
- #[test]
- fn validate_listing_rejects_missing_bins() {
- let mut listing = base_listing();
- listing.bins.clear();
- assert_validation_err(
- listing,
- TradeListingValidationError::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_missing_primary_bin_id() {
- assert!(RadrootsInventoryBinId::parse(" ").is_err());
- }
-
- #[test]
- 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::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_negative_quantity() {
- let mut listing = base_listing();
- listing.bins[0].quantity.amount = "-1".parse().unwrap();
- assert_validation_err(listing, TradeListingValidationError::InvalidBin);
- }
-
- #[test]
- fn validate_listing_rejects_non_canonical_quantity() {
- let mut listing = base_listing();
- listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg;
- 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::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_negative_price_amount() {
- let mut listing = base_listing();
- listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap();
- assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
- }
-
- #[test]
- 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::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_negative_inventory() {
- let mut listing = base_listing();
- listing.inventory_available = Some("-1".parse().unwrap());
- assert_validation_err(listing, TradeListingValidationError::InvalidInventory);
- }
-
- #[test]
- fn validate_listing_rejects_missing_availability() {
- let mut listing = base_listing();
- listing.availability = None;
- assert_validation_err(listing, TradeListingValidationError::MissingAvailability);
- }
-
- #[test]
- fn validate_listing_rejects_missing_location() {
- let mut listing = base_listing();
- listing.location = None;
- assert_validation_err(listing, TradeListingValidationError::MissingLocation);
- }
-
- #[test]
- fn validate_listing_rejects_missing_location_locality() {
- let mut listing = base_listing();
- let location = listing.location.as_mut().expect("location");
- location.city = None;
- location.region = None;
- location.country = None;
- assert_validation_err(
- listing,
- TradeListingValidationError::MissingLocationLocality,
- );
- }
-
- #[test]
- fn validate_listing_rejects_missing_location_geohash() {
- let mut listing = base_listing();
- listing.location.as_mut().expect("location").geohash = " ".into();
- assert_validation_err(
- listing,
- TradeListingValidationError::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("g".to_string()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_invalid_location_geohash() {
- let mut listing = base_listing();
- listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
- assert_validation_err(
- listing,
- TradeListingValidationError::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("g".to_string()),
- },
- );
- }
-
- #[test]
- fn validate_listing_rejects_missing_delivery_method() {
- let mut listing = base_listing();
- listing.delivery_method = None;
- assert_validation_err(listing, TradeListingValidationError::MissingDeliveryMethod);
- }
-
- #[test]
- fn validation_error_display_covers_all_variants() {
- let errors = vec![
- TradeListingValidationError::InvalidKind { kind: 9 },
- TradeListingValidationError::MissingListingId,
- TradeListingValidationError::ListingEventNotFound {
- listing_addr: "addr".into(),
- },
- TradeListingValidationError::ListingEventFetchFailed {
- listing_addr: "addr".into(),
- },
- TradeListingValidationError::ParseError {
- error: crate::listing::ListingParseError::InvalidTag("d".into()),
- },
- TradeListingValidationError::InvalidSeller,
- TradeListingValidationError::MissingFarmProfile,
- TradeListingValidationError::MissingFarmRecord,
- TradeListingValidationError::MissingTitle,
- TradeListingValidationError::MissingDescription,
- TradeListingValidationError::MissingProductType,
- TradeListingValidationError::MissingBins,
- TradeListingValidationError::MissingPrimaryBin,
- TradeListingValidationError::InvalidBin,
- TradeListingValidationError::MissingPrice,
- TradeListingValidationError::InvalidPrice,
- TradeListingValidationError::MissingInventory,
- TradeListingValidationError::InvalidInventory,
- TradeListingValidationError::MissingAvailability,
- TradeListingValidationError::MissingLocation,
- TradeListingValidationError::MissingLocationLocality,
- TradeListingValidationError::MissingLocationGeohash,
- TradeListingValidationError::InvalidLocationGeohash,
- TradeListingValidationError::MissingDeliveryMethod,
- ];
- for error in errors {
- assert!(!error.to_string().trim().is_empty());
- }
- }
-}
diff --git a/crates/trade/src/operational_listing/draft.rs b/crates/trade/src/operational_listing/draft.rs
@@ -0,0 +1,517 @@
+//! Canonicalization for Radroots Listing v1 drafts.
+
+#![forbid(unsafe_code)]
+
+use core::fmt;
+
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::ToString, vec::Vec};
+
+#[cfg(feature = "std")]
+use std::{string::ToString, vec::Vec};
+
+use radroots_authority::RadrootsActorContext;
+use radroots_event::{
+ contract::RadrootsActorRole,
+ ids::{
+ RadrootsClassifiedListingAddress, RadrootsIdParseError, RadrootsInventoryBinId,
+ RadrootsPublicKey,
+ },
+ kinds::KIND_CLASSIFIED_LISTING,
+ operational_listing::RadrootsOperationalListing,
+};
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingEditDocumentV1 {
+ pub listing: RadrootsOperationalListing,
+}
+
+impl RadrootsOperationalListingEditDocumentV1 {
+ pub fn new(listing: RadrootsOperationalListing) -> Self {
+ Self { listing }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingCanonicalEdit {
+ listing: RadrootsOperationalListing,
+ seller_pubkey: RadrootsPublicKey,
+ public_listing_addr: RadrootsClassifiedListingAddress,
+}
+
+impl RadrootsOperationalListingCanonicalEdit {
+ pub fn new(
+ mut listing: RadrootsOperationalListing,
+ seller_pubkey: RadrootsPublicKey,
+ ) -> Result<Self, RadrootsOperationalListingEditError> {
+ let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str())
+ .map_err(RadrootsOperationalListingEditError::InvalidFarmPubkey)?;
+ if farm_pubkey != seller_pubkey {
+ return Err(RadrootsOperationalListingEditError::FarmPubkeyMismatch {
+ expected_pubkey: seller_pubkey,
+ actual_pubkey: farm_pubkey,
+ });
+ }
+ listing.farm.pubkey = farm_pubkey.as_str().to_string();
+ validate_listing_bins(&listing)?;
+
+ let public_listing_addr = listing_addr(
+ KIND_CLASSIFIED_LISTING,
+ &seller_pubkey,
+ listing.d_tag.as_str(),
+ );
+
+ Ok(Self {
+ listing,
+ seller_pubkey,
+ public_listing_addr,
+ })
+ }
+
+ pub fn listing(&self) -> &RadrootsOperationalListing {
+ &self.listing
+ }
+
+ pub fn seller_pubkey(&self) -> &RadrootsPublicKey {
+ &self.seller_pubkey
+ }
+
+ pub fn public_listing_addr(&self) -> &RadrootsClassifiedListingAddress {
+ &self.public_listing_addr
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsOperationalListingEditError {
+ InvalidFarmPubkey(RadrootsIdParseError),
+ InvalidClassifiedListingAddress(RadrootsIdParseError),
+ ActorRoleUnsatisfied {
+ required_role: RadrootsActorRole,
+ },
+ FarmPubkeyMismatch {
+ expected_pubkey: RadrootsPublicKey,
+ actual_pubkey: RadrootsPublicKey,
+ },
+ MissingPrimaryBin {
+ primary_bin_id: RadrootsInventoryBinId,
+ },
+ DuplicateBinId {
+ bin_id: RadrootsInventoryBinId,
+ },
+}
+
+impl fmt::Display for RadrootsOperationalListingEditError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidFarmPubkey(error) => {
+ write!(f, "invalid listing edit farm pubkey: {error}")
+ }
+ Self::InvalidClassifiedListingAddress(error) => {
+ write!(f, "invalid listing edit address: {error}")
+ }
+ Self::ActorRoleUnsatisfied { required_role } => write!(
+ f,
+ "listing edit actor does not satisfy required role {required_role:?}"
+ ),
+ Self::FarmPubkeyMismatch { .. } => {
+ f.write_str("listing edit farm pubkey does not match seller")
+ }
+ Self::MissingPrimaryBin { .. } => f.write_str("listing edit primary bin is missing"),
+ Self::DuplicateBinId { .. } => f.write_str("listing edit contains duplicate bin ID"),
+ }
+ }
+}
+
+impl core::error::Error for RadrootsOperationalListingEditError {}
+
+fn validate_listing_bins(
+ listing: &RadrootsOperationalListing,
+) -> Result<(), RadrootsOperationalListingEditError> {
+ let primary_bin_id = listing.primary_bin_id.clone();
+ let mut seen_bin_ids = Vec::new();
+ let mut primary_bin_found = false;
+ for bin in &listing.bins {
+ if seen_bin_ids
+ .iter()
+ .any(|seen_bin_id| seen_bin_id == &bin.bin_id)
+ {
+ return Err(RadrootsOperationalListingEditError::DuplicateBinId {
+ bin_id: bin.bin_id.clone(),
+ });
+ }
+ if bin.bin_id == primary_bin_id {
+ primary_bin_found = true;
+ }
+ seen_bin_ids.push(bin.bin_id.clone());
+ }
+
+ if !primary_bin_found {
+ return Err(RadrootsOperationalListingEditError::MissingPrimaryBin { primary_bin_id });
+ }
+ Ok(())
+}
+
+fn listing_addr(
+ kind: u32,
+ seller_pubkey: &RadrootsPublicKey,
+ d_tag: &str,
+) -> RadrootsClassifiedListingAddress {
+ RadrootsClassifiedListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str()))
+ .expect("typed listing identity must form a listing address")
+}
+
+pub fn canonicalize_operational_listing_edit(
+ actor: &RadrootsActorContext,
+ mut document: RadrootsOperationalListingEditDocumentV1,
+) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditError> {
+ if !actor.satisfies(RadrootsActorRole::Seller) {
+ return Err(RadrootsOperationalListingEditError::ActorRoleUnsatisfied {
+ required_role: RadrootsActorRole::Seller,
+ });
+ }
+
+ let seller_pubkey = actor.pubkey().clone();
+ let farm_pubkey = document.listing.farm.pubkey.as_str();
+ if farm_pubkey.is_empty() {
+ document.listing.farm.pubkey = seller_pubkey.as_str().to_string();
+ }
+
+ RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey)
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_authority::RadrootsActorContext;
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_event::{
+ contract::RadrootsActorRole,
+ farm::RadrootsFarmRef,
+ ids::{
+ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId,
+ RadrootsPublicKey,
+ },
+ kinds::KIND_CLASSIFIED_LISTING,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingBin,
+ RadrootsOperationalListingProduct,
+ },
+ };
+
+ use super::{
+ RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1,
+ RadrootsOperationalListingEditError, canonicalize_operational_listing_edit,
+ };
+
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ fn d_tag(raw: &str) -> RadrootsDTag {
+ RadrootsDTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ RadrootsInventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn listing() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "coffee".to_string(),
+ title: "Coffee".to_string(),
+ category: "coffee".to_string(),
+ summary: Some("Single origin coffee".to_string()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ }
+ }
+
+ fn seller_actor() -> RadrootsActorContext {
+ RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor")
+ }
+
+ fn buyer_actor() -> RadrootsActorContext {
+ RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor")
+ }
+
+ #[test]
+ fn draft_document_wraps_listing() {
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing());
+
+ assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(document.listing.product.title, "Coffee");
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn draft_document_deserializes_as_untrusted_input() {
+ let json = serde_json::to_string(&RadrootsOperationalListingEditDocumentV1::new(listing()))
+ .expect("serialize document");
+
+ let document: RadrootsOperationalListingEditDocumentV1 =
+ serde_json::from_str(&json).expect("deserialize document");
+ let canonical = canonicalize_operational_listing_edit(&seller_actor(), document)
+ .expect("canonical draft");
+
+ assert_eq!(canonical.seller_pubkey().as_str(), SELLER);
+ assert_eq!(canonical.listing().product.title, "Coffee");
+ }
+
+ #[test]
+ fn canonical_draft_carries_seller_listing_and_addresses() {
+ let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller");
+ let listing = listing();
+
+ let canonical =
+ RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey.clone())
+ .expect("canonical");
+
+ assert_eq!(canonical.seller_pubkey(), &seller_pubkey);
+ assert_eq!(
+ canonical.public_listing_addr().as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ }
+
+ #[test]
+ fn listing_edit_error_variants_are_precise() {
+ assert!(matches!(
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(
+ RadrootsPublicKey::parse("bad").unwrap_err()
+ ),
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ assert!(matches!(
+ RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(
+ RadrootsClassifiedListingAddress::parse("bad").unwrap_err()
+ ),
+ RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(_)
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_fills_missing_farm_pubkey_and_derives_address() {
+ let mut listing = listing();
+ listing.farm.pubkey.clear();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let canonical = canonicalize_operational_listing_edit(&seller_actor(), document)
+ .expect("canonical draft");
+
+ assert_eq!(canonical.seller_pubkey().as_str(), SELLER);
+ assert_eq!(
+ canonical.public_listing_addr().as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(canonical.listing().farm.pubkey, SELLER);
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_non_seller_actor() {
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing());
+
+ let error = canonicalize_operational_listing_edit(&buyer_actor(), document).unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::ActorRoleUnsatisfied {
+ required_role: RadrootsActorRole::Seller
+ }
+ );
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_mismatched_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = OTHER.to_string();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_invalid_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = "bad".to_string();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_mismatched_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = OTHER.to_string();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_invalid_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = "bad".to_string();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_empty_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey.clear();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_missing_primary_bin() {
+ let mut listing = listing();
+ listing.primary_bin_id = bin_id("bin-2");
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::MissingPrimaryBin {
+ primary_bin_id: bin_id("bin-2")
+ }
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_missing_primary_bin() {
+ let mut listing = listing();
+ listing.primary_bin_id = bin_id("bin-2");
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::MissingPrimaryBin {
+ primary_bin_id: bin_id("bin-2")
+ }
+ );
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_duplicate_bin_ids() {
+ let mut listing = listing();
+ listing.bins.push(listing.bins[0].clone());
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::DuplicateBinId {
+ bin_id: bin_id("bin-1")
+ }
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_duplicate_bin_ids() {
+ let mut listing = listing();
+ listing.bins.push(listing.bins[0].clone());
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::DuplicateBinId {
+ bin_id: bin_id("bin-1")
+ }
+ );
+ }
+}
diff --git a/crates/trade/src/operational_listing/mod.rs b/crates/trade/src/operational_listing/mod.rs
@@ -0,0 +1,251 @@
+pub mod draft;
+pub mod model;
+pub mod mutation;
+pub mod price_ext;
+pub mod validation;
+
+use radroots_event::{
+ RadrootsEventEnvelope,
+ ids::{
+ RadrootsAddressableCoordinateParts, RadrootsClassifiedListingAddress, RadrootsDTag,
+ RadrootsIdParseError, RadrootsPublicKey,
+ },
+ operational_listing::{RadrootsOperationalListing, RadrootsOperationalListingParseError},
+};
+use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event;
+
+pub use self::draft::{
+ RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1,
+ RadrootsOperationalListingEditError, canonicalize_operational_listing_edit,
+};
+#[cfg(feature = "serde_json")]
+pub use self::mutation::build_operational_listing_mutation_draft;
+pub use self::mutation::{
+ RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation,
+ RadrootsOperationalListingMutationError,
+};
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsClassifiedListingAddressParts {
+ pub address: RadrootsClassifiedListingAddress,
+ pub kind: u32,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub listing_id: RadrootsDTag,
+}
+
+impl RadrootsClassifiedListingAddressParts {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> {
+ parse_classified_listing_address(value)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsPublicClassifiedListingAddress {
+ pub address: RadrootsClassifiedListingAddress,
+ pub kind: u32,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub listing_id: RadrootsDTag,
+}
+
+impl RadrootsPublicClassifiedListingAddress {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> {
+ parse_public_classified_listing_address(value)
+ }
+}
+
+pub fn parse_classified_listing_address(
+ value: impl AsRef<str>,
+) -> Result<RadrootsClassifiedListingAddressParts, RadrootsIdParseError> {
+ let value = value.as_ref();
+ let address = RadrootsClassifiedListingAddress::parse(value)?;
+ let parts = RadrootsAddressableCoordinateParts::parse(address.as_str())
+ .expect("typed listing address must contain valid coordinate parts");
+ Ok(RadrootsClassifiedListingAddressParts {
+ address,
+ kind: parts.kind,
+ seller_pubkey: parts.pubkey,
+ listing_id: parts.d_tag,
+ })
+}
+
+pub fn parse_public_classified_listing_address(
+ value: impl AsRef<str>,
+) -> Result<RadrootsPublicClassifiedListingAddress, RadrootsIdParseError> {
+ let parts = parse_classified_listing_address(value)?;
+ Ok(RadrootsPublicClassifiedListingAddress {
+ address: parts.address,
+ kind: parts.kind,
+ seller_pubkey: parts.seller_pubkey,
+ listing_id: parts.listing_id,
+ })
+}
+
+pub fn parse_operational_listing_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> {
+ operational_listing_from_nostr_event(event)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ RadrootsClassifiedListingAddressParts, RadrootsPublicClassifiedListingAddress,
+ parse_classified_listing_address, parse_operational_listing_event,
+ parse_public_classified_listing_address,
+ };
+ use radroots_event::{
+ RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
+ ids::RadrootsClassifiedListingAddress,
+ kinds::{KIND_CLASSIFIED_LISTING, KIND_PROFILE},
+ operational_listing::RadrootsOperationalListingParseError,
+ };
+
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ fn listing_event() -> RadrootsEventEnvelope {
+ RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: "9".repeat(64),
+ author: SELLER.to_string(),
+ created_at: 1,
+ kind: KIND_CLASSIFIED_LISTING,
+ tags: vec![
+ vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec!["p".into(), SELLER.into()],
+ vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
+ vec!["key".into(), "coffee".into()],
+ vec!["title".into(), "Coffee".into()],
+ vec!["category".into(), "coffee".into()],
+ vec!["summary".into(), "Single origin".into()],
+ vec!["radroots:primary_bin".into(), "bin-1".into()],
+ vec![
+ "radroots:bin".into(),
+ "bin-1".into(),
+ "1000".into(),
+ "g".into(),
+ ],
+ vec![
+ "radroots:price".into(),
+ "bin-1".into(),
+ "20".into(),
+ "USD".into(),
+ "1".into(),
+ "g".into(),
+ ],
+ ],
+ content: String::new(),
+ sig: "f".repeat(128),
+ })
+ .expect("listing event")
+ }
+
+ #[test]
+ fn parse_operational_listing_event_rejects_non_listing_kind() {
+ let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: "8".repeat(64),
+ author: SELLER.to_string(),
+ created_at: 1,
+ kind: KIND_PROFILE,
+ tags: vec![],
+ content: String::new(),
+ sig: "f".repeat(128),
+ })
+ .expect("profile event");
+
+ assert!(matches!(
+ parse_operational_listing_event(&event),
+ Err(RadrootsOperationalListingParseError::InvalidKind(
+ KIND_PROFILE
+ ))
+ ));
+ }
+
+ #[test]
+ fn parse_operational_listing_event_accepts_listing_kind() {
+ let listing = parse_operational_listing_event(&listing_event()).expect("listing");
+
+ assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(listing.farm.pubkey, SELLER);
+ assert_eq!(listing.primary_bin_id.as_str(), "bin-1");
+ }
+
+ #[test]
+ fn listing_address_associated_parsers_delegate_to_public_parsers() {
+ let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1");
+
+ let listing =
+ RadrootsClassifiedListingAddressParts::parse(raw.clone()).expect("listing address");
+ let public = RadrootsPublicClassifiedListingAddress::parse(&raw).expect("public address");
+ let typed = parse_public_classified_listing_address(
+ RadrootsClassifiedListingAddress::parse(&raw).expect("typed addr"),
+ )
+ .expect("typed public address");
+
+ assert_eq!(listing.address.as_str(), raw);
+ assert_eq!(public.address.as_str(), raw);
+ assert_eq!(typed.address.as_str(), raw);
+ assert_eq!(listing.seller_pubkey.as_str(), SELLER);
+ assert_eq!(public.seller_pubkey.as_str(), SELLER);
+ assert_eq!(typed.seller_pubkey.as_str(), SELLER);
+ }
+
+ #[test]
+ fn parse_public_classified_listing_address_accepts_public_listing_kind() {
+ let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1");
+ let parsed = parse_public_classified_listing_address(&raw).expect("public listing address");
+
+ assert_eq!(parsed.address.as_str(), raw);
+ assert_eq!(parsed.kind, KIND_CLASSIFIED_LISTING);
+ assert_eq!(parsed.seller_pubkey.as_str(), SELLER);
+ assert_eq!(parsed.listing_id.as_str(), "listing-1");
+ }
+
+ #[test]
+ fn parse_classified_listing_address_rejects_retired_listing_kind() {
+ let raw = format!("30403:{SELLER}:listing-1");
+
+ assert!(matches!(
+ parse_classified_listing_address(&raw),
+ Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ })
+ ));
+ assert!(matches!(
+ parse_public_classified_listing_address(&raw),
+ Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ })
+ ));
+ }
+
+ #[test]
+ fn parse_public_classified_listing_address_maps_invalid_listing_addresses() {
+ assert!(matches!(
+ parse_public_classified_listing_address("not-an-address"),
+ Err(radroots_event::ids::RadrootsIdParseError::InvalidFormat)
+ ));
+
+ let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
+ assert!(matches!(
+ parse_public_classified_listing_address(&raw),
+ Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: KIND_PROFILE,
+ })
+ ));
+ assert!(RadrootsClassifiedListingAddress::parse(&raw).is_err());
+ }
+
+ #[test]
+ fn parse_classified_listing_address_rejects_non_listing_kind() {
+ let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
+
+ assert!(matches!(
+ parse_classified_listing_address(&raw),
+ Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: KIND_PROFILE,
+ })
+ ));
+ }
+}
diff --git a/crates/trade/src/operational_listing/model.rs b/crates/trade/src/operational_listing/model.rs
@@ -0,0 +1,17 @@
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsOperationalListingSubtotal {
+ pub price_amount: radroots_core::RadrootsCoreMoney,
+ pub price_currency: radroots_core::RadrootsCoreCurrency,
+ pub quantity_amount: radroots_core::RadrootsCoreDecimal,
+ pub quantity_unit: radroots_core::RadrootsCoreUnit,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsOperationalListingTotal {
+ pub price_amount: radroots_core::RadrootsCoreMoney,
+ pub price_currency: radroots_core::RadrootsCoreCurrency,
+ pub quantity_amount: radroots_core::RadrootsCoreDecimal,
+ pub quantity_unit: radroots_core::RadrootsCoreUnit,
+}
diff --git a/crates/trade/src/operational_listing/mutation.rs b/crates/trade/src/operational_listing/mutation.rs
@@ -0,0 +1,502 @@
+//! Mutation draft preparation for Radroots Listing v1.
+
+#![forbid(unsafe_code)]
+
+use core::fmt;
+
+#[cfg(all(feature = "serde_json", not(feature = "std")))]
+use alloc::string::{String, ToString};
+
+#[cfg(all(feature = "serde_json", feature = "std"))]
+use std::string::{String, ToString};
+
+use radroots_event::ids::RadrootsClassifiedListingAddress;
+#[cfg(feature = "serde_json")]
+use radroots_event::{
+ draft::{RadrootsDraftError, RadrootsEventDraft},
+ kinds::KIND_CLASSIFIED_LISTING,
+};
+#[cfg(feature = "serde_json")]
+use radroots_event_codec::operational_listing::encode::to_wire_parts_with_kind;
+
+use crate::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
+
+/// Listing v1 mutation intent for draft preparation only.
+///
+/// Publish and update target the public listing event, while local-only draft
+/// persistence and archive are intentionally unsupported as wire events.
+#[derive(Clone, Debug)]
+pub enum RadrootsOperationalListingMutation {
+ Publish {
+ draft: RadrootsOperationalListingCanonicalEdit,
+ },
+ Update {
+ draft: RadrootsOperationalListingCanonicalEdit,
+ },
+ SaveDraft {
+ draft: RadrootsOperationalListingCanonicalEdit,
+ },
+ Archive {
+ listing_addr: RadrootsClassifiedListingAddress,
+ },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsOperationalListingLifecycleState {
+ Draft,
+ Published,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsOperationalListingMutationError {
+ UnsupportedMutation,
+ #[cfg(feature = "serde_json")]
+ EncodeListing(String),
+ #[cfg(feature = "serde_json")]
+ FrozenDraft(RadrootsDraftError),
+}
+
+impl fmt::Display for RadrootsOperationalListingMutationError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::UnsupportedMutation => f.write_str("listing mutation is not supported"),
+ #[cfg(feature = "serde_json")]
+ Self::EncodeListing(error) => {
+ write!(f, "failed to encode listing mutation: {error}")
+ }
+ #[cfg(feature = "serde_json")]
+ Self::FrozenDraft(error) => {
+ write!(f, "failed to build listing mutation draft: {error}")
+ }
+ }
+ }
+}
+
+impl core::error::Error for RadrootsOperationalListingMutationError {}
+
+#[cfg(feature = "serde_json")]
+const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1";
+
+impl RadrootsOperationalListingMutation {
+ pub fn publish(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
+ Self::Publish { draft }
+ }
+
+ pub fn update(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
+ Self::Update { draft }
+ }
+
+ pub fn save_draft(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
+ Self::SaveDraft { draft }
+ }
+
+ pub fn archive(listing_addr: RadrootsClassifiedListingAddress) -> Self {
+ Self::Archive { listing_addr }
+ }
+
+ pub fn lifecycle_state(
+ &self,
+ ) -> Result<RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutationError>
+ {
+ match self {
+ Self::Publish { .. } | Self::Update { .. } => {
+ Ok(RadrootsOperationalListingLifecycleState::Published)
+ }
+ Self::SaveDraft { .. } => Ok(RadrootsOperationalListingLifecycleState::Draft),
+ Self::Archive { .. } => {
+ Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
+ }
+ }
+ }
+
+ pub fn canonical_draft(
+ &self,
+ ) -> Result<&RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingMutationError>
+ {
+ match self {
+ Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => {
+ Ok(draft)
+ }
+ Self::Archive { .. } => {
+ Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
+ }
+ }
+ }
+
+ pub fn listing_addr(
+ &self,
+ ) -> Result<&RadrootsClassifiedListingAddress, RadrootsOperationalListingMutationError> {
+ match self {
+ Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()),
+ Self::SaveDraft { draft } => Ok(draft.public_listing_addr()),
+ Self::Archive { .. } => {
+ Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
+ }
+ }
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub fn build_operational_listing_mutation_draft(
+ mutation: &RadrootsOperationalListingMutation,
+ created_at: u64,
+) -> Result<RadrootsEventDraft, RadrootsOperationalListingMutationError> {
+ let (draft, kind, contract_id) = match mutation {
+ RadrootsOperationalListingMutation::Publish { draft }
+ | RadrootsOperationalListingMutation::Update { draft } => (
+ draft,
+ KIND_CLASSIFIED_LISTING,
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID,
+ ),
+ RadrootsOperationalListingMutation::SaveDraft { .. }
+ | RadrootsOperationalListingMutation::Archive { .. } => {
+ return Err(RadrootsOperationalListingMutationError::UnsupportedMutation);
+ }
+ };
+ let parts = to_wire_parts_with_kind(draft.listing(), kind).map_err(|error| {
+ RadrootsOperationalListingMutationError::EncodeListing(error.to_string())
+ })?;
+ RadrootsEventDraft::new(
+ contract_id,
+ parts.kind,
+ created_at,
+ parts.tags,
+ parts.content,
+ draft.seller_pubkey().as_str(),
+ )
+ .map_err(RadrootsOperationalListingMutationError::FrozenDraft)
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_event::{
+ RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
+ contract::validate_event_contract_shape,
+ farm::RadrootsFarmRef,
+ ids::{
+ RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId,
+ RadrootsPublicKey,
+ },
+ kinds::KIND_CLASSIFIED_LISTING,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingStatus,
+ },
+ resource_area::RadrootsResourceAreaRef,
+ };
+
+ use crate::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
+ use crate::operational_listing::validation::validate_operational_listing_event;
+
+ use super::{
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState,
+ RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError,
+ build_operational_listing_mutation_draft,
+ };
+
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ fn d_tag(raw: &str) -> RadrootsDTag {
+ RadrootsDTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ RadrootsInventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn listing() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "coffee".to_string(),
+ title: "Coffee".to_string(),
+ category: "coffee".to_string(),
+ summary: Some("Single origin coffee".to_string()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
+ location: Some(RadrootsOperationalListingPublicLocation {
+ primary: "Farm".to_string(),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
+ }),
+ images: None,
+ }
+ }
+
+ fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit {
+ RadrootsOperationalListingCanonicalEdit::new(
+ listing(),
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .expect("canonical listing edit")
+ }
+
+ #[test]
+ fn supported_mutations_report_lifecycle_states() {
+ assert_eq!(
+ RadrootsOperationalListingMutation::publish(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(
+ RadrootsOperationalListingMutation::update(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(
+ RadrootsOperationalListingMutation::save_draft(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Draft
+ );
+ }
+
+ #[test]
+ fn supported_mutations_expose_canonical_drafts() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ publish
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .as_str(),
+ SELLER
+ );
+ assert_eq!(
+ update
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .as_str(),
+ SELLER
+ );
+ assert_eq!(
+ save_draft
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .as_str(),
+ SELLER
+ );
+ assert_eq!(
+ publish
+ .canonical_draft()
+ .expect("draft")
+ .listing()
+ .d_tag
+ .as_str(),
+ "AAAAAAAAAAAAAAAAAAAAAg"
+ );
+ }
+
+ #[test]
+ fn supported_mutations_report_listing_addresses() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ publish.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(
+ update.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(
+ save_draft.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ }
+
+ #[test]
+ fn archive_is_explicitly_unsupported() {
+ let archive = RadrootsOperationalListingMutation::archive(
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address"),
+ );
+
+ assert_eq!(
+ archive.lifecycle_state().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ assert_eq!(
+ archive.canonical_draft().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ assert_eq!(
+ archive.listing_addr().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_maps_publish_and_update_to_published_listing() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+
+ let publish_draft =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+ let update_draft =
+ build_operational_listing_mutation_draft(&update, 1_700_000_000).expect("draft");
+
+ assert_eq!(publish_draft.kind_u32(), KIND_CLASSIFIED_LISTING);
+ assert_eq!(
+ publish_draft.contract_id(),
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
+ );
+ assert_eq!(publish_draft.expected_pubkey_str(), SELLER);
+ assert_eq!(publish_draft.created_at_u64(), 1_700_000_000);
+ assert_eq!(publish_draft.content(), "# Coffee\n\nSingle origin coffee");
+ assert_eq!(update_draft.kind_u32(), KIND_CLASSIFIED_LISTING);
+ assert_eq!(
+ update_draft.contract_id(),
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
+ );
+ assert_eq!(update_draft.expected_pubkey_str(), SELLER);
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_rejects_save_draft() {
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ build_operational_listing_mutation_draft(&save_draft, 1_700_000_000).unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_rejects_archive() {
+ let archive = RadrootsOperationalListingMutation::archive(
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address"),
+ );
+
+ assert_eq!(
+ build_operational_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_reports_encode_errors() {
+ let mut listing = listing();
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "bad d tag".to_string(),
+ });
+ let draft = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .expect("canonical listing edit");
+ let publish = RadrootsOperationalListingMutation::publish(draft);
+
+ let err = build_operational_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err();
+
+ assert!(matches!(
+ err,
+ RadrootsOperationalListingMutationError::EncodeListing(_)
+ ));
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_event_id_is_stable_for_fixed_input() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+
+ let first =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+ let second =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+
+ assert_eq!(
+ first.expected_event_id_str(),
+ second.expected_event_id_str()
+ );
+ assert_eq!(first.expected_event_id_str().len(), 64);
+ assert_eq!(first.tags_as_vec(), second.tags_as_vec());
+ assert_eq!(first.content(), second.content());
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_output_validates_as_operational_listing() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let draft =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+
+ let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: draft.expected_event_id_str().to_owned(),
+ author: draft.expected_pubkey_str().to_owned(),
+ created_at: draft.created_at_u64(),
+ kind: draft.kind_u32(),
+ tags: draft.tags_as_vec(),
+ content: draft.content().to_owned(),
+ sig: "f".repeat(128),
+ })
+ .expect("listing event");
+ validate_event_contract_shape(&event, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID)
+ .expect("operational listing contract");
+ let validated = validate_operational_listing_event(&event).expect("validated listing");
+
+ assert_eq!(validated.seller_pubkey, SELLER);
+ assert!(validated.listing_addr.contains(&format!(":{SELLER}:")));
+ }
+}
diff --git a/crates/trade/src/operational_listing/price_ext.rs b/crates/trade/src/operational_listing/price_ext.rs
@@ -0,0 +1,217 @@
+use crate::operational_listing::model::{
+ RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal,
+};
+use radroots_core::{
+ RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError,
+ RadrootsCoreQuantityPriceOps,
+};
+use radroots_event::operational_listing::RadrootsOperationalListingBin;
+
+pub trait BinPricingExt {
+ fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal;
+ fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal;
+}
+
+pub trait BinPricingTryExt {
+ fn try_subtotal_for_count(
+ &self,
+ bin_count: u32,
+ ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError>;
+ fn try_total_for_count(
+ &self,
+ bin_count: u32,
+ ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError>;
+}
+
+#[inline]
+fn effective_quantity(bin: &RadrootsOperationalListingBin, bin_count: u32) -> RadrootsCoreQuantity {
+ let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count);
+ RadrootsCoreQuantity::new(amount, bin.quantity.unit)
+}
+
+impl BinPricingExt for RadrootsOperationalListingBin {
+ fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal {
+ let effective_qty = effective_quantity(self, bin_count);
+ let money = self
+ .price_per_canonical_unit
+ .cost_for_rounded(&effective_qty);
+ let currency = money.currency;
+
+ RadrootsOperationalListingSubtotal {
+ price_amount: money,
+ price_currency: currency,
+ quantity_amount: effective_qty.amount,
+ quantity_unit: effective_qty.unit,
+ }
+ }
+
+ fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal {
+ let sub = self.subtotal_for_count(bin_count);
+ RadrootsOperationalListingTotal {
+ price_amount: sub.price_amount,
+ price_currency: sub.price_currency,
+ quantity_amount: sub.quantity_amount,
+ quantity_unit: sub.quantity_unit,
+ }
+ }
+}
+
+impl BinPricingTryExt for RadrootsOperationalListingBin {
+ fn try_subtotal_for_count(
+ &self,
+ bin_count: u32,
+ ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError> {
+ let effective_qty = effective_quantity(self, bin_count);
+ let money = self
+ .price_per_canonical_unit
+ .try_cost_for_rounded(&effective_qty)?;
+ let currency = money.currency;
+
+ Ok(RadrootsOperationalListingSubtotal {
+ price_amount: money,
+ price_currency: currency,
+ quantity_amount: effective_qty.amount,
+ quantity_unit: effective_qty.unit,
+ })
+ }
+
+ fn try_total_for_count(
+ &self,
+ bin_count: u32,
+ ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError> {
+ let sub = self.try_subtotal_for_count(bin_count)?;
+ Ok(RadrootsOperationalListingTotal {
+ price_amount: sub.price_amount,
+ price_currency: sub.price_currency,
+ quantity_amount: sub.quantity_amount,
+ quantity_unit: sub.quantity_unit,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{BinPricingExt, BinPricingTryExt};
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit,
+ };
+ use radroots_event::ids::RadrootsInventoryBinId;
+ use radroots_event::operational_listing::RadrootsOperationalListingBin;
+
+ fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ RadrootsInventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn valid_bin() -> RadrootsOperationalListingBin {
+ RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(2u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
+ ),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }
+ }
+
+ #[test]
+ fn try_subtotal_for_rejects_unit_mismatch() {
+ let bin = RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each),
+ ),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ };
+
+ let err = bin.try_subtotal_for_count(1).unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreQuantityPriceError::UnitMismatch {
+ have: RadrootsCoreUnit::MassG,
+ want: RadrootsCoreUnit::Each,
+ }
+ );
+ }
+
+ #[test]
+ fn subtotal_and_total_for_count_follow_effective_quantity() {
+ let bin = valid_bin();
+ let subtotal = bin.subtotal_for_count(3);
+ let total = bin.total_for_count(3);
+
+ assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32));
+ assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG);
+ assert_eq!(
+ subtotal.price_amount.amount,
+ RadrootsCoreDecimal::from(30u32)
+ );
+ assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD);
+
+ assert_eq!(total.quantity_amount, subtotal.quantity_amount);
+ assert_eq!(total.quantity_unit, subtotal.quantity_unit);
+ assert_eq!(total.price_amount, subtotal.price_amount);
+ assert_eq!(total.price_currency, subtotal.price_currency);
+ }
+
+ #[test]
+ fn try_subtotal_and_try_total_match_non_fallible_paths() {
+ let bin = valid_bin();
+ let subtotal = bin.try_subtotal_for_count(4).expect("subtotal");
+ let total = bin.try_total_for_count(4).expect("total");
+
+ assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32));
+ assert_eq!(
+ subtotal.price_amount.amount,
+ RadrootsCoreDecimal::from(40u32)
+ );
+ assert_eq!(total.quantity_amount, subtotal.quantity_amount);
+ assert_eq!(total.price_amount, subtotal.price_amount);
+ }
+
+ #[test]
+ fn try_total_for_count_propagates_subtotal_errors() {
+ let bin = RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each),
+ ),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ };
+
+ let err = bin.try_total_for_count(1).unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreQuantityPriceError::UnitMismatch {
+ have: RadrootsCoreUnit::MassG,
+ want: RadrootsCoreUnit::Each,
+ }
+ );
+ }
+}
diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs
@@ -0,0 +1,732 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{
+ format,
+ string::{String, ToString},
+};
+
+use radroots_core::{
+ RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit,
+};
+use radroots_event::{
+ RadrootsEventEnvelope,
+ ids::RadrootsClassifiedListingAddress,
+ kinds::is_classified_listing_kind,
+ location::{has_textual_locality, is_public_geohash5},
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingPublicLocation,
+ },
+ trade_validation::RadrootsOperationalListingValidationError as OperationalListingValidationError,
+};
+
+use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsOperationalListingTradeProjection {
+ pub listing_id: String,
+ pub listing_addr: RadrootsClassifiedListingAddress,
+ pub seller_pubkey: String,
+ pub title: String,
+ pub description: String,
+ pub product_type: String,
+ pub primary_bin_id: String,
+ pub bin_quantity: RadrootsCoreQuantity,
+ pub unit: RadrootsCoreUnit,
+ pub unit_price: RadrootsCoreMoney,
+ pub inventory_available: RadrootsCoreDecimal,
+ pub availability: RadrootsOperationalListingAvailability,
+ pub location: RadrootsOperationalListingPublicLocation,
+ pub delivery_method: RadrootsOperationalListingDeliveryMethod,
+ pub listing: RadrootsOperationalListing,
+}
+
+pub fn validate_operational_listing_event(
+ event: &RadrootsEventEnvelope,
+) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> {
+ if !is_classified_listing_kind(event.kind_u32()) {
+ return Err(OperationalListingValidationError::InvalidKind {
+ kind: event.kind_u32(),
+ });
+ }
+
+ let listing = operational_listing_from_nostr_event(event)
+ .map_err(|error| OperationalListingValidationError::ParseError { error })?;
+ let listing_id = listing.d_tag.trim().to_string();
+
+ let seller_pubkey = event.author_str().to_string();
+ if listing.farm.pubkey != seller_pubkey {
+ return Err(OperationalListingValidationError::InvalidSeller);
+ }
+ let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id);
+ let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr_raw)
+ .expect("validated listing identity must form a listing address");
+
+ let title = listing.product.title.trim().to_string();
+ if title.is_empty() {
+ return Err(OperationalListingValidationError::MissingTitle);
+ }
+
+ let description = listing
+ .product
+ .summary
+ .as_ref()
+ .map(|s| s.trim().to_string())
+ .unwrap_or_default();
+ if description.is_empty() {
+ return Err(OperationalListingValidationError::MissingDescription);
+ }
+
+ let product_type = if !listing.product.category.trim().is_empty() {
+ listing.product.category.trim().to_string()
+ } else {
+ listing.product.key.trim().to_string()
+ };
+ if product_type.is_empty() {
+ return Err(OperationalListingValidationError::MissingProductType);
+ }
+
+ if listing.bins.is_empty() {
+ return Err(OperationalListingValidationError::MissingBins);
+ }
+ let primary_bin_id = listing.primary_bin_id.trim().to_string();
+ let primary_bin = listing
+ .bins
+ .iter()
+ .find(|bin| bin.bin_id == primary_bin_id)
+ .ok_or(OperationalListingValidationError::MissingPrimaryBin)?;
+
+ if primary_bin.quantity.amount.is_sign_negative() {
+ return Err(OperationalListingValidationError::InvalidBin);
+ }
+ if !primary_bin.quantity.is_canonical() {
+ return Err(OperationalListingValidationError::InvalidBin);
+ }
+ if !primary_bin
+ .price_per_canonical_unit
+ .is_price_per_canonical_unit()
+ {
+ return Err(OperationalListingValidationError::InvalidPrice);
+ }
+ if primary_bin
+ .price_per_canonical_unit
+ .amount
+ .amount
+ .is_sign_negative()
+ {
+ return Err(OperationalListingValidationError::InvalidPrice);
+ }
+ if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit {
+ return Err(OperationalListingValidationError::InvalidPrice);
+ }
+
+ let inventory_available = listing
+ .inventory_available
+ .ok_or(OperationalListingValidationError::MissingInventory)?;
+ if inventory_available.is_sign_negative() {
+ return Err(OperationalListingValidationError::InvalidInventory);
+ }
+
+ let availability = listing
+ .availability
+ .clone()
+ .ok_or(OperationalListingValidationError::MissingAvailability)?;
+ let location = listing
+ .location
+ .clone()
+ .ok_or(OperationalListingValidationError::MissingLocation)?;
+ if !has_textual_locality(
+ &location.primary,
+ location.city.as_deref(),
+ location.region.as_deref(),
+ location.country.as_deref(),
+ ) {
+ return Err(OperationalListingValidationError::MissingLocationLocality);
+ }
+ validate_listing_location_geohash(&location.geohash)?;
+ let delivery_method = listing
+ .delivery_method
+ .clone()
+ .ok_or(OperationalListingValidationError::MissingDeliveryMethod)?;
+
+ Ok(RadrootsOperationalListingTradeProjection {
+ listing_id,
+ listing_addr,
+ seller_pubkey,
+ title,
+ description,
+ product_type,
+ primary_bin_id: primary_bin_id.clone(),
+ bin_quantity: primary_bin.quantity.clone(),
+ unit: primary_bin.quantity.unit,
+ unit_price: primary_bin.price_per_canonical_unit.amount.clone(),
+ inventory_available,
+ availability,
+ location,
+ delivery_method,
+ listing,
+ })
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn validate_listing_location_geohash(
+ geohash: &str,
+) -> Result<(), OperationalListingValidationError> {
+ if geohash.trim().is_empty() {
+ return Err(OperationalListingValidationError::MissingLocationGeohash);
+ }
+ if !is_public_geohash5(geohash) {
+ return Err(OperationalListingValidationError::InvalidLocationGeohash);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{OperationalListingValidationError, validate_operational_listing_event};
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ };
+ use radroots_event::{
+ RadrootsEventEnvelope,
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ kinds::KIND_CLASSIFIED_LISTING,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
+ },
+ };
+
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ fn d_tag(raw: &str) -> RadrootsDTag {
+ RadrootsDTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> RadrootsInventoryBinId {
+ RadrootsInventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn base_listing() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: SELLER.into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: RadrootsOperationalListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![RadrootsOperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsOperationalListingAvailability::Status {
+ status:
+ radroots_event::operational_listing::RadrootsOperationalListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
+ location: Some(RadrootsOperationalListingPublicLocation {
+ primary: "Farm".into(),
+ city: Some("Town".into()),
+ region: Some("Region".into()),
+ country: Some("US".into()),
+ geohash: "9q8yy".into(),
+ }),
+ images: None,
+ }
+ }
+
+ fn base_event(listing: &RadrootsOperationalListing) -> RadrootsEventEnvelope {
+ let mut tags = 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),
+ ],
+ 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(),
+ ],
+ ];
+ 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 {
+ RadrootsOperationalListingAvailability::Status { status } => tags.push(vec![
+ "status".into(),
+ match status {
+ radroots_event::operational_listing::RadrootsOperationalListingStatus::Active => "active".into(),
+ radroots_event::operational_listing::RadrootsOperationalListingStatus::Sold => "sold".into(),
+ radroots_event::operational_listing::RadrootsOperationalListingStatus::Other { value } => {
+ value.clone()
+ }
+ },
+ ]),
+ RadrootsOperationalListingAvailability::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 {
+ RadrootsOperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()),
+ RadrootsOperationalListingDeliveryMethod::LocalDelivery => {
+ tag.push("local_delivery".into())
+ }
+ RadrootsOperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()),
+ RadrootsOperationalListingDeliveryMethod::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_CLASSIFIED_LISTING, tags, String::new())
+ }
+
+ fn event_with_parts(
+ author: &str,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: String,
+ ) -> RadrootsEventEnvelope {
+ RadrootsEventEnvelope::new(radroots_event::RadrootsEventEnvelopeParts {
+ id: "9".repeat(64),
+ author: author.to_string(),
+ created_at: 0,
+ kind,
+ tags,
+ content,
+ sig: "f".repeat(128),
+ })
+ .expect("event")
+ }
+
+ fn assert_validation_err(
+ listing: RadrootsOperationalListing,
+ expected: OperationalListingValidationError,
+ ) {
+ let event = base_event(&listing);
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(format!("{err}"), format!("{expected}"));
+ }
+
+ #[test]
+ fn validate_listing_ok() {
+ let listing = base_listing();
+ let event = base_event(&listing);
+ assert!(validate_operational_listing_event(&event).is_ok());
+ }
+
+ #[test]
+ fn validate_listing_rejects_retired_kind() {
+ let listing = base_listing();
+ let event = event_with_parts(
+ SELLER,
+ 30403,
+ base_event(&listing).tags_as_vec(),
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(
+ err,
+ OperationalListingValidationError::InvalidKind { kind: 30403 }
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_d_tag() {
+ let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new());
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(
+ err,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::MissingTag("d".to_string())
+ }
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_currency() {
+ let event = event_with_parts(
+ SELLER,
+ KIND_CLASSIFIED_LISTING,
+ vec![
+ vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec!["p".into(), SELLER.into()],
+ vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
+ vec!["key".into(), "coffee".into()],
+ vec!["title".into(), "Coffee".into()],
+ vec!["category".into(), "coffee".into()],
+ vec!["summary".into(), "Single origin".into()],
+ vec![
+ "quantity".into(),
+ "1".into(),
+ "lb".into(),
+ "bag".into(),
+ "5".into(),
+ ],
+ vec![
+ "price".into(),
+ "20".into(),
+ "US".into(),
+ "1".into(),
+ "lb".into(),
+ ],
+ vec![
+ "location".into(),
+ "Farm".into(),
+ "Town".into(),
+ "Region".into(),
+ ],
+ vec!["status".into(), "active".into()],
+ vec!["delivery".into(), "pickup".into()],
+ ],
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert!(format!("{err:?}").starts_with("ParseError"));
+ }
+
+ #[test]
+ fn validate_listing_rejects_mismatched_seller() {
+ let listing = base_listing();
+ let event = event_with_parts(
+ OTHER_SELLER,
+ KIND_CLASSIFIED_LISTING,
+ base_event(&listing).tags_as_vec(),
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(err, OperationalListingValidationError::InvalidSeller);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_inventory() {
+ let mut listing = base_listing();
+ listing.inventory_available = None;
+ let event = base_event(&listing);
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(err, OperationalListingValidationError::MissingInventory);
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_kind() {
+ let listing = base_listing();
+ let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new());
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(
+ err,
+ OperationalListingValidationError::InvalidKind { kind: 0 }
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_title() {
+ let mut listing = base_listing();
+ listing.product.title = " ".into();
+ assert_validation_err(listing, OperationalListingValidationError::MissingTitle);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_description() {
+ let mut listing = base_listing();
+ listing.product.summary = Some(" ".into());
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingDescription,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_product_type() {
+ let mut listing = base_listing();
+ listing.product.category = " ".into();
+ listing.product.key = " ".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingProductType,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_bins() {
+ let mut listing = base_listing();
+ listing.bins.clear();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:primary_bin".into()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_primary_bin_id() {
+ assert!(RadrootsInventoryBinId::parse(" ").is_err());
+ }
+
+ #[test]
+ fn validate_listing_rejects_primary_bin_not_found() {
+ let mut listing = base_listing();
+ listing.primary_bin_id = bin_id("missing");
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:primary_bin".into()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_negative_quantity() {
+ let mut listing = base_listing();
+ listing.bins[0].quantity.amount = "-1".parse().unwrap();
+ assert_validation_err(listing, OperationalListingValidationError::InvalidBin);
+ }
+
+ #[test]
+ fn validate_listing_rejects_non_canonical_quantity() {
+ let mut listing = base_listing();
+ listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::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,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:price".into()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_negative_price_amount() {
+ let mut listing = base_listing();
+ listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap();
+ assert_validation_err(listing, OperationalListingValidationError::InvalidPrice);
+ }
+
+ #[test]
+ 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,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:price".into()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_negative_inventory() {
+ let mut listing = base_listing();
+ listing.inventory_available = Some("-1".parse().unwrap());
+ assert_validation_err(listing, OperationalListingValidationError::InvalidInventory);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_availability() {
+ let mut listing = base_listing();
+ listing.availability = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingAvailability,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location() {
+ let mut listing = base_listing();
+ listing.location = None;
+ assert_validation_err(listing, OperationalListingValidationError::MissingLocation);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location_locality() {
+ let mut listing = base_listing();
+ let location = listing.location.as_mut().expect("location");
+ location.city = None;
+ location.region = None;
+ location.country = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingLocationLocality,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = " ".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("g".to_string()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("g".to_string()),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_delivery_method() {
+ let mut listing = base_listing();
+ listing.delivery_method = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingDeliveryMethod,
+ );
+ }
+
+ #[test]
+ fn validation_error_display_covers_all_variants() {
+ let errors = vec![
+ OperationalListingValidationError::InvalidKind { kind: 9 },
+ OperationalListingValidationError::MissingListingId,
+ OperationalListingValidationError::ListingEventNotFound {
+ listing_addr: "addr".into(),
+ },
+ OperationalListingValidationError::ListingEventFetchFailed {
+ listing_addr: "addr".into(),
+ },
+ OperationalListingValidationError::ParseError {
+ error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("d".into()),
+ },
+ OperationalListingValidationError::InvalidSeller,
+ OperationalListingValidationError::MissingFarmProfile,
+ OperationalListingValidationError::MissingFarmRecord,
+ OperationalListingValidationError::MissingTitle,
+ OperationalListingValidationError::MissingDescription,
+ OperationalListingValidationError::MissingProductType,
+ OperationalListingValidationError::MissingBins,
+ OperationalListingValidationError::MissingPrimaryBin,
+ OperationalListingValidationError::InvalidBin,
+ OperationalListingValidationError::MissingPrice,
+ OperationalListingValidationError::InvalidPrice,
+ OperationalListingValidationError::MissingInventory,
+ OperationalListingValidationError::InvalidInventory,
+ OperationalListingValidationError::MissingAvailability,
+ OperationalListingValidationError::MissingLocation,
+ OperationalListingValidationError::MissingLocationLocality,
+ OperationalListingValidationError::MissingLocationGeohash,
+ OperationalListingValidationError::InvalidLocationGeohash,
+ OperationalListingValidationError::MissingDeliveryMethod,
+ ];
+ for error in errors {
+ assert!(!error.to_string().trim().is_empty());
+ }
+ }
+}
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -11,8 +11,8 @@ use radroots_event::RadrootsEventEnvelope;
#[cfg(feature = "event_store")]
use radroots_event::RadrootsEventEnvelopeParts;
use radroots_event::ids::{
- RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress,
- RadrootsOrderId, RadrootsPublicKey,
+ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError,
+ RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey,
};
#[cfg(feature = "serde_json")]
use radroots_event::order::RadrootsOrderEventType;
@@ -35,8 +35,8 @@ use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
-use crate::listing::{
- RadrootsPublicListingAddress, RadrootsPublicListingAddressError, parse_public_listing_address,
+use crate::operational_listing::{
+ RadrootsPublicClassifiedListingAddress, parse_public_classified_listing_address,
};
use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_commitments};
@@ -44,10 +44,6 @@ use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_co
pub enum RadrootsOrderCanonicalizationError {
#[error("{0} cannot be empty")]
EmptyField(&'static str),
- #[error("invalid listing_addr: {0}")]
- InvalidListingAddress(String),
- #[error("listing_addr must reference a public NIP-99 listing")]
- InvalidListingKind,
#[error("buyer_pubkey must match the requested signer identity")]
InvalidBuyerSigner,
#[error("seller_pubkey must match listing_addr seller")]
@@ -372,9 +368,6 @@ pub enum RadrootsOrderIssue {
RequestAuthorMismatch {
event_id: RadrootsEventId,
},
- RequestListingAddressInvalid {
- event_id: RadrootsEventId,
- },
RequestSellerListingMismatch {
event_id: RadrootsEventId,
},
@@ -396,9 +389,6 @@ pub enum RadrootsOrderIssue {
DecisionSellerMismatch {
event_id: RadrootsEventId,
},
- DecisionListingAddressInvalid {
- event_id: RadrootsEventId,
- },
DecisionListingMismatch {
event_id: RadrootsEventId,
},
@@ -441,9 +431,6 @@ pub enum RadrootsOrderIssue {
CancellationSellerMismatch {
event_id: RadrootsEventId,
},
- CancellationListingAddressInvalid {
- event_id: RadrootsEventId,
- },
CancellationListingMismatch {
event_id: RadrootsEventId,
},
@@ -500,7 +487,7 @@ pub struct RadrootsOrderProjection {
pub agreement_event_id: Option<RadrootsEventId>,
pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub listing_addr: Option<RadrootsListingAddress>,
+ pub listing_addr: Option<RadrootsClassifiedListingAddress>,
pub buyer_pubkey: Option<RadrootsPublicKey>,
pub seller_pubkey: Option<RadrootsPublicKey>,
pub last_event_id: Option<RadrootsEventId>,
@@ -532,7 +519,7 @@ pub struct RadrootsOrderWorkflowProjection {
pub agreement_event_id: Option<RadrootsEventId>,
pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub listing_addr: Option<RadrootsListingAddress>,
+ pub listing_addr: Option<RadrootsClassifiedListingAddress>,
pub buyer_pubkey: Option<RadrootsPublicKey>,
pub seller_pubkey: Option<RadrootsPublicKey>,
pub last_event_id: Option<RadrootsEventId>,
@@ -576,32 +563,32 @@ pub enum RadrootsOrderEconomicsDigestError {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingInventoryBinAvailability {
+pub struct RadrootsOperationalListingInventoryBinAvailability {
pub bin_id: RadrootsInventoryBinId,
pub available_count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingInventoryOrderReservation {
+pub struct RadrootsOperationalListingInventoryOrderReservation {
pub order_id: RadrootsOrderId,
pub agreement_event_id: RadrootsEventId,
pub bin_count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingInventoryBinAccounting {
+pub struct RadrootsOperationalListingInventoryBinAccounting {
pub bin_id: RadrootsInventoryBinId,
pub available_count: u64,
pub pending_reserved_count: u64,
pub committed_reserved_count: u64,
pub remaining_count: u64,
pub over_reserved: bool,
- pub pending_orders: Vec<RadrootsListingInventoryOrderReservation>,
- pub committed_orders: Vec<RadrootsListingInventoryOrderReservation>,
+ pub pending_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>,
+ pub committed_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsListingInventoryAccountingIssue {
+pub enum RadrootsOperationalListingInventoryAccountingIssue {
InvalidOrder {
order_id: RadrootsOrderId,
event_ids: Vec<RadrootsEventId>,
@@ -623,14 +610,14 @@ pub enum RadrootsListingInventoryAccountingIssue {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingInventoryAccountingProjection {
- pub listing_addr: RadrootsListingAddress,
+pub struct RadrootsOperationalListingInventoryAccountingProjection {
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub listing_event_id: RadrootsEventId,
- pub bins: Vec<RadrootsListingInventoryBinAccounting>,
+ pub bins: Vec<RadrootsOperationalListingInventoryBinAccounting>,
pub declined_order_ids: Vec<RadrootsOrderId>,
pub cancelled_order_ids: Vec<RadrootsOrderId>,
pub invalid_event_ids: Vec<RadrootsEventId>,
- pub issues: Vec<RadrootsListingInventoryAccountingIssue>,
+ pub issues: Vec<RadrootsOperationalListingInventoryAccountingIssue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -663,7 +650,7 @@ pub(crate) enum RadrootsTradeLocatorGroupedOrderEventRecordsResolution {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingInventoryAccountingInputs<I, J, K, L> {
+pub struct RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L> {
pub bins: I,
pub requests: J,
pub decisions: K,
@@ -671,8 +658,8 @@ pub struct RadrootsListingInventoryAccountingInputs<I, J, K, L> {
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
-struct RadrootsListingInventoryAccountingRecords {
- bins: Vec<RadrootsListingInventoryBinAvailability>,
+struct RadrootsOperationalListingInventoryAccountingRecords {
+ bins: Vec<RadrootsOperationalListingInventoryBinAvailability>,
requests: Vec<RadrootsOrderRequestRecord>,
decisions: Vec<RadrootsOrderDecisionRecord>,
cancellations: Vec<RadrootsOrderCancellationRecord>,
@@ -957,21 +944,21 @@ pub(crate) fn reduce_grouped_order_event_records(
}
}
-pub fn reduce_listing_inventory_accounting<I, J, K, L>(
- listing_addr: &RadrootsListingAddress,
+pub fn reduce_operational_listing_inventory_accounting<I, J, K, L>(
+ listing_addr: &RadrootsClassifiedListingAddress,
listing_event_id: &RadrootsEventId,
- inputs: RadrootsListingInventoryAccountingInputs<I, J, K, L>,
-) -> RadrootsListingInventoryAccountingProjection
+ inputs: RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L>,
+) -> RadrootsOperationalListingInventoryAccountingProjection
where
- I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>,
+ I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>,
J: IntoIterator<Item = RadrootsOrderRequestRecord>,
K: IntoIterator<Item = RadrootsOrderDecisionRecord>,
L: IntoIterator<Item = RadrootsOrderCancellationRecord>,
{
- reduce_listing_inventory_accounting_records(
+ reduce_operational_listing_inventory_accounting_records(
listing_addr,
listing_event_id,
- RadrootsListingInventoryAccountingRecords {
+ RadrootsOperationalListingInventoryAccountingRecords {
bins: inputs.bins.into_iter().collect(),
requests: inputs.requests.into_iter().collect(),
decisions: inputs.decisions.into_iter().collect(),
@@ -980,11 +967,11 @@ where
)
}
-fn reduce_listing_inventory_accounting_records(
- listing_addr: &RadrootsListingAddress,
+fn reduce_operational_listing_inventory_accounting_records(
+ listing_addr: &RadrootsClassifiedListingAddress,
listing_event_id: &RadrootsEventId,
- records: RadrootsListingInventoryAccountingRecords,
-) -> RadrootsListingInventoryAccountingProjection {
+ records: RadrootsOperationalListingInventoryAccountingRecords,
+) -> RadrootsOperationalListingInventoryAccountingProjection {
let (mut bins, mut issues) = normalized_listing_inventory_bins(records.bins);
let requests = unique_request_records(records.requests)
.into_iter()
@@ -1055,10 +1042,12 @@ fn reduce_listing_inventory_accounting_records(
);
}
invalid_event_ids.extend(event_ids.iter().cloned());
- issues.push(RadrootsListingInventoryAccountingIssue::InvalidOrder {
- order_id,
- event_ids,
- });
+ issues.push(
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
+ order_id,
+ event_ids,
+ },
+ );
}
RadrootsTradeWorkflowState::Missing
| RadrootsTradeWorkflowState::Requested
@@ -1072,7 +1061,7 @@ fn reduce_listing_inventory_accounting_records(
sort_and_dedup_values(&mut invalid_event_ids);
finish_inventory_accounting_bins(&mut bins, &mut issues);
issues.sort_by(inventory_issue_sort_key);
- RadrootsListingInventoryAccountingProjection {
+ RadrootsOperationalListingInventoryAccountingProjection {
listing_addr: listing_addr.clone(),
listing_event_id: listing_event_id.clone(),
bins,
@@ -1105,8 +1094,7 @@ pub fn canonicalize_order_request_for_signer(
signer_pubkey: &str,
) -> Result<RadrootsOrderRequest, RadrootsOrderCanonicalizationError> {
let order_id = request.order_id.clone();
- let listing_addr_raw = request.listing_addr.to_string();
- let listing_addr = parse_public_listing_addr(&listing_addr_raw)?;
+ let listing_addr = public_listing_addr(&request.listing_addr);
let buyer_pubkey = request.buyer_pubkey.clone();
if buyer_pubkey.as_str() != signer_pubkey {
@@ -1132,8 +1120,7 @@ pub fn canonicalize_order_decision_for_signer(
signer_pubkey: &str,
) -> Result<RadrootsOrderDecision, RadrootsOrderCanonicalizationError> {
let order_id = decision_event.order_id.clone();
- let listing_addr_raw = decision_event.listing_addr.to_string();
- let listing_addr = parse_public_listing_addr(&listing_addr_raw)?;
+ let listing_addr = public_listing_addr(&decision_event.listing_addr);
let seller_pubkey = decision_event.seller_pubkey.clone();
if seller_pubkey.as_str() != signer_pubkey || seller_pubkey != listing_addr.seller_pubkey {
@@ -1335,21 +1322,12 @@ fn validate_order_request_record(
});
valid = false;
}
- match parse_public_listing_addr(&request.payload.listing_addr) {
- Ok(listing_addr) => {
- if listing_addr.seller_pubkey != request.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::RequestSellerListingMismatch {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
- }
- Err(_) => {
- issues.push(RadrootsOrderIssue::RequestListingAddressInvalid {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
+ let listing_addr = public_listing_addr(&request.payload.listing_addr);
+ if listing_addr.seller_pubkey != request.payload.seller_pubkey {
+ issues.push(RadrootsOrderIssue::RequestSellerListingMismatch {
+ event_id: request.event_id.clone(),
+ });
+ valid = false;
}
valid
}
@@ -1399,23 +1377,14 @@ fn validate_order_decision_record(
});
valid = false;
}
- match parse_public_listing_addr(&decision.payload.listing_addr) {
- Ok(listing_addr) => {
- if decision.payload.listing_addr != request.payload.listing_addr
- || listing_addr.seller_pubkey != decision.payload.seller_pubkey
- {
- issues.push(RadrootsOrderIssue::DecisionListingMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- }
- Err(_) => {
- issues.push(RadrootsOrderIssue::DecisionListingAddressInvalid {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
+ let listing_addr = public_listing_addr(&decision.payload.listing_addr);
+ if decision.payload.listing_addr != request.payload.listing_addr
+ || listing_addr.seller_pubkey != decision.payload.seller_pubkey
+ {
+ issues.push(RadrootsOrderIssue::DecisionListingMismatch {
+ event_id: decision.event_id.clone(),
+ });
+ valid = false;
}
if decision.root_event_id != request.event_id {
issues.push(RadrootsOrderIssue::DecisionRootMismatch {
@@ -1485,23 +1454,14 @@ fn validate_order_cancellation_record(
});
valid = false;
}
- match parse_public_listing_addr(&cancellation.payload.listing_addr) {
- Ok(listing_addr) => {
- if cancellation.payload.listing_addr != request.payload.listing_addr
- || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey
- {
- issues.push(RadrootsOrderIssue::CancellationListingMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- }
- Err(_) => {
- issues.push(RadrootsOrderIssue::CancellationListingAddressInvalid {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
+ let listing_addr = public_listing_addr(&cancellation.payload.listing_addr);
+ if cancellation.payload.listing_addr != request.payload.listing_addr
+ || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey
+ {
+ issues.push(RadrootsOrderIssue::CancellationListingMismatch {
+ event_id: cancellation.event_id.clone(),
+ });
+ valid = false;
}
if cancellation.root_event_id != request.event_id {
issues.push(RadrootsOrderIssue::CancellationRootMismatch {
@@ -1587,13 +1547,13 @@ fn unique_records_by_event_id<T>(
fn normalized_listing_inventory_bins<I>(
bins: I,
) -> (
- Vec<RadrootsListingInventoryBinAccounting>,
- Vec<RadrootsListingInventoryAccountingIssue>,
+ Vec<RadrootsOperationalListingInventoryBinAccounting>,
+ Vec<RadrootsOperationalListingInventoryAccountingIssue>,
)
where
- I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>,
+ I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>,
{
- let mut normalized: Vec<RadrootsListingInventoryBinAccounting> = Vec::new();
+ let mut normalized: Vec<RadrootsOperationalListingInventoryBinAccounting> = Vec::new();
let mut issues = Vec::new();
for bin in bins {
let bin_id = bin.bin_id;
@@ -1608,14 +1568,14 @@ where
existing.available_count = u64::MAX;
existing.remaining_count = u64::MAX;
issues.push(
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: existing.bin_id.clone(),
event_ids: Vec::new(),
},
);
}
} else {
- normalized.push(RadrootsListingInventoryBinAccounting {
+ normalized.push(RadrootsOperationalListingInventoryBinAccounting {
bin_id,
available_count: bin.available_count,
pending_reserved_count: 0,
@@ -1657,11 +1617,11 @@ fn listing_order_ids(
}
fn add_pending_inventory_reservations_from_economics(
- bins: &mut [RadrootsListingInventoryBinAccounting],
+ bins: &mut [RadrootsOperationalListingInventoryBinAccounting],
order_id: &RadrootsOrderId,
agreement_event_id: &RadrootsEventId,
economics: &RadrootsOrderEconomics,
- issues: &mut Vec<RadrootsListingInventoryAccountingIssue>,
+ issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
) {
for item in &economics.items {
if let Some(bin) = bins.iter_mut().find(|bin| bin.bin_id == item.bin_id) {
@@ -1674,7 +1634,7 @@ fn add_pending_inventory_reservations_from_economics(
);
} else {
issues.push(
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: item.bin_id.clone(),
event_ids: vec![agreement_event_id.clone()],
},
@@ -1684,23 +1644,23 @@ fn add_pending_inventory_reservations_from_economics(
}
fn add_inventory_reservation_event(
- bin: &mut RadrootsListingInventoryBinAccounting,
+ bin: &mut RadrootsOperationalListingInventoryBinAccounting,
order_id: &RadrootsOrderId,
event_id: &RadrootsEventId,
bin_count: u64,
- issues: &mut Vec<RadrootsListingInventoryAccountingIssue>,
+ issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
) {
if let Some(next_count) = bin.pending_reserved_count.checked_add(bin_count) {
bin.pending_reserved_count = next_count;
bin.pending_orders
- .push(RadrootsListingInventoryOrderReservation {
+ .push(RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id.clone(),
agreement_event_id: event_id.clone(),
bin_count,
});
} else {
issues.push(
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: bin.bin_id.clone(),
event_ids: vec![event_id.clone()],
},
@@ -1709,8 +1669,8 @@ fn add_inventory_reservation_event(
}
fn finish_inventory_accounting_bins(
- bins: &mut [RadrootsListingInventoryBinAccounting],
- issues: &mut Vec<RadrootsListingInventoryAccountingIssue>,
+ bins: &mut [RadrootsOperationalListingInventoryBinAccounting],
+ issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
) {
for bin in bins.iter_mut() {
bin.pending_orders.sort_by(|left, right| {
@@ -1736,12 +1696,14 @@ fn finish_inventory_accounting_bins(
.map(|reservation| reservation.agreement_event_id.clone())
.collect::<Vec<_>>();
sort_and_dedup_values(&mut event_ids);
- issues.push(RadrootsListingInventoryAccountingIssue::OverReserved {
- bin_id: bin.bin_id.clone(),
- available_count: bin.available_count,
- reserved_count,
- event_ids,
- });
+ issues.push(
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
+ bin_id: bin.bin_id.clone(),
+ available_count: bin.available_count,
+ reserved_count,
+ event_ids,
+ },
+ );
}
}
bins.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
@@ -1761,7 +1723,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven
RadrootsOrderIssue::RequestPayloadInvalid { event_id }
| RadrootsOrderIssue::RequestOrderIdMismatch { event_id }
| RadrootsOrderIssue::RequestAuthorMismatch { event_id }
- | RadrootsOrderIssue::RequestListingAddressInvalid { event_id }
| RadrootsOrderIssue::RequestSellerListingMismatch { event_id }
| RadrootsOrderIssue::DecisionPayloadInvalid { event_id }
| RadrootsOrderIssue::DecisionOrderIdMismatch { event_id }
@@ -1769,7 +1730,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven
| RadrootsOrderIssue::DecisionCounterpartyMismatch { event_id }
| RadrootsOrderIssue::DecisionBuyerMismatch { event_id }
| RadrootsOrderIssue::DecisionSellerMismatch { event_id }
- | RadrootsOrderIssue::DecisionListingAddressInvalid { event_id }
| RadrootsOrderIssue::DecisionListingMismatch { event_id }
| RadrootsOrderIssue::DecisionRootMismatch { event_id }
| RadrootsOrderIssue::DecisionPreviousMismatch { event_id }
@@ -1783,7 +1743,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven
| RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id }
| RadrootsOrderIssue::CancellationBuyerMismatch { event_id }
| RadrootsOrderIssue::CancellationSellerMismatch { event_id }
- | RadrootsOrderIssue::CancellationListingAddressInvalid { event_id }
| RadrootsOrderIssue::CancellationListingMismatch { event_id }
| RadrootsOrderIssue::CancellationRootMismatch { event_id }
| RadrootsOrderIssue::CancellationPreviousMismatch { event_id }
@@ -1809,20 +1768,11 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven
event_ids
}
-fn parse_public_listing_addr(
- value: impl AsRef<str>,
-) -> Result<RadrootsPublicListingAddress, RadrootsOrderCanonicalizationError> {
- parse_public_listing_address(value).map_err(|error| match error {
- RadrootsPublicListingAddressError::InvalidAddress(error) => {
- RadrootsOrderCanonicalizationError::InvalidListingAddress(error.to_string())
- }
- RadrootsPublicListingAddressError::InvalidListingKind { .. } => {
- RadrootsOrderCanonicalizationError::InvalidListingKind
- }
- RadrootsPublicListingAddressError::InvalidKind { .. } => {
- RadrootsOrderCanonicalizationError::InvalidListingKind
- }
- })
+fn public_listing_addr(
+ value: &RadrootsClassifiedListingAddress,
+) -> RadrootsPublicClassifiedListingAddress {
+ parse_public_classified_listing_address(value)
+ .expect("typed classified listing address must remain a valid kind-30402 coordinate")
}
fn canonicalize_items(
@@ -1910,8 +1860,8 @@ fn sort_and_dedup_values<T: Ord>(values: &mut Vec<T>) {
}
fn inventory_issue_sort_key(
- left: &RadrootsListingInventoryAccountingIssue,
- right: &RadrootsListingInventoryAccountingIssue,
+ left: &RadrootsOperationalListingInventoryAccountingIssue,
+ right: &RadrootsOperationalListingInventoryAccountingIssue,
) -> core::cmp::Ordering {
inventory_issue_rank(left)
.cmp(&inventory_issue_rank(right))
@@ -1919,32 +1869,46 @@ fn inventory_issue_sort_key(
.then_with(|| inventory_issue_event_ids(left).cmp(inventory_issue_event_ids(right)))
}
-fn inventory_issue_rank(issue: &RadrootsListingInventoryAccountingIssue) -> u8 {
+fn inventory_issue_rank(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> u8 {
match issue {
- RadrootsListingInventoryAccountingIssue::InvalidOrder { .. } => 0,
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { .. } => 1,
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 2,
- RadrootsListingInventoryAccountingIssue::OverReserved { .. } => 3,
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. } => 0,
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. } => 1,
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 2,
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. } => 3,
}
}
-fn inventory_issue_id(issue: &RadrootsListingInventoryAccountingIssue) -> &str {
+fn inventory_issue_id(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> &str {
match issue {
- RadrootsListingInventoryAccountingIssue::InvalidOrder { order_id, .. } => order_id,
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { bin_id, .. }
- | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { bin_id, .. }
- | RadrootsListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id,
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id, .. } => {
+ order_id
+ }
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
+ bin_id, ..
+ }
+ | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
+ bin_id, ..
+ }
+ | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id,
}
}
fn inventory_issue_event_ids(
- issue: &RadrootsListingInventoryAccountingIssue,
+ issue: &RadrootsOperationalListingInventoryAccountingIssue,
) -> &[RadrootsEventId] {
match issue {
- RadrootsListingInventoryAccountingIssue::InvalidOrder { event_ids, .. }
- | RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { event_ids, .. }
- | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { event_ids, .. }
- | RadrootsListingInventoryAccountingIssue::OverReserved { event_ids, .. } => event_ids,
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { event_ids, .. }
+ | RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
+ event_ids,
+ ..
+ }
+ | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
+ event_ids,
+ ..
+ }
+ | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { event_ids, .. } => {
+ event_ids
+ }
}
}
@@ -1967,43 +1931,40 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 {
RadrootsOrderIssue::RequestPayloadInvalid { .. } => 2,
RadrootsOrderIssue::RequestOrderIdMismatch { .. } => 3,
RadrootsOrderIssue::RequestAuthorMismatch { .. } => 4,
- RadrootsOrderIssue::RequestListingAddressInvalid { .. } => 5,
- RadrootsOrderIssue::RequestSellerListingMismatch { .. } => 6,
- RadrootsOrderIssue::DecisionPayloadInvalid { .. } => 7,
- RadrootsOrderIssue::DecisionOrderIdMismatch { .. } => 8,
- RadrootsOrderIssue::DecisionAuthorMismatch { .. } => 9,
- RadrootsOrderIssue::DecisionCounterpartyMismatch { .. } => 10,
- RadrootsOrderIssue::DecisionBuyerMismatch { .. } => 11,
- RadrootsOrderIssue::DecisionSellerMismatch { .. } => 12,
- RadrootsOrderIssue::DecisionListingAddressInvalid { .. } => 13,
- RadrootsOrderIssue::DecisionListingMismatch { .. } => 14,
- RadrootsOrderIssue::DecisionRootMismatch { .. } => 15,
- RadrootsOrderIssue::DecisionPreviousMismatch { .. } => 16,
- RadrootsOrderIssue::DecisionMissingInventoryCommitments { .. } => 17,
- RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { .. } => 18,
- RadrootsOrderIssue::DecisionMissingReason { .. } => 19,
- RadrootsOrderIssue::ConflictingDecisions { .. } => 20,
- RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. } => 21,
- RadrootsOrderIssue::CancellationPayloadInvalid { .. } => 22,
- RadrootsOrderIssue::CancellationOrderIdMismatch { .. } => 23,
- RadrootsOrderIssue::CancellationAuthorMismatch { .. } => 24,
- RadrootsOrderIssue::CancellationCounterpartyMismatch { .. } => 25,
- RadrootsOrderIssue::CancellationBuyerMismatch { .. } => 26,
- RadrootsOrderIssue::CancellationSellerMismatch { .. } => 27,
- RadrootsOrderIssue::CancellationListingAddressInvalid { .. } => 28,
- RadrootsOrderIssue::CancellationListingMismatch { .. } => 29,
- RadrootsOrderIssue::CancellationRootMismatch { .. } => 30,
- RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 31,
- RadrootsOrderIssue::ForkedLifecycle { .. } => 32,
- RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 33,
- RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 34,
- RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 35,
- RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 36,
- RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 37,
- RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 38,
- RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 39,
- RadrootsOrderIssue::DeterministicValidationFailure { .. } => 40,
- RadrootsOrderIssue::StaleListingEvent { .. } => 41,
+ RadrootsOrderIssue::RequestSellerListingMismatch { .. } => 5,
+ RadrootsOrderIssue::DecisionPayloadInvalid { .. } => 6,
+ RadrootsOrderIssue::DecisionOrderIdMismatch { .. } => 7,
+ RadrootsOrderIssue::DecisionAuthorMismatch { .. } => 8,
+ RadrootsOrderIssue::DecisionCounterpartyMismatch { .. } => 9,
+ RadrootsOrderIssue::DecisionBuyerMismatch { .. } => 10,
+ RadrootsOrderIssue::DecisionSellerMismatch { .. } => 11,
+ RadrootsOrderIssue::DecisionListingMismatch { .. } => 12,
+ RadrootsOrderIssue::DecisionRootMismatch { .. } => 13,
+ RadrootsOrderIssue::DecisionPreviousMismatch { .. } => 14,
+ RadrootsOrderIssue::DecisionMissingInventoryCommitments { .. } => 15,
+ RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { .. } => 16,
+ RadrootsOrderIssue::DecisionMissingReason { .. } => 17,
+ RadrootsOrderIssue::ConflictingDecisions { .. } => 18,
+ RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. } => 19,
+ RadrootsOrderIssue::CancellationPayloadInvalid { .. } => 20,
+ RadrootsOrderIssue::CancellationOrderIdMismatch { .. } => 21,
+ RadrootsOrderIssue::CancellationAuthorMismatch { .. } => 22,
+ RadrootsOrderIssue::CancellationCounterpartyMismatch { .. } => 23,
+ RadrootsOrderIssue::CancellationBuyerMismatch { .. } => 24,
+ RadrootsOrderIssue::CancellationSellerMismatch { .. } => 25,
+ RadrootsOrderIssue::CancellationListingMismatch { .. } => 26,
+ RadrootsOrderIssue::CancellationRootMismatch { .. } => 27,
+ RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 28,
+ RadrootsOrderIssue::ForkedLifecycle { .. } => 29,
+ RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 30,
+ RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 31,
+ RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 32,
+ RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 33,
+ RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 34,
+ RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 35,
+ RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 36,
+ RadrootsOrderIssue::DeterministicValidationFailure { .. } => 37,
+ RadrootsOrderIssue::StaleListingEvent { .. } => 38,
}
}
@@ -2011,12 +1972,13 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 {
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::{
- RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryAccountingIssue,
- RadrootsListingInventoryBinAvailability, RadrootsOrderCancellationRecord,
+ RadrootsOperationalListingInventoryAccountingInputs,
+ RadrootsOperationalListingInventoryAccountingIssue,
+ RadrootsOperationalListingInventoryBinAvailability, RadrootsOrderCancellationRecord,
RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue,
RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderWorkflowProjection,
RadrootsTradeLocatorProjectionResolution, RadrootsTradeWorkflowState,
- reduce_listing_inventory_accounting, reduce_order_event_records,
+ reduce_operational_listing_inventory_accounting, reduce_order_event_records,
reduce_order_event_records_for_trade_locator, reduce_order_events,
};
use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
@@ -2027,10 +1989,10 @@ mod tests {
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr,
ids::{
- RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
- RadrootsOrderQuoteId, RadrootsPublicKey,
+ RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId,
+ RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey,
},
- kinds::KIND_LISTING,
+ kinds::KIND_CLASSIFIED_LISTING,
order::{
RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
@@ -2067,19 +2029,18 @@ mod tests {
RadrootsInventoryBinId::parse(raw).expect("bin id")
}
- fn listing_addr() -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("listing address")
- }
-
- fn other_seller_listing_addr() -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("{KIND_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("other seller listing address")
+ fn listing_addr() -> RadrootsClassifiedListingAddress {
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address")
}
- fn retired_listing_addr() -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("retired listing address")
+ fn other_seller_listing_addr() -> RadrootsClassifiedListingAddress {
+ RadrootsClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("other seller listing address")
}
#[cfg(feature = "serde_json")]
@@ -2230,8 +2191,8 @@ mod tests {
}
fn assert_inventory_issue_kind(
- issues: &[RadrootsListingInventoryAccountingIssue],
- expected: RadrootsListingInventoryAccountingIssue,
+ issues: &[RadrootsOperationalListingInventoryAccountingIssue],
+ expected: RadrootsOperationalListingInventoryAccountingIssue,
) {
let expected_kind = discriminant(&expected);
assert!(
@@ -2690,19 +2651,6 @@ mod tests {
assert_eq!(canonical.items[1].bin_id, bin_id("bin-1"));
assert!(matches!(
- super::parse_public_listing_addr("not-an-address"),
- Err(super::RadrootsOrderCanonicalizationError::InvalidListingAddress(_))
- ));
- assert!(matches!(
- super::parse_public_listing_addr(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")),
- Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind)
- ));
- assert!(matches!(
- super::parse_public_listing_addr(format!("30023:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")),
- Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind)
- ));
-
- assert!(matches!(
super::canonicalize_order_request_for_signer(request_record().payload, SELLER),
Err(super::RadrootsOrderCanonicalizationError::InvalidBuyerSigner)
));
@@ -2714,15 +2662,6 @@ mod tests {
Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
));
- let mut invalid_kind = request_record().payload;
- invalid_kind.listing_addr =
- RadrootsListingAddress::parse(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
- .expect("retired listing draft address");
- assert!(matches!(
- super::canonicalize_order_request_for_signer(invalid_kind, BUYER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind)
- ));
-
let mut missing_items = request_record().payload;
missing_items.items.clear();
assert!(matches!(
@@ -2739,6 +2678,63 @@ mod tests {
}
#[test]
+ fn classified_listing_address_rejects_wrong_kind_before_typed_order_construction() {
+ let raw = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg");
+
+ assert!(matches!(
+ RadrootsClassifiedListingAddress::parse(&raw),
+ Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ })
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn order_request_deserialization_rejects_wrong_listing_kind() {
+ let mut value = serde_json::to_value(request_record().payload).expect("serialize request");
+ value["listing_addr"] =
+ serde_json::Value::String(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"));
+
+ let error = serde_json::from_value::<RadrootsOrderRequest>(value)
+ .expect_err("wrong listing kind must fail before constructing an order request");
+ let message = error.to_string();
+ assert!(message.contains("kind 30403"));
+ assert!(message.contains("required kind 30402"));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn order_event_codec_rejects_wrong_listing_kind_before_typed_record() {
+ let wrong_addr = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg");
+ let mut parts =
+ order_request_event_build(&listing_event_ptr(), &request_record().payload).unwrap();
+ let mut envelope: serde_json::Value =
+ serde_json::from_str(&parts.content).expect("request envelope");
+ envelope["listing_addr"] = serde_json::Value::String(wrong_addr.clone());
+ parts.content = serde_json::to_string(&envelope).expect("mutated request envelope");
+ let listing_tag = parts
+ .tags
+ .iter_mut()
+ .find(|tag| tag.first().is_some_and(|name| name == "a"))
+ .expect("listing address tag");
+ listing_tag[1] = wrong_addr;
+
+ assert!(matches!(
+ super::order_event_record_from_event(&event_from_parts(17, BUYER, parts)),
+ Err(super::RadrootsOrderEventDecodeError::Envelope(
+ radroots_event_codec::order::RadrootsOrderEnvelopeParseError::InvalidListingAddr(
+ radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ }
+ )
+ ))
+ ));
+ }
+
+ #[test]
fn canonicalize_order_decision_reports_signer_and_decision_errors() {
let canonical =
super::canonicalize_order_decision_for_signer(accepted_decision().payload, SELLER)
@@ -2931,21 +2927,21 @@ mod tests {
assert_eq!(issue_event_id, &event_id(7));
let mut inventory_issues = [
- RadrootsListingInventoryAccountingIssue::OverReserved {
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
bin_id: bin_id("bin-2"),
available_count: 1,
reserved_count: 2,
event_ids: vec![event_id(8)],
},
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-1"),
event_ids: vec![event_id(7)],
},
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: bin_id("bin-3"),
event_ids: vec![event_id(6)],
},
- RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
order_id: order_id("order-1"),
event_ids: vec![event_id(5)],
},
@@ -2953,31 +2949,31 @@ mod tests {
inventory_issues.sort_by(super::inventory_issue_sort_key);
assert!(matches!(
inventory_issues[0],
- RadrootsListingInventoryAccountingIssue::InvalidOrder { .. }
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. }
));
assert!(matches!(
inventory_issues[1],
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { .. }
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. }
));
assert!(matches!(
inventory_issues[2],
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. }
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. }
));
assert!(matches!(
inventory_issues[3],
- RadrootsListingInventoryAccountingIssue::OverReserved { .. }
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. }
));
let mut tied_inventory_issues = [
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-2"),
event_ids: vec![event_id(9)],
},
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-1"),
event_ids: vec![event_id(8)],
},
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-1"),
event_ids: vec![event_id(7)],
},
@@ -3018,9 +3014,6 @@ mod tests {
RadrootsOrderIssue::RequestAuthorMismatch {
event_id: id.clone(),
},
- RadrootsOrderIssue::RequestListingAddressInvalid {
- event_id: id.clone(),
- },
RadrootsOrderIssue::RequestSellerListingMismatch {
event_id: id.clone(),
},
@@ -3042,9 +3035,6 @@ mod tests {
RadrootsOrderIssue::DecisionSellerMismatch {
event_id: id.clone(),
},
- RadrootsOrderIssue::DecisionListingAddressInvalid {
- event_id: id.clone(),
- },
RadrootsOrderIssue::DecisionListingMismatch {
event_id: id.clone(),
},
@@ -3087,9 +3077,6 @@ mod tests {
RadrootsOrderIssue::CancellationSellerMismatch {
event_id: id.clone(),
},
- RadrootsOrderIssue::CancellationListingAddressInvalid {
- event_id: id.clone(),
- },
RadrootsOrderIssue::CancellationListingMismatch {
event_id: id.clone(),
},
@@ -3169,19 +3156,19 @@ mod tests {
fn inventory_issue_helpers_cover_all_issue_variants() {
let id = event_id(9);
let issues = vec![
- RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
order_id: order_id("order-1"),
event_ids: vec![id.clone()],
},
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: bin_id("bin-1"),
event_ids: vec![id.clone()],
},
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-2"),
event_ids: vec![id.clone()],
},
- RadrootsListingInventoryAccountingIssue::OverReserved {
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
bin_id: bin_id("bin-3"),
available_count: 1,
reserved_count: 2,
@@ -3252,11 +3239,11 @@ mod tests {
#[test]
fn inventory_accounting_private_helpers_cover_merge_sort_and_overflow_paths() {
let (bins, issues) = super::normalized_listing_inventory_bins(vec![
- RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-1"),
available_count: 1,
},
- RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-1"),
available_count: 2,
},
@@ -3265,7 +3252,7 @@ mod tests {
assert_eq!(bins[0].available_count, 3);
assert_eq!(bins[0].remaining_count, 3);
- let mut overflow_bin = super::RadrootsListingInventoryBinAccounting {
+ let mut overflow_bin = super::RadrootsOperationalListingInventoryBinAccounting {
bin_id: bin_id("bin-overflow"),
available_count: u64::MAX,
pending_reserved_count: u64::MAX,
@@ -3285,13 +3272,13 @@ mod tests {
);
assert_inventory_issue_kind(
&overflow_issues,
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: bin_id("bin-overflow"),
event_ids: Vec::new(),
},
);
- let mut sorting_bin = super::RadrootsListingInventoryBinAccounting {
+ let mut sorting_bin = super::RadrootsOperationalListingInventoryBinAccounting {
bin_id: bin_id("bin-sort"),
available_count: 1,
pending_reserved_count: 2,
@@ -3299,34 +3286,34 @@ mod tests {
remaining_count: 1,
over_reserved: false,
pending_orders: vec![
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-2"),
agreement_event_id: event_id(92),
bin_count: 1,
},
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-1"),
agreement_event_id: event_id(91),
bin_count: 1,
},
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-1"),
agreement_event_id: event_id(90),
bin_count: 1,
},
],
committed_orders: vec![
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-2"),
agreement_event_id: event_id(95),
bin_count: 1,
},
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-1"),
agreement_event_id: event_id(94),
bin_count: 1,
},
- super::RadrootsListingInventoryOrderReservation {
+ super::RadrootsOperationalListingInventoryOrderReservation {
order_id: order_id("order-1"),
agreement_event_id: event_id(93),
bin_count: 1,
@@ -3355,7 +3342,7 @@ mod tests {
);
assert_inventory_issue_kind(
&finish_issues,
- RadrootsListingInventoryAccountingIssue::OverReserved {
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
bin_id: bin_id("bin-sort"),
available_count: 1,
reserved_count: 2,
@@ -3468,12 +3455,6 @@ mod tests {
},
);
assert_request_issue(
- |request| request.payload.listing_addr = retired_listing_addr(),
- RadrootsOrderIssue::RequestListingAddressInvalid {
- event_id: event_id(1),
- },
- );
- assert_request_issue(
|request| request.payload.seller_pubkey = public_key(OTHER),
RadrootsOrderIssue::RequestSellerListingMismatch {
event_id: event_id(1),
@@ -3530,12 +3511,6 @@ mod tests {
},
);
assert_decision_issue(
- |decision| decision.payload.listing_addr = retired_listing_addr(),
- RadrootsOrderIssue::DecisionListingAddressInvalid {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
|decision| decision.payload.listing_addr = other_seller_listing_addr(),
RadrootsOrderIssue::DecisionListingMismatch {
event_id: event_id(2),
@@ -3607,12 +3582,6 @@ mod tests {
},
);
assert_cancellation_issue(
- |cancellation| cancellation.payload.listing_addr = retired_listing_addr(),
- RadrootsOrderIssue::CancellationListingAddressInvalid {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
|cancellation| cancellation.payload.listing_addr = other_seller_listing_addr(),
RadrootsOrderIssue::CancellationListingMismatch {
event_id: event_id(5),
@@ -3715,20 +3684,20 @@ mod tests {
terminal_cancellation.root_event_id = event_id(30);
terminal_cancellation.payload.order_id = order_id("order-3");
- let projection = reduce_listing_inventory_accounting(
+ let projection = reduce_operational_listing_inventory_accounting(
&listing_addr(),
&event_id(9),
- RadrootsListingInventoryAccountingInputs {
+ RadrootsOperationalListingInventoryAccountingInputs {
bins: vec![
- RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-1"),
available_count: 1,
},
- RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-overflow"),
available_count: u64::MAX,
},
- RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-overflow"),
available_count: 1,
},
@@ -3748,21 +3717,21 @@ mod tests {
assert_eq!(projection.cancelled_order_ids, vec![order_id("order-3")]);
assert_inventory_issue_kind(
&projection.issues,
- RadrootsListingInventoryAccountingIssue::ArithmeticOverflow {
+ RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
bin_id: bin_id("bin-overflow"),
event_ids: Vec::new(),
},
);
assert_inventory_issue_kind(
&projection.issues,
- RadrootsListingInventoryAccountingIssue::UnknownInventoryBin {
+ RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
bin_id: bin_id("bin-missing"),
event_ids: Vec::new(),
},
);
assert_inventory_issue_kind(
&projection.issues,
- RadrootsListingInventoryAccountingIssue::OverReserved {
+ RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
bin_id: bin_id("bin-1"),
available_count: 0,
reserved_count: 0,
@@ -3770,11 +3739,11 @@ mod tests {
},
);
- let invalid_without_request = reduce_listing_inventory_accounting(
+ let invalid_without_request = reduce_operational_listing_inventory_accounting(
&listing_addr(),
&event_id(9),
- RadrootsListingInventoryAccountingInputs {
- bins: Vec::<RadrootsListingInventoryBinAvailability>::new(),
+ RadrootsOperationalListingInventoryAccountingInputs {
+ bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(),
requests: Vec::<RadrootsOrderRequestRecord>::new(),
decisions: vec![accepted_decision()],
cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
@@ -3783,7 +3752,7 @@ mod tests {
assert_eq!(invalid_without_request.invalid_event_ids, vec![event_id(2)]);
assert_inventory_issue_kind(
&invalid_without_request.issues,
- RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
order_id: order_id("order-1"),
event_ids: Vec::new(),
},
@@ -3791,11 +3760,11 @@ mod tests {
let mut duplicate_request = request_record();
duplicate_request.event_id = event_id(11);
- let invalid_duplicate_requests = reduce_listing_inventory_accounting(
+ let invalid_duplicate_requests = reduce_operational_listing_inventory_accounting(
&listing_addr(),
&event_id(9),
- RadrootsListingInventoryAccountingInputs {
- bins: Vec::<RadrootsListingInventoryBinAvailability>::new(),
+ RadrootsOperationalListingInventoryAccountingInputs {
+ bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(),
requests: vec![request_record(), duplicate_request],
decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
@@ -3807,7 +3776,7 @@ mod tests {
);
assert_inventory_issue_kind(
&invalid_duplicate_requests.issues,
- RadrootsListingInventoryAccountingIssue::InvalidOrder {
+ RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
order_id: order_id("order-1"),
event_ids: Vec::new(),
},
@@ -3930,11 +3899,11 @@ mod tests {
#[test]
fn inventory_accounting_reserves_only_accepted_agreements() {
- let requested_projection = reduce_listing_inventory_accounting(
+ let requested_projection = reduce_operational_listing_inventory_accounting(
&listing_addr(),
&event_id(8),
- RadrootsListingInventoryAccountingInputs {
- bins: vec![RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryAccountingInputs {
+ bins: vec![RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-1"),
available_count: 3,
}],
@@ -3947,11 +3916,11 @@ mod tests {
assert_eq!(requested_projection.bins[0].pending_reserved_count, 0);
assert_eq!(requested_projection.bins[0].remaining_count, 3);
- let projection = reduce_listing_inventory_accounting(
+ let projection = reduce_operational_listing_inventory_accounting(
&listing_addr(),
&event_id(9),
- RadrootsListingInventoryAccountingInputs {
- bins: vec![RadrootsListingInventoryBinAvailability {
+ RadrootsOperationalListingInventoryAccountingInputs {
+ bins: vec![RadrootsOperationalListingInventoryBinAvailability {
bin_id: bin_id("bin-1"),
available_count: 3,
}],
diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs
@@ -1,5 +1,5 @@
pub use crate::identity::*;
-pub use crate::listing::*;
+pub use crate::operational_listing::*;
#[cfg(feature = "serde_json")]
pub use crate::validation_receipt::*;
pub use crate::workflow::*;
diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs
@@ -4,9 +4,9 @@ use std::collections::{BTreeMap, BTreeSet};
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts,
- ids::{RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId},
- kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind},
- listing::{RadrootsListingAvailability, RadrootsListingDeliveryMethod, RadrootsListingStatus},
+ ids::{RadrootsEventId, RadrootsIdParseError, RadrootsClassifiedListingAddress, RadrootsOrderId},
+ kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_classified_listing_kind, is_order_event_kind},
+ operational_listing::{RadrootsOperationalListingAvailability, RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingStatus},
order::RadrootsOrderEventType,
tags::TAG_D,
};
@@ -20,7 +20,7 @@ use thiserror::Error;
use crate::{
identity::RadrootsTradeLocator,
- listing::validation::{RadrootsTradeListing, validate_listing_event},
+ operational_listing::validation::{RadrootsOperationalListingTradeProjection, validate_operational_listing_event},
order::{
RadrootsGroupedOrderEventRecords, RadrootsOrderEventDecodeError, RadrootsOrderEventRecord,
RadrootsOrderProjectionQueryResult, RadrootsTradeLocatorProjectionQueryResult,
@@ -68,7 +68,7 @@ pub enum RadrootsTradeProjectionError {
#[error("stored listing event {event_id} failed validation: {source}")]
ListingValidation {
event_id: String,
- source: radroots_event::trade_validation::RadrootsTradeValidationListingError,
+ source: radroots_event::trade_validation::RadrootsOperationalListingValidationError,
},
#[error("stored order event {event_id} could not decode as an order record: {source}")]
OrderDecode {
@@ -96,7 +96,7 @@ pub enum RadrootsTradeProjectionError {
source: RadrootsIdParseError,
},
#[error("stored listing projection has invalid listing_addr {listing_addr}: {source}")]
- ListingAddress {
+ ClassifiedListingAddress {
listing_addr: String,
source: RadrootsIdParseError,
},
@@ -145,7 +145,7 @@ impl RadrootsProjectionRefreshRequest {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RadrootsProjectionRefreshReceipt {
pub scanned_events: usize,
- pub listing_upserts: usize,
+ pub operational_listing_upserts: usize,
pub trade_upserts: usize,
pub validation_receipts: usize,
pub transport_observations: i64,
@@ -153,8 +153,8 @@ pub struct RadrootsProjectionRefreshReceipt {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingProjectionRow {
- pub listing_addr: RadrootsListingAddress,
+pub struct RadrootsOperationalListingProjectionRow {
+ pub listing_addr: RadrootsClassifiedListingAddress,
pub listing_event_id: String,
pub seller_pubkey: String,
pub title: String,
@@ -173,12 +173,12 @@ pub struct RadrootsListingProjectionRow {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsListingSearchRequest {
+pub struct RadrootsOperationalListingSearchRequest {
pub query: String,
pub limit: u32,
}
-impl RadrootsListingSearchRequest {
+impl RadrootsOperationalListingSearchRequest {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
@@ -220,16 +220,17 @@ pub async fn refresh_product_projections(
receipt.last_event_seq = Some(stored_event.seq);
receipt.transport_observations +=
transport_observation_count_for_event(store, &stored_event.event_id).await?;
- if is_listing_kind(stored_event.kind) {
+ if is_classified_listing_kind(stored_event.kind) {
let event = stored_event_to_nostr_event(stored_event)?;
- let listing = validate_listing_event(&event).map_err(|source| {
+ let listing = validate_operational_listing_event(&event).map_err(|source| {
RadrootsTradeProjectionError::ListingValidation {
event_id: stored_event.event_id.clone(),
source,
}
})?;
- upsert_listing_projection(store, stored_event, &listing, updated_at_ms).await?;
- receipt.listing_upserts += 1;
+ upsert_operational_listing_projection(store, stored_event, &listing, updated_at_ms)
+ .await?;
+ receipt.operational_listing_upserts += 1;
} else if is_order_event_kind(stored_event.kind) {
let event = stored_event_to_nostr_event(stored_event)?;
let record = order_event_record_from_event(&event).map_err(|source| {
@@ -278,12 +279,12 @@ pub async fn refresh_product_projections(
Ok(receipt)
}
-pub async fn search_listing_projection(
+pub async fn search_operational_listing_projection(
store: &RadrootsEventStore,
- request: &RadrootsListingSearchRequest,
-) -> Result<Vec<RadrootsListingProjectionRow>, RadrootsTradeProjectionError> {
+ request: &RadrootsOperationalListingSearchRequest,
+) -> Result<Vec<RadrootsOperationalListingProjectionRow>, RadrootsTradeProjectionError> {
request.validate()?;
- let rows = if let Some(query) = listing_fts_query(&request.query) {
+ let rows = if let Some(query) = operational_listing_fts_query(&request.query) {
sqlx::query(
"SELECT p.listing_addr, p.listing_event_id, p.seller_pubkey, p.title, p.description, p.product_type, p.price_amount, p.price_currency, p.inventory_available, p.delivery_method, p.locality_primary, p.locality_city, p.locality_region, p.locality_country, p.geohash5, p.updated_at_ms FROM listing_projection p JOIN listing_search_fts f ON f.listing_addr = p.listing_addr WHERE listing_search_fts MATCH ? ORDER BY bm25(listing_search_fts), p.updated_at_ms DESC, p.listing_addr LIMIT ?",
)
@@ -299,7 +300,9 @@ pub async fn search_listing_projection(
.fetch_all(store.pool())
.await?
};
- rows.into_iter().map(listing_projection_row).collect()
+ rows.into_iter()
+ .map(operational_listing_projection_row)
+ .collect()
}
pub async fn trade_projection_query_for_order_id(
@@ -343,10 +346,10 @@ pub async fn trade_projection_query_for_trade_locator(
})
}
-async fn upsert_listing_projection(
+async fn upsert_operational_listing_projection(
store: &RadrootsEventStore,
stored_event: &RadrootsStoredEvent,
- listing: &RadrootsTradeListing,
+ listing: &RadrootsOperationalListingTradeProjection,
updated_at_ms: i64,
) -> Result<(), RadrootsTradeProjectionError> {
let listing_json = serde_json::to_string(&listing.listing).map_err(|source| {
@@ -538,7 +541,7 @@ async fn upsert_trade_projection_row(
.bind(trade_workflow_status_label(&projection.status))
.bind(bool_i64(projection.lifecycle_terminal))
.bind(trade_rhi_state_label(&projection.status, projection.validation_receipt_event_id.as_ref()))
- .bind(projection.listing_addr.as_ref().map(RadrootsListingAddress::as_str))
+ .bind(projection.listing_addr.as_ref().map(RadrootsClassifiedListingAddress::as_str))
.bind(projection.buyer_pubkey.as_ref().map(|value| value.as_str()))
.bind(projection.seller_pubkey.as_ref().map(|value| value.as_str()))
.bind(projection.request_event_id.as_ref().map(|value| value.as_str()))
@@ -799,17 +802,17 @@ async fn transport_observation_count_for_event(
Ok(row.try_get("count")?)
}
-fn listing_projection_row(
+fn operational_listing_projection_row(
row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsListingProjectionRow, RadrootsTradeProjectionError> {
+) -> Result<RadrootsOperationalListingProjectionRow, RadrootsTradeProjectionError> {
let listing_addr = row.try_get::<String, _>("listing_addr")?;
- let listing_addr = RadrootsListingAddress::parse(&listing_addr).map_err(|source| {
- RadrootsTradeProjectionError::ListingAddress {
+ let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr).map_err(|source| {
+ RadrootsTradeProjectionError::ClassifiedListingAddress {
listing_addr: listing_addr.clone(),
source,
}
})?;
- Ok(RadrootsListingProjectionRow {
+ Ok(RadrootsOperationalListingProjectionRow {
listing_addr,
listing_event_id: row.try_get("listing_event_id")?,
seller_pubkey: row.try_get("seller_pubkey")?,
@@ -829,27 +832,27 @@ fn listing_projection_row(
})
}
-fn listing_availability_label(availability: &RadrootsListingAvailability) -> String {
+fn listing_availability_label(availability: &RadrootsOperationalListingAvailability) -> String {
match availability {
- RadrootsListingAvailability::Window { .. } => "window".to_owned(),
- RadrootsListingAvailability::Status { status } => match status {
- RadrootsListingStatus::Active => "active".to_owned(),
- RadrootsListingStatus::Sold => "sold".to_owned(),
- RadrootsListingStatus::Other { value } => value.trim().to_owned(),
+ RadrootsOperationalListingAvailability::Window { .. } => "window".to_owned(),
+ RadrootsOperationalListingAvailability::Status { status } => match status {
+ RadrootsOperationalListingStatus::Active => "active".to_owned(),
+ RadrootsOperationalListingStatus::Sold => "sold".to_owned(),
+ RadrootsOperationalListingStatus::Other { value } => value.trim().to_owned(),
},
}
}
-fn listing_delivery_method_label(delivery_method: &RadrootsListingDeliveryMethod) -> String {
+fn listing_delivery_method_label(delivery_method: &RadrootsOperationalListingDeliveryMethod) -> String {
match delivery_method {
- RadrootsListingDeliveryMethod::Pickup => "pickup".to_owned(),
- RadrootsListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(),
- RadrootsListingDeliveryMethod::Shipping => "shipping".to_owned(),
- RadrootsListingDeliveryMethod::Other { method } => method.trim().to_owned(),
+ RadrootsOperationalListingDeliveryMethod::Pickup => "pickup".to_owned(),
+ RadrootsOperationalListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(),
+ RadrootsOperationalListingDeliveryMethod::Shipping => "shipping".to_owned(),
+ RadrootsOperationalListingDeliveryMethod::Other { method } => method.trim().to_owned(),
}
}
-fn listing_locality_search_text(listing: &RadrootsTradeListing) -> String {
+fn listing_locality_search_text(listing: &RadrootsOperationalListingTradeProjection) -> String {
[
Some(listing.location.primary.as_str()),
listing.location.city.as_deref(),
@@ -863,7 +866,7 @@ fn listing_locality_search_text(listing: &RadrootsTradeListing) -> String {
.join(" ")
}
-fn listing_fts_query(query: &str) -> Option<String> {
+fn operational_listing_fts_query(query: &str) -> Option<String> {
let terms = query
.split(|character: char| !character.is_alphanumeric())
.map(str::trim)
@@ -921,10 +924,10 @@ mod tests {
draft::RadrootsSignedEvent,
farm::RadrootsFarmRef,
ids::RadrootsOrderQuoteId,
- kinds::KIND_LISTING,
- listing::{
- RadrootsListing, RadrootsListingBin, RadrootsListingProduct,
- RadrootsListingPublicLocation,
+ kinds::KIND_CLASSIFIED_LISTING,
+ operational_listing::{
+ RadrootsOperationalListing, RadrootsOperationalListingBin, RadrootsOperationalListingProduct,
+ RadrootsOperationalListingPublicLocation,
},
order::{
RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem,
@@ -985,15 +988,15 @@ mod tests {
raw.parse().expect("decimal")
}
- fn listing() -> RadrootsListing {
- RadrootsListing {
+ fn listing() -> RadrootsOperationalListing {
+ RadrootsOperationalListing {
d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("d tag"),
published_at: Some(1_700_000_000),
farm: RadrootsFarmRef {
pubkey: SELLER.to_owned(),
d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(),
},
- product: RadrootsListingProduct {
+ product: RadrootsOperationalListingProduct {
key: "pea-shoots".to_owned(),
title: "Pea shoots".to_owned(),
category: "greens".to_owned(),
@@ -1005,7 +1008,7 @@ mod tests {
year: None,
},
primary_bin_id: "bin-1".parse().expect("bin"),
- bins: vec![RadrootsListingBin {
+ bins: vec![RadrootsOperationalListingBin {
bin_id: "bin-1".parse().expect("bin"),
quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each),
price_per_canonical_unit: RadrootsCoreQuantityPrice {
@@ -1022,11 +1025,11 @@ mod tests {
plot: None,
discounts: None,
inventory_available: Some(decimal("9")),
- availability: Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
+ availability: Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
}),
- delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingPublicLocation {
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
+ location: Some(RadrootsOperationalListingPublicLocation {
primary: "Old Town".to_owned(),
city: Some("Victoria".to_owned()),
region: Some("BC".to_owned()),
@@ -1038,7 +1041,7 @@ mod tests {
}
fn signed_listing_event() -> RadrootsSignedEvent {
- let parts = radroots_event_codec::listing::encode::to_wire_parts(&listing())
+ let parts = radroots_event_codec::operational_listing::encode::to_wire_parts(&listing())
.expect("listing parts");
sign_parts(
parts.kind,
@@ -1049,10 +1052,10 @@ mod tests {
)
}
- fn listing_addr(event: &RadrootsSignedEvent) -> RadrootsListingAddress {
- RadrootsListingAddress::parse(format!(
+ fn listing_addr(event: &RadrootsSignedEvent) -> RadrootsClassifiedListingAddress {
+ RadrootsClassifiedListingAddress::parse(format!(
"{}:{}:{}",
- KIND_LISTING,
+ KIND_CLASSIFIED_LISTING,
event.pubkey_str(),
listing().d_tag
))
@@ -1283,14 +1286,14 @@ mod tests {
.await
.expect("refresh");
assert_eq!(refresh.scanned_events, 4);
- assert_eq!(refresh.listing_upserts, 1);
+ assert_eq!(refresh.operational_listing_upserts, 1);
assert_eq!(refresh.trade_upserts, 1);
assert_eq!(refresh.validation_receipts, 1);
assert_eq!(refresh.transport_observations, 1);
- let rows = search_listing_projection(
+ let rows = search_operational_listing_projection(
&store,
- &RadrootsListingSearchRequest::new("pea victoria").with_limit(10),
+ &RadrootsOperationalListingSearchRequest::new("pea victoria").with_limit(10),
)
.await
.expect("search");
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -1075,7 +1075,7 @@ fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> String {
mod tests {
use super::*;
use radroots_event::{
- ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId},
+ ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId},
trade::{
RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
@@ -1126,7 +1126,7 @@ mod tests {
farm_id: dtag("farm-1"),
lines: vec![RadrootsTradeCandidateLineV1 {
line_id: dtag(&format!("line-{line_suffix}")),
- listing_addr: RadrootsAddressableCoordinate::parse(format!(
+ listing_addr: RadrootsClassifiedListingAddress::parse(format!(
"30402:{}:listing-{line_suffix}",
hex_64('b')
))
diff --git a/crates/trade_sp1_guest/src/lib.rs b/crates/trade_sp1_guest/src/lib.rs
@@ -12,7 +12,7 @@ pub const RADROOTS_SP1_TRADE_PROTOCOL_VERSION: &str = "radroots.trade.v1";
pub const RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH: &str =
"0x3d8f7f463904d71f2d0d14b1551450756697e51c7b658e10c6d5c20a7bc61f08";
pub const RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET: &str = "trade.order_acceptance.v1";
-pub const RADROOTS_SP1_TRADE_KIND_LISTING: u32 = 30402;
+pub const RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING: u32 = 30402;
pub const RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST: u32 = 3422;
pub const RADROOTS_SP1_TRADE_KIND_ORDER_DECISION: u32 = 3423;
@@ -429,7 +429,7 @@ fn validate_event_evidence(
&witness.listing_event_id,
&witness.request.seller_pubkey,
RadrootsSp1TradeEventEvidenceRole::Seller,
- &[RADROOTS_SP1_TRADE_KIND_LISTING],
+ &[RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING],
"listing",
)?;
@@ -842,7 +842,7 @@ struct StateRootMaterial<'a> {
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::{
- RADROOTS_SP1_TRADE_KIND_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION,
+ RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION,
RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET,
RADROOTS_SP1_TRADE_PROTOCOL_VERSION, RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH,
RADROOTS_SP1_TRADE_WITNESS_VERSION, RadrootsSp1TradeCanonicalEventEvidence,
@@ -908,7 +908,7 @@ mod tests {
.to_string(),
signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111"
.to_string(),
- kind: RADROOTS_SP1_TRADE_KIND_LISTING,
+ kind: RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING,
canonical_event_hash:
"0x1010101010101010101010101010101010101010101010101010101010101010".to_string(),
signature_hash:
diff --git a/crates/trade_sp1_host/src/lib.rs b/crates/trade_sp1_host/src/lib.rs
@@ -1782,7 +1782,7 @@ mod tests {
verify_validation_receipt_event,
};
use radroots_trade_sp1_guest::{
- RADROOTS_SP1_TRADE_KIND_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION,
+ RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION,
RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET,
RADROOTS_SP1_TRADE_PROTOCOL_VERSION, RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH,
RADROOTS_SP1_TRADE_WITNESS_VERSION, RadrootsSp1TradeCanonicalEventEvidence,
@@ -1859,7 +1859,7 @@ mod tests {
.to_string(),
signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111"
.to_string(),
- kind: RADROOTS_SP1_TRADE_KIND_LISTING,
+ kind: RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING,
canonical_event_hash:
"0x1010101010101010101010101010101010101010101010101010101010101010".to_string(),
signature_hash:
diff --git a/dto_bindgen.toml b/dto_bindgen.toml
@@ -75,7 +75,7 @@ source_files = [
"crates/event/src/lib.rs",
"crates/event/src/list.rs",
"crates/event/src/list_set.rs",
- "crates/event/src/listing.rs",
+ "crates/event/src/operational_listing.rs",
"crates/event/src/message.rs",
"crates/event/src/message_file.rs",
"crates/event/src/order.rs",
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -52,8 +52,8 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [
"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/operational_listing/parse_event.v1.json",
+ "crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json",
),
(
"contracts/conformance/vectors/profile/metadata.v1.json",
@@ -1541,14 +1541,14 @@ const RESOURCE_CAP_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
-const LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [
+const OPERATIONAL_LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [
EventBoundarySourceWitness {
- relative_path: "crates/event/src/listing.rs",
- required_fragments: &["pub struct RadrootsListing"],
+ relative_path: "crates/event/src/operational_listing.rs",
+ required_fragments: &["pub struct RadrootsOperationalListing"],
},
EventBoundarySourceWitness {
relative_path: "crates/event/src/kinds.rs",
- required_fragments: &["pub const KIND_LISTING: u32 = 30402;"],
+ required_fragments: &["pub const KIND_CLASSIFIED_LISTING: u32 = 30402;"],
},
];
@@ -2028,15 +2028,15 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
witnesses: &RESOURCE_CAP_WITNESSES,
},
EventBoundaryExpectation {
- domain: "listing",
+ domain: "operational_listing",
kind: "30402",
- radroots_type: "RadrootsListing",
+ radroots_type: "RadrootsOperationalListing",
rpc_methods: &[
- "events.listing.publish",
- "events.listing.list",
- "events.listing.get",
+ "events.operational_listing.publish",
+ "events.operational_listing.list",
+ "events.operational_listing.get",
],
- witnesses: &LISTING_WITNESSES,
+ witnesses: &OPERATIONAL_LISTING_WITNESSES,
},
EventBoundaryExpectation {
domain: "dvm_request",
@@ -6463,7 +6463,7 @@ version = "1.0.0"
source = "synthetic"
[public]
-domains = ["profile", "farm", "listing", "trade"]
+domains = ["profile", "farm", "operational_listing", "trade"]
[shared_types]
public = [
@@ -6473,11 +6473,11 @@ public = [
"RadrootsEventEnvelope",
"RadrootsEventRef",
"RadrootsEventPtr",
- "RadrootsListingAddress",
+ "RadrootsClassifiedListingAddress",
"RadrootsAuthoredProfile",
"RadrootsInboundProfileMetadata",
"RadrootsFarm",
- "RadrootsListing",
+ "RadrootsOperationalListing",
]
[errors]
@@ -6505,23 +6505,23 @@ rust_types = ["radroots_event::profile::RadrootsAuthoredProfile"]
[operations.profile_build_authored_draft.conformance]
vector = "contracts/conformance/vectors/profile/metadata.v1.json"
-[operations.listing_build_draft]
-domain = "listing"
-id = "listing.build_draft"
+[operations.operational_listing_build_draft]
+domain = "operational_listing"
+id = "operational_listing.build_draft"
stability = "beta"
-inputs = ["RadrootsListing"]
+inputs = ["RadrootsOperationalListing"]
outputs = ["RadrootsNip01EventWireParts"]
error_class = "encode_error"
deterministic = true
signing = "native"
transport = "native"
-[operations.listing_build_draft.implementation]
+[operations.operational_listing_build_draft.implementation]
rust_modules = ["crates/core/src/unit.rs"]
-rust_types = ["radroots_event::listing::RadrootsListing"]
+rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"]
-[operations.listing_build_draft.conformance]
-vector = "contracts/conformance/vectors/listing/build_draft.v1.json"
+[operations.operational_listing_build_draft.conformance]
+vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json"
"#,
);
write_file(
@@ -6584,15 +6584,15 @@ vector = "contracts/conformance/vectors/listing/build_draft.v1.json"
.join("contracts")
.join("conformance")
.join("vectors")
- .join("listing")
+ .join("operational_listing")
.join("build_draft.v1.json"),
r#"{
- "suite": "listing",
+ "suite": "operational_listing",
"contract_version": "1.0.0",
"vectors": [
{
- "id": "listing_build_draft_minimal_001",
- "kind": "listing.build_draft",
+ "id": "operational_listing_build_draft_minimal_001",
+ "kind": "operational_listing.build_draft",
"input": {},
"expected": {}
}
diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs
@@ -15,6 +15,8 @@ const BINDING_DEPENDENCIES: &[&str] = &[
const RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS: &[&str] = &[
"KIND_LISTING_DRAFT",
+ "KIND_CLASSIFIED_LISTING_DRAFT",
+ "KIND_OPERATIONAL_LISTING_DRAFT",
"KIND_ORDER_REVISION",
"KIND_TRADE_QUESTION",
"KIND_TRADE_ANSWER",
@@ -28,9 +30,15 @@ const RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS: &[&str] = &[
"KIND_TRADE_TRANSITION_PROOF_REQUEST",
"KIND_TRADE_TRANSITION_PROOF_RESULT",
"RADROOTS_SP1_TRADE_KIND_LISTING_DRAFT",
+ "RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING_DRAFT",
+ "RADROOTS_SP1_TRADE_KIND_OPERATIONAL_LISTING_DRAFT",
"RadrootsListingDraft",
+ "RadrootsClassifiedListingDraft",
+ "RadrootsOperationalListingDraft",
"RadrootsCanonicalListingDraft",
"RadrootsListingDraftError",
+ "RadrootsClassifiedListingDraftError",
+ "RadrootsOperationalListingDraftError",
"RadrootsOrderRevision",
"RadrootsOrderRevisionId",
"RadrootsTradeValidationListingRequest",
@@ -67,6 +75,53 @@ const RETIRED_PROTOCOL_EVENT_SURFACE_ALLOWED_PATHS: &[&str] = &[
"tools/xtask/src/hygiene.rs",
];
+const RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS: &[&str] = &[
+ "KIND_LISTING",
+ "LISTING_EVENT_KINDS",
+ "is_listing_kind",
+ "is_listing_event_kind",
+ "RADROOTS_SP1_TRADE_KIND_LISTING",
+ "ListingTagOptions",
+ "ListingDecodeError",
+ "ListingParseError",
+ "ListingProjection",
+ "ListingInventoryAccounting",
+ "ListingAddress",
+ "ListingSnapshot",
+ "RADROOTS_LISTING_PRODUCT_TAG_KEYS",
+ "RadrootsCanonicalListingEdit",
+ "RadrootsPublicListingAddress",
+ "RadrootsPublicListingAddressError",
+ "RadrootsTradeListing",
+ "RadrootsTradeListingSubtotal",
+ "RadrootsTradeListingTotal",
+ "RadrootsTradeValidationListingError",
+ "farm_listings_list_set",
+ "farm_listings_list_set_from_listings",
+ "listing_from_event",
+ "listing_from_event_parts",
+ "listing_from_nostr_event",
+ "parse_listing_address",
+ "parse_listing_address_str",
+ "parse_listing_event",
+ "parse_public_listing_address",
+ "search_listing_projection",
+ "to_json_wire_parts_with_kind",
+ "validate_listing_event",
+ "listing_tags",
+ "listing_tags_with_options",
+ "listing_tags_full",
+ "listing_build_tags",
+ "decode_listing_from_event_parts",
+ "listing_markdown_content",
+ "build_listing_mutation_draft",
+ "canonicalize_listing_edit",
+ "reduce_listing_inventory_accounting",
+];
+
+const RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES: &[&str] = &["RadrootsListing"];
+const RETIRED_LISTING_CONTRACT_ID: &str = "radroots.listing.published.v1";
+
pub fn run(args: &[String], root: &Path) -> Result<(), String> {
match args.first().map(String::as_str) {
Some("forbidden-identifiers") => validate_forbidden_identifiers(root),
@@ -111,6 +166,8 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> {
"RadrootsActiveOrder",
"RadrootsActiveTrade",
"RadrootsTradeListingParseError",
+ "RadrootsClassifiedListingTradeProjectionParseError",
+ "RadrootsOperationalListingTradeProjectionParseError",
"RadrootsTradeDomain",
"radroots_sdk::trade::",
"TradeListingParseError",
@@ -185,6 +242,7 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> {
&["tools/xtask/src/hygiene.rs"],
&mut failures,
);
+ reject_retired_listing_aliases(root, &mut failures);
reject_binding_dependencies(root, &mut failures);
reject_forbidden_crate_paths(root, &mut failures);
reject_existing_paths(
@@ -229,6 +287,133 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> {
}
}
+fn reject_retired_listing_aliases(root: &Path, failures: &mut Vec<String>) {
+ let rel_roots = [
+ PathBuf::from("crates"),
+ PathBuf::from("contracts"),
+ PathBuf::from("tools"),
+ PathBuf::from("build"),
+ PathBuf::from("dto_bindgen.toml"),
+ ];
+
+ for file in files_under(root, &rel_roots) {
+ let rel = display_path(root, &file);
+ if rel == "tools/xtask/src/hygiene.rs" {
+ continue;
+ }
+ if is_retired_listing_module_path(&rel) {
+ failures.push(format!(
+ "retired listing public aliases must not reappear: {rel}: legacy listing module path"
+ ));
+ }
+
+ let Ok(content) = fs::read_to_string(&file) else {
+ continue;
+ };
+ for (line_index, line) in content.lines().enumerate() {
+ if is_retired_listing_negative_guard(&rel, line) {
+ continue;
+ }
+
+ for token in RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS {
+ if contains_identifier_token(line, token) {
+ failures.push(format!(
+ "retired listing public aliases must not reappear: {}:{}: {}",
+ rel,
+ line_index + 1,
+ line.trim()
+ ));
+ }
+ }
+ for prefix in RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES {
+ if contains_identifier_prefix(line, prefix) {
+ failures.push(format!(
+ "retired listing public aliases must not reappear: {}:{}: {}",
+ rel,
+ line_index + 1,
+ line.trim()
+ ));
+ }
+ }
+ if line.contains(RETIRED_LISTING_CONTRACT_ID) {
+ failures.push(format!(
+ "retired listing public aliases must not reappear: {}:{}: {}",
+ rel,
+ line_index + 1,
+ line.trim()
+ ));
+ }
+ if is_listing_module_scope(&rel) && contains_retired_listing_module_reference(line) {
+ failures.push(format!(
+ "retired listing public aliases must not reappear: {}:{}: {}",
+ rel,
+ line_index + 1,
+ line.trim()
+ ));
+ }
+ }
+ }
+}
+
+fn contains_identifier_token(line: &str, token: &str) -> bool {
+ line.match_indices(token).any(|(index, _)| {
+ let before = line[..index].chars().next_back();
+ let after = line[index + token.len()..].chars().next();
+ before.is_none_or(|ch| !is_identifier_continue(ch))
+ && after.is_none_or(|ch| !is_identifier_continue(ch))
+ })
+}
+
+fn contains_identifier_prefix(line: &str, prefix: &str) -> bool {
+ line.match_indices(prefix).any(|(index, _)| {
+ line[..index]
+ .chars()
+ .next_back()
+ .is_none_or(|ch| !is_identifier_continue(ch))
+ })
+}
+
+fn is_identifier_continue(ch: char) -> bool {
+ ch.is_ascii_alphanumeric() || ch == '_'
+}
+
+fn is_retired_listing_module_path(rel: &str) -> bool {
+ is_listing_module_scope(rel)
+ && Path::new(rel).components().any(|component| {
+ component.as_os_str() == "listing"
+ || Path::new(component.as_os_str())
+ .file_stem()
+ .is_some_and(|stem| stem == "listing")
+ })
+}
+
+fn is_listing_module_scope(rel: &str) -> bool {
+ rel.starts_with("crates/event/src/")
+ || rel.starts_with("crates/event/tests/")
+ || rel.starts_with("crates/event_codec/src/")
+ || rel.starts_with("crates/event_codec/tests/")
+ || rel.starts_with("crates/trade/src/")
+ || rel.starts_with("crates/trade/tests/")
+ || rel.starts_with("contracts/conformance/vectors/")
+ || rel == "dto_bindgen.toml"
+}
+
+fn contains_retired_listing_module_reference(line: &str) -> bool {
+ line.match_indices("listing").any(|(index, _)| {
+ let before = &line[..index];
+ let after = line[index + "listing".len()..].chars().next();
+ let starts_module_segment =
+ before.ends_with("::") || before.trim_end().ends_with("mod") || before.ends_with('/');
+ starts_module_segment && after.is_none_or(|ch| !is_identifier_continue(ch))
+ })
+}
+
+fn is_retired_listing_negative_guard(rel: &str, line: &str) -> bool {
+ (rel == "crates/event/src/dto.rs" && line.contains("\"RadrootsListingCancel\""))
+ || (rel == "crates/event/src/contract.rs"
+ && line.contains("event_contract(\"radroots.listing.published.v1\").is_none()"))
+}
+
fn reject_binding_dependencies(root: &Path, failures: &mut Vec<String>) {
for file in manifest_files(root) {
let rel = display_path(root, &file);
@@ -404,7 +589,7 @@ fn is_raw_protocol_field(line: &str) -> bool {
fn is_allowed_raw_boundary(struct_name: &str) -> bool {
struct_name == "RadrootsOrderEnvelope"
|| struct_name == "RadrootsValidationReceiptTags"
- || struct_name == "RadrootsTradeListing"
+ || struct_name == "RadrootsOperationalListingTradeProjection"
|| struct_name.ends_with("Projection")
|| struct_name.ends_with("Accounting")
|| struct_name.ends_with("Availability")
@@ -528,7 +713,7 @@ mod tests {
write_file(
&root,
"crates/event/src/kinds.rs",
- "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\npub const KIND_TRADE_LISTING_VALIDATE_REQ: u64 = 5321;\npub const KIND_LISTING_DRAFT: u32 = 30403;\n",
+ "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\npub const KIND_TRADE_LISTING_VALIDATE_REQ: u64 = 5321;\npub const KIND_LISTING_DRAFT: u32 = 30403;\npub const KIND_CLASSIFIED_LISTING_DRAFT: u32 = 30403;\npub const KIND_LISTING: u32 = 30402;\n",
);
write_file(
&root,
@@ -550,6 +735,7 @@ mod tests {
assert!(err.contains("removed identifier 'tangle' must not reappear"));
assert!(err.contains("removed trade and DVM kind constants must not reappear"));
assert!(err.contains("retired V1 public event surfaces must not reappear"));
+ assert!(err.contains("retired listing public aliases must not reappear"));
assert!(err.contains("wasm-bindgen"));
assert!(err.contains("uniffi"));
assert!(err.contains("crates/sql_wasm_bridge"));
@@ -559,6 +745,62 @@ mod tests {
}
#[test]
+ fn listing_alias_guard_is_token_and_path_aware() {
+ let clean_root = unique_temp_dir("listing_alias_clean");
+ write_file(
+ &clean_root,
+ "crates/event/src/kinds.rs",
+ "pub const CLASSIFIED_LISTING_EVENT_KINDS: [u32; 1] = [KIND_CLASSIFIED_LISTING];\n",
+ );
+ write_file(
+ &clean_root,
+ "crates/event/src/operational_listing.rs",
+ "pub struct RadrootsOperationalListing;\n",
+ );
+ validate_forbidden_identifiers(&clean_root).expect("new listing taxonomy is accepted");
+ let _ = fs::remove_dir_all(clean_root);
+
+ let dirty_root = unique_temp_dir("listing_alias_dirty");
+ write_file(
+ &dirty_root,
+ "crates/event/src/listing.rs",
+ "pub const LISTING_EVENT_KINDS: [u32; 1] = [KIND_LISTING];\n",
+ );
+ write_file(
+ &dirty_root,
+ "contracts/conformance/listing.json",
+ "{\"event_contract\":\"radroots.listing.published.v1\"}\n",
+ );
+ write_file(
+ &dirty_root,
+ "crates/event_codec/tests/listing.rs",
+ "#[test]\nfn legacy_module_path() {}\n",
+ );
+ write_file(
+ &dirty_root,
+ "contracts/conformance/vectors/listing/build_tags.v1.json",
+ "{}\n",
+ );
+ write_file(
+ &dirty_root,
+ "crates/event_codec/tests/retired_aliases.rs",
+ "use radroots_trade::{RadrootsPublicListingAddress, listing_from_event, listing_tags_with_options};\nconst _: &str = RADROOTS_LISTING_PRODUCT_TAG_KEYS;\n",
+ );
+ let err =
+ validate_forbidden_identifiers(&dirty_root).expect_err("old aliases are rejected");
+ assert!(err.contains("retired listing public aliases must not reappear"));
+ assert!(err.contains("legacy listing module path"));
+ assert!(err.contains(RETIRED_LISTING_CONTRACT_ID));
+ assert!(err.contains("crates/event_codec/tests/listing.rs"));
+ assert!(err.contains("RadrootsPublicListingAddress"));
+ assert!(err.contains("listing_from_event"));
+ assert!(err.contains("listing_tags_with_options"));
+ assert!(err.contains("contracts/conformance/vectors/listing/build_tags.v1.json"));
+ assert!(err.contains("RADROOTS_LISTING_PRODUCT_TAG_KEYS"));
+ let _ = fs::remove_dir_all(dirty_root);
+ }
+
+ #[test]
fn run_dispatches_forbidden_identifiers() {
let root = unique_temp_dir("run");
write_file(