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 }