lib

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

decode.rs (25776B)


      1 #![cfg(feature = "serde_json")]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     string::{String, ToString},
      6     vec::Vec,
      7 };
      8 
      9 use radroots_core::{
     10     RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
     11     RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
     12 };
     13 use radroots_events::{
     14     RadrootsNostrEvent,
     15     farm::RadrootsFarmRef,
     16     ids::{RadrootsDTag, RadrootsInventoryBinId},
     17     kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_listing_kind},
     18     listing::{
     19         RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
     20         RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
     21         RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
     22     },
     23     plot::RadrootsPlotRef,
     24     resource_area::RadrootsResourceAreaRef,
     25     tags::{TAG_D, TAG_PUBLISHED_AT},
     26 };
     27 
     28 use crate::d_tag::validate_d_tag_tag;
     29 use crate::error::EventParseError;
     30 use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
     31 
     32 const EXPECTED_LISTING_KINDS: &str = "30402 or 30403";
     33 const TAG_A: &str = "a";
     34 const TAG_P: &str = "p";
     35 const TAG_PRICE: &str = "price";
     36 const TAG_RADROOTS_BIN: &str = "radroots:bin";
     37 const TAG_RADROOTS_PRICE: &str = "radroots:price";
     38 const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount";
     39 const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
     40 const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area";
     41 const TAG_RADROOTS_PLOT: &str = "radroots:plot";
     42 const TAG_LOCATION: &str = "location";
     43 const TAG_IMAGE: &str = "image";
     44 const TAG_GEOHASH: &str = "g";
     45 const TAG_INVENTORY: &str = "inventory";
     46 const TAG_DELIVERY: &str = "delivery";
     47 const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
     48 const TAG_STATUS: &str = "status";
     49 const TAG_EXPIRES_AT: &str = "expires_at";
     50 
     51 fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> {
     52     value
     53         .parse::<RadrootsCoreDecimal>()
     54         .map_err(|_| EventParseError::InvalidTag(field))
     55 }
     56 
     57 fn parse_currency(
     58     value: &str,
     59     field: &'static str,
     60 ) -> Result<RadrootsCoreCurrency, EventParseError> {
     61     let upper = value.trim().to_ascii_uppercase();
     62     RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| EventParseError::InvalidTag(field))
     63 }
     64 
     65 fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, EventParseError> {
     66     value
     67         .parse::<RadrootsCoreUnit>()
     68         .map_err(|_| EventParseError::InvalidTag(field))
     69 }
     70 
     71 fn parse_u64_tag_value(
     72     value: Option<&String>,
     73     field: &'static str,
     74 ) -> Result<u64, EventParseError> {
     75     value
     76         .ok_or(EventParseError::InvalidTag(field))?
     77         .parse::<u64>()
     78         .map_err(|_| EventParseError::InvalidTag(field))
     79 }
     80 
     81 fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
     82     let tag = tags
     83         .iter()
     84         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
     85         .ok_or(EventParseError::MissingTag(TAG_D))?;
     86     let value = tag
     87         .get(1)
     88         .map(|value| value.to_string())
     89         .ok_or(EventParseError::InvalidTag(TAG_D))?;
     90     if value.trim().is_empty() {
     91         return Err(EventParseError::InvalidTag(TAG_D));
     92     }
     93     validate_d_tag_tag(&value, TAG_D)?;
     94     Ok(value)
     95 }
     96 
     97 fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseError> {
     98     for tag in tags
     99         .iter()
    100         .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
    101     {
    102         let value = tag
    103             .get(1)
    104             .map(|value| value.to_string())
    105             .ok_or(EventParseError::InvalidTag(TAG_A))?;
    106         let mut parts = value.splitn(3, ':');
    107         let kind = parts
    108             .next()
    109             .and_then(|raw| raw.parse::<u32>().ok())
    110             .ok_or(EventParseError::InvalidTag(TAG_A))?;
    111         if kind != KIND_FARM {
    112             continue;
    113         }
    114         let pubkey = parts
    115             .next()
    116             .ok_or(EventParseError::InvalidTag(TAG_A))?
    117             .to_string();
    118         let d_tag = parts
    119             .next()
    120             .ok_or(EventParseError::InvalidTag(TAG_A))?
    121             .to_string();
    122         if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    123             return Err(EventParseError::InvalidTag(TAG_A));
    124         }
    125         validate_d_tag_tag(&d_tag, TAG_A)?;
    126         return Ok(RadrootsFarmRef { pubkey, d_tag });
    127     }
    128     Err(EventParseError::MissingTag(TAG_A))
    129 }
    130 
    131 fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> {
    132     let tag = tags
    133         .iter()
    134         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P))
    135         .ok_or(EventParseError::MissingTag(TAG_P))?;
    136     let value = tag
    137         .get(1)
    138         .map(|value| value.to_string())
    139         .ok_or(EventParseError::InvalidTag(TAG_P))?;
    140     if value.trim().is_empty() {
    141         return Err(EventParseError::InvalidTag(TAG_P));
    142     }
    143     Ok(value)
    144 }
    145 
    146 fn parse_resource_area(
    147     tags: &[Vec<String>],
    148 ) -> Result<Option<RadrootsResourceAreaRef>, EventParseError> {
    149     let tag = tags
    150         .iter()
    151         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
    152     let Some(tag) = tag else {
    153         return Ok(None);
    154     };
    155     let value = tag
    156         .get(1)
    157         .map(|value| value.to_string())
    158         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
    159     let mut parts = value.splitn(3, ':');
    160     let kind = parts
    161         .next()
    162         .and_then(|raw| raw.parse::<u32>().ok())
    163         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?;
    164     if kind != KIND_RESOURCE_AREA {
    165         return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
    166     }
    167     let pubkey = parts
    168         .next()
    169         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
    170         .to_string();
    171     let d_tag = parts
    172         .next()
    173         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?
    174         .to_string();
    175     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    176         return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA));
    177     }
    178     validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)?;
    179     Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag }))
    180 }
    181 
    182 fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, EventParseError> {
    183     let tag = tags
    184         .iter()
    185         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT));
    186     let Some(tag) = tag else {
    187         return Ok(None);
    188     };
    189     let value = tag
    190         .get(1)
    191         .map(|value| value.to_string())
    192         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
    193     let mut parts = value.splitn(3, ':');
    194     let kind = parts
    195         .next()
    196         .and_then(|raw| raw.parse::<u32>().ok())
    197         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?;
    198     if kind != KIND_PLOT {
    199         return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
    200     }
    201     let pubkey = parts
    202         .next()
    203         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?
    204         .to_string();
    205     let d_tag = parts
    206         .next()
    207         .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?
    208         .to_string();
    209     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    210         return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT));
    211     }
    212     validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)?;
    213     Ok(Some(RadrootsPlotRef { pubkey, d_tag }))
    214 }
    215 
    216 pub fn listing_from_event(
    217     kind: u32,
    218     tags: &[Vec<String>],
    219     content: &str,
    220 ) -> Result<RadrootsListing, EventParseError> {
    221     if !is_listing_kind(kind) {
    222         return Err(EventParseError::InvalidKind {
    223             expected: EXPECTED_LISTING_KINDS,
    224             got: kind,
    225         });
    226     }
    227     listing_from_event_parts(tags, content)
    228 }
    229 
    230 pub fn listing_from_event_parts(
    231     tags: &[Vec<String>],
    232     _content: &str,
    233 ) -> Result<RadrootsListing, EventParseError> {
    234     let d_tag = parse_d_tag(tags)?;
    235     let farm_ref = parse_farm_ref(tags)?;
    236     let farm_pubkey = parse_farm_pubkey(tags)?;
    237     let resource_area = parse_resource_area(tags)?;
    238     let plot = parse_plot_ref(tags)?;
    239 
    240     let mut product = RadrootsListingProduct {
    241         key: String::new(),
    242         title: String::new(),
    243         category: String::new(),
    244         summary: None,
    245         process: None,
    246         lot: None,
    247         location: None,
    248         profile: None,
    249         year: None,
    250     };
    251     let mut primary_bin_id: Option<String> = None;
    252     let mut bin_drafts: Vec<BinDraft> = Vec::new();
    253     let mut bin_order = 0usize;
    254     let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new();
    255     let mut location: Option<RadrootsListingLocation> = None;
    256     let mut inventory_available: Option<RadrootsCoreDecimal> = None;
    257     let mut availability_status: Option<RadrootsListingStatus> = None;
    258     let mut availability_start: Option<u64> = None;
    259     let mut availability_end: Option<u64> = None;
    260     let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None;
    261     let mut images: Vec<RadrootsListingImage> = Vec::new();
    262     let mut geohash: Option<String> = None;
    263     let mut published_at: Option<u64> = None;
    264 
    265     let has_structured_location = tags
    266         .iter()
    267         .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3);
    268 
    269     for tag in tags {
    270         if tag.is_empty() {
    271             continue;
    272         }
    273         match tag[0].as_str() {
    274             "key" => set_if_empty(&mut product.key, tag.get(1)),
    275             "title" => set_if_empty(&mut product.title, tag.get(1)),
    276             "category" => set_if_empty(&mut product.category, tag.get(1)),
    277             "summary" => set_optional(&mut product.summary, tag.get(1)),
    278             TAG_PUBLISHED_AT => {
    279                 published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?);
    280             }
    281             "process" => set_optional(&mut product.process, tag.get(1)),
    282             "lot" => set_optional(&mut product.lot, tag.get(1)),
    283             "location" => {
    284                 let parse_structured_location = match tag.len() {
    285                     0 | 1 => false,
    286                     2 => !has_structured_location && location.is_none(),
    287                     _ => true,
    288                 };
    289                 if parse_structured_location {
    290                     let primary = tag
    291                         .get(1)
    292                         .and_then(|value| clean_value(value))
    293                         .ok_or(EventParseError::InvalidTag(TAG_LOCATION))?;
    294                     let mut parsed = RadrootsListingLocation {
    295                         primary,
    296                         city: None,
    297                         region: None,
    298                         country: None,
    299                         lat: None,
    300                         lng: None,
    301                         geohash: None,
    302                     };
    303                     if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) {
    304                         parsed.city = Some(city);
    305                     }
    306                     if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) {
    307                         parsed.region = Some(region);
    308                     }
    309                     if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) {
    310                         parsed.country = Some(country);
    311                     }
    312                     location = Some(parsed);
    313                 } else {
    314                     set_optional(&mut product.location, tag.get(1));
    315                 }
    316             }
    317             "profile" => set_optional(&mut product.profile, tag.get(1)),
    318             "year" => set_optional(&mut product.year, tag.get(1)),
    319             TAG_PRICE => {}
    320             TAG_RADROOTS_PRIMARY_BIN => {
    321                 let value = tag
    322                     .get(1)
    323                     .and_then(|value| clean_value(value))
    324                     .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
    325                 if let Some(existing) = primary_bin_id.as_ref() {
    326                     if existing != &value {
    327                         return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
    328                     }
    329                 } else {
    330                     primary_bin_id = Some(value);
    331                 }
    332             }
    333             TAG_RADROOTS_BIN => {
    334                 if tag.len() < 4 || tag.len() > 7 {
    335                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
    336                 }
    337                 let bin_id = tag
    338                     .get(1)
    339                     .and_then(|value| clean_value(value))
    340                     .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_BIN))?;
    341                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
    342                 let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?;
    343                 if unit != unit.canonical_unit() {
    344                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
    345                 }
    346                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    347                 if bin.quantity.is_some() {
    348                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN));
    349                 }
    350                 bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit));
    351 
    352                 match tag.as_slice() {
    353                     [_, _, _, _, display_amount_raw, display_unit_raw]
    354                     | [_, _, _, _, display_amount_raw, display_unit_raw, _] => {
    355                         let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?;
    356                         let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?;
    357                         bin.display_amount = Some(display_amount);
    358                         bin.display_unit = Some(display_unit);
    359                         if let [_, _, _, _, _, _, label] = tag.as_slice() {
    360                             bin.display_label = clean_value(label);
    361                         }
    362                     }
    363                     [_, _, _, _, _] => return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)),
    364                     _ => {}
    365                 }
    366             }
    367             TAG_RADROOTS_PRICE => {
    368                 if tag.len() < 6 || tag.len() > 8 {
    369                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
    370                 }
    371                 let bin_id = tag
    372                     .get(1)
    373                     .and_then(|value| clean_value(value))
    374                     .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRICE))?;
    375                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
    376                 let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?;
    377                 let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
    378                 let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?;
    379                 let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
    380                     RadrootsCoreMoney::new(amount, currency),
    381                     RadrootsCoreQuantity::new(per_amount, per_unit),
    382                 );
    383                 if !price_per_canonical_unit.is_price_per_canonical_unit() {
    384                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
    385                 }
    386                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    387                 if bin.price_per_canonical_unit.is_some() {
    388                     return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
    389                 }
    390                 bin.price_per_canonical_unit = Some(price_per_canonical_unit);
    391 
    392                 match tag.as_slice() {
    393                     [_, _, _, _, _, _, _] => {
    394                         return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
    395                     }
    396                     [_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
    397                         let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
    398                         let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?;
    399                         bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency));
    400                         bin.display_price_unit = Some(display_unit);
    401                     }
    402                     _ => {}
    403                 }
    404             }
    405             TAG_RADROOTS_DISCOUNT => {
    406                 let payload = tag
    407                     .get(1)
    408                     .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))?;
    409                 discounts.push(parse_discount(payload)?);
    410             }
    411             TAG_GEOHASH => {
    412                 if let Some(value) = tag.get(1).and_then(|value| clean_value(value)) {
    413                     geohash = Some(value);
    414                 }
    415             }
    416             TAG_INVENTORY => {
    417                 let value = tag
    418                     .get(1)
    419                     .ok_or(EventParseError::InvalidTag(TAG_INVENTORY))?;
    420                 inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
    421             }
    422             TAG_RADROOTS_AVAILABILITY_START => {
    423                 availability_start = Some(parse_u64_tag_value(
    424                     tag.get(1),
    425                     TAG_RADROOTS_AVAILABILITY_START,
    426                 )?);
    427             }
    428             TAG_EXPIRES_AT => {
    429                 availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?);
    430             }
    431             TAG_STATUS => {
    432                 let status = tag
    433                     .get(1)
    434                     .and_then(|value| clean_value(value))
    435                     .unwrap_or_default();
    436                 availability_status = Some(parse_status(&status));
    437             }
    438             TAG_DELIVERY => {
    439                 let method = tag
    440                     .get(1)
    441                     .and_then(|value| clean_value(value))
    442                     .unwrap_or_default();
    443                 delivery_method = Some(match method.as_str() {
    444                     "pickup" => RadrootsListingDeliveryMethod::Pickup,
    445                     "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
    446                     "shipping" => RadrootsListingDeliveryMethod::Shipping,
    447                     "other" => RadrootsListingDeliveryMethod::Other {
    448                         method: tag
    449                             .get(2)
    450                             .and_then(|value| clean_value(value))
    451                             .unwrap_or_default(),
    452                     },
    453                     other => RadrootsListingDeliveryMethod::Other {
    454                         method: other.to_string(),
    455                     },
    456                 });
    457             }
    458             TAG_IMAGE => {
    459                 let url = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_IMAGE))?;
    460                 if url.trim().is_empty() {
    461                     continue;
    462                 }
    463                 images.push(RadrootsListingImage {
    464                     url: url.to_string(),
    465                     size: tag.get(2).and_then(|value| parse_image_size(value)),
    466                 });
    467             }
    468             _ => {}
    469         }
    470     }
    471 
    472     let availability = match availability_status {
    473         Some(status) => Some(RadrootsListingAvailability::Status { status }),
    474         None => match (availability_start, availability_end) {
    475             (None, None) => None,
    476             (start, end) => Some(RadrootsListingAvailability::Window { start, end }),
    477         },
    478     };
    479 
    480     let location = location.map(|mut location| {
    481         location.geohash = location.geohash.or(geohash);
    482         location
    483     });
    484 
    485     if farm_pubkey != farm_ref.pubkey {
    486         return Err(EventParseError::InvalidTag(TAG_P));
    487     }
    488 
    489     let primary_bin_id =
    490         primary_bin_id.ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?;
    491     let bins = build_bins(bin_drafts)?;
    492     if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
    493         return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN));
    494     }
    495 
    496     let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?;
    497     let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
    498         .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?;
    499 
    500     Ok(RadrootsListing {
    501         d_tag,
    502         published_at,
    503         farm: farm_ref,
    504         product,
    505         primary_bin_id,
    506         bins,
    507         resource_area,
    508         plot,
    509         discounts: if discounts.is_empty() {
    510             None
    511         } else {
    512             Some(discounts)
    513         },
    514         inventory_available,
    515         availability,
    516         delivery_method,
    517         location,
    518         images: if images.is_empty() {
    519             None
    520         } else {
    521             Some(images)
    522         },
    523     })
    524 }
    525 
    526 pub fn data_from_event(
    527     id: String,
    528     author: String,
    529     published_at: u32,
    530     kind: u32,
    531     content: String,
    532     tags: Vec<Vec<String>>,
    533 ) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> {
    534     let listing = listing_from_event(kind, &tags, &content)?;
    535     Ok(RadrootsParsedData::new(
    536         id,
    537         author,
    538         published_at,
    539         kind,
    540         listing,
    541     ))
    542 }
    543 
    544 pub fn parsed_from_event(
    545     id: String,
    546     author: String,
    547     published_at: u32,
    548     kind: u32,
    549     content: String,
    550     tags: Vec<Vec<String>>,
    551     sig: String,
    552 ) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> {
    553     let data = data_from_event(
    554         id.clone(),
    555         author.clone(),
    556         published_at,
    557         kind,
    558         content.clone(),
    559         tags.clone(),
    560     )?;
    561     Ok(RadrootsParsedEvent::from_parts(
    562         id,
    563         author,
    564         published_at,
    565         kind,
    566         content,
    567         tags,
    568         sig,
    569         data.data,
    570     ))
    571 }
    572 
    573 pub fn data_from_nostr_event(
    574     event: &RadrootsNostrEvent,
    575 ) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> {
    576     data_from_event(
    577         event.id.clone(),
    578         event.author.clone(),
    579         event.created_at,
    580         event.kind,
    581         event.content.clone(),
    582         event.tags.clone(),
    583     )
    584 }
    585 
    586 pub fn parsed_from_nostr_event(
    587     event: &RadrootsNostrEvent,
    588 ) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> {
    589     parsed_from_event(
    590         event.id.clone(),
    591         event.author.clone(),
    592         event.created_at,
    593         event.kind,
    594         event.content.clone(),
    595         event.tags.clone(),
    596         event.sig.clone(),
    597     )
    598 }
    599 
    600 fn clean_value(value: &str) -> Option<String> {
    601     let trimmed = value.trim();
    602     if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
    603         None
    604     } else {
    605         Some(trimmed.to_string())
    606     }
    607 }
    608 
    609 fn set_if_empty(target: &mut String, value: Option<&String>) {
    610     if target.trim().is_empty()
    611         && let Some(value) = value.and_then(|value| clean_value(value))
    612     {
    613         *target = value;
    614     }
    615 }
    616 
    617 fn set_optional(target: &mut Option<String>, value: Option<&String>) {
    618     if target.is_none()
    619         && let Some(value) = value.and_then(|value| clean_value(value))
    620     {
    621         *target = Some(value);
    622     }
    623 }
    624 
    625 fn parse_status(value: &str) -> RadrootsListingStatus {
    626     match value.trim().to_ascii_lowercase().as_str() {
    627         "active" => RadrootsListingStatus::Active,
    628         "sold" => RadrootsListingStatus::Sold,
    629         other => RadrootsListingStatus::Other {
    630             value: other.to_string(),
    631         },
    632     }
    633 }
    634 
    635 fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
    636     let (w_raw, h_raw) = value.split_once('x')?;
    637     let w = w_raw.parse::<u32>().ok()?;
    638     let h = h_raw.parse::<u32>().ok()?;
    639     Some(RadrootsListingImageSize { w, h })
    640 }
    641 
    642 fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, EventParseError> {
    643     serde_json::from_str(payload).map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))
    644 }
    645 
    646 #[derive(Clone, Debug)]
    647 struct BinDraft {
    648     bin_id: String,
    649     order_index: usize,
    650     quantity: Option<RadrootsCoreQuantity>,
    651     display_amount: Option<RadrootsCoreDecimal>,
    652     display_unit: Option<RadrootsCoreUnit>,
    653     display_label: Option<String>,
    654     price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>,
    655     display_price: Option<RadrootsCoreMoney>,
    656     display_price_unit: Option<RadrootsCoreUnit>,
    657 }
    658 
    659 fn upsert_bin<'a>(
    660     bins: &'a mut Vec<BinDraft>,
    661     bin_id: &str,
    662     order_index: &mut usize,
    663 ) -> &'a mut BinDraft {
    664     if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) {
    665         return &mut bins[position];
    666     }
    667     bins.push(BinDraft {
    668         bin_id: bin_id.to_string(),
    669         order_index: *order_index,
    670         quantity: None,
    671         display_amount: None,
    672         display_unit: None,
    673         display_label: None,
    674         price_per_canonical_unit: None,
    675         display_price: None,
    676         display_price_unit: None,
    677     });
    678     *order_index += 1;
    679     let index = bins.len() - 1;
    680     &mut bins[index]
    681 }
    682 
    683 fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, EventParseError> {
    684     drafts.sort_by_key(|draft| draft.order_index);
    685     let mut bins = Vec::with_capacity(drafts.len());
    686     for draft in drafts {
    687         let quantity = draft
    688             .quantity
    689             .ok_or(EventParseError::MissingTag(TAG_RADROOTS_BIN))?;
    690         let price = draft
    691             .price_per_canonical_unit
    692             .ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRICE))?;
    693         if quantity.unit != price.quantity.unit {
    694             return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE));
    695         }
    696         bins.push(RadrootsListingBin {
    697             bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
    698                 .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_BIN))?,
    699             quantity,
    700             price_per_canonical_unit: price,
    701             display_amount: draft.display_amount,
    702             display_unit: draft.display_unit,
    703             display_label: draft.display_label,
    704             display_price: draft.display_price,
    705             display_price_unit: draft.display_price_unit,
    706         });
    707     }
    708     Ok(bins)
    709 }