lib

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

decode.rs (33230B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{
      3     string::{String, ToString},
      4     vec::Vec,
      5 };
      6 
      7 use radroots_core::pricing::Discount;
      8 use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
      9 use radroots_event::{
     10     envelope::EventEnvelope,
     11     envelope::kind::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_classified_listing_kind},
     12     farm::FarmRef,
     13     farm::location::is_public_geohash5,
     14     farm::plot::PlotRef,
     15     farm::resource_area::ResourceAreaRef,
     16     id::{DTag, InventoryBinId},
     17     listing::operational::OperationalListingParseError,
     18     listing::operational::{
     19         OperationalListing, OperationalListingAvailability, OperationalListingBin,
     20         OperationalListingDeliveryMethod, OperationalListingImage, OperationalListingImageSize,
     21         OperationalListingProduct, OperationalListingPublicLocation, OperationalListingStatus,
     22     },
     23     tag::name::{TAG_D, TAG_PUBLISHED_AT},
     24 };
     25 
     26 use crate::d_tag::validate_d_tag_tag;
     27 use crate::error::EventParseError;
     28 use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
     29 
     30 const EXPECTED_CLASSIFIED_LISTING_KINDS: &str = "30402";
     31 const TAG_A: &str = "a";
     32 const TAG_P: &str = "p";
     33 const TAG_PRICE: &str = "price";
     34 const TAG_RADROOTS_BIN: &str = "radroots:bin";
     35 const TAG_RADROOTS_PRICE: &str = "radroots:price";
     36 const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount";
     37 const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
     38 const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area";
     39 const TAG_RADROOTS_PLOT: &str = "radroots:plot";
     40 const TAG_LOCATION: &str = "location";
     41 const TAG_IMAGE: &str = "image";
     42 const TAG_GEOHASH: &str = "g";
     43 const TAG_LABEL: &str = "l";
     44 const TAG_LABEL_NS: &str = "L";
     45 const TAG_DD: &str = "dd";
     46 const TAG_DD_LAT: &str = "dd.lat";
     47 const TAG_DD_LON: &str = "dd.lon";
     48 const TAG_INVENTORY: &str = "inventory";
     49 const TAG_DELIVERY: &str = "delivery";
     50 const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
     51 const TAG_STATUS: &str = "status";
     52 const TAG_EXPIRES_AT: &str = "expires_at";
     53 
     54 #[derive(Clone, Debug, PartialEq, Eq)]
     55 enum OperationalListingDecodeError {
     56     MissingTag(&'static str),
     57     InvalidTag(&'static str),
     58     InvalidNumber(&'static str),
     59     InvalidUnit(&'static str),
     60     InvalidCurrency(&'static str),
     61     InvalidJson(&'static str),
     62     #[cfg(feature = "json")]
     63     InvalidDiscount(&'static str),
     64 }
     65 
     66 impl OperationalListingDecodeError {
     67     fn into_listing_parse_error(self) -> OperationalListingParseError {
     68         match self {
     69             Self::MissingTag(field) => OperationalListingParseError::MissingTag(field.to_string()),
     70             Self::InvalidTag(field) => OperationalListingParseError::InvalidTag(field.to_string()),
     71             Self::InvalidNumber(field) => {
     72                 OperationalListingParseError::InvalidNumber(field.to_string())
     73             }
     74             Self::InvalidUnit(_) => OperationalListingParseError::InvalidUnit,
     75             Self::InvalidCurrency(_) => OperationalListingParseError::InvalidCurrency,
     76             Self::InvalidJson(field) => {
     77                 OperationalListingParseError::InvalidJson(field.to_string())
     78             }
     79             #[cfg(feature = "json")]
     80             Self::InvalidDiscount(field) => {
     81                 OperationalListingParseError::InvalidDiscount(field.to_string())
     82             }
     83         }
     84     }
     85 
     86     fn into_event_parse_error(self) -> EventParseError {
     87         match self {
     88             Self::MissingTag(field) => EventParseError::MissingTag(field),
     89             Self::InvalidTag(field)
     90             | Self::InvalidNumber(field)
     91             | Self::InvalidUnit(field)
     92             | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field),
     93             #[cfg(feature = "json")]
     94             Self::InvalidDiscount(field) => EventParseError::InvalidTag(field),
     95             Self::InvalidJson(_) => EventParseError::InvalidJson("content"),
     96         }
     97     }
     98 }
     99 
    100 struct OperationalListingLocationDraft {
    101     primary: String,
    102     city: Option<String>,
    103     region: Option<String>,
    104     country: Option<String>,
    105 }
    106 
    107 fn parse_decimal(
    108     value: &str,
    109     field: &'static str,
    110 ) -> Result<Decimal, OperationalListingDecodeError> {
    111     value
    112         .parse::<Decimal>()
    113         .map_err(|_| OperationalListingDecodeError::InvalidNumber(field))
    114 }
    115 
    116 fn reject_private_listing_location_content(
    117     content: &str,
    118 ) -> Result<(), OperationalListingDecodeError> {
    119     let trimmed = content.trim();
    120     if !trimmed.starts_with('{') {
    121         return Ok(());
    122     }
    123     #[cfg(feature = "json")]
    124     {
    125         let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
    126             return Ok(());
    127         };
    128         let Some(location) = value.get("location").and_then(|value| value.as_object()) else {
    129             return Ok(());
    130         };
    131         for key in [
    132             "lat",
    133             "lng",
    134             "lon",
    135             "point",
    136             "polygon",
    137             "coordinates",
    138             "accuracy",
    139             "altitude",
    140             "label",
    141             "tag_0",
    142             "gcs",
    143         ] {
    144             if location.contains_key(key) {
    145                 return Err(OperationalListingDecodeError::InvalidJson("location"));
    146             }
    147         }
    148     }
    149     Ok(())
    150 }
    151 
    152 fn parse_currency(
    153     value: &str,
    154     field: &'static str,
    155 ) -> Result<Currency, OperationalListingDecodeError> {
    156     let upper = value.trim().to_ascii_uppercase();
    157     Currency::from_str_upper(&upper)
    158         .map_err(|_| OperationalListingDecodeError::InvalidCurrency(field))
    159 }
    160 
    161 fn parse_unit(value: &str, field: &'static str) -> Result<Unit, OperationalListingDecodeError> {
    162     value
    163         .parse::<Unit>()
    164         .map_err(|_| OperationalListingDecodeError::InvalidUnit(field))
    165 }
    166 
    167 fn parse_u64_tag_value(
    168     value: Option<&String>,
    169     field: &'static str,
    170 ) -> Result<u64, OperationalListingDecodeError> {
    171     value
    172         .ok_or(OperationalListingDecodeError::InvalidTag(field))?
    173         .parse::<u64>()
    174         .map_err(|_| OperationalListingDecodeError::InvalidNumber(field))
    175 }
    176 
    177 fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> {
    178     let tag = tags
    179         .iter()
    180         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
    181         .ok_or(OperationalListingDecodeError::MissingTag(TAG_D))?;
    182     let value = tag
    183         .get(1)
    184         .map(|value| value.to_string())
    185         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_D))?;
    186     if value.trim().is_empty() {
    187         return Err(OperationalListingDecodeError::InvalidTag(TAG_D));
    188     }
    189     validate_d_tag_tag(&value, TAG_D)
    190         .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?;
    191     Ok(value)
    192 }
    193 
    194 fn parse_farm_ref(tags: &[Vec<String>]) -> Result<FarmRef, OperationalListingDecodeError> {
    195     for tag in tags
    196         .iter()
    197         .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
    198     {
    199         let value = tag
    200             .get(1)
    201             .map(|value| value.to_string())
    202             .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?;
    203         let mut parts = value.splitn(3, ':');
    204         let kind = parts
    205             .next()
    206             .and_then(|raw| raw.parse::<u32>().ok())
    207             .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?;
    208         if kind != KIND_FARM {
    209             continue;
    210         }
    211         let pubkey = parts
    212             .next()
    213             .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?
    214             .to_string();
    215         let d_tag = parts
    216             .next()
    217             .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?
    218             .to_string();
    219         if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    220             return Err(OperationalListingDecodeError::InvalidTag(TAG_A));
    221         }
    222         validate_d_tag_tag(&d_tag, TAG_A)
    223             .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_A))?;
    224         return Ok(FarmRef { pubkey, d_tag });
    225     }
    226     Err(OperationalListingDecodeError::MissingTag(TAG_A))
    227 }
    228 
    229 fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> {
    230     let tag = tags
    231         .iter()
    232         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P))
    233         .ok_or(OperationalListingDecodeError::MissingTag(TAG_P))?;
    234     let value = tag
    235         .get(1)
    236         .map(|value| value.to_string())
    237         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_P))?;
    238     if value.trim().is_empty() {
    239         return Err(OperationalListingDecodeError::InvalidTag(TAG_P));
    240     }
    241     Ok(value)
    242 }
    243 
    244 fn parse_resource_area(
    245     tags: &[Vec<String>],
    246 ) -> Result<Option<ResourceAreaRef>, OperationalListingDecodeError> {
    247     let tag = tags
    248         .iter()
    249         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
    250     let Some(tag) = tag else {
    251         return Ok(None);
    252     };
    253     let value = tag.get(1).map(|value| value.to_string()).ok_or(
    254         OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA),
    255     )?;
    256     let mut parts = value.splitn(3, ':');
    257     let kind = parts.next().and_then(|raw| raw.parse::<u32>().ok()).ok_or(
    258         OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA),
    259     )?;
    260     if kind != KIND_RESOURCE_AREA {
    261         return Err(OperationalListingDecodeError::InvalidTag(
    262             TAG_RADROOTS_RESOURCE_AREA,
    263         ));
    264     }
    265     let pubkey = parts
    266         .next()
    267         .ok_or(OperationalListingDecodeError::InvalidTag(
    268             TAG_RADROOTS_RESOURCE_AREA,
    269         ))?
    270         .to_string();
    271     let d_tag = parts
    272         .next()
    273         .ok_or(OperationalListingDecodeError::InvalidTag(
    274             TAG_RADROOTS_RESOURCE_AREA,
    275         ))?
    276         .to_string();
    277     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    278         return Err(OperationalListingDecodeError::InvalidTag(
    279             TAG_RADROOTS_RESOURCE_AREA,
    280         ));
    281     }
    282     validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)
    283         .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
    284     Ok(Some(ResourceAreaRef { pubkey, d_tag }))
    285 }
    286 
    287 fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<PlotRef>, OperationalListingDecodeError> {
    288     let tag = tags
    289         .iter()
    290         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
    291     let Some(tag) = tag else {
    292         return Ok(None);
    293     };
    294     let value = tag
    295         .get(1)
    296         .map(|value| value.to_string())
    297         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
    298     let mut parts = value.splitn(3, ':');
    299     let kind = parts
    300         .next()
    301         .and_then(|raw| raw.parse::<u32>().ok())
    302         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
    303     if kind != KIND_PLOT {
    304         return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
    305     }
    306     let pubkey = parts
    307         .next()
    308         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
    309         .to_string();
    310     let d_tag = parts
    311         .next()
    312         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?
    313         .to_string();
    314     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    315         return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT));
    316     }
    317     validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)
    318         .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?;
    319     Ok(Some(PlotRef { pubkey, d_tag }))
    320 }
    321 
    322 pub fn operational_listing_from_event(
    323     kind: u32,
    324     tags: &[Vec<String>],
    325     content: &str,
    326 ) -> Result<OperationalListing, EventParseError> {
    327     if !is_classified_listing_kind(kind) {
    328         return Err(EventParseError::InvalidKind {
    329             expected: EXPECTED_CLASSIFIED_LISTING_KINDS,
    330             got: kind,
    331         });
    332     }
    333     decode_operational_listing_from_event_parts(tags, content)
    334         .map_err(OperationalListingDecodeError::into_event_parse_error)
    335 }
    336 
    337 pub fn operational_listing_from_event_parts(
    338     tags: &[Vec<String>],
    339     content: &str,
    340 ) -> Result<OperationalListing, OperationalListingParseError> {
    341     decode_operational_listing_from_event_parts(tags, content)
    342         .map_err(OperationalListingDecodeError::into_listing_parse_error)
    343 }
    344 
    345 pub fn operational_listing_from_nostr_event(
    346     event: &EventEnvelope,
    347 ) -> Result<OperationalListing, OperationalListingParseError> {
    348     if !is_classified_listing_kind(event.kind_u32()) {
    349         return Err(OperationalListingParseError::InvalidKind(event.kind_u32()));
    350     }
    351     operational_listing_from_event_parts(&event.tags_as_vec(), event.content())
    352 }
    353 
    354 fn decode_operational_listing_from_event_parts(
    355     tags: &[Vec<String>],
    356     content: &str,
    357 ) -> Result<OperationalListing, OperationalListingDecodeError> {
    358     reject_private_listing_location_content(content)?;
    359     let d_tag = parse_d_tag(tags)?;
    360     let farm_ref = parse_farm_ref(tags)?;
    361     let farm_pubkey = parse_farm_pubkey(tags)?;
    362     let resource_area = parse_resource_area(tags)?;
    363     let plot = parse_plot_ref(tags)?;
    364 
    365     let mut product = OperationalListingProduct {
    366         key: String::new(),
    367         title: String::new(),
    368         category: String::new(),
    369         summary: None,
    370         process: None,
    371         lot: None,
    372         location: None,
    373         profile: None,
    374         year: None,
    375     };
    376     let mut primary_bin_id: Option<String> = None;
    377     let mut bin_drafts: Vec<BinDraft> = Vec::new();
    378     let mut bin_order = 0usize;
    379     let mut discounts: Vec<Discount> = Vec::new();
    380     let mut location: Option<OperationalListingLocationDraft> = None;
    381     let mut inventory_available: Option<Decimal> = None;
    382     let mut availability_status: Option<OperationalListingStatus> = None;
    383     let mut availability_start: Option<u64> = None;
    384     let mut availability_end: Option<u64> = None;
    385     let mut delivery_method: Option<OperationalListingDeliveryMethod> = None;
    386     let mut images: Vec<OperationalListingImage> = Vec::new();
    387     let mut geohash: Option<String> = None;
    388     let mut published_at: Option<u64> = None;
    389 
    390     for tag in tags {
    391         if tag.is_empty() {
    392             continue;
    393         }
    394         match tag[0].as_str() {
    395             "key" => set_if_empty(&mut product.key, tag.get(1)),
    396             "title" => set_if_empty(&mut product.title, tag.get(1)),
    397             "category" => set_if_empty(&mut product.category, tag.get(1)),
    398             "summary" => set_optional(&mut product.summary, tag.get(1)),
    399             TAG_PUBLISHED_AT => {
    400                 published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?);
    401             }
    402             "process" => set_optional(&mut product.process, tag.get(1)),
    403             "lot" => set_optional(&mut product.lot, tag.get(1)),
    404             "location" => {
    405                 let parse_structured_location = !matches!(tag.len(), 0..=2);
    406                 if parse_structured_location {
    407                     let primary = tag
    408                         .get(1)
    409                         .and_then(|value| clean_value(value))
    410                         .ok_or(OperationalListingDecodeError::InvalidTag(TAG_LOCATION))?;
    411                     let mut parsed = OperationalListingLocationDraft {
    412                         primary,
    413                         city: None,
    414                         region: None,
    415                         country: None,
    416                     };
    417                     if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
    418                         parsed.city = Some(city);
    419                     }
    420                     if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) {
    421                         parsed.region = Some(region);
    422                     }
    423                     if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) {
    424                         parsed.country = Some(country);
    425                     }
    426                     location = Some(parsed);
    427                 } else {
    428                     set_optional(&mut product.location, tag.get(1));
    429                 }
    430             }
    431             "profile" => set_optional(&mut product.profile, tag.get(1)),
    432             "year" => set_optional(&mut product.year, tag.get(1)),
    433             TAG_PRICE => {}
    434             TAG_RADROOTS_PRIMARY_BIN => {
    435                 let value = tag.get(1).and_then(|value| clean_value(value)).ok_or(
    436                     OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN),
    437                 )?;
    438                 if let Some(existing) = primary_bin_id.as_ref() {
    439                     if existing != &value {
    440                         return Err(OperationalListingDecodeError::InvalidTag(
    441                             TAG_RADROOTS_PRIMARY_BIN,
    442                         ));
    443                     }
    444                 } else {
    445                     primary_bin_id = Some(value);
    446                 }
    447             }
    448             TAG_RADROOTS_BIN => {
    449                 if tag.len() < 4 || tag.len() > 7 {
    450                     return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
    451                 }
    452                 let bin_id = tag
    453                     .get(1)
    454                     .and_then(|value| clean_value(value))
    455                     .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?;
    456                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
    457                 let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?;
    458                 if unit != unit.canonical_unit() {
    459                     return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
    460                 }
    461                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    462                 if bin.quantity.is_some() {
    463                     return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
    464                 }
    465                 bin.quantity =
    466                     Some(Quantity::try_new(amount, unit).map_err(|_| {
    467                         OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)
    468                     })?);
    469 
    470                 match tag.as_slice() {
    471                     [_, _, _, _, display_amount_raw, display_unit_raw]
    472                     | [_, _, _, _, display_amount_raw, display_unit_raw, _] => {
    473                         let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?;
    474                         let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?;
    475                         bin.display_amount = Some(display_amount);
    476                         bin.display_unit = Some(display_unit);
    477                         if let [_, _, _, _, _, _, label] = tag.as_slice() {
    478                             bin.display_label = clean_value(label);
    479                         }
    480                     }
    481                     [_, _, _, _, _] => {
    482                         return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN));
    483                     }
    484                     _ => {}
    485                 }
    486             }
    487             TAG_RADROOTS_PRICE => {
    488                 if tag.len() < 6 || tag.len() > 8 {
    489                     return Err(OperationalListingDecodeError::InvalidTag(
    490                         TAG_RADROOTS_PRICE,
    491                     ));
    492                 }
    493                 let bin_id = tag.get(1).and_then(|value| clean_value(value)).ok_or(
    494                     OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE),
    495                 )?;
    496                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
    497                 let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?;
    498                 let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
    499                 let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?;
    500                 let money = Money::try_new(amount, currency)
    501                     .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?;
    502                 let quantity = Quantity::try_new(per_amount, per_unit)
    503                     .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?;
    504                 let price_per_canonical_unit = QuantityPrice::try_new(money, quantity)
    505                     .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?;
    506                 if !price_per_canonical_unit.is_price_per_canonical_unit() {
    507                     return Err(OperationalListingDecodeError::InvalidTag(
    508                         TAG_RADROOTS_PRICE,
    509                     ));
    510                 }
    511                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    512                 if bin.price_per_canonical_unit.is_some() {
    513                     return Err(OperationalListingDecodeError::InvalidTag(
    514                         TAG_RADROOTS_PRICE,
    515                     ));
    516                 }
    517                 bin.price_per_canonical_unit = Some(price_per_canonical_unit);
    518 
    519                 match tag.as_slice() {
    520                     [_, _, _, _, _, _, _] => {
    521                         return Err(OperationalListingDecodeError::InvalidTag(
    522                             TAG_RADROOTS_PRICE,
    523                         ));
    524                     }
    525                     [_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
    526                         let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
    527                         let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?;
    528                         bin.display_price =
    529                             Some(Money::try_new(display_price, currency).map_err(|_| {
    530                                 OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)
    531                             })?);
    532                         bin.display_price_unit = Some(display_unit);
    533                     }
    534                     _ => {}
    535                 }
    536             }
    537             TAG_RADROOTS_DISCOUNT => {
    538                 let payload = tag.get(1).ok_or(OperationalListingDecodeError::InvalidTag(
    539                     TAG_RADROOTS_DISCOUNT,
    540                 ))?;
    541                 discounts.push(parse_discount(payload)?);
    542             }
    543             TAG_GEOHASH => {
    544                 let value = tag
    545                     .get(1)
    546                     .and_then(|value| clean_value(value))
    547                     .ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?;
    548                 if !is_public_geohash5(&value) {
    549                     return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH));
    550                 }
    551                 let value = value.to_ascii_lowercase();
    552                 if geohash.as_ref().is_some_and(|existing| existing != &value) {
    553                     return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH));
    554                 }
    555                 geohash = Some(value);
    556             }
    557             TAG_DD => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD)),
    558             TAG_DD_LAT => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LAT)),
    559             TAG_DD_LON => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LON)),
    560             TAG_LABEL => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL)),
    561             TAG_LABEL_NS => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL_NS)),
    562             TAG_INVENTORY => {
    563                 let value = tag
    564                     .get(1)
    565                     .ok_or(OperationalListingDecodeError::InvalidTag(TAG_INVENTORY))?;
    566                 inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
    567             }
    568             TAG_RADROOTS_AVAILABILITY_START => {
    569                 availability_start = Some(parse_u64_tag_value(
    570                     tag.get(1),
    571                     TAG_RADROOTS_AVAILABILITY_START,
    572                 )?);
    573             }
    574             TAG_EXPIRES_AT => {
    575                 availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?);
    576             }
    577             TAG_STATUS => {
    578                 let status = tag
    579                     .get(1)
    580                     .and_then(|value| clean_value(value))
    581                     .unwrap_or_default();
    582                 availability_status = Some(parse_status(&status));
    583             }
    584             TAG_DELIVERY => {
    585                 let method = tag
    586                     .get(1)
    587                     .and_then(|value| clean_value(value))
    588                     .unwrap_or_default();
    589                 delivery_method = Some(match method.as_str() {
    590                     "pickup" => OperationalListingDeliveryMethod::Pickup,
    591                     "local_delivery" => OperationalListingDeliveryMethod::LocalDelivery,
    592                     "shipping" => OperationalListingDeliveryMethod::Shipping,
    593                     "other" => OperationalListingDeliveryMethod::Other {
    594                         method: tag
    595                             .get(2)
    596                             .and_then(|value| clean_value(value))
    597                             .unwrap_or_default(),
    598                     },
    599                     other => OperationalListingDeliveryMethod::Other {
    600                         method: other.to_string(),
    601                     },
    602                 });
    603             }
    604             TAG_IMAGE => {
    605                 let url = tag
    606                     .get(1)
    607                     .ok_or(OperationalListingDecodeError::InvalidTag(TAG_IMAGE))?;
    608                 if url.trim().is_empty() {
    609                     continue;
    610                 }
    611                 images.push(OperationalListingImage {
    612                     url: url.to_string(),
    613                     size: tag.get(2).and_then(|value| parse_image_size(value)),
    614                 });
    615             }
    616             _ => {}
    617         }
    618     }
    619 
    620     let availability = match availability_status {
    621         Some(status) => Some(OperationalListingAvailability::Status { status }),
    622         None => match (availability_start, availability_end) {
    623             (None, None) => None,
    624             (start, end) => Some(OperationalListingAvailability::Window { start, end }),
    625         },
    626     };
    627 
    628     let location = location
    629         .map(|location| {
    630             let geohash = geohash.ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?;
    631             Ok::<OperationalListingPublicLocation, OperationalListingDecodeError>(
    632                 OperationalListingPublicLocation {
    633                     primary: location.primary,
    634                     city: location.city,
    635                     region: location.region,
    636                     country: location.country,
    637                     geohash,
    638                 },
    639             )
    640         })
    641         .transpose()?;
    642 
    643     if farm_pubkey != farm_ref.pubkey {
    644         return Err(OperationalListingDecodeError::InvalidTag(TAG_P));
    645     }
    646 
    647     let primary_bin_id = primary_bin_id.ok_or(OperationalListingDecodeError::MissingTag(
    648         TAG_RADROOTS_PRIMARY_BIN,
    649     ))?;
    650     let bins = build_bins(bin_drafts)?;
    651     if !bins.iter().any(|bin| bin.bin_id.as_str() == primary_bin_id) {
    652         return Err(OperationalListingDecodeError::InvalidTag(
    653             TAG_RADROOTS_PRIMARY_BIN,
    654         ));
    655     }
    656 
    657     let d_tag =
    658         DTag::parse(&d_tag).map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?;
    659     let primary_bin_id = InventoryBinId::parse(&primary_bin_id)
    660         .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
    661 
    662     Ok(OperationalListing {
    663         d_tag,
    664         published_at,
    665         farm: farm_ref,
    666         product,
    667         primary_bin_id,
    668         bins,
    669         resource_area,
    670         plot,
    671         discounts: if discounts.is_empty() {
    672             None
    673         } else {
    674             Some(discounts)
    675         },
    676         inventory_available,
    677         availability,
    678         delivery_method,
    679         location,
    680         images: if images.is_empty() {
    681             None
    682         } else {
    683             Some(images)
    684         },
    685     })
    686 }
    687 
    688 pub fn data_from_event(
    689     id: String,
    690     author: String,
    691     published_at: u64,
    692     kind: u32,
    693     content: String,
    694     tags: Vec<Vec<String>>,
    695 ) -> Result<RadrootsParsedData<OperationalListing>, EventParseError> {
    696     let listing = operational_listing_from_event(kind, &tags, &content)?;
    697     Ok(RadrootsParsedData::new(
    698         id,
    699         author,
    700         published_at,
    701         kind,
    702         listing,
    703     ))
    704 }
    705 
    706 pub fn parsed_from_event(
    707     id: String,
    708     author: String,
    709     published_at: u64,
    710     kind: u32,
    711     content: String,
    712     tags: Vec<Vec<String>>,
    713     sig: String,
    714 ) -> Result<RadrootsParsedEvent<OperationalListing>, EventParseError> {
    715     let data = data_from_event(
    716         id.clone(),
    717         author.clone(),
    718         published_at,
    719         kind,
    720         content.clone(),
    721         tags.clone(),
    722     )?;
    723     RadrootsParsedEvent::from_parts(
    724         id,
    725         author,
    726         published_at,
    727         kind,
    728         content,
    729         tags,
    730         sig,
    731         data.data,
    732     )
    733 }
    734 
    735 pub fn data_from_nostr_event(
    736     event: &EventEnvelope,
    737 ) -> Result<RadrootsParsedData<OperationalListing>, EventParseError> {
    738     data_from_event(
    739         event.id_hex(),
    740         event.author().to_hex().to_string(),
    741         event.created_at_u64(),
    742         event.kind_u32(),
    743         event.content().to_string(),
    744         event.tags_as_vec(),
    745     )
    746 }
    747 
    748 pub fn parsed_from_nostr_event(
    749     event: &EventEnvelope,
    750 ) -> Result<RadrootsParsedEvent<OperationalListing>, EventParseError> {
    751     parsed_from_event(
    752         event.id_hex(),
    753         event.author().to_hex().to_string(),
    754         event.created_at_u64(),
    755         event.kind_u32(),
    756         event.content().to_string(),
    757         event.tags_as_vec(),
    758         event.signature_hex(),
    759     )
    760 }
    761 
    762 fn clean_value(value: &str) -> Option<String> {
    763     let trimmed = value.trim();
    764     if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
    765         None
    766     } else {
    767         Some(trimmed.to_string())
    768     }
    769 }
    770 
    771 fn set_if_empty(target: &mut String, value: Option<&String>) {
    772     if target.trim().is_empty()
    773         && let Some(value) = value.and_then(|value| clean_value(value))
    774     {
    775         *target = value;
    776     }
    777 }
    778 
    779 fn set_optional(target: &mut Option<String>, value: Option<&String>) {
    780     if target.is_none()
    781         && let Some(value) = value.and_then(|value| clean_value(value))
    782     {
    783         *target = Some(value);
    784     }
    785 }
    786 
    787 fn parse_status(value: &str) -> OperationalListingStatus {
    788     match value.trim().to_ascii_lowercase().as_str() {
    789         "active" => OperationalListingStatus::Active,
    790         "sold" => OperationalListingStatus::Sold,
    791         other => OperationalListingStatus::Other {
    792             value: other.to_string(),
    793         },
    794     }
    795 }
    796 
    797 fn parse_image_size(value: &str) -> Option<OperationalListingImageSize> {
    798     let (w_raw, h_raw) = value.split_once('x')?;
    799     let w = w_raw.parse::<u32>().ok()?;
    800     let h = h_raw.parse::<u32>().ok()?;
    801     Some(OperationalListingImageSize { w, h })
    802 }
    803 
    804 fn parse_discount(payload: &str) -> Result<Discount, OperationalListingDecodeError> {
    805     #[cfg(feature = "json")]
    806     {
    807         serde_json::from_str(payload)
    808             .map_err(|_| OperationalListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT))
    809     }
    810     #[cfg(not(feature = "json"))]
    811     {
    812         let _ = payload;
    813         Err(OperationalListingDecodeError::InvalidJson("discount"))
    814     }
    815 }
    816 
    817 #[derive(Clone, Debug)]
    818 struct BinDraft {
    819     bin_id: String,
    820     order_index: usize,
    821     quantity: Option<Quantity>,
    822     display_amount: Option<Decimal>,
    823     display_unit: Option<Unit>,
    824     display_label: Option<String>,
    825     price_per_canonical_unit: Option<QuantityPrice>,
    826     display_price: Option<Money>,
    827     display_price_unit: Option<Unit>,
    828 }
    829 
    830 fn upsert_bin<'a>(
    831     bins: &'a mut Vec<BinDraft>,
    832     bin_id: &str,
    833     order_index: &mut usize,
    834 ) -> &'a mut BinDraft {
    835     if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) {
    836         return &mut bins[position];
    837     }
    838     bins.push(BinDraft {
    839         bin_id: bin_id.to_string(),
    840         order_index: *order_index,
    841         quantity: None,
    842         display_amount: None,
    843         display_unit: None,
    844         display_label: None,
    845         price_per_canonical_unit: None,
    846         display_price: None,
    847         display_price_unit: None,
    848     });
    849     *order_index += 1;
    850     let index = bins.len() - 1;
    851     &mut bins[index]
    852 }
    853 
    854 fn build_bins(
    855     mut drafts: Vec<BinDraft>,
    856 ) -> Result<Vec<OperationalListingBin>, OperationalListingDecodeError> {
    857     drafts.sort_by_key(|draft| draft.order_index);
    858     let mut bins = Vec::with_capacity(drafts.len());
    859     for draft in drafts {
    860         let quantity = draft
    861             .quantity
    862             .ok_or(OperationalListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?;
    863         let price =
    864             draft
    865                 .price_per_canonical_unit
    866                 .ok_or(OperationalListingDecodeError::MissingTag(
    867                     TAG_RADROOTS_PRICE,
    868                 ))?;
    869         if quantity.unit() != price.quantity().unit() {
    870             return Err(OperationalListingDecodeError::InvalidTag(
    871                 TAG_RADROOTS_PRICE,
    872             ));
    873         }
    874         bins.push(OperationalListingBin {
    875             bin_id: InventoryBinId::parse(&draft.bin_id)
    876                 .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?,
    877             quantity,
    878             price_per_canonical_unit: price,
    879             display_amount: draft.display_amount,
    880             display_unit: draft.display_unit,
    881             display_label: draft.display_label,
    882             display_price: draft.display_price,
    883             display_price_unit: draft.display_price_unit,
    884         });
    885     }
    886     Ok(bins)
    887 }