commit 04616d6600ad08202cfb3cf07abd64c7e66ce0f1
parent 80f133f29c3aca04438523289be57e64c7150282
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Dec 2025 20:12:16 +0000
listing: centralize trade tag encoding options
- Add trade-field tag options and listing_tags_full helper
- Encode inventory/availability/delivery tags behind option flags
- Refactor trade codec to use shared tag builder and drop duplication
- Bump Rust toolchain directive to 1.88
Diffstat:
4 files changed, 133 insertions(+), 84 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -27,7 +27,7 @@
- trade: trade/listing domain models and tags.
## Rust Code Directives
-- Toolchain: Rust 1.86, edition 2024; use workspace versions from the root Cargo.toml.
+- Toolchain: Rust 1.88, edition 2024; use workspace versions from the root Cargo.toml.
- Portability: preserve no_std patterns; gate std usage with cfg(feature = "std") and use alloc when needed.
- Safety: avoid unsafe; prefer safe, explicit APIs. Add #![forbid(unsafe_code)] on new crates/modules.
- Public API: keep Radroots* prefix; avoid hidden panics; return Result/Option for fallible ops; use precise error enums (thiserror where appropriate).
diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs
@@ -11,8 +11,9 @@ use radroots_core::{
RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity,
};
use radroots_events::listing::{
- RadrootsListing, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation,
- RadrootsListingQuantity,
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
+ RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation, RadrootsListingQuantity,
+ RadrootsListingStatus,
};
use radroots_events::tags::TAG_D;
@@ -29,6 +30,11 @@ 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_PUBLISHED_AT: &str = "published_at";
+const TAG_STATUS: &str = "status";
+const TAG_EXPIRES_AT: &str = "expires_at";
const GEOHASH_PRECISION_DEFAULT: usize = 9;
const DD_MAX_RESOLUTION_DEFAULT: u32 = 9;
@@ -41,6 +47,9 @@ pub struct ListingTagOptions {
pub dd_max_resolution: u32,
pub include_geohash: bool,
pub include_gps: bool,
+ pub include_inventory: bool,
+ pub include_availability: bool,
+ pub include_delivery: bool,
}
impl Default for ListingTagOptions {
@@ -50,6 +59,20 @@ impl Default for ListingTagOptions {
dd_max_resolution: DD_MAX_RESOLUTION_DEFAULT,
include_geohash: true,
include_gps: 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()
}
}
}
@@ -58,6 +81,10 @@ pub fn listing_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, Event
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,
@@ -108,6 +135,47 @@ pub fn listing_tags_with_options(
}
}
+ if options.include_inventory {
+ if let Some(inventory) = &listing.inventory_available {
+ tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]);
+ }
+ }
+
+ if options.include_availability {
+ if 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_PUBLISHED_AT.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 {
+ if 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 {
if let Some(primary) = clean_value(&location.primary) {
let mut tag = Vec::with_capacity(5);
@@ -429,6 +497,14 @@ fn discount_tag_parts(
}
}
+fn status_as_str(status: &RadrootsListingStatus) -> &str {
+ match status {
+ RadrootsListingStatus::Active => "active",
+ RadrootsListingStatus::Sold => "sold",
+ RadrootsListingStatus::Other { value } => value.as_str(),
+ }
+}
+
#[cfg(feature = "serde_json")]
#[derive(serde::Serialize, Clone)]
struct QuantityDiscountPayload {
diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs
@@ -5,13 +5,16 @@ use radroots_core::{
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_events::listing::{
- RadrootsListing, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize,
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
+ RadrootsListingDiscount, RadrootsListingImage, RadrootsListingImageSize,
RadrootsListingLocation, RadrootsListingProduct, RadrootsListingQuantity,
+ RadrootsListingStatus,
};
use radroots_events::tags::TAG_D;
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::listing::decode::listing_from_event;
use radroots_events_codec::listing::encode::{listing_build_tags, to_wire_parts};
+use radroots_events_codec::listing::tags::listing_tags_full;
use std::str::FromStr;
fn sample_listing(d_tag: &str) -> RadrootsListing {
@@ -245,3 +248,50 @@ fn listing_build_tags_includes_listing_fields() {
&& t.get(2).map(|s| s.as_str()) == Some("1200x800")
}));
}
+
+#[test]
+fn listing_tags_full_includes_trade_fields() {
+ let mut listing = sample_listing("listing-1");
+ 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.get(0).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.get(0).map(|s| s.as_str()) == Some("published_at")
+ && t.get(1).map(|s| s.as_str()) == Some("1730000000")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("expires_at")
+ && t.get(1).map(|s| s.as_str()) == Some("1731000000")
+ }));
+ assert!(tags.iter().any(|t| {
+ t.get(0).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("listing-1");
+ listing.availability = Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ });
+
+ let tags = listing_tags_full(&listing).unwrap();
+
+ assert!(tags.iter().any(|t| {
+ t.get(0).map(|s| s.as_str()) == Some("status")
+ && t.get(1).map(|s| s.as_str()) == Some("active")
+ }));
+}
diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs
@@ -11,7 +11,7 @@ use radroots_events::listing::{
};
use radroots_events::tags::TAG_D;
use radroots_events_codec::error::EventEncodeError;
-use radroots_events_codec::listing::tags::listing_tags;
+use radroots_events_codec::listing::tags::{listing_tags_with_options, ListingTagOptions};
#[cfg(feature = "ts-rs")]
use ts_rs::TS;
@@ -115,77 +115,8 @@ pub fn listing_from_event_parts(
}
pub fn listing_tags_build(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, TradeListingParseError> {
- let mut tags = listing_tags(listing).map_err(map_listing_tags_error)?;
- if let Some(inventory) = &listing.inventory_available {
- tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]);
- }
-
- if 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_PUBLISHED_AT.to_string(), start.to_string()]);
- }
- if let Some(end) = end {
- tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]);
- }
- }
- }
- }
-
- if 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 mut tag = Vec::with_capacity(5);
- tag.push(TAG_LOCATION.to_string());
- tag.push(location.primary.clone());
- if let Some(city) = location.city.as_ref().and_then(|v| clean_value(v)) {
- tag.push(city);
- }
- if let Some(region) = location.region.as_ref().and_then(|v| clean_value(v)) {
- tag.push(region);
- }
- if let Some(country) = location.country.as_ref().and_then(|v| clean_value(v)) {
- tag.push(country);
- }
- tags.push(tag);
- if let Some(geohash) = location.geohash.as_ref().and_then(|v| clean_value(v)) {
- tags.push(vec![TAG_GEOHASH.to_string(), geohash]);
- }
- }
-
- if let Some(images) = &listing.images {
- for image in images {
- if image.url.trim().is_empty() {
- continue;
- }
- let mut tag = Vec::with_capacity(3);
- tag.push(TAG_IMAGE.to_string());
- tag.push(image.url.clone());
- if let Some(size) = &image.size {
- tag.push(format!("{}x{}", size.w, size.h));
- }
- tags.push(tag);
- }
- }
-
- Ok(tags)
+ let options = ListingTagOptions::with_trade_fields();
+ listing_tags_with_options(listing, options).map_err(map_listing_tags_error)
}
fn map_listing_tags_error(err: EventEncodeError) -> TradeListingParseError {
@@ -444,14 +375,6 @@ fn parse_status(value: &str) -> RadrootsListingStatus {
}
}
-fn status_as_str(status: &RadrootsListingStatus) -> &str {
- match status {
- RadrootsListingStatus::Active => "active",
- RadrootsListingStatus::Sold => "sold",
- RadrootsListingStatus::Other { value } => value.as_str(),
- }
-}
-
fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
let mut parts = value.split('x');
let w = parts.next()?.parse::<u32>().ok()?;