lib

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

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:
MAGENTS.md | 2+-
Mevents-codec/src/listing/tags.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mevents-codec/tests/listing.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtrade/src/listing/codec.rs | 83+++----------------------------------------------------------------------------
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()?;