lib

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

codec.rs (87017B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{string::String, vec::Vec};
      5 
      6 use radroots_core::{
      7     RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney,
      8     RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
      9 };
     10 use radroots_events::farm::RadrootsFarmRef;
     11 use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
     12 use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA};
     13 use radroots_events::listing::{
     14     RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
     15     RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
     16     RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
     17 };
     18 pub(crate) use radroots_events::order::RadrootsListingParseError as ListingParseError;
     19 use radroots_events::plot::RadrootsPlotRef;
     20 use radroots_events::resource_area::RadrootsResourceAreaRef;
     21 use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT};
     22 use radroots_events_codec::d_tag::is_d_tag_base64url;
     23 use radroots_events_codec::error::EventEncodeError;
     24 use radroots_events_codec::listing::tags::listing_tags_full;
     25 
     26 const TAG_PRICE: &str = "price";
     27 const TAG_RADROOTS_BIN: &str = "radroots:bin";
     28 const TAG_RADROOTS_PRICE: &str = "radroots:price";
     29 const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount";
     30 const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
     31 const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area";
     32 const TAG_RADROOTS_PLOT: &str = "radroots:plot";
     33 const TAG_LOCATION: &str = "location";
     34 const TAG_IMAGE: &str = "image";
     35 const TAG_GEOHASH: &str = "g";
     36 const TAG_INVENTORY: &str = "inventory";
     37 const TAG_DELIVERY: &str = "delivery";
     38 const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start";
     39 const TAG_STATUS: &str = "status";
     40 const TAG_EXPIRES_AT: &str = "expires_at";
     41 const TAG_P: &str = "p";
     42 const TAG_A: &str = "a";
     43 
     44 fn parse_decimal(s: &str, field: &str) -> Result<RadrootsCoreDecimal, ListingParseError> {
     45     s.parse::<RadrootsCoreDecimal>()
     46         .map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
     47 }
     48 
     49 fn parse_currency(s: &str) -> Result<RadrootsCoreCurrency, ListingParseError> {
     50     let upper = s.trim().to_ascii_uppercase();
     51     RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| ListingParseError::InvalidCurrency)
     52 }
     53 
     54 fn parse_unit(s: &str) -> Result<RadrootsCoreUnit, ListingParseError> {
     55     s.parse::<RadrootsCoreUnit>()
     56         .map_err(|_| ListingParseError::InvalidUnit)
     57 }
     58 
     59 fn parse_u64_tag_value(value: Option<&String>, field: &str) -> Result<u64, ListingParseError> {
     60     value
     61         .ok_or_else(|| ListingParseError::InvalidTag(field.to_string()))?
     62         .parse::<u64>()
     63         .map_err(|_| ListingParseError::InvalidNumber(field.to_string()))
     64 }
     65 
     66 fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingParseError> {
     67     let tag = tags
     68         .iter()
     69         .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_D))
     70         .ok_or_else(|| ListingParseError::MissingTag(TAG_D.to_string()))?;
     71     let value = tag
     72         .get(1)
     73         .map(|s| s.to_string())
     74         .ok_or_else(|| ListingParseError::InvalidTag(TAG_D.to_string()))?;
     75     if value.trim().is_empty() {
     76         return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
     77     }
     78     if !is_d_tag_base64url(&value) {
     79         return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
     80     }
     81     Ok(value)
     82 }
     83 
     84 pub fn listing_from_event_parts(
     85     tags: &[Vec<String>],
     86     content: &str,
     87 ) -> Result<RadrootsListing, ListingParseError> {
     88     let d_tag = parse_d_tag(tags)?;
     89     let farm_ref = parse_farm_ref(tags)?;
     90     let farm_pubkey = parse_farm_pubkey(tags)?;
     91     let resource_area = parse_resource_area(tags)?;
     92     let plot = parse_plot_ref(tags)?;
     93 
     94     if !content.trim().is_empty() {
     95         #[cfg(feature = "serde_json")]
     96         {
     97             if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) {
     98                 if listing.d_tag != d_tag {
     99                     return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
    100                 }
    101                 if listing.farm.pubkey.trim().is_empty() || listing.farm.d_tag.trim().is_empty() {
    102                     listing.farm = farm_ref;
    103                 } else if listing.farm.pubkey != farm_ref.pubkey
    104                     || listing.farm.d_tag != farm_ref.d_tag
    105                 {
    106                     return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
    107                 }
    108                 if listing.farm.pubkey != farm_pubkey {
    109                     return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
    110                 }
    111                 if let Some(tag_area) = resource_area {
    112                     match listing.resource_area.as_ref() {
    113                         None => listing.resource_area = Some(tag_area),
    114                         Some(area) => {
    115                             if area.pubkey != tag_area.pubkey || area.d_tag != tag_area.d_tag {
    116                                 return Err(ListingParseError::InvalidTag(
    117                                     TAG_RADROOTS_RESOURCE_AREA.to_string(),
    118                                 ));
    119                             }
    120                         }
    121                     }
    122                 }
    123                 if let Some(tag_plot) = plot {
    124                     match listing.plot.as_ref() {
    125                         None => listing.plot = Some(tag_plot),
    126                         Some(existing) => {
    127                             if existing.pubkey != tag_plot.pubkey
    128                                 || existing.d_tag != tag_plot.d_tag
    129                             {
    130                                 return Err(ListingParseError::InvalidTag(
    131                                     TAG_RADROOTS_PLOT.to_string(),
    132                                 ));
    133                             }
    134                         }
    135                     }
    136                 }
    137                 return Ok(listing);
    138             }
    139         }
    140     }
    141 
    142     listing_from_tags(tags, d_tag, farm_ref, farm_pubkey, resource_area, plot)
    143 }
    144 
    145 #[allow(dead_code)]
    146 pub fn listing_tags_build(
    147     listing: &RadrootsListing,
    148 ) -> Result<Vec<Vec<String>>, ListingParseError> {
    149     listing_tags_full(listing).map_err(map_listing_tags_error)
    150 }
    151 
    152 #[allow(dead_code)]
    153 fn map_listing_tags_error(err: EventEncodeError) -> ListingParseError {
    154     match err {
    155         EventEncodeError::EmptyRequiredField(field) => {
    156             ListingParseError::MissingTag(field.to_string())
    157         }
    158         EventEncodeError::InvalidField(field) => ListingParseError::InvalidTag(field.to_string()),
    159         EventEncodeError::Json => ListingParseError::InvalidJson("discount".to_string()),
    160         EventEncodeError::InvalidKind(kind) => ListingParseError::InvalidKind(kind),
    161     }
    162 }
    163 
    164 fn listing_from_tags(
    165     tags: &[Vec<String>],
    166     d_tag: String,
    167     farm_ref: RadrootsFarmRef,
    168     farm_pubkey: String,
    169     resource_area: Option<RadrootsResourceAreaRef>,
    170     plot: Option<RadrootsPlotRef>,
    171 ) -> Result<RadrootsListing, ListingParseError> {
    172     if !is_d_tag_base64url(&d_tag) {
    173         return Err(ListingParseError::InvalidTag(TAG_D.to_string()));
    174     }
    175     let d_tag = match RadrootsDTag::parse(&d_tag) {
    176         Ok(d_tag) => d_tag,
    177         Err(_) => unreachable!(),
    178     };
    179     let mut product = RadrootsListingProduct {
    180         key: String::new(),
    181         title: String::new(),
    182         category: String::new(),
    183         summary: None,
    184         process: None,
    185         lot: None,
    186         location: None,
    187         profile: None,
    188         year: None,
    189     };
    190 
    191     let mut primary_bin_id: Option<String> = None;
    192     let mut bin_drafts: Vec<BinDraft> = Vec::new();
    193     let mut bin_order = 0usize;
    194     let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new();
    195     let mut location: Option<RadrootsListingLocation> = None;
    196     let mut inventory_available: Option<RadrootsCoreDecimal> = None;
    197     let mut availability_status: Option<RadrootsListingStatus> = None;
    198     let mut availability_start: Option<u64> = None;
    199     let mut availability_end: Option<u64> = None;
    200     let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None;
    201     let mut images: Vec<RadrootsListingImage> = Vec::new();
    202     let mut geohash: Option<String> = None;
    203     let mut published_at: Option<u64> = None;
    204 
    205     let has_structured_location = tags
    206         .iter()
    207         .any(|tag| tag.first().map(|k| k.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3);
    208 
    209     for tag in tags {
    210         if tag.is_empty() {
    211             continue;
    212         }
    213         let key = tag[0].as_str();
    214         match key {
    215             "key" => set_if_empty(&mut product.key, tag.get(1)),
    216             "title" => set_if_empty(&mut product.title, tag.get(1)),
    217             "category" => set_if_empty(&mut product.category, tag.get(1)),
    218             "summary" => set_optional(&mut product.summary, tag.get(1)),
    219             TAG_PUBLISHED_AT => {
    220                 published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?);
    221             }
    222             "process" => set_optional(&mut product.process, tag.get(1)),
    223             "lot" => set_optional(&mut product.lot, tag.get(1)),
    224             "location" => {
    225                 let parse_structured_location = match tag.len() {
    226                     0 | 1 => false,
    227                     2 => !has_structured_location && location.is_none(),
    228                     _ => true,
    229                 };
    230                 if parse_structured_location {
    231                     let primary = &tag[1];
    232                     if primary.trim().is_empty() {
    233                         return Err(ListingParseError::InvalidTag(TAG_LOCATION.to_string()));
    234                     }
    235                     let mut loc = RadrootsListingLocation {
    236                         primary: primary.to_string(),
    237                         city: None,
    238                         region: None,
    239                         country: None,
    240                         lat: None,
    241                         lng: None,
    242                         geohash: None,
    243                     };
    244                     if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) {
    245                         loc.city = Some(city);
    246                     }
    247                     if let Some(region) = tag.get(3).and_then(|v| clean_value(v)) {
    248                         loc.region = Some(region);
    249                     }
    250                     if let Some(country) = tag.get(4).and_then(|v| clean_value(v)) {
    251                         loc.country = Some(country);
    252                     }
    253                     location = Some(loc);
    254                 } else {
    255                     set_optional(&mut product.location, tag.get(1));
    256                 }
    257             }
    258             "profile" => set_optional(&mut product.profile, tag.get(1)),
    259             "year" => set_optional(&mut product.year, tag.get(1)),
    260             TAG_PRICE => {
    261                 let _ = tag;
    262             }
    263             TAG_RADROOTS_PRIMARY_BIN => {
    264                 let value = tag.get(1).and_then(|v| clean_value(v)).ok_or_else(|| {
    265                     ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string())
    266                 })?;
    267                 if let Some(existing) = primary_bin_id.as_ref() {
    268                     if existing != &value {
    269                         return Err(ListingParseError::InvalidTag(
    270                             TAG_RADROOTS_PRIMARY_BIN.to_string(),
    271                         ));
    272                     }
    273                 } else {
    274                     primary_bin_id = Some(value);
    275                 }
    276             }
    277             TAG_RADROOTS_BIN => {
    278                 if tag.len() < 4 {
    279                     return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
    280                 }
    281                 if tag.len() > 7 {
    282                     return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
    283                 }
    284                 let bin_id = clean_value(&tag[1])
    285                     .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?;
    286                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?;
    287                 let unit = parse_unit(&tag[3])?;
    288                 if unit != unit.canonical_unit() {
    289                     return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
    290                 }
    291                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    292                 if bin.quantity.is_some() {
    293                     return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
    294                 }
    295                 bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit));
    296 
    297                 match tag.as_slice() {
    298                     [_, _, _, _, display_amount_raw, display_unit_raw]
    299                     | [_, _, _, _, display_amount_raw, display_unit_raw, _] => {
    300                         let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?;
    301                         let display_unit = parse_unit(display_unit_raw)?;
    302                         bin.display_amount = Some(display_amount);
    303                         bin.display_unit = Some(display_unit);
    304                         if let [_, _, _, _, _, _, label] = tag.as_slice() {
    305                             bin.display_label = clean_value(label);
    306                         }
    307                     }
    308                     [_, _, _, _, _] => {
    309                         return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()));
    310                     }
    311                     _ => {}
    312                 }
    313             }
    314             TAG_RADROOTS_PRICE => {
    315                 if tag.len() < 6 {
    316                     return Err(ListingParseError::InvalidTag(
    317                         TAG_RADROOTS_PRICE.to_string(),
    318                     ));
    319                 }
    320                 if tag.len() > 8 {
    321                     return Err(ListingParseError::InvalidTag(
    322                         TAG_RADROOTS_PRICE.to_string(),
    323                     ));
    324                 }
    325                 let bin_id = clean_value(&tag[1])
    326                     .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?;
    327                 let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?;
    328                 let currency = parse_currency(&tag[3])?;
    329                 let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?;
    330                 let per_unit = parse_unit(&tag[5])?;
    331                 let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
    332                     RadrootsCoreMoney::new(amount, currency),
    333                     RadrootsCoreQuantity::new(per_amount, per_unit),
    334                 );
    335                 if !price_per_canonical_unit.is_price_per_canonical_unit() {
    336                     return Err(ListingParseError::InvalidTag(
    337                         TAG_RADROOTS_PRICE.to_string(),
    338                     ));
    339                 }
    340                 let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order);
    341                 if bin.price_per_canonical_unit.is_some() {
    342                     return Err(ListingParseError::InvalidTag(
    343                         TAG_RADROOTS_PRICE.to_string(),
    344                     ));
    345                 }
    346                 bin.price_per_canonical_unit = Some(price_per_canonical_unit);
    347 
    348                 match tag.as_slice() {
    349                     [_, _, _, _, _, _, _] => {
    350                         return Err(ListingParseError::InvalidTag(
    351                             TAG_RADROOTS_PRICE.to_string(),
    352                         ));
    353                     }
    354                     [_, _, _, _, _, _, display_price_raw, display_unit_raw] => {
    355                         let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?;
    356                         let display_unit = parse_unit(display_unit_raw)?;
    357                         bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency));
    358                         bin.display_price_unit = Some(display_unit);
    359                     }
    360                     _ => {}
    361                 }
    362             }
    363             TAG_RADROOTS_DISCOUNT => {
    364                 let payload = tag.get(1).ok_or_else(|| {
    365                     ListingParseError::InvalidTag(TAG_RADROOTS_DISCOUNT.to_string())
    366                 })?;
    367                 let discount = parse_discount(payload)?;
    368                 discounts.push(discount);
    369             }
    370             TAG_GEOHASH => {
    371                 if let Some(value) = tag.get(1).and_then(|v| clean_value(v)) {
    372                     geohash = Some(value);
    373                 }
    374             }
    375             TAG_INVENTORY => {
    376                 let value = tag
    377                     .get(1)
    378                     .ok_or_else(|| ListingParseError::InvalidTag(TAG_INVENTORY.to_string()))?;
    379                 inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?);
    380             }
    381             TAG_RADROOTS_AVAILABILITY_START => {
    382                 let value = tag.get(1).ok_or_else(|| {
    383                     ListingParseError::InvalidTag(TAG_RADROOTS_AVAILABILITY_START.to_string())
    384                 })?;
    385                 availability_start = Some(value.parse::<u64>().map_err(|_| {
    386                     ListingParseError::InvalidNumber(TAG_RADROOTS_AVAILABILITY_START.to_string())
    387                 })?);
    388             }
    389             TAG_EXPIRES_AT => {
    390                 let value = tag
    391                     .get(1)
    392                     .ok_or_else(|| ListingParseError::InvalidTag(TAG_EXPIRES_AT.to_string()))?;
    393                 availability_end =
    394                     Some(value.parse::<u64>().map_err(|_| {
    395                         ListingParseError::InvalidNumber(TAG_EXPIRES_AT.to_string())
    396                     })?);
    397             }
    398             TAG_STATUS => {
    399                 let status = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default();
    400                 availability_status = Some(parse_status(&status));
    401             }
    402             TAG_DELIVERY => {
    403                 let method = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default();
    404                 delivery_method = Some(match method.as_str() {
    405                     "pickup" => RadrootsListingDeliveryMethod::Pickup,
    406                     "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
    407                     "shipping" => RadrootsListingDeliveryMethod::Shipping,
    408                     "other" => {
    409                         let detail = tag.get(2).and_then(|v| clean_value(v)).unwrap_or_default();
    410                         RadrootsListingDeliveryMethod::Other { method: detail }
    411                     }
    412                     other => RadrootsListingDeliveryMethod::Other {
    413                         method: other.to_string(),
    414                     },
    415                 });
    416             }
    417             TAG_IMAGE => {
    418                 let url = tag
    419                     .get(1)
    420                     .ok_or_else(|| ListingParseError::InvalidTag(TAG_IMAGE.to_string()))?;
    421                 if url.trim().is_empty() {
    422                     continue;
    423                 }
    424                 let size = tag.get(2).and_then(|s| parse_image_size(s));
    425                 images.push(RadrootsListingImage {
    426                     url: url.to_string(),
    427                     size,
    428                 });
    429             }
    430             _ => {}
    431         }
    432     }
    433 
    434     let availability = match availability_status {
    435         Some(status) => Some(RadrootsListingAvailability::Status { status }),
    436         None => match (availability_start, availability_end) {
    437             (None, None) => None,
    438             (start, end) => Some(RadrootsListingAvailability::Window { start, end }),
    439         },
    440     };
    441 
    442     let location = location.map(|mut loc| {
    443         loc.geohash = loc.geohash.or(geohash);
    444         loc
    445     });
    446 
    447     if farm_pubkey != farm_ref.pubkey {
    448         return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
    449     }
    450 
    451     let primary_bin_id = primary_bin_id
    452         .and_then(|v| clean_value(&v))
    453         .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?;
    454     let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id)
    455         .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?;
    456     let bins = build_bins(bin_drafts)?;
    457     if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) {
    458         return Err(ListingParseError::InvalidTag(
    459             TAG_RADROOTS_PRIMARY_BIN.to_string(),
    460         ));
    461     }
    462 
    463     Ok(RadrootsListing {
    464         d_tag,
    465         published_at,
    466         farm: farm_ref,
    467         product,
    468         primary_bin_id,
    469         bins,
    470         resource_area,
    471         plot,
    472         discounts: if discounts.is_empty() {
    473             None
    474         } else {
    475             Some(discounts)
    476         },
    477         inventory_available,
    478         availability,
    479         delivery_method,
    480         location,
    481         images: if images.is_empty() {
    482             None
    483         } else {
    484             Some(images)
    485         },
    486     })
    487 }
    488 
    489 fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingParseError> {
    490     for tag in tags
    491         .iter()
    492         .filter(|t| t.first().map(|s| s.as_str()) == Some(TAG_A))
    493     {
    494         let value = tag
    495             .get(1)
    496             .map(|s| s.to_string())
    497             .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?;
    498         let mut parts = value.splitn(3, ':');
    499         let kind = parts
    500             .next()
    501             .and_then(|v| v.parse::<u32>().ok())
    502             .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?;
    503         if kind != KIND_FARM {
    504             continue;
    505         }
    506         let pubkey = parts
    507             .next()
    508             .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?
    509             .to_string();
    510         let d_tag = parts
    511             .next()
    512             .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?
    513             .to_string();
    514         if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    515             return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
    516         }
    517         if !is_d_tag_base64url(&d_tag) {
    518             return Err(ListingParseError::InvalidTag(TAG_A.to_string()));
    519         }
    520         return Ok(RadrootsFarmRef { pubkey, d_tag });
    521     }
    522     Err(ListingParseError::MissingTag(TAG_A.to_string()))
    523 }
    524 
    525 fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingParseError> {
    526     let tag = tags
    527         .iter()
    528         .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_P))
    529         .ok_or_else(|| ListingParseError::MissingTag(TAG_P.to_string()))?;
    530     let value = tag
    531         .get(1)
    532         .map(|s| s.to_string())
    533         .ok_or_else(|| ListingParseError::InvalidTag(TAG_P.to_string()))?;
    534     if value.trim().is_empty() {
    535         return Err(ListingParseError::InvalidTag(TAG_P.to_string()));
    536     }
    537     Ok(value)
    538 }
    539 
    540 fn parse_resource_area(
    541     tags: &[Vec<String>],
    542 ) -> Result<Option<RadrootsResourceAreaRef>, ListingParseError> {
    543     let tag = tags
    544         .iter()
    545         .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA));
    546     let Some(tag) = tag else {
    547         return Ok(None);
    548     };
    549     let value = tag
    550         .get(1)
    551         .map(|s| s.to_string())
    552         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?;
    553     let mut parts = value.splitn(3, ':');
    554     let kind = parts
    555         .next()
    556         .and_then(|v| v.parse::<u32>().ok())
    557         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?;
    558     if kind != KIND_RESOURCE_AREA {
    559         return Err(ListingParseError::InvalidTag(
    560             TAG_RADROOTS_RESOURCE_AREA.to_string(),
    561         ));
    562     }
    563     let pubkey = parts
    564         .next()
    565         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?
    566         .to_string();
    567     let d_tag = parts
    568         .next()
    569         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?
    570         .to_string();
    571     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    572         return Err(ListingParseError::InvalidTag(
    573             TAG_RADROOTS_RESOURCE_AREA.to_string(),
    574         ));
    575     }
    576     if !is_d_tag_base64url(&d_tag) {
    577         return Err(ListingParseError::InvalidTag(
    578             TAG_RADROOTS_RESOURCE_AREA.to_string(),
    579         ));
    580     }
    581     Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag }))
    582 }
    583 
    584 fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingParseError> {
    585     let tag = tags
    586         .iter()
    587         .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT));
    588     let Some(tag) = tag else {
    589         return Ok(None);
    590     };
    591     let value = tag
    592         .get(1)
    593         .map(|s| s.to_string())
    594         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?;
    595     let mut parts = value.splitn(3, ':');
    596     let kind = parts
    597         .next()
    598         .and_then(|v| v.parse::<u32>().ok())
    599         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?;
    600     if kind != KIND_PLOT {
    601         return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
    602     }
    603     let pubkey = parts
    604         .next()
    605         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?
    606         .to_string();
    607     let d_tag = parts
    608         .next()
    609         .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?
    610         .to_string();
    611     if pubkey.trim().is_empty() || d_tag.trim().is_empty() {
    612         return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
    613     }
    614     if !is_d_tag_base64url(&d_tag) {
    615         return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()));
    616     }
    617     Ok(Some(RadrootsPlotRef { pubkey, d_tag }))
    618 }
    619 
    620 #[cfg(test)]
    621 mod tests {
    622     use super::*;
    623     use radroots_core::{
    624         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
    625         RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
    626         RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
    627     };
    628     use radroots_events::farm::RadrootsFarmRef;
    629     use radroots_events::listing::RadrootsListing;
    630 
    631     fn farm_ref() -> RadrootsFarmRef {
    632         RadrootsFarmRef {
    633             pubkey: "seller".to_string(),
    634             d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
    635         }
    636     }
    637 
    638     fn listing_d_tag() -> String {
    639         "AAAAAAAAAAAAAAAAAAAAAg".to_string()
    640     }
    641 
    642     fn d_tag(raw: &str) -> RadrootsDTag {
    643         RadrootsDTag::parse(raw).expect("d tag")
    644     }
    645 
    646     fn base_event_tags() -> Vec<Vec<String>> {
    647         vec![
    648             vec![TAG_D.into(), listing_d_tag()],
    649             vec![TAG_P.into(), "seller".into()],
    650             vec![
    651                 TAG_A.into(),
    652                 format!("{KIND_FARM}:seller:{}", farm_ref().d_tag),
    653             ],
    654         ]
    655     }
    656 
    657     fn base_trade_tags() -> Vec<Vec<String>> {
    658         vec![
    659             vec!["key".into(), "coffee".into()],
    660             vec!["title".into(), "Coffee".into()],
    661             vec!["category".into(), "coffee".into()],
    662             vec!["summary".into(), "Single origin".into()],
    663             vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()],
    664             vec![
    665                 TAG_RADROOTS_BIN.into(),
    666                 "bin-1".into(),
    667                 "1000".into(),
    668                 "g".into(),
    669                 "1".into(),
    670                 "kg".into(),
    671                 "bag".into(),
    672             ],
    673             vec![
    674                 TAG_RADROOTS_PRICE.into(),
    675                 "bin-1".into(),
    676                 "0.01".into(),
    677                 "USD".into(),
    678                 "1".into(),
    679                 "g".into(),
    680                 "10".into(),
    681                 "kg".into(),
    682             ],
    683         ]
    684     }
    685 
    686     fn parse_base_listing_from_tags() -> RadrootsListing {
    687         listing_from_tags(
    688             &base_trade_tags(),
    689             listing_d_tag(),
    690             farm_ref(),
    691             "seller".to_string(),
    692             None,
    693             None,
    694         )
    695         .expect("listing")
    696     }
    697 
    698     fn parse_error_tag(error: ListingParseError) -> String {
    699         match error {
    700             ListingParseError::InvalidKind(_) => "kind".to_string(),
    701             ListingParseError::MissingTag(tag) => tag,
    702             ListingParseError::InvalidTag(tag) => tag,
    703             ListingParseError::InvalidNumber(field) => field,
    704             ListingParseError::InvalidUnit => "unit".to_string(),
    705             ListingParseError::InvalidCurrency => "currency".to_string(),
    706             ListingParseError::InvalidJson(field) => field,
    707             ListingParseError::InvalidDiscount(kind) => kind,
    708         }
    709     }
    710 
    711     #[test]
    712     fn listing_parses_radroots_bins() {
    713         let tags = base_trade_tags();
    714 
    715         let listing = listing_from_tags(
    716             &tags,
    717             listing_d_tag(),
    718             farm_ref(),
    719             "seller".to_string(),
    720             None,
    721             None,
    722         )
    723         .expect("listing");
    724 
    725         assert_eq!(listing.primary_bin_id, "bin-1");
    726         assert_eq!(listing.bins.len(), 1);
    727         assert_eq!(listing.bins[0].quantity.unit, RadrootsCoreUnit::MassG);
    728         assert_eq!(
    729             listing.bins[0].price_per_canonical_unit.quantity.unit,
    730             RadrootsCoreUnit::MassG
    731         );
    732         assert_eq!(
    733             listing.bins[0].display_unit.expect("display unit").code(),
    734             "kg"
    735         );
    736     }
    737 
    738     #[test]
    739     fn listing_from_tags_roundtrips_published_at_tag() {
    740         let mut tags = base_trade_tags();
    741         tags.push(vec![TAG_PUBLISHED_AT.into(), "1781895600".into()]);
    742 
    743         let listing = listing_from_tags(
    744             &tags,
    745             listing_d_tag(),
    746             farm_ref(),
    747             "seller".to_string(),
    748             None,
    749             None,
    750         )
    751         .expect("listing");
    752 
    753         assert_eq!(listing.published_at, Some(1_781_895_600));
    754 
    755         let published_at = tags
    756             .iter_mut()
    757             .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT))
    758             .expect("published_at tag");
    759         published_at[1] = "bad".to_string();
    760 
    761         let err = listing_from_tags(
    762             &tags,
    763             listing_d_tag(),
    764             farm_ref(),
    765             "seller".to_string(),
    766             None,
    767             None,
    768         )
    769         .unwrap_err();
    770 
    771         assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
    772 
    773         let mut missing_value = base_trade_tags();
    774         missing_value.push(vec![TAG_PUBLISHED_AT.into()]);
    775         let err = listing_from_tags(
    776             &missing_value,
    777             listing_d_tag(),
    778             farm_ref(),
    779             "seller".to_string(),
    780             None,
    781             None,
    782         )
    783         .unwrap_err();
    784         assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string());
    785     }
    786 
    787     #[test]
    788     fn listing_from_tags_rejects_invalid_d_tag() {
    789         let tags = base_trade_tags();
    790 
    791         let err = listing_from_tags(
    792             &tags,
    793             "invalid:tag".to_string(),
    794             farm_ref(),
    795             "seller".to_string(),
    796             None,
    797             None,
    798         )
    799         .unwrap_err();
    800 
    801         assert_eq!(parse_error_tag(err), TAG_D.to_string());
    802     }
    803 
    804     #[test]
    805     fn parse_scalar_helpers_cover_success_and_error_paths() {
    806         assert_eq!(
    807             parse_decimal("1.5", "f").unwrap(),
    808             "1.5".parse::<RadrootsCoreDecimal>().unwrap()
    809         );
    810         assert_eq!(
    811             parse_error_tag(parse_decimal("x", "f").unwrap_err()),
    812             "f".to_string()
    813         );
    814         assert_eq!(parse_currency(" usd ").unwrap(), RadrootsCoreCurrency::USD);
    815         assert_eq!(
    816             parse_error_tag(parse_currency("12").unwrap_err()),
    817             "currency".to_string()
    818         );
    819         assert_eq!(parse_unit("g").unwrap(), RadrootsCoreUnit::MassG);
    820         assert_eq!(
    821             parse_error_tag(parse_unit("not-unit").unwrap_err()),
    822             "unit".to_string()
    823         );
    824     }
    825 
    826     #[test]
    827     fn parse_error_display_covers_all_variants() {
    828         let errors = [
    829             ListingParseError::MissingTag("d".into()),
    830             ListingParseError::InvalidTag("a".into()),
    831             ListingParseError::InvalidNumber("n".into()),
    832             ListingParseError::InvalidUnit,
    833             ListingParseError::InvalidCurrency,
    834             ListingParseError::InvalidJson("j".into()),
    835             ListingParseError::InvalidDiscount("x".into()),
    836         ];
    837         for error in errors {
    838             assert!(!error.to_string().trim().is_empty());
    839         }
    840     }
    841 
    842     #[test]
    843     fn parse_d_tag_covers_all_paths() {
    844         assert_eq!(
    845             parse_error_tag(parse_d_tag(&[]).unwrap_err()),
    846             TAG_D.to_string()
    847         );
    848         assert_eq!(
    849             parse_error_tag(parse_d_tag(&[vec![TAG_D.into()]]).unwrap_err()),
    850             TAG_D.to_string()
    851         );
    852         assert_eq!(
    853             parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), " ".into()]]).unwrap_err()),
    854             TAG_D.to_string()
    855         );
    856         assert_eq!(
    857             parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), "invalid".into()]]).unwrap_err()),
    858             TAG_D.to_string()
    859         );
    860         assert_eq!(
    861             parse_d_tag(&[vec![TAG_D.into(), listing_d_tag()]]).unwrap(),
    862             listing_d_tag()
    863         );
    864     }
    865 
    866     #[test]
    867     fn listing_from_event_parts_uses_json_content_and_backfills_tags() {
    868         let mut listing = parse_base_listing_from_tags();
    869         listing.farm.pubkey = String::new();
    870         listing.farm.d_tag = String::new();
    871         listing.resource_area = None;
    872         listing.plot = None;
    873 
    874         let mut tags = base_event_tags();
    875         tags.push(vec![
    876             TAG_RADROOTS_RESOURCE_AREA.into(),
    877             format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
    878         ]);
    879         tags.push(vec![
    880             TAG_RADROOTS_PLOT.into(),
    881             format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
    882         ]);
    883 
    884         let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
    885             .expect("event listing");
    886         assert_eq!(parsed.d_tag, listing_d_tag());
    887         assert_eq!(parsed.farm.pubkey, farm_ref().pubkey);
    888         assert_eq!(parsed.farm.d_tag, farm_ref().d_tag);
    889         assert_eq!(
    890             parsed.resource_area.unwrap().d_tag,
    891             "AAAAAAAAAAAAAAAAAAAAAQ"
    892         );
    893         assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw");
    894     }
    895 
    896     #[test]
    897     fn listing_from_event_parts_rejects_conflicting_content_values() {
    898         let tags = base_event_tags();
    899 
    900         let mut listing = parse_base_listing_from_tags();
    901         listing.d_tag = d_tag("AAAAAAAAAAAAAAAAAAAAAw");
    902         let err =
    903             listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
    904         assert_eq!(parse_error_tag(err), TAG_D.to_string());
    905 
    906         let mut listing = parse_base_listing_from_tags();
    907         listing.farm.pubkey = "other".into();
    908         let err =
    909             listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
    910         assert_eq!(parse_error_tag(err), TAG_A.to_string());
    911 
    912         let mut listing = parse_base_listing_from_tags();
    913         listing.farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".into();
    914         let err =
    915             listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err();
    916         assert_eq!(parse_error_tag(err), TAG_A.to_string());
    917 
    918         let mut listing = parse_base_listing_from_tags();
    919         listing.farm.d_tag = String::new();
    920         let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
    921             .expect("backfill empty farm d_tag");
    922         assert_eq!(parsed.farm.d_tag, farm_ref().d_tag);
    923 
    924         let listing = parse_base_listing_from_tags();
    925         let mut mismatched_pubkey_tags = tags.clone();
    926         mismatched_pubkey_tags[1][1] = "other".into();
    927         let err = listing_from_event_parts(
    928             &mismatched_pubkey_tags,
    929             &serde_json::to_string(&listing).unwrap(),
    930         )
    931         .unwrap_err();
    932         assert_eq!(parse_error_tag(err), TAG_P.to_string());
    933 
    934         let mut listing = parse_base_listing_from_tags();
    935         listing.resource_area = Some(RadrootsResourceAreaRef {
    936             pubkey: "seller".into(),
    937             d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
    938         });
    939         let mut resource_tags = tags.clone();
    940         resource_tags.push(vec![
    941             TAG_RADROOTS_RESOURCE_AREA.into(),
    942             format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
    943         ]);
    944         let err =
    945             listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap())
    946                 .unwrap_err();
    947         assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
    948 
    949         let mut listing = parse_base_listing_from_tags();
    950         listing.resource_area = Some(RadrootsResourceAreaRef {
    951             pubkey: "other".into(),
    952             d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
    953         });
    954         let mut resource_tags = tags.clone();
    955         resource_tags.push(vec![
    956             TAG_RADROOTS_RESOURCE_AREA.into(),
    957             format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
    958         ]);
    959         let err =
    960             listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap())
    961                 .unwrap_err();
    962         assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
    963 
    964         let mut listing = parse_base_listing_from_tags();
    965         listing.plot = Some(RadrootsPlotRef {
    966             pubkey: "seller".into(),
    967             d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
    968         });
    969         let mut plot_tags = tags.clone();
    970         plot_tags.push(vec![
    971             TAG_RADROOTS_PLOT.into(),
    972             format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
    973         ]);
    974         let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap())
    975             .unwrap_err();
    976         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
    977 
    978         let mut listing = parse_base_listing_from_tags();
    979         listing.plot = Some(RadrootsPlotRef {
    980             pubkey: "other".into(),
    981             d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
    982         });
    983         let mut plot_tags = tags.clone();
    984         plot_tags.push(vec![
    985             TAG_RADROOTS_PLOT.into(),
    986             format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
    987         ]);
    988         let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap())
    989             .unwrap_err();
    990         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
    991     }
    992 
    993     #[test]
    994     fn listing_from_event_parts_accepts_matching_resource_and_plot_refs() {
    995         let mut listing = parse_base_listing_from_tags();
    996         listing.resource_area = Some(RadrootsResourceAreaRef {
    997             pubkey: "seller".into(),
    998             d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(),
    999         });
   1000         listing.plot = Some(RadrootsPlotRef {
   1001             pubkey: "seller".into(),
   1002             d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(),
   1003         });
   1004         let mut tags = base_event_tags();
   1005         tags.push(vec![
   1006             TAG_RADROOTS_RESOURCE_AREA.into(),
   1007             format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
   1008         ]);
   1009         tags.push(vec![
   1010             TAG_RADROOTS_PLOT.into(),
   1011             format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"),
   1012         ]);
   1013         let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap())
   1014             .expect("matching refs");
   1015         assert_eq!(
   1016             parsed.resource_area.unwrap().d_tag,
   1017             "AAAAAAAAAAAAAAAAAAAAAQ"
   1018         );
   1019         assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw");
   1020     }
   1021 
   1022     #[test]
   1023     fn listing_from_event_parts_rejects_invalid_plot_tag_shapes() {
   1024         let mut tags = base_event_tags();
   1025         tags.push(vec![TAG_RADROOTS_PLOT.into()]);
   1026         let err = listing_from_event_parts(&tags, "").unwrap_err();
   1027         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
   1028 
   1029         let mut tags = base_event_tags();
   1030         tags.push(vec![TAG_RADROOTS_PLOT.into(), "bad".into()]);
   1031         let err = listing_from_event_parts(&tags, "").unwrap_err();
   1032         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string());
   1033     }
   1034 
   1035     #[test]
   1036     fn listing_from_event_parts_falls_back_to_tag_parser() {
   1037         let mut tags = base_event_tags();
   1038         tags.extend(base_trade_tags());
   1039         let listing =
   1040             listing_from_event_parts(&tags, "{invalid-json").expect("fallback tags parse");
   1041         assert_eq!(listing.primary_bin_id, "bin-1");
   1042         assert_eq!(listing.bins.len(), 1);
   1043     }
   1044 
   1045     #[test]
   1046     fn listing_from_event_parts_rejects_missing_reference_tags() {
   1047         let mut missing_farm_ref = base_event_tags();
   1048         missing_farm_ref.extend(base_trade_tags());
   1049         missing_farm_ref.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_A));
   1050         let err = listing_from_event_parts(&missing_farm_ref, "").unwrap_err();
   1051         assert_eq!(parse_error_tag(err), TAG_A.to_string());
   1052 
   1053         let mut missing_farm_pubkey = base_event_tags();
   1054         missing_farm_pubkey.extend(base_trade_tags());
   1055         missing_farm_pubkey.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_P));
   1056         let err = listing_from_event_parts(&missing_farm_pubkey, "").unwrap_err();
   1057         assert_eq!(parse_error_tag(err), TAG_P.to_string());
   1058 
   1059         let mut invalid_resource_area = base_event_tags();
   1060         invalid_resource_area.extend(base_trade_tags());
   1061         invalid_resource_area.push(vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]);
   1062         let err = listing_from_event_parts(&invalid_resource_area, "").unwrap_err();
   1063         assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string());
   1064     }
   1065 
   1066     #[test]
   1067     fn listing_tags_build_and_error_mapping_cover_paths() {
   1068         let listing = parse_base_listing_from_tags();
   1069         let built = listing_tags_build(&listing).expect("build tags");
   1070         assert!(
   1071             built
   1072                 .iter()
   1073                 .any(|tag| tag.get(0).map(|v| v.as_str()) == Some(TAG_RADROOTS_PRIMARY_BIN))
   1074         );
   1075 
   1076         let mapped = map_listing_tags_error(EventEncodeError::EmptyRequiredField("d"));
   1077         assert_eq!(parse_error_tag(mapped), "d".to_string());
   1078         let mapped = map_listing_tags_error(EventEncodeError::InvalidField("f"));
   1079         assert_eq!(parse_error_tag(mapped), "f".to_string());
   1080         let mapped = map_listing_tags_error(EventEncodeError::Json);
   1081         assert_eq!(parse_error_tag(mapped), "discount".to_string());
   1082         let mapped = map_listing_tags_error(EventEncodeError::InvalidKind(1));
   1083         assert_eq!(parse_error_tag(mapped), "kind".to_string());
   1084     }
   1085 
   1086     #[test]
   1087     fn listing_from_tags_parses_trade_specific_optional_fields() {
   1088         let mut tags = base_trade_tags();
   1089         tags.push(Vec::new());
   1090         tags.push(vec![TAG_PRICE.into(), "ignored".into()]);
   1091         tags.push(vec!["process".into(), "washed".into()]);
   1092         tags.push(vec!["lot".into(), "lot-7".into()]);
   1093         tags.push(vec!["profile".into(), "fruity".into()]);
   1094         tags.push(vec!["year".into(), "2024".into()]);
   1095         tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()]);
   1096         tags.push(vec![
   1097             TAG_LOCATION.into(),
   1098             "Farm".into(),
   1099             "Town".into(),
   1100             "Region".into(),
   1101             "SE".into(),
   1102         ]);
   1103         tags.push(vec![TAG_GEOHASH.into(), "u6se".into()]);
   1104         tags.push(vec![TAG_INVENTORY.into(), "8".into()]);
   1105         tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "10".into()]);
   1106         tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]);
   1107         tags.push(vec![TAG_DELIVERY.into(), "other".into(), "drone".into()]);
   1108         tags.push(vec![
   1109             TAG_IMAGE.into(),
   1110             "https://cdn/image.png".into(),
   1111             "100x200".into(),
   1112         ]);
   1113         tags.push(vec![TAG_IMAGE.into(), " ".into()]);
   1114         let discount = RadrootsCoreDiscount {
   1115             scope: RadrootsCoreDiscountScope::Bin,
   1116             threshold: RadrootsCoreDiscountThreshold::BinCount {
   1117                 bin_id: "bin-1".into(),
   1118                 min: 2,
   1119             },
   1120             value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new(
   1121                 "5".parse::<RadrootsCoreDecimal>().unwrap(),
   1122             )),
   1123         };
   1124         tags.push(vec![
   1125             TAG_RADROOTS_DISCOUNT.into(),
   1126             serde_json::to_string(&discount).unwrap(),
   1127         ]);
   1128 
   1129         let listing = listing_from_tags(
   1130             &tags,
   1131             listing_d_tag(),
   1132             farm_ref(),
   1133             "seller".to_string(),
   1134             None,
   1135             None,
   1136         )
   1137         .expect("listing");
   1138 
   1139         assert_eq!(
   1140             format!("{:?}", listing.availability),
   1141             "Some(Window { start: Some(10), end: Some(20) })"
   1142         );
   1143         assert_eq!(
   1144             format!("{:?}", listing.delivery_method),
   1145             "Some(Other { method: \"drone\" })"
   1146         );
   1147         assert_eq!(
   1148             listing.location.as_ref().unwrap().geohash.as_deref(),
   1149             Some("u6se")
   1150         );
   1151         assert_eq!(listing.product.process.as_deref(), Some("washed"));
   1152         assert_eq!(listing.product.lot.as_deref(), Some("lot-7"));
   1153         assert_eq!(listing.product.profile.as_deref(), Some("fruity"));
   1154         assert_eq!(listing.product.year.as_deref(), Some("2024"));
   1155         assert_eq!(listing.images.as_ref().unwrap().len(), 1);
   1156         assert_eq!(listing.discounts.as_ref().unwrap().len(), 1);
   1157     }
   1158 
   1159     #[test]
   1160     fn listing_from_tags_uses_unstructured_location_and_custom_delivery() {
   1161         let mut tags = base_trade_tags();
   1162         tags.push(vec![TAG_LOCATION.into(), "Farm".into()]);
   1163         tags.push(vec![TAG_LOCATION.into(), "fallback".into()]);
   1164         tags.push(vec![TAG_DELIVERY.into(), "parcel".into()]);
   1165 
   1166         let listing = listing_from_tags(
   1167             &tags,
   1168             listing_d_tag(),
   1169             farm_ref(),
   1170             "seller".to_string(),
   1171             None,
   1172             None,
   1173         )
   1174         .expect("listing");
   1175 
   1176         assert_eq!(listing.product.location.as_deref(), Some("fallback"));
   1177         assert_eq!(
   1178             format!("{:?}", listing.delivery_method),
   1179             "Some(Other { method: \"parcel\" })"
   1180         );
   1181     }
   1182 
   1183     #[test]
   1184     fn listing_from_tags_parses_delivery_enum_variants() {
   1185         let mut local_delivery = base_trade_tags();
   1186         local_delivery.push(vec![TAG_DELIVERY.into(), "local_delivery".into()]);
   1187         let listing = listing_from_tags(
   1188             &local_delivery,
   1189             listing_d_tag(),
   1190             farm_ref(),
   1191             "seller".to_string(),
   1192             None,
   1193             None,
   1194         )
   1195         .expect("local delivery parse");
   1196         assert_eq!(
   1197             format!("{:?}", listing.delivery_method),
   1198             "Some(LocalDelivery)"
   1199         );
   1200 
   1201         let mut shipping = base_trade_tags();
   1202         shipping.push(vec![TAG_DELIVERY.into(), "shipping".into()]);
   1203         let listing = listing_from_tags(
   1204             &shipping,
   1205             listing_d_tag(),
   1206             farm_ref(),
   1207             "seller".to_string(),
   1208             None,
   1209             None,
   1210         )
   1211         .expect("shipping parse");
   1212         assert_eq!(format!("{:?}", listing.delivery_method), "Some(Shipping)");
   1213     }
   1214 
   1215     #[test]
   1216     fn listing_from_tags_rejects_empty_structured_location_primary() {
   1217         let mut tags = base_trade_tags();
   1218         tags.push(vec![
   1219             TAG_LOCATION.into(),
   1220             " ".into(),
   1221             "Town".into(),
   1222             "Region".into(),
   1223         ]);
   1224         let err = listing_from_tags(
   1225             &tags,
   1226             listing_d_tag(),
   1227             farm_ref(),
   1228             "seller".to_string(),
   1229             None,
   1230             None,
   1231         )
   1232         .unwrap_err();
   1233         assert_eq!(parse_error_tag(err), TAG_LOCATION.to_string());
   1234     }
   1235 
   1236     #[test]
   1237     fn listing_from_tags_handles_short_location_tags_when_structured_present() {
   1238         let mut tags = base_trade_tags();
   1239         tags.push(vec![
   1240             TAG_LOCATION.into(),
   1241             "Farm".into(),
   1242             "Town".into(),
   1243             "Region".into(),
   1244         ]);
   1245         tags.push(vec![TAG_LOCATION.into()]);
   1246         tags.push(vec![TAG_LOCATION.into(), "fallback".into()]);
   1247         let listing = listing_from_tags(
   1248             &tags,
   1249             listing_d_tag(),
   1250             farm_ref(),
   1251             "seller".to_string(),
   1252             None,
   1253             None,
   1254         )
   1255         .expect("listing");
   1256         assert_eq!(listing.location.unwrap().primary, "Farm".to_string());
   1257     }
   1258 
   1259     #[test]
   1260     fn listing_from_tags_rejects_invalid_tag_forms() {
   1261         let mut tags = base_trade_tags();
   1262         tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "other".into()]);
   1263         let err = listing_from_tags(
   1264             &tags,
   1265             listing_d_tag(),
   1266             farm_ref(),
   1267             "seller".to_string(),
   1268             None,
   1269             None,
   1270         )
   1271         .unwrap_err();
   1272         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
   1273 
   1274         let mut tags = base_trade_tags();
   1275         tags.push(vec![TAG_RADROOTS_BIN.into(), "bin-1".into(), "1000".into()]);
   1276         let err = listing_from_tags(
   1277             &tags,
   1278             listing_d_tag(),
   1279             farm_ref(),
   1280             "seller".to_string(),
   1281             None,
   1282             None,
   1283         )
   1284         .unwrap_err();
   1285         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1286 
   1287         let mut tags = base_trade_tags();
   1288         tags.push(vec![
   1289             TAG_RADROOTS_PRICE.into(),
   1290             "bin-1".into(),
   1291             "1".into(),
   1292             "USD".into(),
   1293             "1".into(),
   1294         ]);
   1295         let err = listing_from_tags(
   1296             &tags,
   1297             listing_d_tag(),
   1298             farm_ref(),
   1299             "seller".to_string(),
   1300             None,
   1301             None,
   1302         )
   1303         .unwrap_err();
   1304         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1305 
   1306         let mut tags = base_trade_tags();
   1307         tags.push(vec![
   1308             TAG_RADROOTS_BIN.into(),
   1309             "bin-1".into(),
   1310             "1000".into(),
   1311             "g".into(),
   1312             "1".into(),
   1313             "kg".into(),
   1314             "label".into(),
   1315             "extra".into(),
   1316         ]);
   1317         let err = listing_from_tags(
   1318             &tags,
   1319             listing_d_tag(),
   1320             farm_ref(),
   1321             "seller".to_string(),
   1322             None,
   1323             None,
   1324         )
   1325         .unwrap_err();
   1326         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1327 
   1328         let mut tags = base_trade_tags();
   1329         tags.push(vec![
   1330             TAG_RADROOTS_BIN.into(),
   1331             " ".into(),
   1332             "1000".into(),
   1333             "g".into(),
   1334         ]);
   1335         let err = listing_from_tags(
   1336             &tags,
   1337             listing_d_tag(),
   1338             farm_ref(),
   1339             "seller".to_string(),
   1340             None,
   1341             None,
   1342         )
   1343         .unwrap_err();
   1344         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1345 
   1346         let mut tags = base_trade_tags();
   1347         tags.push(vec![
   1348             TAG_RADROOTS_BIN.into(),
   1349             "bin-1".into(),
   1350             "1000".into(),
   1351             "kg".into(),
   1352         ]);
   1353         let err = listing_from_tags(
   1354             &tags,
   1355             listing_d_tag(),
   1356             farm_ref(),
   1357             "seller".to_string(),
   1358             None,
   1359             None,
   1360         )
   1361         .unwrap_err();
   1362         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1363 
   1364         let mut tags = base_trade_tags();
   1365         tags.push(vec![
   1366             TAG_RADROOTS_BIN.into(),
   1367             "bin-1".into(),
   1368             "1000".into(),
   1369             "g".into(),
   1370             "1".into(),
   1371         ]);
   1372         let err = listing_from_tags(
   1373             &tags,
   1374             listing_d_tag(),
   1375             farm_ref(),
   1376             "seller".to_string(),
   1377             None,
   1378             None,
   1379         )
   1380         .unwrap_err();
   1381         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1382 
   1383         let mut tags = base_trade_tags();
   1384         tags.push(vec![
   1385             TAG_RADROOTS_PRICE.into(),
   1386             " ".into(),
   1387             "1".into(),
   1388             "USD".into(),
   1389             "1".into(),
   1390             "g".into(),
   1391         ]);
   1392         let err = listing_from_tags(
   1393             &tags,
   1394             listing_d_tag(),
   1395             farm_ref(),
   1396             "seller".to_string(),
   1397             None,
   1398             None,
   1399         )
   1400         .unwrap_err();
   1401         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1402 
   1403         let mut tags = base_trade_tags();
   1404         tags.push(vec![
   1405             TAG_RADROOTS_PRICE.into(),
   1406             "bin-1".into(),
   1407             "1".into(),
   1408             "USD".into(),
   1409             "1".into(),
   1410             "kg".into(),
   1411         ]);
   1412         let err = listing_from_tags(
   1413             &tags,
   1414             listing_d_tag(),
   1415             farm_ref(),
   1416             "seller".to_string(),
   1417             None,
   1418             None,
   1419         )
   1420         .unwrap_err();
   1421         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1422 
   1423         let mut tags = base_trade_tags();
   1424         tags.push(vec![
   1425             TAG_RADROOTS_PRICE.into(),
   1426             "bin-1".into(),
   1427             "1".into(),
   1428             "USD".into(),
   1429             "1".into(),
   1430             "g".into(),
   1431             "9".into(),
   1432         ]);
   1433         let err = listing_from_tags(
   1434             &tags,
   1435             listing_d_tag(),
   1436             farm_ref(),
   1437             "seller".to_string(),
   1438             None,
   1439             None,
   1440         )
   1441         .unwrap_err();
   1442         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1443 
   1444         let mut tags = base_trade_tags();
   1445         tags.push(vec![
   1446             TAG_RADROOTS_PRICE.into(),
   1447             "bin-1".into(),
   1448             "1".into(),
   1449             "USD".into(),
   1450             "1".into(),
   1451             "g".into(),
   1452             "9".into(),
   1453             "bad".into(),
   1454         ]);
   1455         let err = listing_from_tags(
   1456             &tags,
   1457             listing_d_tag(),
   1458             farm_ref(),
   1459             "seller".to_string(),
   1460             None,
   1461             None,
   1462         )
   1463         .unwrap_err();
   1464         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1465 
   1466         let mut tags = base_trade_tags();
   1467         tags.push(vec![
   1468             TAG_RADROOTS_PRICE.into(),
   1469             "bin-1".into(),
   1470             "1".into(),
   1471             "USD".into(),
   1472             "1".into(),
   1473             "g".into(),
   1474             "9".into(),
   1475             "kg".into(),
   1476             "x".into(),
   1477         ]);
   1478         let err = listing_from_tags(
   1479             &tags,
   1480             listing_d_tag(),
   1481             farm_ref(),
   1482             "seller".to_string(),
   1483             None,
   1484             None,
   1485         )
   1486         .unwrap_err();
   1487         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1488 
   1489         let mut tags = base_trade_tags();
   1490         tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into()]);
   1491         let err = listing_from_tags(
   1492             &tags,
   1493             listing_d_tag(),
   1494             farm_ref(),
   1495             "seller".to_string(),
   1496             None,
   1497             None,
   1498         )
   1499         .unwrap_err();
   1500         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
   1501     }
   1502 
   1503     #[test]
   1504     fn listing_from_tags_rejects_trade_field_parse_failures() {
   1505         let mut tags = base_trade_tags();
   1506         tags.push(vec![TAG_RADROOTS_DISCOUNT.into(), "{".into()]);
   1507         let err = listing_from_tags(
   1508             &tags,
   1509             listing_d_tag(),
   1510             farm_ref(),
   1511             "seller".to_string(),
   1512             None,
   1513             None,
   1514         )
   1515         .unwrap_err();
   1516         assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string());
   1517 
   1518         let mut tags = base_trade_tags();
   1519         tags.push(vec![TAG_INVENTORY.into()]);
   1520         let err = listing_from_tags(
   1521             &tags,
   1522             listing_d_tag(),
   1523             farm_ref(),
   1524             "seller".to_string(),
   1525             None,
   1526             None,
   1527         )
   1528         .unwrap_err();
   1529         assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string());
   1530 
   1531         let mut tags = base_trade_tags();
   1532         tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "bad".into()]);
   1533         let err = listing_from_tags(
   1534             &tags,
   1535             listing_d_tag(),
   1536             farm_ref(),
   1537             "seller".to_string(),
   1538             None,
   1539             None,
   1540         )
   1541         .unwrap_err();
   1542         assert_eq!(
   1543             parse_error_tag(err),
   1544             TAG_RADROOTS_AVAILABILITY_START.to_string()
   1545         );
   1546 
   1547         let mut tags = base_trade_tags();
   1548         tags.push(vec![TAG_EXPIRES_AT.into(), "bad".into()]);
   1549         let err = listing_from_tags(
   1550             &tags,
   1551             listing_d_tag(),
   1552             farm_ref(),
   1553             "seller".to_string(),
   1554             None,
   1555             None,
   1556         )
   1557         .unwrap_err();
   1558         assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string());
   1559 
   1560         let mut tags = base_trade_tags();
   1561         tags.push(vec![TAG_RADROOTS_DISCOUNT.into()]);
   1562         let err = listing_from_tags(
   1563             &tags,
   1564             listing_d_tag(),
   1565             farm_ref(),
   1566             "seller".to_string(),
   1567             None,
   1568             None,
   1569         )
   1570         .unwrap_err();
   1571         assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string());
   1572 
   1573         let mut tags = base_trade_tags();
   1574         tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into()]);
   1575         let err = listing_from_tags(
   1576             &tags,
   1577             listing_d_tag(),
   1578             farm_ref(),
   1579             "seller".to_string(),
   1580             None,
   1581             None,
   1582         )
   1583         .unwrap_err();
   1584         assert_eq!(
   1585             parse_error_tag(err),
   1586             TAG_RADROOTS_AVAILABILITY_START.to_string()
   1587         );
   1588 
   1589         let mut tags = base_trade_tags();
   1590         tags.push(vec![TAG_EXPIRES_AT.into()]);
   1591         let err = listing_from_tags(
   1592             &tags,
   1593             listing_d_tag(),
   1594             farm_ref(),
   1595             "seller".to_string(),
   1596             None,
   1597             None,
   1598         )
   1599         .unwrap_err();
   1600         assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string());
   1601 
   1602         let mut tags = base_trade_tags();
   1603         tags.push(vec![TAG_IMAGE.into()]);
   1604         let err = listing_from_tags(
   1605             &tags,
   1606             listing_d_tag(),
   1607             farm_ref(),
   1608             "seller".to_string(),
   1609             None,
   1610             None,
   1611         )
   1612         .unwrap_err();
   1613         assert_eq!(parse_error_tag(err), TAG_IMAGE.to_string());
   1614 
   1615         let mut tags = base_trade_tags();
   1616         tags.push(vec![TAG_INVENTORY.into(), "bad".into()]);
   1617         let err = listing_from_tags(
   1618             &tags,
   1619             listing_d_tag(),
   1620             farm_ref(),
   1621             "seller".to_string(),
   1622             None,
   1623             None,
   1624         )
   1625         .unwrap_err();
   1626         assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string());
   1627 
   1628         let mut tags = base_trade_tags();
   1629         tags.push(vec![
   1630             TAG_RADROOTS_BIN.into(),
   1631             "bin-1".into(),
   1632             "bad".into(),
   1633             "g".into(),
   1634         ]);
   1635         let err = listing_from_tags(
   1636             &tags,
   1637             listing_d_tag(),
   1638             farm_ref(),
   1639             "seller".to_string(),
   1640             None,
   1641             None,
   1642         )
   1643         .unwrap_err();
   1644         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1645 
   1646         let mut tags = base_trade_tags();
   1647         tags.push(vec![
   1648             TAG_RADROOTS_BIN.into(),
   1649             "bin-1".into(),
   1650             "500".into(),
   1651             "bad".into(),
   1652         ]);
   1653         let err = listing_from_tags(
   1654             &tags,
   1655             listing_d_tag(),
   1656             farm_ref(),
   1657             "seller".to_string(),
   1658             None,
   1659             None,
   1660         )
   1661         .unwrap_err();
   1662         assert_eq!(parse_error_tag(err), "unit".to_string());
   1663 
   1664         let mut tags = base_trade_tags();
   1665         tags.push(vec![
   1666             TAG_RADROOTS_BIN.into(),
   1667             "bin-2".into(),
   1668             "500".into(),
   1669             "g".into(),
   1670             "bad".into(),
   1671             "g".into(),
   1672         ]);
   1673         let err = listing_from_tags(
   1674             &tags,
   1675             listing_d_tag(),
   1676             farm_ref(),
   1677             "seller".to_string(),
   1678             None,
   1679             None,
   1680         )
   1681         .unwrap_err();
   1682         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1683 
   1684         let mut tags = base_trade_tags();
   1685         tags.push(vec![
   1686             TAG_RADROOTS_BIN.into(),
   1687             "bin-2".into(),
   1688             "500".into(),
   1689             "g".into(),
   1690             "1".into(),
   1691             "bad".into(),
   1692         ]);
   1693         let err = listing_from_tags(
   1694             &tags,
   1695             listing_d_tag(),
   1696             farm_ref(),
   1697             "seller".to_string(),
   1698             None,
   1699             None,
   1700         )
   1701         .unwrap_err();
   1702         assert_eq!(parse_error_tag(err), "unit".to_string());
   1703 
   1704         let mut tags = base_trade_tags();
   1705         tags.push(vec![
   1706             TAG_RADROOTS_PRICE.into(),
   1707             "bin-1".into(),
   1708             "bad".into(),
   1709             "USD".into(),
   1710             "1".into(),
   1711             "g".into(),
   1712         ]);
   1713         let err = listing_from_tags(
   1714             &tags,
   1715             listing_d_tag(),
   1716             farm_ref(),
   1717             "seller".to_string(),
   1718             None,
   1719             None,
   1720         )
   1721         .unwrap_err();
   1722         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1723 
   1724         let mut tags = base_trade_tags();
   1725         tags.push(vec![
   1726             TAG_RADROOTS_PRICE.into(),
   1727             "bin-1".into(),
   1728             "10".into(),
   1729             "US".into(),
   1730             "1".into(),
   1731             "g".into(),
   1732         ]);
   1733         let err = listing_from_tags(
   1734             &tags,
   1735             listing_d_tag(),
   1736             farm_ref(),
   1737             "seller".to_string(),
   1738             None,
   1739             None,
   1740         )
   1741         .unwrap_err();
   1742         assert_eq!(parse_error_tag(err), "currency".to_string());
   1743 
   1744         let mut tags = base_trade_tags();
   1745         tags.push(vec![
   1746             TAG_RADROOTS_PRICE.into(),
   1747             "bin-1".into(),
   1748             "10".into(),
   1749             "USD".into(),
   1750             "bad".into(),
   1751             "g".into(),
   1752         ]);
   1753         let err = listing_from_tags(
   1754             &tags,
   1755             listing_d_tag(),
   1756             farm_ref(),
   1757             "seller".to_string(),
   1758             None,
   1759             None,
   1760         )
   1761         .unwrap_err();
   1762         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1763 
   1764         let mut tags = base_trade_tags();
   1765         tags.push(vec![
   1766             TAG_RADROOTS_PRICE.into(),
   1767             "bin-1".into(),
   1768             "10".into(),
   1769             "USD".into(),
   1770             "1".into(),
   1771             "bad".into(),
   1772         ]);
   1773         let err = listing_from_tags(
   1774             &tags,
   1775             listing_d_tag(),
   1776             farm_ref(),
   1777             "seller".to_string(),
   1778             None,
   1779             None,
   1780         )
   1781         .unwrap_err();
   1782         assert_eq!(parse_error_tag(err), "unit".to_string());
   1783 
   1784         let mut tags = base_trade_tags();
   1785         tags.push(vec![
   1786             TAG_RADROOTS_PRICE.into(),
   1787             "bin-2".into(),
   1788             "10".into(),
   1789             "USD".into(),
   1790             "1".into(),
   1791             "g".into(),
   1792             "bad".into(),
   1793             "g".into(),
   1794         ]);
   1795         let err = listing_from_tags(
   1796             &tags,
   1797             listing_d_tag(),
   1798             farm_ref(),
   1799             "seller".to_string(),
   1800             None,
   1801             None,
   1802         )
   1803         .unwrap_err();
   1804         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1805 
   1806         let mut tags = base_trade_tags();
   1807         tags.push(vec![
   1808             TAG_RADROOTS_PRICE.into(),
   1809             "bin-2".into(),
   1810             "10".into(),
   1811             "USD".into(),
   1812             "1".into(),
   1813             "g".into(),
   1814             "12".into(),
   1815             "bad".into(),
   1816         ]);
   1817         let err = listing_from_tags(
   1818             &tags,
   1819             listing_d_tag(),
   1820             farm_ref(),
   1821             "seller".to_string(),
   1822             None,
   1823             None,
   1824         )
   1825         .unwrap_err();
   1826         assert_eq!(parse_error_tag(err), "unit".to_string());
   1827     }
   1828 
   1829     #[test]
   1830     fn listing_from_tags_covers_bin_display_and_price_shape_edges() {
   1831         let tags = vec![
   1832             vec!["key".into(), "coffee".into()],
   1833             vec!["title".into(), "Coffee".into()],
   1834             vec!["category".into(), "coffee".into()],
   1835             vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
   1836             vec![
   1837                 TAG_RADROOTS_BIN.into(),
   1838                 "bin-2".into(),
   1839                 "500".into(),
   1840                 "g".into(),
   1841                 "1".into(),
   1842             ],
   1843             vec![
   1844                 TAG_RADROOTS_PRICE.into(),
   1845                 "bin-2".into(),
   1846                 "0.02".into(),
   1847                 "USD".into(),
   1848                 "1".into(),
   1849                 "g".into(),
   1850                 "10".into(),
   1851             ],
   1852         ];
   1853         let err = listing_from_tags(
   1854             &tags,
   1855             listing_d_tag(),
   1856             farm_ref(),
   1857             "seller".to_string(),
   1858             None,
   1859             None,
   1860         )
   1861         .unwrap_err();
   1862         assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string());
   1863 
   1864         let tags = vec![
   1865             vec!["key".into(), "coffee".into()],
   1866             vec!["title".into(), "Coffee".into()],
   1867             vec!["category".into(), "coffee".into()],
   1868             vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
   1869             vec![
   1870                 TAG_RADROOTS_BIN.into(),
   1871                 "bin-2".into(),
   1872                 "500".into(),
   1873                 "g".into(),
   1874                 "1".into(),
   1875                 "kg".into(),
   1876             ],
   1877             vec![
   1878                 TAG_RADROOTS_PRICE.into(),
   1879                 "bin-2".into(),
   1880                 "0.02".into(),
   1881                 "USD".into(),
   1882                 "1".into(),
   1883                 "g".into(),
   1884                 "10".into(),
   1885             ],
   1886         ];
   1887         let err = listing_from_tags(
   1888             &tags,
   1889             listing_d_tag(),
   1890             farm_ref(),
   1891             "seller".to_string(),
   1892             None,
   1893             None,
   1894         )
   1895         .unwrap_err();
   1896         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1897     }
   1898 
   1899     #[test]
   1900     fn listing_from_tags_rejects_missing_primary_bin_and_invalid_seller() {
   1901         let mut tags = base_trade_tags();
   1902         tags.retain(|tag| tag[0] != TAG_RADROOTS_PRIMARY_BIN);
   1903         let err = listing_from_tags(
   1904             &tags,
   1905             listing_d_tag(),
   1906             farm_ref(),
   1907             "seller".to_string(),
   1908             None,
   1909             None,
   1910         )
   1911         .unwrap_err();
   1912         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
   1913 
   1914         let err = listing_from_tags(
   1915             &base_trade_tags(),
   1916             listing_d_tag(),
   1917             farm_ref(),
   1918             "other".to_string(),
   1919             None,
   1920             None,
   1921         )
   1922         .unwrap_err();
   1923         assert_eq!(parse_error_tag(err), TAG_P.to_string());
   1924 
   1925         let mut tags = base_trade_tags();
   1926         tags[4][1] = "missing".into();
   1927         let err = listing_from_tags(
   1928             &tags,
   1929             listing_d_tag(),
   1930             farm_ref(),
   1931             "seller".to_string(),
   1932             None,
   1933             None,
   1934         )
   1935         .unwrap_err();
   1936         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
   1937 
   1938         let mut tags = base_trade_tags();
   1939         tags[4][1] = "bad id".into();
   1940         let err = listing_from_tags(
   1941             &tags,
   1942             listing_d_tag(),
   1943             farm_ref(),
   1944             "seller".to_string(),
   1945             None,
   1946             None,
   1947         )
   1948         .unwrap_err();
   1949         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string());
   1950     }
   1951 
   1952     #[test]
   1953     fn listing_from_tags_rejects_incomplete_bin_draft() {
   1954         let tags = vec![
   1955             vec!["key".into(), "coffee".into()],
   1956             vec!["title".into(), "Coffee".into()],
   1957             vec!["category".into(), "coffee".into()],
   1958             vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()],
   1959             vec![
   1960                 TAG_RADROOTS_BIN.into(),
   1961                 "bin-1".into(),
   1962                 "500".into(),
   1963                 "g".into(),
   1964             ],
   1965         ];
   1966         let err = listing_from_tags(
   1967             &tags,
   1968             listing_d_tag(),
   1969             farm_ref(),
   1970             "seller".to_string(),
   1971             None,
   1972             None,
   1973         )
   1974         .unwrap_err();
   1975         assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string());
   1976     }
   1977 
   1978     #[test]
   1979     fn parse_farm_and_reference_helpers_cover_all_paths() {
   1980         let valid_farm_tags = vec![vec![
   1981             TAG_A.into(),
   1982             format!("{KIND_FARM}:seller:AAAAAAAAAAAAAAAAAAAAAA"),
   1983         ]];
   1984         let farm = parse_farm_ref(&valid_farm_tags).unwrap();
   1985         assert_eq!(farm.pubkey, farm_ref().pubkey);
   1986         assert_eq!(farm.d_tag, farm_ref().d_tag);
   1987         assert_eq!(
   1988             parse_error_tag(parse_farm_ref(&[]).unwrap_err()),
   1989             TAG_A.to_string()
   1990         );
   1991         assert_eq!(
   1992             parse_error_tag(parse_farm_ref(&[vec![TAG_A.into()]]).unwrap_err()),
   1993             TAG_A.to_string()
   1994         );
   1995         assert_eq!(
   1996             parse_error_tag(parse_farm_ref(&[vec![TAG_A.into(), "bad".into()]]).unwrap_err()),
   1997             TAG_A.to_string()
   1998         );
   1999         assert_eq!(
   2000             parse_error_tag(
   2001                 parse_farm_ref(&[vec![TAG_A.into(), format!("1:seller:{}", farm_ref().d_tag)]])
   2002                     .unwrap_err()
   2003             ),
   2004             TAG_A.to_string()
   2005         );
   2006         assert_eq!(
   2007             parse_error_tag(
   2008                 parse_farm_ref(&[vec![
   2009                     TAG_A.into(),
   2010                     format!("{KIND_FARM}: :{}", farm_ref().d_tag)
   2011                 ]])
   2012                 .unwrap_err()
   2013             ),
   2014             TAG_A.to_string()
   2015         );
   2016         assert_eq!(
   2017             parse_error_tag(
   2018                 parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:")]]).unwrap_err()
   2019             ),
   2020             TAG_A.to_string()
   2021         );
   2022         assert_eq!(
   2023             parse_error_tag(
   2024                 parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}")]]).unwrap_err()
   2025             ),
   2026             TAG_A.to_string()
   2027         );
   2028         assert_eq!(
   2029             parse_error_tag(
   2030                 parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller")]]).unwrap_err()
   2031             ),
   2032             TAG_A.to_string()
   2033         );
   2034         assert_eq!(
   2035             parse_error_tag(
   2036                 parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:not-base64")]])
   2037                     .unwrap_err()
   2038             ),
   2039             TAG_A.to_string()
   2040         );
   2041 
   2042         assert_eq!(
   2043             parse_farm_pubkey(&[vec![TAG_P.into(), "seller".into()]]).unwrap(),
   2044             "seller".to_string()
   2045         );
   2046         assert_eq!(
   2047             parse_error_tag(parse_farm_pubkey(&[]).unwrap_err()),
   2048             TAG_P.to_string()
   2049         );
   2050         assert_eq!(
   2051             parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into()]]).unwrap_err()),
   2052             TAG_P.to_string()
   2053         );
   2054         assert_eq!(
   2055             parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into(), " ".into()]]).unwrap_err()),
   2056             TAG_P.to_string()
   2057         );
   2058 
   2059         assert!(parse_resource_area(&[]).unwrap().is_none());
   2060         let area_tag = vec![vec![
   2061             TAG_RADROOTS_RESOURCE_AREA.into(),
   2062             format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
   2063         ]];
   2064         assert!(parse_resource_area(&area_tag).unwrap().is_some());
   2065         let missing_area = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into()]];
   2066         assert_eq!(
   2067             parse_error_tag(parse_resource_area(&missing_area).unwrap_err()),
   2068             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2069         );
   2070         let invalid_area_kind = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]];
   2071         assert_eq!(
   2072             parse_error_tag(parse_resource_area(&invalid_area_kind).unwrap_err()),
   2073             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2074         );
   2075         let missing_area_pubkey = vec![vec![
   2076             TAG_RADROOTS_RESOURCE_AREA.into(),
   2077             format!("{KIND_RESOURCE_AREA}"),
   2078         ]];
   2079         assert_eq!(
   2080             parse_error_tag(parse_resource_area(&missing_area_pubkey).unwrap_err()),
   2081             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2082         );
   2083         let missing_area_d = vec![vec![
   2084             TAG_RADROOTS_RESOURCE_AREA.into(),
   2085             format!("{KIND_RESOURCE_AREA}:seller"),
   2086         ]];
   2087         assert_eq!(
   2088             parse_error_tag(parse_resource_area(&missing_area_d).unwrap_err()),
   2089             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2090         );
   2091         let bad_area = vec![vec![
   2092             TAG_RADROOTS_RESOURCE_AREA.into(),
   2093             "1:seller:bad".into(),
   2094         ]];
   2095         assert_eq!(
   2096             parse_error_tag(parse_resource_area(&bad_area).unwrap_err()),
   2097             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2098         );
   2099         let empty_area = vec![vec![
   2100             TAG_RADROOTS_RESOURCE_AREA.into(),
   2101             format!("{KIND_RESOURCE_AREA}: :{}", listing_d_tag()),
   2102         ]];
   2103         assert_eq!(
   2104             parse_error_tag(parse_resource_area(&empty_area).unwrap_err()),
   2105             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2106         );
   2107         let empty_area_d = vec![vec![
   2108             TAG_RADROOTS_RESOURCE_AREA.into(),
   2109             format!("{KIND_RESOURCE_AREA}:seller:"),
   2110         ]];
   2111         assert_eq!(
   2112             parse_error_tag(parse_resource_area(&empty_area_d).unwrap_err()),
   2113             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2114         );
   2115         let invalid_area_d = vec![vec![
   2116             TAG_RADROOTS_RESOURCE_AREA.into(),
   2117             format!("{KIND_RESOURCE_AREA}:seller:not-base64"),
   2118         ]];
   2119         assert_eq!(
   2120             parse_error_tag(parse_resource_area(&invalid_area_d).unwrap_err()),
   2121             TAG_RADROOTS_RESOURCE_AREA.to_string()
   2122         );
   2123 
   2124         assert!(parse_plot_ref(&[]).unwrap().is_none());
   2125         let plot_tag = vec![vec![
   2126             TAG_RADROOTS_PLOT.into(),
   2127             format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAQ"),
   2128         ]];
   2129         assert!(parse_plot_ref(&plot_tag).unwrap().is_some());
   2130         let missing_plot = vec![vec![TAG_RADROOTS_PLOT.into()]];
   2131         assert_eq!(
   2132             parse_error_tag(parse_plot_ref(&missing_plot).unwrap_err()),
   2133             TAG_RADROOTS_PLOT.to_string()
   2134         );
   2135         let missing_plot_pubkey = vec![vec![TAG_RADROOTS_PLOT.into(), format!("{KIND_PLOT}")]];
   2136         assert_eq!(
   2137             parse_error_tag(parse_plot_ref(&missing_plot_pubkey).unwrap_err()),
   2138             TAG_RADROOTS_PLOT.to_string()
   2139         );
   2140         let missing_plot_d = vec![vec![
   2141             TAG_RADROOTS_PLOT.into(),
   2142             format!("{KIND_PLOT}:seller"),
   2143         ]];
   2144         assert_eq!(
   2145             parse_error_tag(parse_plot_ref(&missing_plot_d).unwrap_err()),
   2146             TAG_RADROOTS_PLOT.to_string()
   2147         );
   2148         let bad_plot = vec![vec![TAG_RADROOTS_PLOT.into(), "1:seller:bad".into()]];
   2149         assert_eq!(
   2150             parse_error_tag(parse_plot_ref(&bad_plot).unwrap_err()),
   2151             TAG_RADROOTS_PLOT.to_string()
   2152         );
   2153         let empty_plot = vec![vec![
   2154             TAG_RADROOTS_PLOT.into(),
   2155             format!("{KIND_PLOT}: :{}", listing_d_tag()),
   2156         ]];
   2157         assert_eq!(
   2158             parse_error_tag(parse_plot_ref(&empty_plot).unwrap_err()),
   2159             TAG_RADROOTS_PLOT.to_string()
   2160         );
   2161         let empty_plot_d = vec![vec![
   2162             TAG_RADROOTS_PLOT.into(),
   2163             format!("{KIND_PLOT}:seller:"),
   2164         ]];
   2165         assert_eq!(
   2166             parse_error_tag(parse_plot_ref(&empty_plot_d).unwrap_err()),
   2167             TAG_RADROOTS_PLOT.to_string()
   2168         );
   2169         let invalid_plot_d = vec![vec![
   2170             TAG_RADROOTS_PLOT.into(),
   2171             format!("{KIND_PLOT}:seller:not-base64"),
   2172         ]];
   2173         assert_eq!(
   2174             parse_error_tag(parse_plot_ref(&invalid_plot_d).unwrap_err()),
   2175             TAG_RADROOTS_PLOT.to_string()
   2176         );
   2177     }
   2178 
   2179     #[test]
   2180     fn helper_functions_cover_assigners_and_classifiers() {
   2181         assert_eq!(clean_value(" value "), Some("value".into()));
   2182         assert_eq!(clean_value(" "), None);
   2183         assert_eq!(clean_value("null"), None);
   2184 
   2185         let mut s = String::new();
   2186         let val = "one".to_string();
   2187         set_if_empty(&mut s, Some(&val));
   2188         assert_eq!(s, "one");
   2189         let next = "two".to_string();
   2190         set_if_empty(&mut s, Some(&next));
   2191         assert_eq!(s, "one");
   2192         let mut empty = String::new();
   2193         let nullish = "null".to_string();
   2194         set_if_empty(&mut empty, Some(&nullish));
   2195         assert_eq!(empty, "");
   2196 
   2197         let mut opt = None;
   2198         let v = "set".to_string();
   2199         set_optional(&mut opt, Some(&v));
   2200         assert_eq!(opt.as_deref(), Some("set"));
   2201         let w = "skip".to_string();
   2202         set_optional(&mut opt, Some(&w));
   2203         assert_eq!(opt.as_deref(), Some("set"));
   2204         let mut opt_none = None;
   2205         let blank = " ".to_string();
   2206         set_optional(&mut opt_none, Some(&blank));
   2207         assert_eq!(opt_none, None);
   2208 
   2209         assert_eq!(format!("{:?}", parse_status("ACTIVE")), "Active");
   2210         assert_eq!(format!("{:?}", parse_status("sold")), "Sold");
   2211         assert_eq!(
   2212             format!("{:?}", parse_status("queued")),
   2213             "Other { value: \"queued\" }"
   2214         );
   2215 
   2216         assert_eq!(parse_image_size("100x200").unwrap().w, 100);
   2217         assert!(parse_image_size("100").is_none());
   2218         assert!(parse_image_size("invalid").is_none());
   2219         assert!(parse_image_size("badx100").is_none());
   2220         assert!(parse_image_size("100xbad").is_none());
   2221     }
   2222 
   2223     #[test]
   2224     fn parse_discount_and_bin_helpers_cover_error_paths() {
   2225         let discount = RadrootsCoreDiscount {
   2226             scope: RadrootsCoreDiscountScope::OrderTotal,
   2227             threshold: RadrootsCoreDiscountThreshold::OrderQuantity {
   2228                 min: RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
   2229             },
   2230             value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
   2231                 "1".parse().unwrap(),
   2232                 RadrootsCoreCurrency::USD,
   2233             )),
   2234         };
   2235         let payload = serde_json::to_string(&discount).unwrap();
   2236         assert!(parse_discount(&payload).is_ok());
   2237         assert_eq!(
   2238             parse_error_tag(parse_discount("{").unwrap_err()),
   2239             TAG_RADROOTS_DISCOUNT.to_string()
   2240         );
   2241         assert_eq!(
   2242             parse_error_tag(ListingParseError::InvalidJson("x".into())),
   2243             "x".to_string()
   2244         );
   2245 
   2246         let mut drafts = Vec::new();
   2247         let mut order_index = 0usize;
   2248         let first = upsert_bin(&mut drafts, "a", &mut order_index);
   2249         first.quantity = Some(RadrootsCoreQuantity::new(
   2250             "1".parse().unwrap(),
   2251             RadrootsCoreUnit::MassG,
   2252         ));
   2253         first.price_per_canonical_unit = Some(RadrootsCoreQuantityPrice::new(
   2254             RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
   2255             RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
   2256         ));
   2257 
   2258         let second = upsert_bin(&mut drafts, "a", &mut order_index);
   2259         assert_eq!(second.order_index, 0);
   2260         assert_eq!(order_index, 1);
   2261         assert!(build_bins(drafts).is_ok());
   2262 
   2263         let draft_missing_qty = BinDraft {
   2264             bin_id: "b".into(),
   2265             order_index: 0,
   2266             quantity: None,
   2267             display_amount: None,
   2268             display_unit: None,
   2269             display_label: None,
   2270             price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
   2271                 RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
   2272                 RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
   2273             )),
   2274             display_price: None,
   2275             display_price_unit: None,
   2276         };
   2277         assert_eq!(
   2278             parse_error_tag(build_bins(vec![draft_missing_qty]).unwrap_err()),
   2279             TAG_RADROOTS_BIN.to_string()
   2280         );
   2281 
   2282         let draft_missing_price = BinDraft {
   2283             bin_id: "b".into(),
   2284             order_index: 0,
   2285             quantity: Some(RadrootsCoreQuantity::new(
   2286                 "1".parse().unwrap(),
   2287                 RadrootsCoreUnit::MassG,
   2288             )),
   2289             display_amount: None,
   2290             display_unit: None,
   2291             display_label: None,
   2292             price_per_canonical_unit: None,
   2293             display_price: None,
   2294             display_price_unit: None,
   2295         };
   2296         assert_eq!(
   2297             parse_error_tag(build_bins(vec![draft_missing_price]).unwrap_err()),
   2298             TAG_RADROOTS_PRICE.to_string()
   2299         );
   2300 
   2301         let draft_mismatch = BinDraft {
   2302             bin_id: "b".into(),
   2303             order_index: 0,
   2304             quantity: Some(RadrootsCoreQuantity::new(
   2305                 "1".parse().unwrap(),
   2306                 RadrootsCoreUnit::MassG,
   2307             )),
   2308             display_amount: None,
   2309             display_unit: None,
   2310             display_label: None,
   2311             price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
   2312                 RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
   2313                 RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::Each),
   2314             )),
   2315             display_price: None,
   2316             display_price_unit: None,
   2317         };
   2318         assert_eq!(
   2319             parse_error_tag(build_bins(vec![draft_mismatch]).unwrap_err()),
   2320             TAG_RADROOTS_PRICE.to_string()
   2321         );
   2322 
   2323         let draft_invalid_bin = BinDraft {
   2324             bin_id: "bad id".into(),
   2325             order_index: 0,
   2326             quantity: Some(RadrootsCoreQuantity::new(
   2327                 "1".parse().unwrap(),
   2328                 RadrootsCoreUnit::MassG,
   2329             )),
   2330             display_amount: None,
   2331             display_unit: None,
   2332             display_label: None,
   2333             price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new(
   2334                 RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD),
   2335                 RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG),
   2336             )),
   2337             display_price: None,
   2338             display_price_unit: None,
   2339         };
   2340         assert_eq!(
   2341             parse_error_tag(build_bins(vec![draft_invalid_bin]).unwrap_err()),
   2342             TAG_RADROOTS_BIN.to_string()
   2343         );
   2344 
   2345         let tags = vec![
   2346             vec!["key".into(), "coffee".into()],
   2347             vec!["title".into(), "Coffee".into()],
   2348             vec!["category".into(), "coffee".into()],
   2349             vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()],
   2350             vec![
   2351                 TAG_RADROOTS_BIN.into(),
   2352                 "bin-2".into(),
   2353                 "500".into(),
   2354                 "g".into(),
   2355             ],
   2356             vec![
   2357                 TAG_RADROOTS_PRICE.into(),
   2358                 "bin-2".into(),
   2359                 "0.02".into(),
   2360                 "USD".into(),
   2361                 "1".into(),
   2362                 "g".into(),
   2363             ],
   2364             vec![TAG_GEOHASH.into()],
   2365         ];
   2366         let listing = listing_from_tags(
   2367             &tags,
   2368             listing_d_tag(),
   2369             farm_ref(),
   2370             "seller".to_string(),
   2371             None,
   2372             None,
   2373         )
   2374         .expect("compact listing");
   2375         assert_eq!(listing.primary_bin_id, "bin-2");
   2376     }
   2377 }
   2378 
   2379 fn clean_value(value: &str) -> Option<String> {
   2380     let trimmed = value.trim();
   2381     if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
   2382         None
   2383     } else {
   2384         Some(trimmed.to_string())
   2385     }
   2386 }
   2387 
   2388 fn set_if_empty(target: &mut String, value: Option<&String>) {
   2389     if target.trim().is_empty()
   2390         && let Some(v) = value.and_then(|v| clean_value(v))
   2391     {
   2392         *target = v;
   2393     }
   2394 }
   2395 
   2396 fn set_optional(target: &mut Option<String>, value: Option<&String>) {
   2397     if target.is_none()
   2398         && let Some(v) = value.and_then(|v| clean_value(v))
   2399     {
   2400         *target = Some(v);
   2401     }
   2402 }
   2403 
   2404 fn parse_status(value: &str) -> RadrootsListingStatus {
   2405     match value.trim().to_ascii_lowercase().as_str() {
   2406         "active" => RadrootsListingStatus::Active,
   2407         "sold" => RadrootsListingStatus::Sold,
   2408         other => RadrootsListingStatus::Other {
   2409             value: other.to_string(),
   2410         },
   2411     }
   2412 }
   2413 
   2414 fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> {
   2415     let (w_raw, h_raw) = value.split_once('x')?;
   2416     let w = w_raw.parse::<u32>().ok()?;
   2417     let h = h_raw.parse::<u32>().ok()?;
   2418     Some(RadrootsListingImageSize { w, h })
   2419 }
   2420 
   2421 fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingParseError> {
   2422     #[cfg(feature = "serde_json")]
   2423     {
   2424         serde_json::from_str(payload)
   2425             .map_err(|_| ListingParseError::InvalidDiscount(TAG_RADROOTS_DISCOUNT.to_string()))
   2426     }
   2427     #[cfg(not(feature = "serde_json"))]
   2428     {
   2429         let _ = payload;
   2430         Err(ListingParseError::InvalidJson("discount".to_string()))
   2431     }
   2432 }
   2433 
   2434 #[derive(Clone, Debug)]
   2435 struct BinDraft {
   2436     bin_id: String,
   2437     order_index: usize,
   2438     quantity: Option<RadrootsCoreQuantity>,
   2439     display_amount: Option<RadrootsCoreDecimal>,
   2440     display_unit: Option<RadrootsCoreUnit>,
   2441     display_label: Option<String>,
   2442     price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>,
   2443     display_price: Option<RadrootsCoreMoney>,
   2444     display_price_unit: Option<RadrootsCoreUnit>,
   2445 }
   2446 
   2447 fn upsert_bin<'a>(
   2448     bins: &'a mut Vec<BinDraft>,
   2449     bin_id: &str,
   2450     order_index: &mut usize,
   2451 ) -> &'a mut BinDraft {
   2452     if let Some(pos) = bins.iter().position(|bin| bin.bin_id == bin_id) {
   2453         return &mut bins[pos];
   2454     }
   2455     let draft = BinDraft {
   2456         bin_id: bin_id.to_string(),
   2457         order_index: *order_index,
   2458         quantity: None,
   2459         display_amount: None,
   2460         display_unit: None,
   2461         display_label: None,
   2462         price_per_canonical_unit: None,
   2463         display_price: None,
   2464         display_price_unit: None,
   2465     };
   2466     bins.push(draft);
   2467     *order_index += 1;
   2468     let idx = bins.len() - 1;
   2469     &mut bins[idx]
   2470 }
   2471 
   2472 fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingParseError> {
   2473     drafts.sort_by_key(|draft| draft.order_index);
   2474     let mut bins = Vec::with_capacity(drafts.len());
   2475     for draft in drafts {
   2476         let quantity = draft
   2477             .quantity
   2478             .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_BIN.to_string()))?;
   2479         let price = draft
   2480             .price_per_canonical_unit
   2481             .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRICE.to_string()))?;
   2482         if quantity.unit != price.quantity.unit {
   2483             return Err(ListingParseError::InvalidTag(
   2484                 TAG_RADROOTS_PRICE.to_string(),
   2485             ));
   2486         }
   2487         let bin = RadrootsListingBin {
   2488             bin_id: RadrootsInventoryBinId::parse(&draft.bin_id)
   2489                 .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?,
   2490             quantity,
   2491             price_per_canonical_unit: price,
   2492             display_amount: draft.display_amount,
   2493             display_unit: draft.display_unit,
   2494             display_label: draft.display_label,
   2495             display_price: draft.display_price,
   2496             display_price_unit: draft.display_price_unit,
   2497         };
   2498         bins.push(bin);
   2499     }
   2500     Ok(bins)
   2501 }