lib

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

food_availability.rs (45084B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{string::String, vec::Vec};
      3 #[cfg(feature = "std")]
      4 use std::{string::String, vec::Vec};
      5 
      6 use core::{fmt, str::FromStr};
      7 use radroots_blossom::hash::{HashPath, Sha256};
      8 use unicode_general_category::{GeneralCategory, get_general_category};
      9 use url_nostd::{Host, Url};
     10 
     11 use crate::media::AuthoredImage;
     12 
     13 pub const RADROOTS_FOOD_CONTENT_MAX_BYTES: usize = 128 * 1024;
     14 pub const RADROOTS_FOOD_IDENTIFIER_MAX_BYTES: usize = 512;
     15 pub const RADROOTS_FOOD_TEXT_MAX_BYTES: usize = 4 * 1024;
     16 pub const RADROOTS_FOOD_DECIMAL_MAX_DIGITS: usize = 28;
     17 pub const RADROOTS_FOOD_IMAGE_MAX_COUNT: usize = 64;
     18 pub const RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID: &str = "radroots.food.availability.v1";
     19 
     20 /// Errors raised while constructing strict FoodAvailability details.
     21 #[non_exhaustive]
     22 #[derive(Clone, Debug, PartialEq, Eq)]
     23 pub enum FoodAvailabilityError {
     24     ContentMissing,
     25     ContentTooLarge { max: usize, actual: usize },
     26     IdentifierInvalid,
     27     IdentifierTooLarge { max: usize, actual: usize },
     28     TextInvalid,
     29     TextTooLarge { max: usize, actual: usize },
     30     PublishedAtInvalid,
     31     PublishedAtFuture { published_at: u64, created_at: u64 },
     32     PriceInvalid,
     33     PriceCurrencyInvalid,
     34     PriceUnitInvalid,
     35     QuantityInvalid,
     36     QuantityZero,
     37     StatusInvalid,
     38     ImageDimensionsInvalid,
     39     ImageCountExceeded { max: usize, actual: usize },
     40     ImageDuplicateUrl,
     41     ImageDuplicateDigest,
     42 }
     43 
     44 impl FoodAvailabilityError {
     45     pub const fn code(&self) -> &'static str {
     46         match self {
     47             Self::ContentMissing => "food_content_missing",
     48             Self::ContentTooLarge { .. } => "food_content_too_large",
     49             Self::IdentifierInvalid | Self::IdentifierTooLarge { .. } => "food_identifier_invalid",
     50             Self::TextInvalid | Self::TextTooLarge { .. } => "food_text_invalid",
     51             Self::PublishedAtInvalid => "food_published_at_invalid",
     52             Self::PublishedAtFuture { .. } => "food_published_at_future",
     53             Self::PriceInvalid => "price_invalid",
     54             Self::PriceCurrencyInvalid => "price_currency_invalid",
     55             Self::PriceUnitInvalid => "price_unit_invalid",
     56             Self::QuantityInvalid => "quantity_invalid",
     57             Self::QuantityZero => "quantity_zero",
     58             Self::StatusInvalid => "food_status_invalid",
     59             Self::ImageDimensionsInvalid => "food_image_dimensions_invalid",
     60             Self::ImageCountExceeded { .. } => "food_image_count_exceeded",
     61             Self::ImageDuplicateUrl => "food_image_duplicate_url",
     62             Self::ImageDuplicateDigest => "food_image_duplicate_digest",
     63         }
     64     }
     65 }
     66 
     67 impl fmt::Display for FoodAvailabilityError {
     68     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     69         match self {
     70             Self::ContentMissing => {
     71                 formatter.write_str("FoodAvailability content must be non-whitespace")
     72             }
     73             Self::ContentTooLarge { max, actual } => write!(
     74                 formatter,
     75                 "FoodAvailability content is {actual} bytes; max is {max}"
     76             ),
     77             Self::IdentifierInvalid => formatter.write_str(
     78                 "FoodAvailability identifier must be nonempty and contain no whitespace, control, or format characters",
     79             ),
     80             Self::IdentifierTooLarge { max, actual } => write!(
     81                 formatter,
     82                 "FoodAvailability identifier is {actual} bytes; max is {max}"
     83             ),
     84             Self::TextInvalid => formatter.write_str(
     85                 "FoodAvailability text must be trimmed, nonempty, and contain no control or format characters",
     86             ),
     87             Self::TextTooLarge { max, actual } => write!(
     88                 formatter,
     89                 "FoodAvailability text is {actual} bytes; max is {max}"
     90             ),
     91             Self::PublishedAtInvalid => formatter.write_str(
     92                 "FoodAvailability published_at must be a canonical nonzero u64 timestamp",
     93             ),
     94             Self::PublishedAtFuture {
     95                 published_at,
     96                 created_at,
     97             } => write!(
     98                 formatter,
     99                 "FoodAvailability published_at {published_at} exceeds created_at {created_at}"
    100             ),
    101             Self::PriceInvalid => formatter.write_str(
    102                 "FoodAvailability price must be a canonical unsigned decimal with at most 28 digits",
    103             ),
    104             Self::PriceCurrencyInvalid => formatter.write_str(
    105                 "FoodAvailability price currency must be three uppercase ASCII letters",
    106             ),
    107             Self::PriceUnitInvalid => {
    108                 formatter.write_str("FoodAvailability price unit is not governed")
    109             }
    110             Self::QuantityInvalid => formatter.write_str(
    111                 "FoodAvailability quantity must be canonical and use the price unit",
    112             ),
    113             Self::QuantityZero => {
    114                 formatter.write_str("FoodAvailability quantity must be positive")
    115             }
    116             Self::StatusInvalid => {
    117                 formatter.write_str("FoodAvailability status must be active or sold")
    118             }
    119             Self::ImageDimensionsInvalid => formatter.write_str(
    120                 "FoodAvailability image dimensions must be canonical nonzero u32 values",
    121             ),
    122             Self::ImageCountExceeded { max, actual } => write!(
    123                 formatter,
    124                 "FoodAvailability has {actual} images; max is {max}"
    125             ),
    126             Self::ImageDuplicateUrl => {
    127                 formatter.write_str("FoodAvailability image URLs must be unique")
    128             }
    129             Self::ImageDuplicateDigest => {
    130                 formatter.write_str("FoodAvailability image digests must be unique")
    131             }
    132         }
    133     }
    134 }
    135 
    136 #[cfg(feature = "std")]
    137 impl std::error::Error for FoodAvailabilityError {}
    138 
    139 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    140 pub struct FoodContent(String);
    141 
    142 impl FoodContent {
    143     pub fn new(value: impl Into<String>) -> Result<Self, FoodAvailabilityError> {
    144         let value = value.into();
    145         crate::require_invariant(!value.chars().all(is_food_contract_whitespace), &|| {
    146             FoodAvailabilityError::ContentMissing
    147         })?;
    148         crate::require_invariant(value.len() <= RADROOTS_FOOD_CONTENT_MAX_BYTES, &|| {
    149             FoodAvailabilityError::ContentTooLarge {
    150                 max: RADROOTS_FOOD_CONTENT_MAX_BYTES,
    151                 actual: value.len(),
    152             }
    153         })?;
    154         Ok(Self(value))
    155     }
    156 
    157     pub fn as_str(&self) -> &str {
    158         self.0.as_str()
    159     }
    160 
    161     pub fn into_string(self) -> String {
    162         self.0
    163     }
    164 }
    165 
    166 impl AsRef<str> for FoodContent {
    167     fn as_ref(&self) -> &str {
    168         self.as_str()
    169     }
    170 }
    171 
    172 impl fmt::Display for FoodContent {
    173     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    174         formatter.write_str(self.as_str())
    175     }
    176 }
    177 
    178 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    179 pub struct FoodIdentifier(String);
    180 
    181 impl FoodIdentifier {
    182     pub fn parse(value: impl AsRef<str>) -> Result<Self, FoodAvailabilityError> {
    183         let value = value.as_ref();
    184         crate::require_invariant(
    185             [
    186                 !value.is_empty(),
    187                 !value.chars().any(|character| {
    188                     [character.is_whitespace(), is_control_or_format(character)].contains(&true)
    189                 }),
    190             ] == [true; 2],
    191             &|| FoodAvailabilityError::IdentifierInvalid,
    192         )?;
    193         crate::require_invariant(value.len() <= RADROOTS_FOOD_IDENTIFIER_MAX_BYTES, &|| {
    194             FoodAvailabilityError::IdentifierTooLarge {
    195                 max: RADROOTS_FOOD_IDENTIFIER_MAX_BYTES,
    196                 actual: value.len(),
    197             }
    198         })?;
    199         Ok(Self(value.into()))
    200     }
    201 
    202     pub fn as_str(&self) -> &str {
    203         self.0.as_str()
    204     }
    205 
    206     pub fn into_string(self) -> String {
    207         self.0
    208     }
    209 }
    210 
    211 impl AsRef<str> for FoodIdentifier {
    212     fn as_ref(&self) -> &str {
    213         self.as_str()
    214     }
    215 }
    216 
    217 impl fmt::Display for FoodIdentifier {
    218     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    219         formatter.write_str(self.as_str())
    220     }
    221 }
    222 
    223 impl FromStr for FoodIdentifier {
    224     type Err = FoodAvailabilityError;
    225 
    226     fn from_str(value: &str) -> Result<Self, Self::Err> {
    227         Self::parse(value)
    228     }
    229 }
    230 
    231 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    232 pub struct FoodText(String);
    233 
    234 impl FoodText {
    235     pub fn new(value: impl Into<String>) -> Result<Self, FoodAvailabilityError> {
    236         let value = value.into();
    237         crate::require_invariant(
    238             [
    239                 !value.is_empty(),
    240                 value.trim() == value,
    241                 !value.chars().any(is_control_or_format),
    242             ] == [true; 3],
    243             &|| FoodAvailabilityError::TextInvalid,
    244         )?;
    245         crate::require_invariant(value.len() <= RADROOTS_FOOD_TEXT_MAX_BYTES, &|| {
    246             FoodAvailabilityError::TextTooLarge {
    247                 max: RADROOTS_FOOD_TEXT_MAX_BYTES,
    248                 actual: value.len(),
    249             }
    250         })?;
    251         Ok(Self(value))
    252     }
    253 
    254     pub fn as_str(&self) -> &str {
    255         self.0.as_str()
    256     }
    257 
    258     pub fn into_string(self) -> String {
    259         self.0
    260     }
    261 }
    262 
    263 impl AsRef<str> for FoodText {
    264     fn as_ref(&self) -> &str {
    265         self.as_str()
    266     }
    267 }
    268 
    269 impl fmt::Display for FoodText {
    270     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    271         formatter.write_str(self.as_str())
    272     }
    273 }
    274 
    275 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    276 pub struct FoodPublishedAt(u64);
    277 
    278 impl FoodPublishedAt {
    279     pub const fn new(value: u64) -> Result<Self, FoodAvailabilityError> {
    280         if value == 0 {
    281             return Err(FoodAvailabilityError::PublishedAtInvalid);
    282         }
    283         Ok(Self(value))
    284     }
    285 
    286     pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> {
    287         crate::require_invariant(canonical_unsigned_integer(value), &|| {
    288             FoodAvailabilityError::PublishedAtInvalid
    289         })?;
    290         value
    291             .parse::<u64>()
    292             .ok()
    293             .and_then(|parsed| Self::new(parsed).ok())
    294             .ok_or(FoodAvailabilityError::PublishedAtInvalid)
    295     }
    296 
    297     pub const fn as_u64(self) -> u64 {
    298         self.0
    299     }
    300 
    301     pub const fn validate_created_at(self, created_at: u64) -> Result<(), FoodAvailabilityError> {
    302         if self.0 > created_at {
    303             return Err(FoodAvailabilityError::PublishedAtFuture {
    304                 published_at: self.0,
    305                 created_at,
    306             });
    307         }
    308         Ok(())
    309     }
    310 }
    311 
    312 impl fmt::Display for FoodPublishedAt {
    313     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    314         write!(formatter, "{}", self.0)
    315     }
    316 }
    317 
    318 impl FromStr for FoodPublishedAt {
    319     type Err = FoodAvailabilityError;
    320 
    321     fn from_str(value: &str) -> Result<Self, Self::Err> {
    322         Self::parse(value)
    323     }
    324 }
    325 
    326 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    327 pub struct FoodCurrency(String);
    328 
    329 impl FoodCurrency {
    330     pub fn parse(value: impl AsRef<str>) -> Result<Self, FoodAvailabilityError> {
    331         let value = value.as_ref();
    332         crate::require_invariant(
    333             [
    334                 value.len() == 3,
    335                 value.bytes().all(|byte| byte.is_ascii_uppercase()),
    336             ] == [true; 2],
    337             &|| FoodAvailabilityError::PriceCurrencyInvalid,
    338         )?;
    339         Ok(Self(value.into()))
    340     }
    341 
    342     pub fn as_str(&self) -> &str {
    343         self.0.as_str()
    344     }
    345 
    346     pub fn into_string(self) -> String {
    347         self.0
    348     }
    349 }
    350 
    351 impl AsRef<str> for FoodCurrency {
    352     fn as_ref(&self) -> &str {
    353         self.as_str()
    354     }
    355 }
    356 
    357 impl fmt::Display for FoodCurrency {
    358     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    359         formatter.write_str(self.as_str())
    360     }
    361 }
    362 
    363 impl FromStr for FoodCurrency {
    364     type Err = FoodAvailabilityError;
    365 
    366     fn from_str(value: &str) -> Result<Self, Self::Err> {
    367         Self::parse(value)
    368     }
    369 }
    370 
    371 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    372 pub enum FoodUnit {
    373     Gram,
    374     Kilogram,
    375     Pound,
    376     Ounce,
    377     Each,
    378     Dozen,
    379     Bunch,
    380     Punnet,
    381     Bag,
    382     Basket,
    383 }
    384 
    385 impl FoodUnit {
    386     pub const fn as_str(self) -> &'static str {
    387         match self {
    388             Self::Gram => "g",
    389             Self::Kilogram => "kg",
    390             Self::Pound => "lb",
    391             Self::Ounce => "oz",
    392             Self::Each => "each",
    393             Self::Dozen => "dozen",
    394             Self::Bunch => "bunch",
    395             Self::Punnet => "punnet",
    396             Self::Bag => "bag",
    397             Self::Basket => "basket",
    398         }
    399     }
    400 
    401     pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> {
    402         match value {
    403             "g" => Ok(Self::Gram),
    404             "kg" => Ok(Self::Kilogram),
    405             "lb" => Ok(Self::Pound),
    406             "oz" => Ok(Self::Ounce),
    407             "each" => Ok(Self::Each),
    408             "dozen" => Ok(Self::Dozen),
    409             "bunch" => Ok(Self::Bunch),
    410             "punnet" => Ok(Self::Punnet),
    411             "bag" => Ok(Self::Bag),
    412             "basket" => Ok(Self::Basket),
    413             _ => Err(FoodAvailabilityError::PriceUnitInvalid),
    414         }
    415     }
    416 }
    417 
    418 impl fmt::Display for FoodUnit {
    419     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    420         formatter.write_str(self.as_str())
    421     }
    422 }
    423 
    424 impl FromStr for FoodUnit {
    425     type Err = FoodAvailabilityError;
    426 
    427     fn from_str(value: &str) -> Result<Self, Self::Err> {
    428         Self::parse(value)
    429     }
    430 }
    431 
    432 #[derive(Clone, Debug, PartialEq, Eq)]
    433 pub struct FoodPrice {
    434     amount: String,
    435     currency: FoodCurrency,
    436     unit: FoodUnit,
    437 }
    438 
    439 impl FoodPrice {
    440     pub fn new(
    441         amount: impl Into<String>,
    442         currency: FoodCurrency,
    443         unit: FoodUnit,
    444     ) -> Result<Self, FoodAvailabilityError> {
    445         let amount = amount.into();
    446         validate_canonical_decimal(&amount)
    447             .then_some(())
    448             .ok_or(FoodAvailabilityError::PriceInvalid)?;
    449         Ok(Self {
    450             amount,
    451             currency,
    452             unit,
    453         })
    454     }
    455 
    456     pub fn amount(&self) -> &str {
    457         self.amount.as_str()
    458     }
    459 
    460     pub fn currency(&self) -> &FoodCurrency {
    461         &self.currency
    462     }
    463 
    464     pub const fn unit(&self) -> FoodUnit {
    465         self.unit
    466     }
    467 }
    468 
    469 #[derive(Clone, Debug, PartialEq, Eq)]
    470 pub struct FoodQuantity {
    471     amount: String,
    472     unit: FoodUnit,
    473 }
    474 
    475 impl FoodQuantity {
    476     pub fn new(amount: impl Into<String>, unit: FoodUnit) -> Result<Self, FoodAvailabilityError> {
    477         let amount = amount.into();
    478         if !validate_canonical_decimal(&amount) {
    479             return Err(FoodAvailabilityError::QuantityInvalid);
    480         }
    481         if !amount.bytes().any(|byte| matches!(byte, b'1'..=b'9')) {
    482             return Err(FoodAvailabilityError::QuantityZero);
    483         }
    484         Ok(Self { amount, unit })
    485     }
    486 
    487     pub fn amount(&self) -> &str {
    488         self.amount.as_str()
    489     }
    490 
    491     pub const fn unit(&self) -> FoodUnit {
    492         self.unit
    493     }
    494 }
    495 
    496 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    497 pub enum FoodAvailabilityStatus {
    498     Active,
    499     Sold,
    500 }
    501 
    502 impl FoodAvailabilityStatus {
    503     pub const fn as_str(self) -> &'static str {
    504         match self {
    505             Self::Active => "active",
    506             Self::Sold => "sold",
    507         }
    508     }
    509 
    510     pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> {
    511         match value {
    512             "active" => Ok(Self::Active),
    513             "sold" => Ok(Self::Sold),
    514             _ => Err(FoodAvailabilityError::StatusInvalid),
    515         }
    516     }
    517 }
    518 
    519 impl fmt::Display for FoodAvailabilityStatus {
    520     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    521         formatter.write_str(self.as_str())
    522     }
    523 }
    524 
    525 impl FromStr for FoodAvailabilityStatus {
    526     type Err = FoodAvailabilityError;
    527 
    528     fn from_str(value: &str) -> Result<Self, Self::Err> {
    529         Self::parse(value)
    530     }
    531 }
    532 
    533 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    534 pub struct FoodImageDimensions {
    535     width: u32,
    536     height: u32,
    537 }
    538 
    539 impl FoodImageDimensions {
    540     pub const fn new(width: u32, height: u32) -> Result<Self, FoodAvailabilityError> {
    541         if width == 0 || height == 0 {
    542             return Err(FoodAvailabilityError::ImageDimensionsInvalid);
    543         }
    544         Ok(Self { width, height })
    545     }
    546 
    547     pub fn parse(value: &str) -> Result<Self, FoodAvailabilityError> {
    548         let Some((width, height)) = value.split_once('x') else {
    549             return Err(FoodAvailabilityError::ImageDimensionsInvalid);
    550         };
    551         crate::require_invariant(
    552             [
    553                 !height.contains('x'),
    554                 canonical_unsigned_integer(width),
    555                 canonical_unsigned_integer(height),
    556             ] == [true; 3],
    557             &|| FoodAvailabilityError::ImageDimensionsInvalid,
    558         )?;
    559         let width = width
    560             .parse::<u32>()
    561             .map_err(|_| FoodAvailabilityError::ImageDimensionsInvalid)?;
    562         let height = height
    563             .parse::<u32>()
    564             .map_err(|_| FoodAvailabilityError::ImageDimensionsInvalid)?;
    565         Self::new(width, height)
    566     }
    567 
    568     pub const fn width(self) -> u32 {
    569         self.width
    570     }
    571 
    572     pub const fn height(self) -> u32 {
    573         self.height
    574     }
    575 }
    576 
    577 impl fmt::Display for FoodImageDimensions {
    578     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    579         write!(formatter, "{}x{}", self.width, self.height)
    580     }
    581 }
    582 
    583 impl FromStr for FoodImageDimensions {
    584     type Err = FoodAvailabilityError;
    585 
    586     fn from_str(value: &str) -> Result<Self, Self::Err> {
    587         Self::parse(value)
    588     }
    589 }
    590 
    591 /// One byte-verified Blossom image and its declared NIP-58 dimensions.
    592 ///
    593 /// This state proves descriptor-to-byte agreement and an `image/*` media type.
    594 /// It does not prove upload completion, raster decoding, or network availability.
    595 #[derive(Clone, Debug, PartialEq, Eq)]
    596 pub struct FoodAvailabilityImage {
    597     image: AuthoredImage,
    598     dimensions: FoodImageDimensions,
    599 }
    600 
    601 impl FoodAvailabilityImage {
    602     pub const fn new(image: AuthoredImage, dimensions: FoodImageDimensions) -> Self {
    603         Self { image, dimensions }
    604     }
    605 
    606     pub fn image(&self) -> &AuthoredImage {
    607         &self.image
    608     }
    609 
    610     pub const fn dimensions(&self) -> FoodImageDimensions {
    611         self.dimensions
    612     }
    613 
    614     pub fn url(&self) -> &str {
    615         self.image.descriptor().url().as_str()
    616     }
    617 }
    618 
    619 /// Validated semantic inputs for a focused FoodAvailability event.
    620 ///
    621 /// This is intentionally not a signable event draft. The codec layer must
    622 /// still derive canonical tags, enforce generic tag and compact-wire budgets,
    623 /// and bind these details to a per-revision `created_at` before signing.
    624 ///
    625 /// ```compile_fail
    626 /// let _: radroots_event::food::availability::FoodAvailabilityDetails =
    627 ///     serde_json::from_str(r#"{"content":"carrots"}"#).unwrap();
    628 /// ```
    629 #[derive(Clone, Debug, PartialEq, Eq)]
    630 pub struct FoodAvailabilityDetails {
    631     content: FoodContent,
    632     identifier: FoodIdentifier,
    633     title: FoodText,
    634     summary: FoodText,
    635     published_at: FoodPublishedAt,
    636     location: FoodText,
    637     price: FoodPrice,
    638     quantity: Option<FoodQuantity>,
    639     status: FoodAvailabilityStatus,
    640     images: Vec<FoodAvailabilityImage>,
    641 }
    642 
    643 #[derive(Clone, Debug, PartialEq, Eq)]
    644 pub struct FoodAvailabilityDetailsParts {
    645     pub content: FoodContent,
    646     pub identifier: FoodIdentifier,
    647     pub title: FoodText,
    648     pub summary: FoodText,
    649     pub published_at: FoodPublishedAt,
    650     pub location: FoodText,
    651     pub price: FoodPrice,
    652     pub quantity: Option<FoodQuantity>,
    653     pub status: FoodAvailabilityStatus,
    654     pub images: Vec<FoodAvailabilityImage>,
    655 }
    656 
    657 impl FoodAvailabilityDetails {
    658     pub fn new(parts: FoodAvailabilityDetailsParts) -> Result<Self, FoodAvailabilityError> {
    659         crate::require_invariant(
    660             !parts
    661                 .quantity
    662                 .as_ref()
    663                 .is_some_and(|quantity| quantity.unit() != parts.price.unit()),
    664             &|| FoodAvailabilityError::QuantityInvalid,
    665         )?;
    666         validate_images(&parts.images)?;
    667         Ok(Self {
    668             content: parts.content,
    669             identifier: parts.identifier,
    670             title: parts.title,
    671             summary: parts.summary,
    672             published_at: parts.published_at,
    673             location: parts.location,
    674             price: parts.price,
    675             quantity: parts.quantity,
    676             status: parts.status,
    677             images: parts.images,
    678         })
    679     }
    680 
    681     pub fn validate_created_at(&self, created_at: u64) -> Result<(), FoodAvailabilityError> {
    682         self.published_at.validate_created_at(created_at)
    683     }
    684 
    685     pub fn content(&self) -> &FoodContent {
    686         &self.content
    687     }
    688 
    689     pub fn identifier(&self) -> &FoodIdentifier {
    690         &self.identifier
    691     }
    692 
    693     pub fn title(&self) -> &FoodText {
    694         &self.title
    695     }
    696 
    697     pub fn summary(&self) -> &FoodText {
    698         &self.summary
    699     }
    700 
    701     pub const fn published_at(&self) -> FoodPublishedAt {
    702         self.published_at
    703     }
    704 
    705     pub fn location(&self) -> &FoodText {
    706         &self.location
    707     }
    708 
    709     pub fn price(&self) -> &FoodPrice {
    710         &self.price
    711     }
    712 
    713     pub fn quantity(&self) -> Option<&FoodQuantity> {
    714         self.quantity.as_ref()
    715     }
    716 
    717     pub const fn status(&self) -> FoodAvailabilityStatus {
    718         self.status
    719     }
    720 
    721     pub fn images(&self) -> &[FoodAvailabilityImage] {
    722         &self.images
    723     }
    724 }
    725 
    726 fn validate_images(images: &[FoodAvailabilityImage]) -> Result<(), FoodAvailabilityError> {
    727     crate::require_invariant(images.len() <= RADROOTS_FOOD_IMAGE_MAX_COUNT, &|| {
    728         FoodAvailabilityError::ImageCountExceeded {
    729             max: RADROOTS_FOOD_IMAGE_MAX_COUNT,
    730             actual: images.len(),
    731         }
    732     })?;
    733     for (index, image) in images.iter().enumerate() {
    734         crate::require_invariant(
    735             !images[..index]
    736                 .iter()
    737                 .any(|candidate| candidate.url() == image.url()),
    738             &|| FoodAvailabilityError::ImageDuplicateUrl,
    739         )?;
    740         let digest = image.image().descriptor().sha256();
    741         crate::require_invariant(
    742             !images[..index]
    743                 .iter()
    744                 .any(|candidate| candidate.image().descriptor().sha256() == digest),
    745             &|| FoodAvailabilityError::ImageDuplicateDigest,
    746         )?;
    747     }
    748     Ok(())
    749 }
    750 
    751 /// Returns whether an inbound FoodAvailability image is a structural HTTP(S) URL.
    752 ///
    753 /// This is deliberately broader than strict authored Blossom policy. Success
    754 /// makes no byte-verification, upload, reachability, or media-safety claim.
    755 pub fn food_media_http_url_is_valid(value: &str) -> bool {
    756     if [
    757         value.contains("://"),
    758         !value.chars().any(|character| {
    759             [
    760                 character.is_whitespace(),
    761                 matches!(
    762                     get_general_category(character),
    763                     GeneralCategory::Control | GeneralCategory::Format
    764                 ),
    765             ]
    766             .contains(&true)
    767         }),
    768     ] != [true; 2]
    769     {
    770         return false;
    771     }
    772 
    773     let Ok(url) = Url::parse(value) else {
    774         return false;
    775     };
    776     if [
    777         matches!(url.scheme(), "http" | "https"),
    778         url.username().is_empty(),
    779         url.password().is_none(),
    780     ] != [true; 3]
    781     {
    782         return false;
    783     }
    784 
    785     let Some((raw_host, raw_path)) = raw_food_media_host_and_path(value) else {
    786         return false;
    787     };
    788     if [!raw_path.is_empty(), raw_path.starts_with('/')] != [true; 2] {
    789         return false;
    790     }
    791 
    792     match url.host() {
    793         Some(Host::Domain(_)) => raw_food_dns_host_is_valid(raw_host),
    794         Some(Host::Ipv4(_)) => [raw_host.is_ascii(), !raw_host.is_empty()] == [true; 2],
    795         Some(Host::Ipv6(_)) => [raw_host.is_ascii(), !raw_host.is_empty()] == [true; 2],
    796         None => false,
    797     }
    798 }
    799 
    800 /// Extracts a structural Blossom hash from an otherwise valid inbound URL.
    801 ///
    802 /// A `None` result does not invalidate a standard inbound NIP-58 image URL; it
    803 /// only means duplicate-digest diagnostics cannot be derived from its path.
    804 pub fn food_media_blossom_digest(value: &str) -> Option<Sha256> {
    805     if !food_media_http_url_is_valid(value) {
    806         return None;
    807     }
    808     let url = Url::parse(value).ok()?;
    809     HashPath::parse(url.path()).ok().map(|path| path.hash())
    810 }
    811 
    812 fn raw_food_media_host_and_path(value: &str) -> Option<(&str, &str)> {
    813     let (_, remainder) = value.split_once("://")?;
    814     let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
    815     let authority = &remainder[..authority_end];
    816     if [!authority.is_empty(), !authority.contains('@')] != [true; 2] {
    817         return None;
    818     }
    819     let path_and_suffix = &remainder[authority_end..];
    820     let path_end = path_and_suffix
    821         .find(['?', '#'])
    822         .unwrap_or(path_and_suffix.len());
    823     let raw_path = &path_and_suffix[..path_end];
    824 
    825     let raw_host = if let Some(bracketed) = authority.strip_prefix('[') {
    826         let (host, suffix) = bracketed.split_once(']')?;
    827         if [!suffix.is_empty(), !suffix.starts_with(':')] == [true; 2] {
    828             return None;
    829         }
    830         host
    831     } else if let Some((host, _port)) = authority.rsplit_once(':') {
    832         if host.contains(':') {
    833             return None;
    834         }
    835         host
    836     } else {
    837         authority
    838     };
    839 
    840     Some((raw_host, raw_path))
    841 }
    842 
    843 fn raw_food_dns_host_is_valid(host: &str) -> bool {
    844     !host.is_empty()
    845         && host.is_ascii()
    846         && host.len() <= 253
    847         && host.split('.').all(|label| {
    848             let bytes = label.as_bytes();
    849             !bytes.is_empty()
    850                 && bytes.len() <= 63
    851                 && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
    852                 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
    853                 && bytes
    854                     .iter()
    855                     .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
    856         })
    857 }
    858 
    859 fn validate_canonical_decimal(value: &str) -> bool {
    860     let mut digits = 0usize;
    861     let mut seen_dot = false;
    862     let mut digit_after_dot = false;
    863     for byte in value.bytes() {
    864         match byte {
    865             b'0'..=b'9' => {
    866                 digits += 1;
    867                 digit_after_dot |= seen_dot;
    868             }
    869             b'.' if !seen_dot => seen_dot = true,
    870             _ => return false,
    871         }
    872     }
    873     if [digits > 0, digits <= RADROOTS_FOOD_DECIMAL_MAX_DIGITS] != [true; 2] {
    874         return false;
    875     }
    876     if [
    877         seen_dot,
    878         [!digit_after_dot, value.ends_with('0')].contains(&true),
    879     ] == [true; 2]
    880     {
    881         return false;
    882     }
    883     let integer = value.split_once('.').map_or(value, |(integer, _)| integer);
    884     [
    885         !integer.is_empty(),
    886         [integer == "0", !integer.starts_with('0')].contains(&true),
    887     ] == [true; 2]
    888 }
    889 
    890 fn canonical_unsigned_integer(value: &str) -> bool {
    891     [
    892         !value.is_empty(),
    893         value.bytes().all(|byte| byte.is_ascii_digit()),
    894         [value == "0", !value.starts_with('0')].contains(&true),
    895     ] == [true; 3]
    896 }
    897 
    898 fn is_food_contract_whitespace(character: char) -> bool {
    899     [
    900         character.is_whitespace(),
    901         matches!(character, '\u{1c}'..='\u{1f}'),
    902     ]
    903     .contains(&true)
    904 }
    905 
    906 fn is_control_or_format(character: char) -> bool {
    907     matches!(
    908         get_general_category(character),
    909         GeneralCategory::Control | GeneralCategory::Format
    910     )
    911 }
    912 
    913 #[cfg(test)]
    914 #[cfg_attr(coverage_nightly, coverage(off))]
    915 mod tests {
    916     use super::*;
    917     use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256};
    918 
    919     #[test]
    920     fn error_codes_and_messages_are_stable_for_every_variant() {
    921         let cases = [
    922             (
    923                 FoodAvailabilityError::ContentMissing,
    924                 "food_content_missing",
    925             ),
    926             (
    927                 FoodAvailabilityError::ContentTooLarge { max: 1, actual: 2 },
    928                 "food_content_too_large",
    929             ),
    930             (
    931                 FoodAvailabilityError::IdentifierInvalid,
    932                 "food_identifier_invalid",
    933             ),
    934             (
    935                 FoodAvailabilityError::IdentifierTooLarge { max: 1, actual: 2 },
    936                 "food_identifier_invalid",
    937             ),
    938             (FoodAvailabilityError::TextInvalid, "food_text_invalid"),
    939             (
    940                 FoodAvailabilityError::TextTooLarge { max: 1, actual: 2 },
    941                 "food_text_invalid",
    942             ),
    943             (
    944                 FoodAvailabilityError::PublishedAtInvalid,
    945                 "food_published_at_invalid",
    946             ),
    947             (
    948                 FoodAvailabilityError::PublishedAtFuture {
    949                     published_at: 2,
    950                     created_at: 1,
    951                 },
    952                 "food_published_at_future",
    953             ),
    954             (FoodAvailabilityError::PriceInvalid, "price_invalid"),
    955             (
    956                 FoodAvailabilityError::PriceCurrencyInvalid,
    957                 "price_currency_invalid",
    958             ),
    959             (
    960                 FoodAvailabilityError::PriceUnitInvalid,
    961                 "price_unit_invalid",
    962             ),
    963             (FoodAvailabilityError::QuantityInvalid, "quantity_invalid"),
    964             (FoodAvailabilityError::QuantityZero, "quantity_zero"),
    965             (FoodAvailabilityError::StatusInvalid, "food_status_invalid"),
    966             (
    967                 FoodAvailabilityError::ImageDimensionsInvalid,
    968                 "food_image_dimensions_invalid",
    969             ),
    970             (
    971                 FoodAvailabilityError::ImageCountExceeded { max: 1, actual: 2 },
    972                 "food_image_count_exceeded",
    973             ),
    974             (
    975                 FoodAvailabilityError::ImageDuplicateUrl,
    976                 "food_image_duplicate_url",
    977             ),
    978             (
    979                 FoodAvailabilityError::ImageDuplicateDigest,
    980                 "food_image_duplicate_digest",
    981             ),
    982         ];
    983 
    984         for (error, code) in cases {
    985             assert_eq!(error.code(), code);
    986             assert!(!error.to_string().is_empty(), "{code}");
    987         }
    988     }
    989 
    990     #[test]
    991     fn content_enforces_only_non_whitespace_and_utf8_byte_bound() {
    992         for invalid in [
    993             "",
    994             " \t",
    995             "\u{1c}",
    996             "\u{1d}",
    997             "\u{1e}",
    998             "\u{1f}",
    999             "\u{1c}\u{2003}\t",
   1000         ] {
   1001             assert_eq!(
   1002                 FoodContent::new(invalid).unwrap_err(),
   1003                 FoodAvailabilityError::ContentMissing,
   1004                 "{invalid:?}"
   1005             );
   1006         }
   1007         let exact = "é".repeat(RADROOTS_FOOD_CONTENT_MAX_BYTES / 2);
   1008         assert_eq!(
   1009             FoodContent::new(exact.clone()).unwrap().as_str().len(),
   1010             RADROOTS_FOOD_CONTENT_MAX_BYTES
   1011         );
   1012         assert_eq!(
   1013             FoodContent::new(exact + "a").unwrap_err(),
   1014             FoodAvailabilityError::ContentTooLarge {
   1015                 max: RADROOTS_FOOD_CONTENT_MAX_BYTES,
   1016                 actual: RADROOTS_FOOD_CONTENT_MAX_BYTES + 1,
   1017             }
   1018         );
   1019         assert!(FoodContent::new(" harvest\nnotes ").is_ok());
   1020         assert!(FoodContent::new("carrots\u{1c}").is_ok());
   1021     }
   1022 
   1023     #[test]
   1024     fn inbound_food_media_urls_are_structural_without_claiming_blossom() {
   1025         let hash = Sha256::digest(b"carrots").to_string();
   1026         for valid in [
   1027             format!("https://media.example/{hash}.webp"),
   1028             format!("http://media.example:0/{hash}?download=1"),
   1029             "https://media.example/not-a-blossom-path.jpg".to_string(),
   1030             format!("https://[::1]/{hash}"),
   1031             format!("https://127.0.0.1/{hash}"),
   1032             format!("https://localhost/{hash}"),
   1033         ] {
   1034             assert!(food_media_http_url_is_valid(&valid), "{valid}");
   1035         }
   1036         for invalid in [
   1037             format!("ftp://media.example/{hash}"),
   1038             format!("https://user@media.example/{hash}"),
   1039             format!("https://user:password@media.example/{hash}"),
   1040             "https://media.example".to_string(),
   1041             "https://[".to_string(),
   1042             format!("https://-media.example/{hash}"),
   1043             format!("https://media-.example/{hash}"),
   1044             format!("https://media..example/{hash}"),
   1045             format!("https://bad_host.example/{hash}"),
   1046             format!("https://{}.example/{hash}", "a".repeat(64)),
   1047             format!(
   1048                 "https://{}.{}.{}.{}/{hash}",
   1049                 "a".repeat(63),
   1050                 "b".repeat(63),
   1051                 "c".repeat(63),
   1052                 "d".repeat(63)
   1053             ),
   1054             format!("https://média.example/{hash}"),
   1055             format!("https://media.example/a b/{hash}"),
   1056             format!("https://media.example/a\0b/{hash}"),
   1057             format!("https://media.example/\u{200b}{hash}"),
   1058         ] {
   1059             assert!(!food_media_http_url_is_valid(&invalid), "{invalid}");
   1060         }
   1061 
   1062         let blossom = format!("https://media.example/{hash}.webp?download=1");
   1063         assert_eq!(
   1064             food_media_blossom_digest(&blossom),
   1065             Some(Sha256::from_hex(&hash).unwrap())
   1066         );
   1067         assert_eq!(
   1068             food_media_blossom_digest("https://media.example/not-a-hash.jpg"),
   1069             None
   1070         );
   1071         assert_eq!(food_media_blossom_digest("not a URL"), None);
   1072     }
   1073 
   1074     #[test]
   1075     fn identifier_enforces_bytes_whitespace_and_unicode_categories() {
   1076         let exact = "a".repeat(RADROOTS_FOOD_IDENTIFIER_MAX_BYTES);
   1077         assert_eq!(FoodIdentifier::parse(&exact).unwrap().as_str(), exact);
   1078         assert!(matches!(
   1079             FoodIdentifier::parse(exact + "a"),
   1080             Err(FoodAvailabilityError::IdentifierTooLarge { .. })
   1081         ));
   1082         for invalid in [
   1083             "",
   1084             "fresh carrots",
   1085             "carrots\0",
   1086             "carrots\u{85}",
   1087             "carrots\u{200b}",
   1088             "carrots\u{2060}",
   1089         ] {
   1090             assert_eq!(
   1091                 FoodIdentifier::parse(invalid).unwrap_err().code(),
   1092                 "food_identifier_invalid"
   1093             );
   1094         }
   1095     }
   1096 
   1097     #[test]
   1098     fn text_enforces_trim_bytes_and_unicode_categories() {
   1099         let exact = "é".repeat(RADROOTS_FOOD_TEXT_MAX_BYTES / 2);
   1100         assert_eq!(FoodText::new(exact.clone()).unwrap().as_str(), exact);
   1101         assert!(matches!(
   1102             FoodText::new(exact + "a"),
   1103             Err(FoodAvailabilityError::TextTooLarge { .. })
   1104         ));
   1105         for invalid in [
   1106             "",
   1107             " Carrots",
   1108             "Carrots ",
   1109             "Car\nrots",
   1110             "Car\u{85}rots",
   1111             "Car\u{200b}rots",
   1112             "Car\u{2060}rots",
   1113         ] {
   1114             assert_eq!(
   1115                 FoodText::new(invalid).unwrap_err().code(),
   1116                 "food_text_invalid"
   1117             );
   1118         }
   1119     }
   1120 
   1121     #[test]
   1122     fn published_at_is_nonzero_canonical_u64_and_not_in_the_future() {
   1123         assert_eq!(
   1124             FoodPublishedAt::new(0).unwrap_err(),
   1125             FoodAvailabilityError::PublishedAtInvalid
   1126         );
   1127         for invalid in ["", "0", "01", "+1", "18446744073709551616"] {
   1128             assert_eq!(
   1129                 FoodPublishedAt::parse(invalid).unwrap_err(),
   1130                 FoodAvailabilityError::PublishedAtInvalid
   1131             );
   1132         }
   1133         let timestamp = FoodPublishedAt::parse("18446744073709551615").unwrap();
   1134         assert_eq!(timestamp.as_u64(), u64::MAX);
   1135         assert_eq!(timestamp.to_string(), u64::MAX.to_string());
   1136         assert_eq!(
   1137             FoodPublishedAt::new(11)
   1138                 .unwrap()
   1139                 .validate_created_at(10)
   1140                 .unwrap_err(),
   1141             FoodAvailabilityError::PublishedAtFuture {
   1142                 published_at: 11,
   1143                 created_at: 10,
   1144             }
   1145         );
   1146         FoodPublishedAt::new(1)
   1147             .unwrap()
   1148             .validate_created_at(1)
   1149             .unwrap();
   1150     }
   1151 
   1152     #[test]
   1153     fn food_units_are_exact_and_closed() {
   1154         let cases = [
   1155             ("g", FoodUnit::Gram),
   1156             ("kg", FoodUnit::Kilogram),
   1157             ("lb", FoodUnit::Pound),
   1158             ("oz", FoodUnit::Ounce),
   1159             ("each", FoodUnit::Each),
   1160             ("dozen", FoodUnit::Dozen),
   1161             ("bunch", FoodUnit::Bunch),
   1162             ("punnet", FoodUnit::Punnet),
   1163             ("bag", FoodUnit::Bag),
   1164             ("basket", FoodUnit::Basket),
   1165         ];
   1166         for (wire, unit) in cases {
   1167             assert_eq!(FoodUnit::parse(wire).unwrap(), unit);
   1168             assert_eq!(unit.as_str(), wire);
   1169             assert_eq!(unit.to_string(), wire);
   1170         }
   1171         for invalid in ["", "G", "lbs", "crate", " each"] {
   1172             assert_eq!(
   1173                 FoodUnit::parse(invalid).unwrap_err(),
   1174                 FoodAvailabilityError::PriceUnitInvalid
   1175             );
   1176         }
   1177     }
   1178 
   1179     #[test]
   1180     fn price_decimal_is_canonical_bounded_and_may_be_zero() {
   1181         let currency = FoodCurrency::parse("CAD").unwrap();
   1182         for valid in ["0", "1", "0.1", "10.25", "1234567890123456789012345678"] {
   1183             let price = FoodPrice::new(valid, currency.clone(), FoodUnit::Pound).unwrap();
   1184             assert_eq!(price.amount(), valid);
   1185         }
   1186         for invalid in [
   1187             "",
   1188             ".1",
   1189             "1.",
   1190             "00",
   1191             "01",
   1192             "01.2",
   1193             "1.0",
   1194             "1.20",
   1195             "+1",
   1196             "-1",
   1197             "1e3",
   1198             "١",
   1199             "12345678901234567890123456789",
   1200         ] {
   1201             assert_eq!(
   1202                 FoodPrice::new(invalid, currency.clone(), FoodUnit::Pound).unwrap_err(),
   1203                 FoodAvailabilityError::PriceInvalid
   1204             );
   1205         }
   1206     }
   1207 
   1208     #[test]
   1209     fn currency_is_three_uppercase_ascii_letters_without_registry_semantics() {
   1210         for valid in ["CAD", "USD", "ZZZ"] {
   1211             assert_eq!(FoodCurrency::parse(valid).unwrap().as_str(), valid);
   1212         }
   1213         for invalid in ["", "CA", "CADD", "cad", "C1D", "CÁD"] {
   1214             assert_eq!(
   1215                 FoodCurrency::parse(invalid).unwrap_err(),
   1216                 FoodAvailabilityError::PriceCurrencyInvalid
   1217             );
   1218         }
   1219     }
   1220 
   1221     #[test]
   1222     fn quantity_is_positive_canonical_and_retains_its_unit() {
   1223         let quantity = FoodQuantity::new("20.5", FoodUnit::Pound).unwrap();
   1224         assert_eq!(quantity.amount(), "20.5");
   1225         assert_eq!(quantity.unit(), FoodUnit::Pound);
   1226         assert_eq!(
   1227             FoodQuantity::new("0", FoodUnit::Pound).unwrap_err(),
   1228             FoodAvailabilityError::QuantityZero
   1229         );
   1230         assert_eq!(
   1231             FoodQuantity::new("01", FoodUnit::Pound).unwrap_err(),
   1232             FoodAvailabilityError::QuantityInvalid
   1233         );
   1234     }
   1235 
   1236     #[test]
   1237     fn status_is_exact_and_withdrawal_is_not_a_status() {
   1238         assert_eq!(
   1239             FoodAvailabilityStatus::parse("active").unwrap(),
   1240             FoodAvailabilityStatus::Active
   1241         );
   1242         assert_eq!(
   1243             FoodAvailabilityStatus::parse("sold").unwrap(),
   1244             FoodAvailabilityStatus::Sold
   1245         );
   1246         for invalid in ["", "Active", "withdrawn"] {
   1247             assert_eq!(
   1248                 FoodAvailabilityStatus::parse(invalid).unwrap_err(),
   1249                 FoodAvailabilityError::StatusInvalid
   1250             );
   1251         }
   1252     }
   1253 
   1254     #[test]
   1255     fn image_dimensions_are_canonical_nonzero_u32_values() {
   1256         let dimensions = FoodImageDimensions::new(u32::MAX, 1).unwrap();
   1257         assert_eq!(dimensions.width(), u32::MAX);
   1258         assert_eq!(dimensions.height(), 1);
   1259         assert_eq!(dimensions.to_string(), "4294967295x1");
   1260         assert_eq!(
   1261             FoodImageDimensions::parse("800x600").unwrap(),
   1262             FoodImageDimensions::new(800, 600).unwrap()
   1263         );
   1264         assert_eq!(
   1265             FoodImageDimensions::new(0, 1).unwrap_err(),
   1266             FoodAvailabilityError::ImageDimensionsInvalid
   1267         );
   1268         assert_eq!(
   1269             FoodImageDimensions::new(1, 0).unwrap_err(),
   1270             FoodAvailabilityError::ImageDimensionsInvalid
   1271         );
   1272         for invalid in [
   1273             "",
   1274             "0x1",
   1275             "1x0",
   1276             "01x1",
   1277             "1x01",
   1278             "1X1",
   1279             "1x1x1",
   1280             "4294967296x1",
   1281         ] {
   1282             assert_eq!(
   1283                 FoodImageDimensions::parse(invalid).unwrap_err(),
   1284                 FoodAvailabilityError::ImageDimensionsInvalid
   1285             );
   1286         }
   1287     }
   1288 
   1289     #[test]
   1290     fn details_enforce_quantity_unit_and_created_at() {
   1291         let mut parts = details_parts(Vec::new());
   1292         parts.quantity = Some(FoodQuantity::new("12", FoodUnit::Kilogram).unwrap());
   1293         assert_eq!(
   1294             FoodAvailabilityDetails::new(parts).unwrap_err(),
   1295             FoodAvailabilityError::QuantityInvalid
   1296         );
   1297 
   1298         let details = FoodAvailabilityDetails::new(details_parts(Vec::new())).unwrap();
   1299         details.validate_created_at(100).unwrap();
   1300         assert_eq!(details.identifier().as_str(), "nantes-carrots");
   1301         assert_eq!(details.title().as_str(), "Nantes Carrots");
   1302         assert_eq!(details.summary().as_str(), "Fresh bunches");
   1303         assert_eq!(details.location().as_str(), "Central Saanich, BC");
   1304         assert_eq!(
   1305             details.content().as_str(),
   1306             "Nantes carrots available this week."
   1307         );
   1308         assert_eq!(details.price().amount(), "4");
   1309         assert_eq!(details.price().currency().as_str(), "CAD");
   1310         assert_eq!(details.quantity().unwrap().amount(), "24");
   1311         assert_eq!(details.status(), FoodAvailabilityStatus::Active);
   1312         assert!(details.images().is_empty());
   1313         assert_eq!(
   1314             details.validate_created_at(99).unwrap_err().code(),
   1315             "food_published_at_future"
   1316         );
   1317     }
   1318 
   1319     #[test]
   1320     fn details_bound_images_and_apply_url_before_digest_duplicate_precedence() {
   1321         let dimensions = FoodImageDimensions::new(800, 600).unwrap();
   1322         let images = (0..RADROOTS_FOOD_IMAGE_MAX_COUNT)
   1323             .map(|index| {
   1324                 food_image(
   1325                     "https://media.example",
   1326                     index.to_be_bytes().as_slice(),
   1327                     dimensions,
   1328                 )
   1329             })
   1330             .collect::<Vec<_>>();
   1331         assert_eq!(
   1332             FoodAvailabilityDetails::new(details_parts(images))
   1333                 .unwrap()
   1334                 .images()
   1335                 .len(),
   1336             RADROOTS_FOOD_IMAGE_MAX_COUNT
   1337         );
   1338         let too_many = (0..=RADROOTS_FOOD_IMAGE_MAX_COUNT)
   1339             .map(|index| {
   1340                 food_image(
   1341                     "https://media.example",
   1342                     index.to_be_bytes().as_slice(),
   1343                     dimensions,
   1344                 )
   1345             })
   1346             .collect::<Vec<_>>();
   1347         assert_eq!(
   1348             FoodAvailabilityDetails::new(details_parts(too_many)).unwrap_err(),
   1349             FoodAvailabilityError::ImageCountExceeded {
   1350                 max: RADROOTS_FOOD_IMAGE_MAX_COUNT,
   1351                 actual: RADROOTS_FOOD_IMAGE_MAX_COUNT + 1,
   1352             }
   1353         );
   1354 
   1355         let image = food_image("https://media.example", b"carrot", dimensions);
   1356         assert_eq!(
   1357             FoodAvailabilityDetails::new(details_parts(vec![image.clone(), image])).unwrap_err(),
   1358             FoodAvailabilityError::ImageDuplicateUrl
   1359         );
   1360         assert_eq!(
   1361             FoodAvailabilityDetails::new(details_parts(vec![
   1362                 food_image("https://media.example", b"same", dimensions),
   1363                 food_image("https://cache.example", b"same", dimensions),
   1364             ]))
   1365             .unwrap_err(),
   1366             FoodAvailabilityError::ImageDuplicateDigest
   1367         );
   1368     }
   1369 
   1370     fn details_parts(images: Vec<FoodAvailabilityImage>) -> FoodAvailabilityDetailsParts {
   1371         FoodAvailabilityDetailsParts {
   1372             content: FoodContent::new("Nantes carrots available this week.").unwrap(),
   1373             identifier: FoodIdentifier::parse("nantes-carrots").unwrap(),
   1374             title: FoodText::new("Nantes Carrots").unwrap(),
   1375             summary: FoodText::new("Fresh bunches").unwrap(),
   1376             published_at: FoodPublishedAt::new(100).unwrap(),
   1377             location: FoodText::new("Central Saanich, BC").unwrap(),
   1378             price: FoodPrice::new("4", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound)
   1379                 .unwrap(),
   1380             quantity: Some(FoodQuantity::new("24", FoodUnit::Pound).unwrap()),
   1381             status: FoodAvailabilityStatus::Active,
   1382             images,
   1383         }
   1384     }
   1385 
   1386     fn food_image(
   1387         origin: &str,
   1388         bytes: &[u8],
   1389         dimensions: FoodImageDimensions,
   1390     ) -> FoodAvailabilityImage {
   1391         let hash = Sha256::digest(bytes);
   1392         let media_type = MediaType::parse("image/webp").unwrap();
   1393         let descriptor = BlobDescriptor::new(
   1394             BlobUrl::parse(&format!("{origin}/{hash}.webp")).unwrap(),
   1395             hash,
   1396             bytes.len() as u64,
   1397             media_type.clone(),
   1398             1_784_347_200,
   1399         )
   1400         .unwrap()
   1401         .approve_reference()
   1402         .unwrap()
   1403         .verify_bytes(bytes, &media_type)
   1404         .unwrap();
   1405         FoodAvailabilityImage::new(AuthoredImage::try_from(descriptor).unwrap(), dimensions)
   1406     }
   1407 }