lib

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

registry_v7.rs (26298B)


      1 //! Frozen food-availability semantics for event-contract registry v7.
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{boxed::Box, string::String, string::ToString, vec::Vec};
      5 
      6 use core::fmt;
      7 #[cfg(feature = "json")]
      8 use radroots_blossom::BlobUrl;
      9 use radroots_blossom::Sha256;
     10 #[cfg(feature = "json")]
     11 use radroots_event::wire::DEFAULT_RAW_JSON_MAX_BYTES;
     12 use radroots_event::{
     13     envelope::EventTags,
     14     envelope::kind::KIND_CLASSIFIED_LISTING,
     15     food::availability::{
     16         FoodAvailabilityError, FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier,
     17         FoodImageDimensions, FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
     18         RADROOTS_FOOD_DECIMAL_MAX_DIGITS, RADROOTS_FOOD_IMAGE_MAX_COUNT, food_media_blossom_digest,
     19         food_media_http_url_is_valid,
     20     },
     21     listing::classified::ClassifiedListingPartition,
     22 };
     23 
     24 use crate::verification::v1::RadrootsSignatureVerifiedEvent;
     25 
     26 #[cfg(feature = "json")]
     27 const FOOD_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len()
     28     + 64
     29     + "\",\"pubkey\":\"".len()
     30     + 64
     31     + "\",\"created_at\":".len()
     32     + ",\"kind\":30402,\"tags\":".len()
     33     + ",\"content\":".len()
     34     + ",\"sig\":\"".len()
     35     + 128
     36     + "\"}".len();
     37 
     38 const PROHIBITED_FOOD_CAPABILITY_TAGS: &[&str] = &[
     39     "buyer",
     40     "checkout",
     41     "delivery",
     42     "exception",
     43     "group",
     44     "invite",
     45     "order",
     46     "payment",
     47     "pickup",
     48     "proof",
     49     "provenance",
     50     "receipt",
     51     "route",
     52     "route_stop",
     53     "task",
     54 ];
     55 
     56 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     57 pub enum RadrootsFoodAvailabilityImageDiagnostic {
     58     ShapeInvalid,
     59     UrlInvalid,
     60     DimensionsMissing,
     61     DimensionsInvalid,
     62     DuplicateUrl,
     63     DuplicateDigest,
     64     CountExceeded,
     65 }
     66 
     67 impl RadrootsFoodAvailabilityImageDiagnostic {
     68     pub const fn code(self) -> &'static str {
     69         match self {
     70             Self::ShapeInvalid => "food_image_shape_invalid",
     71             Self::UrlInvalid => "food_image_url_invalid",
     72             Self::DimensionsMissing => "food_image_dimensions_missing",
     73             Self::DimensionsInvalid => "food_image_dimensions_invalid",
     74             Self::DuplicateUrl => "food_image_duplicate_url",
     75             Self::DuplicateDigest => "food_image_duplicate_digest",
     76             Self::CountExceeded => "food_image_count_exceeded",
     77         }
     78     }
     79 }
     80 
     81 impl fmt::Display for RadrootsFoodAvailabilityImageDiagnostic {
     82     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     83         formatter.write_str(self.code())
     84     }
     85 }
     86 
     87 #[derive(Clone, Debug, PartialEq, Eq)]
     88 pub struct RadrootsInboundFoodAvailabilityImage {
     89     raw_tag: Vec<String>,
     90     url: Option<String>,
     91     dimensions: Option<FoodImageDimensions>,
     92     diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>,
     93 }
     94 
     95 impl RadrootsInboundFoodAvailabilityImage {
     96     pub fn raw_tag(&self) -> &[String] {
     97         &self.raw_tag
     98     }
     99 
    100     pub fn url(&self) -> Option<&str> {
    101         self.url.as_deref()
    102     }
    103 
    104     pub const fn dimensions(&self) -> Option<FoodImageDimensions> {
    105         self.dimensions
    106     }
    107 
    108     pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] {
    109         &self.diagnostics
    110     }
    111 
    112     pub fn qualifies(&self) -> bool {
    113         self.diagnostics.is_empty()
    114     }
    115 }
    116 
    117 #[derive(Clone, Debug, PartialEq, Eq)]
    118 pub struct RadrootsInboundFoodAvailabilityProjection {
    119     content: FoodContent,
    120     identifier: FoodIdentifier,
    121     title: FoodText,
    122     summary: FoodText,
    123     published_at: FoodPublishedAt,
    124     location: FoodText,
    125     price: FoodPrice,
    126     quantity: Option<FoodQuantity>,
    127     status: FoodAvailabilityStatus,
    128     images: Vec<RadrootsInboundFoodAvailabilityImage>,
    129     diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>,
    130 }
    131 
    132 impl RadrootsInboundFoodAvailabilityProjection {
    133     pub fn content(&self) -> &FoodContent {
    134         &self.content
    135     }
    136 
    137     pub fn identifier(&self) -> &FoodIdentifier {
    138         &self.identifier
    139     }
    140 
    141     pub fn title(&self) -> &FoodText {
    142         &self.title
    143     }
    144 
    145     pub fn summary(&self) -> &FoodText {
    146         &self.summary
    147     }
    148 
    149     pub const fn published_at(&self) -> FoodPublishedAt {
    150         self.published_at
    151     }
    152 
    153     pub fn location(&self) -> &FoodText {
    154         &self.location
    155     }
    156 
    157     pub fn price(&self) -> &FoodPrice {
    158         &self.price
    159     }
    160 
    161     pub fn quantity(&self) -> Option<&FoodQuantity> {
    162         self.quantity.as_ref()
    163     }
    164 
    165     pub const fn status(&self) -> FoodAvailabilityStatus {
    166         self.status
    167     }
    168 
    169     pub fn images(&self) -> &[RadrootsInboundFoodAvailabilityImage] {
    170         &self.images
    171     }
    172 
    173     pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] {
    174         &self.diagnostics
    175     }
    176 }
    177 
    178 #[non_exhaustive]
    179 #[derive(Clone, Debug, PartialEq, Eq)]
    180 pub enum RadrootsFoodAvailabilityProjectionOutcome {
    181     Focused(Box<RadrootsInboundFoodAvailabilityProjection>),
    182     Excluded(ClassifiedListingPartition),
    183 }
    184 
    185 #[non_exhaustive]
    186 #[derive(Clone, Debug, PartialEq, Eq)]
    187 pub enum RadrootsFoodAvailabilityProjectionError {
    188     InvalidKind { expected: u32, actual: u32 },
    189     ProfileAmbiguous,
    190     ProhibitedCapability { tag: String },
    191     TagInvalid,
    192     PriceFrequencyForbidden,
    193     PriceUnitMissing,
    194     EventWireTooLarge { max: usize, actual: usize },
    195     Domain(FoodAvailabilityError),
    196 }
    197 
    198 impl RadrootsFoodAvailabilityProjectionError {
    199     pub const fn code(&self) -> &'static str {
    200         match self {
    201             Self::InvalidKind { .. } => "invalid_kind",
    202             Self::ProfileAmbiguous => "food_profile_ambiguous",
    203             Self::ProhibitedCapability { .. } => "prohibited_capability",
    204             Self::TagInvalid => "food_tag_invalid",
    205             Self::PriceFrequencyForbidden => "price_frequency_forbidden",
    206             Self::PriceUnitMissing => "price_unit_missing",
    207             Self::EventWireTooLarge { .. } => "food_event_wire_too_large",
    208             Self::Domain(error) => error.code(),
    209         }
    210     }
    211 }
    212 
    213 impl fmt::Display for RadrootsFoodAvailabilityProjectionError {
    214     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    215         match self {
    216             Self::InvalidKind { expected, actual } => write!(
    217                 formatter,
    218                 "FoodAvailability event kind must be {expected}, got {actual}"
    219             ),
    220             Self::ProfileAmbiguous => {
    221                 formatter.write_str("classified listing mixes focused and operational markers")
    222             }
    223             Self::ProhibitedCapability { tag } => {
    224                 write!(
    225                     formatter,
    226                     "FoodAvailability tag `{tag}` is a prohibited capability"
    227                 )
    228             }
    229             Self::TagInvalid => formatter.write_str("FoodAvailability core tag shape is invalid"),
    230             Self::PriceFrequencyForbidden => {
    231                 formatter.write_str("FoodAvailability price frequency is forbidden")
    232             }
    233             Self::PriceUnitMissing => formatter.write_str("FoodAvailability price unit is missing"),
    234             Self::EventWireTooLarge { max, actual } => write!(
    235                 formatter,
    236                 "FoodAvailability canonical signed event is {actual} bytes; max is {max}"
    237             ),
    238             Self::Domain(error) => write!(formatter, "{error}"),
    239         }
    240     }
    241 }
    242 
    243 #[cfg(feature = "std")]
    244 impl std::error::Error for RadrootsFoodAvailabilityProjectionError {
    245     fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
    246         match self {
    247             Self::Domain(error) => Some(error),
    248             _ => None,
    249         }
    250     }
    251 }
    252 
    253 impl From<FoodAvailabilityError> for RadrootsFoodAvailabilityProjectionError {
    254     fn from(value: FoodAvailabilityError) -> Self {
    255         Self::Domain(value)
    256     }
    257 }
    258 
    259 /// Projects a signature-and-id verified kind-30402 event at the focused Food boundary.
    260 ///
    261 /// Raw marker partitioning always precedes tag-shape and prohibited-capability
    262 /// validation. Operational and generic NIP-99 inputs are valid exclusions.
    263 pub fn project_verified_food_availability_event(
    264     verified_event: &RadrootsSignatureVerifiedEvent,
    265 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> {
    266     project_verified_food_availability_event_registry_v7(verified_event)
    267 }
    268 
    269 /// Projects a verified listing with contract-registry-v7 semantics.
    270 pub fn project_verified_food_availability_event_registry_v7(
    271     verified_event: &RadrootsSignatureVerifiedEvent,
    272 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> {
    273     let event = verified_event.event();
    274     project_inbound_food_availability_parts(
    275         event.kind_u32(),
    276         event.created_at_u64(),
    277         event.tags(),
    278         event.content(),
    279     )
    280 }
    281 
    282 pub(crate) fn project_inbound_food_availability_parts(
    283     kind: u32,
    284     created_at: u64,
    285     tags: &EventTags,
    286     content: &str,
    287 ) -> Result<RadrootsFoodAvailabilityProjectionOutcome, RadrootsFoodAvailabilityProjectionError> {
    288     if kind != KIND_CLASSIFIED_LISTING {
    289         return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind {
    290             expected: KIND_CLASSIFIED_LISTING,
    291             actual: kind,
    292         });
    293     }
    294 
    295     match classify_classified_listing_tags_registry_v7(tags) {
    296         ClassifiedListingPartition::Ambiguous => {
    297             return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous);
    298         }
    299         partition @ (ClassifiedListingPartition::OperationalListing
    300         | ClassifiedListingPartition::GenericNip99) => {
    301             return Ok(RadrootsFoodAvailabilityProjectionOutcome::Excluded(
    302                 partition,
    303             ));
    304         }
    305         ClassifiedListingPartition::FocusedFoodAvailability => {}
    306     }
    307 
    308     let tags = tags.to_vec();
    309     if let Some(tag) = tags
    310         .iter()
    311         .filter_map(|tag| tag.first())
    312         .find(|name| PROHIBITED_FOOD_CAPABILITY_TAGS.contains(&name.as_str()))
    313     {
    314         return Err(
    315             RadrootsFoodAvailabilityProjectionError::ProhibitedCapability { tag: tag.clone() },
    316         );
    317     }
    318 
    319     let identifier_value = singleton_value(&tags, "d")?;
    320     let title_value = singleton_value(&tags, "title")?;
    321     let summary_value = singleton_value(&tags, "summary")?;
    322     let published_at_value = singleton_value(&tags, "published_at")?;
    323     let location_value = singleton_value(&tags, "location")?;
    324     let status_value = singleton_value(&tags, "status")?;
    325 
    326     let content = FoodContent::new(content.to_string())?;
    327     let identifier = FoodIdentifier::parse(identifier_value)?;
    328     let title = FoodText::new(title_value.to_string())?;
    329     let summary = FoodText::new(summary_value.to_string())?;
    330     let published_at = FoodPublishedAt::parse(published_at_value)?;
    331     published_at.validate_created_at(created_at)?;
    332     let location = FoodText::new(location_value.to_string())?;
    333 
    334     let price_tags = matching_tags(&tags, "price");
    335     let price_tag = match price_tags.as_slice() {
    336         [tag] => *tag,
    337         _ => return Err(FoodAvailabilityError::PriceInvalid.into()),
    338     };
    339     if price_tag.len() > 3 {
    340         return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden);
    341     }
    342     if price_tag.len() != 3 {
    343         return Err(FoodAvailabilityError::PriceInvalid.into());
    344     }
    345     let amount = normalize_decimal(&price_tag[1], FoodAvailabilityError::PriceInvalid)?;
    346     let currency = normalize_currency(&price_tag[2])?;
    347 
    348     let price_unit_tags = matching_tags(&tags, "radroots:price_unit");
    349     if price_unit_tags.is_empty() {
    350         return Err(RadrootsFoodAvailabilityProjectionError::PriceUnitMissing);
    351     }
    352     let unit_value = match price_unit_tags.as_slice() {
    353         [tag] if tag.len() == 2 => tag[1].as_str(),
    354         _ => return Err(FoodAvailabilityError::PriceUnitInvalid.into()),
    355     };
    356     let unit = FoodUnit::parse(unit_value)?;
    357     let price = FoodPrice::new(amount, currency, unit)?;
    358 
    359     let quantity_tags = matching_tags(&tags, "radroots:quantity");
    360     let quantity = if quantity_tags.is_empty() {
    361         None
    362     } else {
    363         let tag = match quantity_tags.as_slice() {
    364             [tag] if tag.len() == 3 => *tag,
    365             _ => {
    366                 return Err(FoodAvailabilityError::QuantityInvalid.into());
    367             }
    368         };
    369         let amount = normalize_decimal(&tag[1], FoodAvailabilityError::QuantityInvalid)?;
    370         let quantity_unit =
    371             FoodUnit::parse(&tag[2]).map_err(|_| FoodAvailabilityError::QuantityInvalid)?;
    372         if quantity_unit != unit {
    373             return Err(FoodAvailabilityError::QuantityInvalid.into());
    374         }
    375         Some(FoodQuantity::new(amount, quantity_unit)?)
    376     };
    377 
    378     let status = FoodAvailabilityStatus::parse(status_value)?;
    379     let image_tags = matching_tags(&tags, "image");
    380     let (images, diagnostics) = project_images(&image_tags);
    381 
    382     Ok(RadrootsFoodAvailabilityProjectionOutcome::Focused(
    383         Box::new(RadrootsInboundFoodAvailabilityProjection {
    384             content,
    385             identifier,
    386             title,
    387             summary,
    388             published_at,
    389             location,
    390             price,
    391             quantity,
    392             status,
    393             images,
    394             diagnostics,
    395         }),
    396     ))
    397 }
    398 
    399 fn classify_classified_listing_tags_registry_v7(tags: &EventTags) -> ClassifiedListingPartition {
    400     let mut has_focused_marker = false;
    401     let mut has_operational_marker = false;
    402 
    403     for name in tags
    404         .as_slice()
    405         .iter()
    406         .filter_map(|tag| tag.as_slice().first().map(String::as_str))
    407     {
    408         match name {
    409             "radroots:price_unit" | "radroots:quantity" => has_focused_marker = true,
    410             "radroots:primary_bin" | "radroots:bin" | "radroots:price" => {
    411                 has_operational_marker = true;
    412             }
    413             _ => {}
    414         }
    415 
    416         if has_focused_marker && has_operational_marker {
    417             return ClassifiedListingPartition::Ambiguous;
    418         }
    419     }
    420 
    421     match (has_focused_marker, has_operational_marker) {
    422         (true, false) => ClassifiedListingPartition::FocusedFoodAvailability,
    423         (false, true) => ClassifiedListingPartition::OperationalListing,
    424         (false, false) => ClassifiedListingPartition::GenericNip99,
    425         (true, true) => ClassifiedListingPartition::Ambiguous,
    426     }
    427 }
    428 
    429 fn matching_tags<'a>(tags: &'a [Vec<String>], name: &str) -> Vec<&'a Vec<String>> {
    430     tags.iter()
    431         .filter(|tag| tag.first().is_some_and(|key| key == name))
    432         .collect()
    433 }
    434 
    435 fn singleton_value<'a>(
    436     tags: &'a [Vec<String>],
    437     name: &str,
    438 ) -> Result<&'a str, RadrootsFoodAvailabilityProjectionError> {
    439     exact_single_value(matching_tags(tags, name))
    440 }
    441 
    442 fn exact_single_value(
    443     matches: Vec<&Vec<String>>,
    444 ) -> Result<&str, RadrootsFoodAvailabilityProjectionError> {
    445     let tag = exact_single_tag_from_matches(matches)?;
    446     if tag.len() != 2 {
    447         return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
    448     }
    449     Ok(tag[1].as_str())
    450 }
    451 
    452 fn exact_single_tag_from_matches(
    453     matches: Vec<&Vec<String>>,
    454 ) -> Result<&Vec<String>, RadrootsFoodAvailabilityProjectionError> {
    455     match matches.as_slice() {
    456         [tag] => Ok(*tag),
    457         _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid),
    458     }
    459 }
    460 
    461 fn normalize_decimal(
    462     value: &str,
    463     error: FoodAvailabilityError,
    464 ) -> Result<String, RadrootsFoodAvailabilityProjectionError> {
    465     let mut digits = 0usize;
    466     let mut dot = None;
    467     for (index, byte) in value.bytes().enumerate() {
    468         match byte {
    469             b'0'..=b'9' => digits += 1,
    470             b'.' if dot.is_none() => dot = Some(index),
    471             _ => return Err(error.into()),
    472         }
    473     }
    474     if digits == 0
    475         || digits > RADROOTS_FOOD_DECIMAL_MAX_DIGITS
    476         || dot.is_some_and(|index| index == 0 || index + 1 == value.len())
    477     {
    478         return Err(error.into());
    479     }
    480 
    481     let (integer, fraction) = dot.map_or((value, None), |index| {
    482         (&value[..index], Some(&value[index + 1..]))
    483     });
    484     let integer = integer.trim_start_matches('0');
    485     let integer = if integer.is_empty() { "0" } else { integer };
    486     let fraction = fraction
    487         .map(|value| value.trim_end_matches('0'))
    488         .filter(|value| !value.is_empty());
    489     let mut normalized = String::with_capacity(value.len());
    490     normalized.push_str(integer);
    491     if let Some(fraction) = fraction {
    492         normalized.push('.');
    493         normalized.push_str(fraction);
    494     }
    495     Ok(normalized)
    496 }
    497 
    498 #[derive(Clone, Debug, PartialEq, Eq)]
    499 #[cfg(feature = "json")]
    500 pub(crate) struct RadrootsStrictFoodAvailabilityProjection {
    501     identifier: FoodIdentifier,
    502     published_at: FoodPublishedAt,
    503 }
    504 
    505 #[cfg(feature = "json")]
    506 impl RadrootsStrictFoodAvailabilityProjection {
    507     pub(crate) fn identifier(&self) -> &FoodIdentifier {
    508         &self.identifier
    509     }
    510 
    511     pub(crate) const fn published_at(&self) -> FoodPublishedAt {
    512         self.published_at
    513     }
    514 }
    515 
    516 #[cfg(feature = "json")]
    517 fn canonical_food_signed_event_size(tags: &[Vec<String>], content: &str, created_at: u64) -> usize {
    518     let mut tags_bytes = 2usize;
    519     for (tag_index, tag) in tags.iter().enumerate() {
    520         if tag_index > 0 {
    521             tags_bytes = tags_bytes.saturating_add(1);
    522         }
    523         tags_bytes = tags_bytes.saturating_add(2);
    524         for (element_index, element) in tag.iter().enumerate() {
    525             if element_index > 0 {
    526                 tags_bytes = tags_bytes.saturating_add(1);
    527             }
    528             tags_bytes = tags_bytes.saturating_add(canonical_json_string_bytes(element));
    529         }
    530     }
    531     FOOD_SIGNED_EVENT_FIXED_BYTES
    532         .saturating_add(decimal_u64_bytes(created_at))
    533         .saturating_add(tags_bytes)
    534         .saturating_add(canonical_json_string_bytes(content))
    535 }
    536 
    537 #[cfg(feature = "json")]
    538 fn decimal_u64_bytes(mut value: u64) -> usize {
    539     let mut bytes = 1usize;
    540     while value >= 10 {
    541         value /= 10;
    542         bytes += 1;
    543     }
    544     bytes
    545 }
    546 
    547 #[cfg(feature = "json")]
    548 fn canonical_json_string_bytes(value: &str) -> usize {
    549     value.chars().fold(2usize, |total, character| {
    550         total.saturating_add(match character {
    551             '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2,
    552             '\u{0000}'..='\u{001f}' => 6,
    553             _ => character.len_utf8(),
    554         })
    555     })
    556 }
    557 
    558 /// Validates a verified event against the exact authored Food wire profile.
    559 ///
    560 /// This does not construct signable parts or elevate inbound media to verified
    561 /// media typestate. It exists solely for comparing already signed revisions.
    562 #[cfg(feature = "json")]
    563 pub(crate) fn project_strict_verified_food_availability_event(
    564     verified_event: &RadrootsSignatureVerifiedEvent,
    565 ) -> Result<RadrootsStrictFoodAvailabilityProjection, RadrootsFoodAvailabilityProjectionError> {
    566     let event = verified_event.event();
    567     if event.kind_u32() != KIND_CLASSIFIED_LISTING {
    568         return Err(RadrootsFoodAvailabilityProjectionError::InvalidKind {
    569             expected: KIND_CLASSIFIED_LISTING,
    570             actual: event.kind_u32(),
    571         });
    572     }
    573     if classify_classified_listing_tags_registry_v7(event.tags())
    574         != ClassifiedListingPartition::FocusedFoodAvailability
    575     {
    576         return Err(RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous);
    577     }
    578 
    579     let tags = event.tags_as_vec();
    580     let actual_wire_bytes =
    581         canonical_food_signed_event_size(&tags, event.content(), event.created_at_u64());
    582     if actual_wire_bytes > DEFAULT_RAW_JSON_MAX_BYTES {
    583         return Err(RadrootsFoodAvailabilityProjectionError::EventWireTooLarge {
    584             max: DEFAULT_RAW_JSON_MAX_BYTES,
    585             actual: actual_wire_bytes,
    586         });
    587     }
    588     let observed_names = tags
    589         .iter()
    590         .map(|tag| tag.first().map(String::as_str))
    591         .collect::<Vec<_>>();
    592     let has_quantity = observed_names.contains(&Some("radroots:quantity"));
    593     let image_count = observed_names
    594         .iter()
    595         .filter(|name| **name == Some("image"))
    596         .count();
    597     let mut expected_names = Vec::with_capacity(8 + usize::from(has_quantity) + image_count);
    598     expected_names.extend([
    599         "d",
    600         "title",
    601         "summary",
    602         "published_at",
    603         "location",
    604         "price",
    605         "radroots:price_unit",
    606     ]);
    607     if has_quantity {
    608         expected_names.push("radroots:quantity");
    609     }
    610     expected_names.push("status");
    611     expected_names.extend(core::iter::repeat_n("image", image_count));
    612     if observed_names
    613         .iter()
    614         .copied()
    615         .ne(expected_names.iter().copied().map(Some))
    616     {
    617         return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
    618     }
    619 
    620     let price_tags = matching_tags(&tags, "price");
    621     let price = match price_tags.as_slice() {
    622         [tag] if tag.len() == 3 => *tag,
    623         [tag] if tag.len() > 3 => {
    624             return Err(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden);
    625         }
    626         _ => return Err(FoodAvailabilityError::PriceInvalid.into()),
    627     };
    628     let normalized_price = normalize_decimal(&price[1], FoodAvailabilityError::PriceInvalid)?;
    629     if normalized_price != price[1] {
    630         return Err(FoodAvailabilityError::PriceInvalid.into());
    631     }
    632     if normalize_currency(&price[2])?.as_str() != price[2] {
    633         return Err(FoodAvailabilityError::PriceCurrencyInvalid.into());
    634     }
    635 
    636     if let Some(quantity) = matching_tags(&tags, "radroots:quantity").first() {
    637         if quantity.len() != 3 {
    638             return Err(FoodAvailabilityError::QuantityInvalid.into());
    639         }
    640         let normalized = normalize_decimal(&quantity[1], FoodAvailabilityError::QuantityInvalid)?;
    641         if normalized != quantity[1] {
    642             return Err(FoodAvailabilityError::QuantityInvalid.into());
    643         }
    644     }
    645 
    646     if image_count > RADROOTS_FOOD_IMAGE_MAX_COUNT {
    647         return Err(FoodAvailabilityError::ImageCountExceeded {
    648             max: RADROOTS_FOOD_IMAGE_MAX_COUNT,
    649             actual: image_count,
    650         }
    651         .into());
    652     }
    653     let mut seen_urls = Vec::<String>::new();
    654     let mut seen_digests = Vec::<Sha256>::new();
    655     for tag in matching_tags(&tags, "image") {
    656         if tag.len() != 3 {
    657             return Err(RadrootsFoodAvailabilityProjectionError::TagInvalid);
    658         }
    659         let url = BlobUrl::parse(&tag[1])
    660             .and_then(BlobUrl::approve)
    661             .map_err(|_| RadrootsFoodAvailabilityProjectionError::TagInvalid)?;
    662         FoodImageDimensions::parse(&tag[2])?;
    663         if seen_urls.iter().any(|seen| seen == &tag[1]) {
    664             return Err(FoodAvailabilityError::ImageDuplicateUrl.into());
    665         }
    666         let digest = url.as_blob_url().hash_path().hash();
    667         if seen_digests.contains(&digest) {
    668             return Err(FoodAvailabilityError::ImageDuplicateDigest.into());
    669         }
    670         seen_urls.push(tag[1].clone());
    671         seen_digests.push(digest);
    672     }
    673 
    674     match project_inbound_food_availability_parts(
    675         event.kind_u32(),
    676         event.created_at_u64(),
    677         event.tags(),
    678         event.content(),
    679     )? {
    680         RadrootsFoodAvailabilityProjectionOutcome::Focused(projection)
    681             if projection.diagnostics().is_empty() =>
    682         {
    683             Ok(RadrootsStrictFoodAvailabilityProjection {
    684                 identifier: projection.identifier().clone(),
    685                 published_at: projection.published_at(),
    686             })
    687         }
    688         _ => Err(RadrootsFoodAvailabilityProjectionError::TagInvalid),
    689     }
    690 }
    691 
    692 fn normalize_currency(
    693     value: &str,
    694 ) -> Result<FoodCurrency, RadrootsFoodAvailabilityProjectionError> {
    695     if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
    696         return Err(FoodAvailabilityError::PriceCurrencyInvalid.into());
    697     }
    698     Ok(FoodCurrency::parse(value.to_ascii_uppercase())?)
    699 }
    700 
    701 fn project_images(
    702     image_tags: &[&Vec<String>],
    703 ) -> (
    704     Vec<RadrootsInboundFoodAvailabilityImage>,
    705     Vec<RadrootsFoodAvailabilityImageDiagnostic>,
    706 ) {
    707     let mut diagnostics = Vec::new();
    708     if image_tags.len() > RADROOTS_FOOD_IMAGE_MAX_COUNT {
    709         diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::CountExceeded);
    710     }
    711     let bounded = &image_tags[..image_tags.len().min(RADROOTS_FOOD_IMAGE_MAX_COUNT)];
    712     let mut images = Vec::with_capacity(bounded.len());
    713     let mut seen_urls = Vec::<String>::new();
    714     let mut seen_digests = Vec::<Sha256>::new();
    715 
    716     for tag in bounded {
    717         let raw_url = tag.get(1).cloned();
    718         let raw_dimensions = tag.get(2).cloned();
    719         let mut image_diagnostics = Vec::new();
    720         if tag.len() != 3 {
    721             image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid);
    722         }
    723 
    724         let url = match raw_url.as_deref() {
    725             Some(value) if food_media_http_url_is_valid(value) => Some(value.to_string()),
    726             Some(_) | None => {
    727                 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::UrlInvalid);
    728                 None
    729             }
    730         };
    731         let dimensions = match raw_dimensions.as_deref() {
    732             None => {
    733                 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing);
    734                 None
    735             }
    736             Some(value) => match FoodImageDimensions::parse(value) {
    737                 Ok(dimensions) => Some(dimensions),
    738                 Err(_) => {
    739                     image_diagnostics
    740                         .push(RadrootsFoodAvailabilityImageDiagnostic::DimensionsInvalid);
    741                     None
    742                 }
    743             },
    744         };
    745 
    746         if let Some(raw_url) = raw_url {
    747             if seen_urls.iter().any(|seen| seen == &raw_url) {
    748                 image_diagnostics.push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateUrl);
    749             }
    750             seen_urls.push(raw_url.clone());
    751             if let Some(digest) = food_media_blossom_digest(&raw_url) {
    752                 if seen_digests.contains(&digest) {
    753                     image_diagnostics
    754                         .push(RadrootsFoodAvailabilityImageDiagnostic::DuplicateDigest);
    755                 }
    756                 seen_digests.push(digest);
    757             }
    758         }
    759 
    760         diagnostics.extend(image_diagnostics.iter().copied());
    761         images.push(RadrootsInboundFoodAvailabilityImage {
    762             raw_tag: (*tag).clone(),
    763             url,
    764             dimensions,
    765             diagnostics: image_diagnostics,
    766         });
    767     }
    768 
    769     (images, diagnostics)
    770 }
    771 
    772 #[cfg(test)]
    773 mod tests;