lib

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

commit 2c6069bdca02455e402fcf7c364386c2b9fd9f1a
parent 78f8befa5bf2a3d71170865d21057d854c3fe5b8
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 04:41:35 +0000

event: add focused food domain primitives

- partition classified listings by exact focused and operational markers
- validate bounded FoodAvailability values and canonical decimal semantics
- require unique byte-verified Blossom images in focused details
- document the non-codec and non-publication boundary

Diffstat:
MCHANGELOG.md | 11+++++++++++
MCargo.lock | 1+
Mcontracts/event_boundary_matrix.md | 19+++++++++++++++++++
Mcontracts/events/social-events.md | 55+++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcontracts/releases/1.0.0-alpha.1.toml | 10++++++++++
Mcrates/event/Cargo.toml | 1+
Mcrates/event/README | 37+++++++++++++++++++++++++++++++++++++
Acrates/event/src/classified_listing.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event/src/food_availability.rs | 1255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/lib.rs | 2++
10 files changed, 1617 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -68,6 +68,17 @@ publish policy both pass for the same source revision. ### Added +- Kind `30402` now has one allocation-free raw marker-name partition that + distinguishes focused FoodAvailability, richer Operational Listing, + marker-free generic NIP-99, and mixed ambiguous inputs before profile + tag-shape validation. +- FoodAvailability now has strict domain and authored-media input primitives + for bounded identifiers and text, canonical decimal price and quantity, + uppercase currency, the closed ten-unit food vocabulary, active or sold + status, timestamps, dimensions, and at most 64 unique byte-verified Blossom + images. These details are not yet a signable authored draft and add no codec, + admission, registry, replica, publication, upload, raster-decoding, or + network-availability claim. - Verified Profile admission binds a signed exact kind-`0` envelope to the tolerant metadata projection, accepts standard tagless events, and exposes deterministic equal-time lowest-id replacement vectors. diff --git a/Cargo.lock b/Cargo.lock @@ -4323,6 +4323,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "unicode-general-category", "url", ] diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -41,6 +41,25 @@ its typed codec, tags, and publication operations are exposed under the `operational_listing` domain. The standard kind identity does not by itself establish that an event satisfies the operational profile. +Raw tag-name presence partitions the standard kind before profile tag-shape +validation. `radroots:price_unit` and `radroots:quantity` are focused +FoodAvailability markers. `radroots:primary_bin`, `radroots:bin`, and +`radroots:price` are Operational Listing markers. Focused-only, +operational-only, marker-free generic NIP-99, and mixed-marker inputs classify +as `FocusedFoodAvailability`, `OperationalListing`, `GenericNip99`, and +`Ambiguous`; malformed tags still contribute their first element, and names +are case-sensitive. This central partition does not inspect kind, values, or +arity and does not validate either profile. + +The current FoodAvailability boundary exposes strict domain and authored-media +input primitives only. Its validated details contain no farm, bin, route, +pickup, delivery, order, or checkout fields, but they are not yet a signable +authored draft. No FoodAvailability codec, registry contract, inbound +admission, operation, replica projection, publication path, or client behavior +is established by this checkpoint. A validated image proves local Blossom +descriptor-to-byte agreement only; it does not prove upload completion, raster +decoding, reachability, or network availability. + ## Kind-1 post boundary rule Ordinary kind-1 events remain interoperable at the generic diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -29,7 +29,8 @@ authoring and admission profile are separate contract layers. The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111` `RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, operational listing -records through `RadrootsOperationalListing`, articles, generic public file metadata, calendar date events, +records through `RadrootsOperationalListing`, the raw kind-`30402` profile partition and validated +FoodAvailability domain primitives, articles, generic public file metadata, calendar date events, calendar time events, reposts, generic reposts, calendar collections, RSVP events, and reports. The closeout contract requires: @@ -72,6 +73,9 @@ The production-v1 public social substrate includes: - `RadrootsReport` for NIP-56 kind `1984` - operational-listing profile validation through `RadrootsOperationalListing` at NIP-99 classified-listing kind `30402` +- validated FoodAvailability details and a raw marker-name partition for focused, operational, + generic NIP-99, and ambiguous kind-`30402` inputs; the focused codec and admission boundary are + not part of this checkpoint - relay-list kind `10002` validation through `RadrootsList` ## Contract Decisions @@ -185,6 +189,48 @@ though both use kind `1063`. The public generic model must cover the current sim including URL, MIME type, SHA-256 hash, original hash, size, dimensions, blurhash, thumbnail, image, summary, alt text, fallback, `magnet`, `i`, and `service`. +### FoodAvailability Domain Boundary + +Kind `30402` routing first inspects only the raw first element of each tag. The focused marker set is +exactly `radroots:price_unit` and `radroots:quantity`; the Operational Listing marker set is exactly +`radroots:primary_bin`, `radroots:bin`, and `radroots:price`. Focused-only, operational-only, +marker-free generic NIP-99, and mixed-marker inputs are distinct partition results. This happens +before profile tag-shape validation, so a malformed one-element marker still counts. Matching is +exact and case-sensitive. `RadrootsClassifiedListingPartition` names those results +`FocusedFoodAvailability`, `OperationalListing`, `GenericNip99`, and `Ambiguous`. The partition is +allocation-free, does not inspect the event kind, marker values, or tag arity, and does not establish +that either profile is valid. + +`RadrootsFoodAvailabilityDetails` and its focused domain values are checked before construction. +The identifier is 1 through 512 UTF-8 bytes, contains no whitespace, and contains no Unicode +control or format character. Content must contain at least one scalar outside Unicode whitespace +and the information-separator range U+001C through U+001F, and is bounded to +131072 UTF-8 bytes; it is not normalized or subjected to the stricter metadata-text policy. Title, +summary, and location are trimmed, nonempty, control-free text bounded to 4096 UTF-8 bytes. +`published_at` is a canonical nonzero `u64` decimal and can be checked as no later than a supplied +`created_at`. + +Price and optional quantity are canonical unsigned plain decimals with at most 28 ASCII digits +excluding the optional decimal point. Price may be zero; quantity must be positive. Currency is +exactly three uppercase ASCII letters. The dedicated unit vocabulary is `g`, `kg`, `lb`, `oz`, +`each`, `dozen`, `bunch`, `punnet`, `bag`, and `basket`, and quantity uses the same unit as price. +Status is exactly `active` or `sold`. + +Image dimensions are two nonzero canonical `u32` decimal components in `WIDTHxHEIGHT` form. +Validated details accept no more than 64 images, reject duplicate URLs or Blossom digests, and +accept only `RadrootsAuthoredImage` values backed by an approved, byte-verified image descriptor. +That proof establishes local descriptor-to-byte agreement only. It does not establish BUD-02 upload +completion, decoded raster dimensions, content safety, retrieval, reachability, or network +availability. + +The details model deliberately has no farm, bin, route, pickup, delivery, order, checkout, or other +commerce-workflow field. It retains `published_at` as input for later replacement checks, but this +checkpoint does not compare revisions or prove its stability. The model is not serializable and is +not a signable authored draft; it emits no tags or wire event. This checkpoint adds no +FoodAvailability codec, registry identity, verified inbound admission, revision validator, replica +projection, signing, publication, or client behavior; those boundaries require separate executable +contract work. + ### Calendar Trust Layers Kinds `31922`, `31923`, `31924`, and `31925` have three explicit, @@ -319,9 +365,10 @@ format-safety policy. No calendar model upgrades an observed relay URL into eith or an availability guarantee. Product routing uses surface-specific kind classifiers rather than a broad public-social set. Home, -Events, Market, Map, and Profile public-content candidates are explicit. Active NIP-99 -classified-listing kind `30402` -can appear in public product surfaces. Report kind `1984` is a moderation/admin candidate, not +Events, Market, Map, and Profile public-content candidates are explicit. A kind-`30402` value alone +does not select FoodAvailability: raw marker partitioning first distinguishes focused, +operational, generic NIP-99, and ambiguous candidates. Full FoodAvailability admission remains a +later boundary. Report kind `1984` is a moderation/admin candidate, not normal feed content. Relay and HTTP auth kinds are transient and excluded from durable social and farm-ops candidate sets. Private farm operations candidates include the farm workspace manifest, farm CRDT change envelope, farm file metadata, and the supported NIP-29 group event subset. diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -145,3 +145,13 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Reject raw Unicode normalization, implicit IDNA, and invalid ASCII DNS labels before constructing Blossom blob URL, approval, or byte-verification typestates." + +[[changes]] +id = "food-availability-domain-foundation" +classification = "feature" +semver_impacts = [ + "add_exported_type", + "add_exported_constant", + "add_enum_variant", +] +summary = "Add raw kind-30402 profile partitioning and validated FoodAvailability domain and media-input primitives without claiming a codec, authored draft, admission, signing, or publication boundary." diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml @@ -42,6 +42,7 @@ serde_json = { workspace = true, default-features = false, features = [ ] } secp256k1 = { workspace = true, optional = true } sha2 = { workspace = true, default-features = false } +unicode-general-category = { workspace = true } url_nostd = { workspace = true } [dev-dependencies] diff --git a/crates/event/README b/crates/event/README @@ -30,6 +30,43 @@ image-typed byte-verified Blossom descriptor. That descriptor state is not an upload receipt; BUD-02 completion remains a runtime prerequisite before signing. +Kind `30402` has a raw, allocation-free marker partition before profile-specific +tag-shape validation. Presence of `radroots:price_unit` or `radroots:quantity` selects +the focused FoodAvailability marker family; presence of +`radroots:primary_bin`, `radroots:bin`, or `radroots:price` selects the richer +Operational Listing marker family. Focused-only, operational-only, marker-free +generic NIP-99, and mixed-marker events produce +`RadrootsClassifiedListingPartition::{FocusedFoodAvailability, +OperationalListing, GenericNip99, Ambiguous}` respectively. A malformed +one-element tag still contributes its raw first name, and marker matching is +case-sensitive. `classify_classified_listing_tags` and the borrowed-slice +variant inspect neither kind, tag values, nor tag arity. + +The FoodAvailability module provides `RadrootsFoodAvailabilityDetails` and +checked identifier, text, publication timestamp, price, currency, unit, +quantity, status, image-dimension, and image values. Content contains at least +one scalar outside Unicode whitespace and U+001C through U+001F, and is bounded +to 131072 UTF-8 bytes. Identifiers reject whitespace plus Unicode control and +format characters; title, summary, and location use trimmed, nonempty, +control-free text bounded to 4096 UTF-8 bytes. Food units are closed to `g`, +`kg`, `lb`, `oz`, `each`, `dozen`, `bunch`, `punnet`, `bag`, and `basket`. +Price permits zero; quantity is strictly positive and uses the price unit. +Image dimensions use two nonzero canonical `u32` decimal values in +`WIDTHxHEIGHT` form. Details accept at most 64 images, require unique image URLs +and Blossom digests, and accept only `RadrootsAuthoredImage` values that already +prove local descriptor-to-byte agreement. Details retain nonzero `published_at` +for later replacement checks and can validate that it is not later than a +supplied `created_at`; this checkpoint does not compare revisions or prove +timestamp stability across them. + +These checked details are domain input, not a signable authored draft or wire +event. This checkpoint supplies no FoodAvailability tag codec, event-contract +registry entry, relay admission, signing, publication, upload receipt, raster +decoding, network retrieval, or availability proof. Successful BUD-02 upload +and any required media decoding or retrievability checks remain separate +runtime responsibilities before a later publication boundary may sign an +event. + The calendar module keeps three different states explicit for NIP-52 kinds `31922`, `31923`, `31924`, and `31925`: the complete structural event envelope, a tolerant baseline NIP-52 projection, and a strict Radroots-admitted diff --git a/crates/event/src/classified_listing.rs b/crates/event/src/classified_listing.rs @@ -0,0 +1,230 @@ +use crate::{RadrootsEventTag, RadrootsEventTags}; + +pub const TAG_RADROOTS_PRICE_UNIT: &str = "radroots:price_unit"; +pub const TAG_RADROOTS_QUANTITY: &str = "radroots:quantity"; +pub const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; +pub const TAG_RADROOTS_BIN: &str = "radroots:bin"; +pub const TAG_RADROOTS_PRICE: &str = "radroots:price"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// The marker-selected profile partition for a NIP-99 classified listing. +pub enum RadrootsClassifiedListingPartition { + FocusedFoodAvailability, + OperationalListing, + GenericNip99, + Ambiguous, +} + +#[inline] +/// Partitions a classified listing by exact raw marker-name presence. +/// +/// The caller owns kind checks and profile validation. Tag values and arity do +/// not affect this partition, so malformed one-element marker tags still count. +pub fn classify_classified_listing_tags( + tags: &RadrootsEventTags, +) -> RadrootsClassifiedListingPartition { + classify_classified_listing_tag_slice(tags.as_slice()) +} + +/// Partitions a borrowed classified-listing tag slice without allocating. +/// +/// The caller owns kind checks and profile validation. Tag values and arity do +/// not affect this partition, so malformed one-element marker tags still count. +pub fn classify_classified_listing_tag_slice( + tags: &[RadrootsEventTag], +) -> RadrootsClassifiedListingPartition { + let mut has_focused_marker = false; + let mut has_operational_marker = false; + + for tag in tags { + let Some(name) = tag.as_slice().first().map(|value| value.as_str()) else { + continue; + }; + + match name { + TAG_RADROOTS_PRICE_UNIT | TAG_RADROOTS_QUANTITY => has_focused_marker = true, + TAG_RADROOTS_PRIMARY_BIN | TAG_RADROOTS_BIN | TAG_RADROOTS_PRICE => { + has_operational_marker = true; + } + _ => {} + } + + if has_focused_marker && has_operational_marker { + return RadrootsClassifiedListingPartition::Ambiguous; + } + } + + match (has_focused_marker, has_operational_marker) { + (true, false) => RadrootsClassifiedListingPartition::FocusedFoodAvailability, + (false, true) => RadrootsClassifiedListingPartition::OperationalListing, + (false, false) => RadrootsClassifiedListingPartition::GenericNip99, + (true, true) => RadrootsClassifiedListingPartition::Ambiguous, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FOCUSED_MARKERS: [&str; 2] = [TAG_RADROOTS_PRICE_UNIT, TAG_RADROOTS_QUANTITY]; + const OPERATIONAL_MARKERS: [&str; 3] = [ + TAG_RADROOTS_PRIMARY_BIN, + TAG_RADROOTS_BIN, + TAG_RADROOTS_PRICE, + ]; + + fn tags(values: &[&[&str]]) -> RadrootsEventTags { + RadrootsEventTags::new( + values + .iter() + .map(|tag| tag.iter().map(|value| (*value).to_owned()).collect()) + .collect(), + ) + .expect("valid test tags") + } + + fn classify(values: &[&[&str]]) -> RadrootsClassifiedListingPartition { + classify_classified_listing_tags(&tags(values)) + } + + #[test] + fn exposes_exact_marker_names() { + assert_eq!(TAG_RADROOTS_PRICE_UNIT, "radroots:price_unit"); + assert_eq!(TAG_RADROOTS_QUANTITY, "radroots:quantity"); + assert_eq!(TAG_RADROOTS_PRIMARY_BIN, "radroots:primary_bin"); + assert_eq!(TAG_RADROOTS_BIN, "radroots:bin"); + assert_eq!(TAG_RADROOTS_PRICE, "radroots:price"); + } + + #[test] + fn each_focused_marker_selects_focused_food_availability() { + for marker in FOCUSED_MARKERS { + assert_eq!( + classify(&[&[marker, "value"]]), + RadrootsClassifiedListingPartition::FocusedFoodAvailability, + "focused marker {marker}" + ); + } + } + + #[test] + fn each_operational_marker_selects_operational_listing() { + for marker in OPERATIONAL_MARKERS { + assert_eq!( + classify(&[&[marker, "value"]]), + RadrootsClassifiedListingPartition::OperationalListing, + "operational marker {marker}" + ); + } + } + + #[test] + fn one_element_malformed_markers_still_partition() { + for marker in FOCUSED_MARKERS { + assert_eq!( + classify(&[&[marker]]), + RadrootsClassifiedListingPartition::FocusedFoodAvailability, + "malformed focused marker {marker}" + ); + } + + for marker in OPERATIONAL_MARKERS { + assert_eq!( + classify(&[&[marker]]), + RadrootsClassifiedListingPartition::OperationalListing, + "malformed operational marker {marker}" + ); + } + } + + #[test] + fn every_focused_and_operational_marker_pair_is_ambiguous_in_either_order() { + for focused in FOCUSED_MARKERS { + for operational in OPERATIONAL_MARKERS { + assert_eq!( + classify(&[&[focused], &[operational]]), + RadrootsClassifiedListingPartition::Ambiguous, + "focused {focused} before operational {operational}" + ); + assert_eq!( + classify(&[&[operational], &[focused]]), + RadrootsClassifiedListingPartition::Ambiguous, + "operational {operational} before focused {focused}" + ); + } + } + } + + #[test] + fn duplicate_markers_do_not_change_the_partition() { + assert_eq!( + classify(&[ + &[TAG_RADROOTS_PRICE_UNIT], + &[TAG_RADROOTS_PRICE_UNIT, "lb"], + &[TAG_RADROOTS_QUANTITY, "20", "lb"], + ]), + RadrootsClassifiedListingPartition::FocusedFoodAvailability + ); + assert_eq!( + classify(&[ + &[TAG_RADROOTS_BIN], + &[TAG_RADROOTS_BIN, "bin-1"], + &[TAG_RADROOTS_PRICE, "bin-1", "3", "CAD", "1", "lb"], + ]), + RadrootsClassifiedListingPartition::OperationalListing + ); + } + + #[test] + fn marker_matching_is_exact_and_case_sensitive() { + for near_match in [ + "RADROOTS:PRICE_UNIT", + "Radroots:price_unit", + "radroots:Price_unit", + "radroots:price-unit", + "radroots:price_unit ", + " radroots:price_unit", + "radroots:price_units", + "xradroots:price_unit", + "radroots:primary_bins", + "radroots:binning", + "radroots:prices", + ] { + assert_eq!( + classify(&[&[near_match, "value"]]), + RadrootsClassifiedListingPartition::GenericNip99, + "near match {near_match}" + ); + } + } + + #[test] + fn marker_names_in_values_do_not_partition() { + let values = [ + &["summary", TAG_RADROOTS_PRICE_UNIT][..], + &["description", TAG_RADROOTS_QUANTITY][..], + &["title", TAG_RADROOTS_PRIMARY_BIN][..], + &["t", TAG_RADROOTS_BIN][..], + &["alt", TAG_RADROOTS_PRICE][..], + ]; + + assert_eq!( + classify(&values), + RadrootsClassifiedListingPartition::GenericNip99 + ); + } + + #[test] + fn empty_tags_are_generic_nip99() { + let tags = RadrootsEventTags::new(Vec::new()).expect("empty tag list is valid"); + + assert_eq!( + classify_classified_listing_tags(&tags), + RadrootsClassifiedListingPartition::GenericNip99 + ); + assert_eq!( + classify_classified_listing_tag_slice(tags.as_slice()), + RadrootsClassifiedListingPartition::GenericNip99 + ); + } +} diff --git a/crates/event/src/food_availability.rs b/crates/event/src/food_availability.rs @@ -0,0 +1,1255 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +use core::{fmt, str::FromStr}; +use unicode_general_category::{GeneralCategory, get_general_category}; + +use crate::media::RadrootsAuthoredImage; + +pub const RADROOTS_FOOD_CONTENT_MAX_BYTES: usize = 128 * 1024; +pub const RADROOTS_FOOD_IDENTIFIER_MAX_BYTES: usize = 512; +pub const RADROOTS_FOOD_TEXT_MAX_BYTES: usize = 4 * 1024; +pub const RADROOTS_FOOD_DECIMAL_MAX_DIGITS: usize = 28; +pub const RADROOTS_FOOD_IMAGE_MAX_COUNT: usize = 64; + +/// Errors raised while constructing strict FoodAvailability details. +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsFoodAvailabilityError { + ContentMissing, + ContentTooLarge { max: usize, actual: usize }, + IdentifierInvalid, + IdentifierTooLarge { max: usize, actual: usize }, + TextInvalid, + TextTooLarge { max: usize, actual: usize }, + PublishedAtInvalid, + PublishedAtFuture { published_at: u64, created_at: u64 }, + PriceInvalid, + PriceCurrencyInvalid, + PriceUnitInvalid, + QuantityInvalid, + QuantityZero, + StatusInvalid, + ImageDimensionsInvalid, + ImageCountExceeded { max: usize, actual: usize }, + ImageDuplicateUrl, + ImageDuplicateDigest, +} + +impl RadrootsFoodAvailabilityError { + pub const fn code(&self) -> &'static str { + match self { + Self::ContentMissing => "food_content_missing", + Self::ContentTooLarge { .. } => "food_content_too_large", + Self::IdentifierInvalid | Self::IdentifierTooLarge { .. } => "food_identifier_invalid", + Self::TextInvalid | Self::TextTooLarge { .. } => "food_text_invalid", + Self::PublishedAtInvalid => "food_published_at_invalid", + Self::PublishedAtFuture { .. } => "food_published_at_future", + Self::PriceInvalid => "price_invalid", + Self::PriceCurrencyInvalid => "price_currency_invalid", + Self::PriceUnitInvalid => "price_unit_invalid", + Self::QuantityInvalid => "quantity_invalid", + Self::QuantityZero => "quantity_zero", + Self::StatusInvalid => "food_status_invalid", + Self::ImageDimensionsInvalid => "food_image_dimensions_invalid", + Self::ImageCountExceeded { .. } => "food_image_count_exceeded", + Self::ImageDuplicateUrl => "food_image_duplicate_url", + Self::ImageDuplicateDigest => "food_image_duplicate_digest", + } + } +} + +impl fmt::Display for RadrootsFoodAvailabilityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContentMissing => { + formatter.write_str("FoodAvailability content must be non-whitespace") + } + Self::ContentTooLarge { max, actual } => write!( + formatter, + "FoodAvailability content is {actual} bytes; max is {max}" + ), + Self::IdentifierInvalid => formatter.write_str( + "FoodAvailability identifier must be nonempty and contain no whitespace, control, or format characters", + ), + Self::IdentifierTooLarge { max, actual } => write!( + formatter, + "FoodAvailability identifier is {actual} bytes; max is {max}" + ), + Self::TextInvalid => formatter.write_str( + "FoodAvailability text must be trimmed, nonempty, and contain no control or format characters", + ), + Self::TextTooLarge { max, actual } => write!( + formatter, + "FoodAvailability text is {actual} bytes; max is {max}" + ), + Self::PublishedAtInvalid => formatter.write_str( + "FoodAvailability published_at must be a canonical nonzero u64 timestamp", + ), + Self::PublishedAtFuture { + published_at, + created_at, + } => write!( + formatter, + "FoodAvailability published_at {published_at} exceeds created_at {created_at}" + ), + Self::PriceInvalid => formatter.write_str( + "FoodAvailability price must be a canonical unsigned decimal with at most 28 digits", + ), + Self::PriceCurrencyInvalid => formatter.write_str( + "FoodAvailability price currency must be three uppercase ASCII letters", + ), + Self::PriceUnitInvalid => { + formatter.write_str("FoodAvailability price unit is not governed") + } + Self::QuantityInvalid => formatter.write_str( + "FoodAvailability quantity must be canonical and use the price unit", + ), + Self::QuantityZero => { + formatter.write_str("FoodAvailability quantity must be positive") + } + Self::StatusInvalid => { + formatter.write_str("FoodAvailability status must be active or sold") + } + Self::ImageDimensionsInvalid => formatter.write_str( + "FoodAvailability image dimensions must be canonical nonzero u32 values", + ), + Self::ImageCountExceeded { max, actual } => write!( + formatter, + "FoodAvailability has {actual} images; max is {max}" + ), + Self::ImageDuplicateUrl => { + formatter.write_str("FoodAvailability image URLs must be unique") + } + Self::ImageDuplicateDigest => { + formatter.write_str("FoodAvailability image digests must be unique") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsFoodAvailabilityError {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodContent(String); + +impl RadrootsFoodContent { + pub fn new(value: impl Into<String>) -> Result<Self, RadrootsFoodAvailabilityError> { + let value = value.into(); + if value.chars().all(is_food_contract_whitespace) { + return Err(RadrootsFoodAvailabilityError::ContentMissing); + } + if value.len() > RADROOTS_FOOD_CONTENT_MAX_BYTES { + return Err(RadrootsFoodAvailabilityError::ContentTooLarge { + max: RADROOTS_FOOD_CONTENT_MAX_BYTES, + actual: value.len(), + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef<str> for RadrootsFoodContent { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for RadrootsFoodContent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodIdentifier(String); + +impl RadrootsFoodIdentifier { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsFoodAvailabilityError> { + let value = value.as_ref(); + if value.is_empty() + || value + .chars() + .any(|character| character.is_whitespace() || is_control_or_format(character)) + { + return Err(RadrootsFoodAvailabilityError::IdentifierInvalid); + } + if value.len() > RADROOTS_FOOD_IDENTIFIER_MAX_BYTES { + return Err(RadrootsFoodAvailabilityError::IdentifierTooLarge { + max: RADROOTS_FOOD_IDENTIFIER_MAX_BYTES, + actual: value.len(), + }); + } + Ok(Self(value.into())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef<str> for RadrootsFoodIdentifier { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for RadrootsFoodIdentifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsFoodIdentifier { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodText(String); + +impl RadrootsFoodText { + pub fn new(value: impl Into<String>) -> Result<Self, RadrootsFoodAvailabilityError> { + let value = value.into(); + if value.is_empty() || value.trim() != value || value.chars().any(is_control_or_format) { + return Err(RadrootsFoodAvailabilityError::TextInvalid); + } + if value.len() > RADROOTS_FOOD_TEXT_MAX_BYTES { + return Err(RadrootsFoodAvailabilityError::TextTooLarge { + max: RADROOTS_FOOD_TEXT_MAX_BYTES, + actual: value.len(), + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef<str> for RadrootsFoodText { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for RadrootsFoodText { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodPublishedAt(u64); + +impl RadrootsFoodPublishedAt { + pub const fn new(value: u64) -> Result<Self, RadrootsFoodAvailabilityError> { + if value == 0 { + return Err(RadrootsFoodAvailabilityError::PublishedAtInvalid); + } + Ok(Self(value)) + } + + pub fn parse(value: &str) -> Result<Self, RadrootsFoodAvailabilityError> { + if !canonical_unsigned_integer(value) { + return Err(RadrootsFoodAvailabilityError::PublishedAtInvalid); + } + value + .parse::<u64>() + .ok() + .and_then(|parsed| Self::new(parsed).ok()) + .ok_or(RadrootsFoodAvailabilityError::PublishedAtInvalid) + } + + pub const fn as_u64(self) -> u64 { + self.0 + } + + pub const fn validate_created_at( + self, + created_at: u64, + ) -> Result<(), RadrootsFoodAvailabilityError> { + if self.0 > created_at { + return Err(RadrootsFoodAvailabilityError::PublishedAtFuture { + published_at: self.0, + created_at, + }); + } + Ok(()) + } +} + +impl fmt::Display for RadrootsFoodPublishedAt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0) + } +} + +impl FromStr for RadrootsFoodPublishedAt { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodCurrency(String); + +impl RadrootsFoodCurrency { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsFoodAvailabilityError> { + let value = value.as_ref(); + if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_uppercase()) { + return Err(RadrootsFoodAvailabilityError::PriceCurrencyInvalid); + } + Ok(Self(value.into())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef<str> for RadrootsFoodCurrency { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for RadrootsFoodCurrency { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsFoodCurrency { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsFoodUnit { + Gram, + Kilogram, + Pound, + Ounce, + Each, + Dozen, + Bunch, + Punnet, + Bag, + Basket, +} + +impl RadrootsFoodUnit { + pub const fn as_str(self) -> &'static str { + match self { + Self::Gram => "g", + Self::Kilogram => "kg", + Self::Pound => "lb", + Self::Ounce => "oz", + Self::Each => "each", + Self::Dozen => "dozen", + Self::Bunch => "bunch", + Self::Punnet => "punnet", + Self::Bag => "bag", + Self::Basket => "basket", + } + } + + pub fn parse(value: &str) -> Result<Self, RadrootsFoodAvailabilityError> { + match value { + "g" => Ok(Self::Gram), + "kg" => Ok(Self::Kilogram), + "lb" => Ok(Self::Pound), + "oz" => Ok(Self::Ounce), + "each" => Ok(Self::Each), + "dozen" => Ok(Self::Dozen), + "bunch" => Ok(Self::Bunch), + "punnet" => Ok(Self::Punnet), + "bag" => Ok(Self::Bag), + "basket" => Ok(Self::Basket), + _ => Err(RadrootsFoodAvailabilityError::PriceUnitInvalid), + } + } +} + +impl fmt::Display for RadrootsFoodUnit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsFoodUnit { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodPrice { + amount: String, + currency: RadrootsFoodCurrency, + unit: RadrootsFoodUnit, +} + +impl RadrootsFoodPrice { + pub fn new( + amount: impl Into<String>, + currency: RadrootsFoodCurrency, + unit: RadrootsFoodUnit, + ) -> Result<Self, RadrootsFoodAvailabilityError> { + let amount = amount.into(); + validate_canonical_decimal(&amount) + .then_some(()) + .ok_or(RadrootsFoodAvailabilityError::PriceInvalid)?; + Ok(Self { + amount, + currency, + unit, + }) + } + + pub fn amount(&self) -> &str { + self.amount.as_str() + } + + pub fn currency(&self) -> &RadrootsFoodCurrency { + &self.currency + } + + pub const fn unit(&self) -> RadrootsFoodUnit { + self.unit + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodQuantity { + amount: String, + unit: RadrootsFoodUnit, +} + +impl RadrootsFoodQuantity { + pub fn new( + amount: impl Into<String>, + unit: RadrootsFoodUnit, + ) -> Result<Self, RadrootsFoodAvailabilityError> { + let amount = amount.into(); + if !validate_canonical_decimal(&amount) { + return Err(RadrootsFoodAvailabilityError::QuantityInvalid); + } + if !amount.bytes().any(|byte| matches!(byte, b'1'..=b'9')) { + return Err(RadrootsFoodAvailabilityError::QuantityZero); + } + Ok(Self { amount, unit }) + } + + pub fn amount(&self) -> &str { + self.amount.as_str() + } + + pub const fn unit(&self) -> RadrootsFoodUnit { + self.unit + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsFoodAvailabilityStatus { + Active, + Sold, +} + +impl RadrootsFoodAvailabilityStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Sold => "sold", + } + } + + pub fn parse(value: &str) -> Result<Self, RadrootsFoodAvailabilityError> { + match value { + "active" => Ok(Self::Active), + "sold" => Ok(Self::Sold), + _ => Err(RadrootsFoodAvailabilityError::StatusInvalid), + } + } +} + +impl fmt::Display for RadrootsFoodAvailabilityStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsFoodAvailabilityStatus { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsFoodImageDimensions { + width: u32, + height: u32, +} + +impl RadrootsFoodImageDimensions { + pub const fn new(width: u32, height: u32) -> Result<Self, RadrootsFoodAvailabilityError> { + if width == 0 || height == 0 { + return Err(RadrootsFoodAvailabilityError::ImageDimensionsInvalid); + } + Ok(Self { width, height }) + } + + pub fn parse(value: &str) -> Result<Self, RadrootsFoodAvailabilityError> { + let Some((width, height)) = value.split_once('x') else { + return Err(RadrootsFoodAvailabilityError::ImageDimensionsInvalid); + }; + if height.contains('x') + || !canonical_unsigned_integer(width) + || !canonical_unsigned_integer(height) + { + return Err(RadrootsFoodAvailabilityError::ImageDimensionsInvalid); + } + let width = width + .parse::<u32>() + .map_err(|_| RadrootsFoodAvailabilityError::ImageDimensionsInvalid)?; + let height = height + .parse::<u32>() + .map_err(|_| RadrootsFoodAvailabilityError::ImageDimensionsInvalid)?; + Self::new(width, height) + } + + pub const fn width(self) -> u32 { + self.width + } + + pub const fn height(self) -> u32 { + self.height + } +} + +impl fmt::Display for RadrootsFoodImageDimensions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}x{}", self.width, self.height) + } +} + +impl FromStr for RadrootsFoodImageDimensions { + type Err = RadrootsFoodAvailabilityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +/// One byte-verified Blossom image and its declared NIP-58 dimensions. +/// +/// This state proves descriptor-to-byte agreement and an `image/*` media type. +/// It does not prove upload completion, raster decoding, or network availability. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodAvailabilityImage { + image: RadrootsAuthoredImage, + dimensions: RadrootsFoodImageDimensions, +} + +impl RadrootsFoodAvailabilityImage { + pub const fn new( + image: RadrootsAuthoredImage, + dimensions: RadrootsFoodImageDimensions, + ) -> Self { + Self { image, dimensions } + } + + pub fn image(&self) -> &RadrootsAuthoredImage { + &self.image + } + + pub const fn dimensions(&self) -> RadrootsFoodImageDimensions { + self.dimensions + } + + pub fn url(&self) -> &str { + self.image.descriptor().url().as_str() + } +} + +/// Validated semantic inputs for a focused FoodAvailability event. +/// +/// This is intentionally not a signable event draft. The codec layer must +/// still derive canonical tags, enforce generic tag and compact-wire budgets, +/// and bind these details to a per-revision `created_at` before signing. +/// +/// ```compile_fail +/// let _: radroots_event::food_availability::RadrootsFoodAvailabilityDetails = +/// serde_json::from_str(r#"{"content":"carrots"}"#).unwrap(); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodAvailabilityDetails { + content: RadrootsFoodContent, + identifier: RadrootsFoodIdentifier, + title: RadrootsFoodText, + summary: RadrootsFoodText, + published_at: RadrootsFoodPublishedAt, + location: RadrootsFoodText, + price: RadrootsFoodPrice, + quantity: Option<RadrootsFoodQuantity>, + status: RadrootsFoodAvailabilityStatus, + images: Vec<RadrootsFoodAvailabilityImage>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodAvailabilityDetailsParts { + pub content: RadrootsFoodContent, + pub identifier: RadrootsFoodIdentifier, + pub title: RadrootsFoodText, + pub summary: RadrootsFoodText, + pub published_at: RadrootsFoodPublishedAt, + pub location: RadrootsFoodText, + pub price: RadrootsFoodPrice, + pub quantity: Option<RadrootsFoodQuantity>, + pub status: RadrootsFoodAvailabilityStatus, + pub images: Vec<RadrootsFoodAvailabilityImage>, +} + +impl RadrootsFoodAvailabilityDetails { + pub fn new( + parts: RadrootsFoodAvailabilityDetailsParts, + ) -> Result<Self, RadrootsFoodAvailabilityError> { + if parts + .quantity + .as_ref() + .is_some_and(|quantity| quantity.unit() != parts.price.unit()) + { + return Err(RadrootsFoodAvailabilityError::QuantityInvalid); + } + validate_images(&parts.images)?; + Ok(Self { + content: parts.content, + identifier: parts.identifier, + title: parts.title, + summary: parts.summary, + published_at: parts.published_at, + location: parts.location, + price: parts.price, + quantity: parts.quantity, + status: parts.status, + images: parts.images, + }) + } + + pub fn validate_created_at( + &self, + created_at: u64, + ) -> Result<(), RadrootsFoodAvailabilityError> { + self.published_at.validate_created_at(created_at) + } + + pub fn content(&self) -> &RadrootsFoodContent { + &self.content + } + + pub fn identifier(&self) -> &RadrootsFoodIdentifier { + &self.identifier + } + + pub fn title(&self) -> &RadrootsFoodText { + &self.title + } + + pub fn summary(&self) -> &RadrootsFoodText { + &self.summary + } + + pub const fn published_at(&self) -> RadrootsFoodPublishedAt { + self.published_at + } + + pub fn location(&self) -> &RadrootsFoodText { + &self.location + } + + pub fn price(&self) -> &RadrootsFoodPrice { + &self.price + } + + pub fn quantity(&self) -> Option<&RadrootsFoodQuantity> { + self.quantity.as_ref() + } + + pub const fn status(&self) -> RadrootsFoodAvailabilityStatus { + self.status + } + + pub fn images(&self) -> &[RadrootsFoodAvailabilityImage] { + &self.images + } +} + +fn validate_images( + images: &[RadrootsFoodAvailabilityImage], +) -> Result<(), RadrootsFoodAvailabilityError> { + if images.len() > RADROOTS_FOOD_IMAGE_MAX_COUNT { + return Err(RadrootsFoodAvailabilityError::ImageCountExceeded { + max: RADROOTS_FOOD_IMAGE_MAX_COUNT, + actual: images.len(), + }); + } + for (index, image) in images.iter().enumerate() { + if images[..index] + .iter() + .any(|candidate| candidate.url() == image.url()) + { + return Err(RadrootsFoodAvailabilityError::ImageDuplicateUrl); + } + let digest = image.image().descriptor().sha256(); + if images[..index] + .iter() + .any(|candidate| candidate.image().descriptor().sha256() == digest) + { + return Err(RadrootsFoodAvailabilityError::ImageDuplicateDigest); + } + } + Ok(()) +} + +fn validate_canonical_decimal(value: &str) -> bool { + let mut digits = 0usize; + let mut seen_dot = false; + let mut digit_after_dot = false; + for byte in value.bytes() { + match byte { + b'0'..=b'9' => { + digits += 1; + digit_after_dot |= seen_dot; + } + b'.' if !seen_dot => seen_dot = true, + _ => return false, + } + } + if digits == 0 || digits > RADROOTS_FOOD_DECIMAL_MAX_DIGITS { + return false; + } + if seen_dot && (!digit_after_dot || value.ends_with('0')) { + return false; + } + let integer = value.split_once('.').map_or(value, |(integer, _)| integer); + !integer.is_empty() && (integer == "0" || !integer.starts_with('0')) +} + +fn canonical_unsigned_integer(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && (value == "0" || !value.starts_with('0')) +} + +fn is_food_contract_whitespace(character: char) -> bool { + character.is_whitespace() || matches!(character, '\u{1c}'..='\u{1f}') +} + +fn is_control_or_format(character: char) -> bool { + matches!( + get_general_category(character), + GeneralCategory::Control | GeneralCategory::Format + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, + RadrootsBlossomSha256, + }; + + #[test] + fn error_codes_and_messages_are_stable_for_every_variant() { + let cases = [ + ( + RadrootsFoodAvailabilityError::ContentMissing, + "food_content_missing", + ), + ( + RadrootsFoodAvailabilityError::ContentTooLarge { max: 1, actual: 2 }, + "food_content_too_large", + ), + ( + RadrootsFoodAvailabilityError::IdentifierInvalid, + "food_identifier_invalid", + ), + ( + RadrootsFoodAvailabilityError::IdentifierTooLarge { max: 1, actual: 2 }, + "food_identifier_invalid", + ), + ( + RadrootsFoodAvailabilityError::TextInvalid, + "food_text_invalid", + ), + ( + RadrootsFoodAvailabilityError::TextTooLarge { max: 1, actual: 2 }, + "food_text_invalid", + ), + ( + RadrootsFoodAvailabilityError::PublishedAtInvalid, + "food_published_at_invalid", + ), + ( + RadrootsFoodAvailabilityError::PublishedAtFuture { + published_at: 2, + created_at: 1, + }, + "food_published_at_future", + ), + (RadrootsFoodAvailabilityError::PriceInvalid, "price_invalid"), + ( + RadrootsFoodAvailabilityError::PriceCurrencyInvalid, + "price_currency_invalid", + ), + ( + RadrootsFoodAvailabilityError::PriceUnitInvalid, + "price_unit_invalid", + ), + ( + RadrootsFoodAvailabilityError::QuantityInvalid, + "quantity_invalid", + ), + (RadrootsFoodAvailabilityError::QuantityZero, "quantity_zero"), + ( + RadrootsFoodAvailabilityError::StatusInvalid, + "food_status_invalid", + ), + ( + RadrootsFoodAvailabilityError::ImageDimensionsInvalid, + "food_image_dimensions_invalid", + ), + ( + RadrootsFoodAvailabilityError::ImageCountExceeded { max: 1, actual: 2 }, + "food_image_count_exceeded", + ), + ( + RadrootsFoodAvailabilityError::ImageDuplicateUrl, + "food_image_duplicate_url", + ), + ( + RadrootsFoodAvailabilityError::ImageDuplicateDigest, + "food_image_duplicate_digest", + ), + ]; + + for (error, code) in cases { + assert_eq!(error.code(), code); + assert!(!error.to_string().is_empty(), "{code}"); + } + } + + #[test] + fn content_enforces_only_non_whitespace_and_utf8_byte_bound() { + for invalid in [ + "", + " \t", + "\u{1c}", + "\u{1d}", + "\u{1e}", + "\u{1f}", + "\u{1c}\u{2003}\t", + ] { + assert_eq!( + RadrootsFoodContent::new(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::ContentMissing, + "{invalid:?}" + ); + } + let exact = "é".repeat(RADROOTS_FOOD_CONTENT_MAX_BYTES / 2); + assert_eq!( + RadrootsFoodContent::new(exact.clone()) + .unwrap() + .as_str() + .len(), + RADROOTS_FOOD_CONTENT_MAX_BYTES + ); + assert_eq!( + RadrootsFoodContent::new(exact + "a").unwrap_err(), + RadrootsFoodAvailabilityError::ContentTooLarge { + max: RADROOTS_FOOD_CONTENT_MAX_BYTES, + actual: RADROOTS_FOOD_CONTENT_MAX_BYTES + 1, + } + ); + assert!(RadrootsFoodContent::new(" harvest\nnotes ").is_ok()); + assert!(RadrootsFoodContent::new("carrots\u{1c}").is_ok()); + } + + #[test] + fn identifier_enforces_bytes_whitespace_and_unicode_categories() { + let exact = "a".repeat(RADROOTS_FOOD_IDENTIFIER_MAX_BYTES); + assert_eq!( + RadrootsFoodIdentifier::parse(&exact).unwrap().as_str(), + exact + ); + assert!(matches!( + RadrootsFoodIdentifier::parse(exact + "a"), + Err(RadrootsFoodAvailabilityError::IdentifierTooLarge { .. }) + )); + for invalid in [ + "", + "fresh carrots", + "carrots\0", + "carrots\u{85}", + "carrots\u{200b}", + "carrots\u{2060}", + ] { + assert_eq!( + RadrootsFoodIdentifier::parse(invalid).unwrap_err().code(), + "food_identifier_invalid" + ); + } + } + + #[test] + fn text_enforces_trim_bytes_and_unicode_categories() { + let exact = "é".repeat(RADROOTS_FOOD_TEXT_MAX_BYTES / 2); + assert_eq!( + RadrootsFoodText::new(exact.clone()).unwrap().as_str(), + exact + ); + assert!(matches!( + RadrootsFoodText::new(exact + "a"), + Err(RadrootsFoodAvailabilityError::TextTooLarge { .. }) + )); + for invalid in [ + "", + " Carrots", + "Carrots ", + "Car\nrots", + "Car\u{85}rots", + "Car\u{200b}rots", + "Car\u{2060}rots", + ] { + assert_eq!( + RadrootsFoodText::new(invalid).unwrap_err().code(), + "food_text_invalid" + ); + } + } + + #[test] + fn published_at_is_nonzero_canonical_u64_and_not_in_the_future() { + assert_eq!( + RadrootsFoodPublishedAt::new(0).unwrap_err(), + RadrootsFoodAvailabilityError::PublishedAtInvalid + ); + for invalid in ["", "0", "01", "+1", "18446744073709551616"] { + assert_eq!( + RadrootsFoodPublishedAt::parse(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::PublishedAtInvalid + ); + } + let timestamp = RadrootsFoodPublishedAt::parse("18446744073709551615").unwrap(); + assert_eq!(timestamp.as_u64(), u64::MAX); + assert_eq!(timestamp.to_string(), u64::MAX.to_string()); + assert_eq!( + RadrootsFoodPublishedAt::new(11) + .unwrap() + .validate_created_at(10) + .unwrap_err(), + RadrootsFoodAvailabilityError::PublishedAtFuture { + published_at: 11, + created_at: 10, + } + ); + } + + #[test] + fn food_units_are_exact_and_closed() { + let cases = [ + ("g", RadrootsFoodUnit::Gram), + ("kg", RadrootsFoodUnit::Kilogram), + ("lb", RadrootsFoodUnit::Pound), + ("oz", RadrootsFoodUnit::Ounce), + ("each", RadrootsFoodUnit::Each), + ("dozen", RadrootsFoodUnit::Dozen), + ("bunch", RadrootsFoodUnit::Bunch), + ("punnet", RadrootsFoodUnit::Punnet), + ("bag", RadrootsFoodUnit::Bag), + ("basket", RadrootsFoodUnit::Basket), + ]; + for (wire, unit) in cases { + assert_eq!(RadrootsFoodUnit::parse(wire).unwrap(), unit); + assert_eq!(unit.as_str(), wire); + assert_eq!(unit.to_string(), wire); + } + for invalid in ["", "G", "lbs", "crate", " each"] { + assert_eq!( + RadrootsFoodUnit::parse(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::PriceUnitInvalid + ); + } + } + + #[test] + fn price_decimal_is_canonical_bounded_and_may_be_zero() { + let currency = RadrootsFoodCurrency::parse("CAD").unwrap(); + for valid in ["0", "1", "0.1", "10.25", "1234567890123456789012345678"] { + let price = + RadrootsFoodPrice::new(valid, currency.clone(), RadrootsFoodUnit::Pound).unwrap(); + assert_eq!(price.amount(), valid); + } + for invalid in [ + "", + ".1", + "1.", + "00", + "01", + "01.2", + "1.0", + "1.20", + "+1", + "-1", + "1e3", + "١", + "12345678901234567890123456789", + ] { + assert_eq!( + RadrootsFoodPrice::new(invalid, currency.clone(), RadrootsFoodUnit::Pound) + .unwrap_err(), + RadrootsFoodAvailabilityError::PriceInvalid + ); + } + } + + #[test] + fn currency_is_three_uppercase_ascii_letters_without_registry_semantics() { + for valid in ["CAD", "USD", "ZZZ"] { + assert_eq!(RadrootsFoodCurrency::parse(valid).unwrap().as_str(), valid); + } + for invalid in ["", "CA", "CADD", "cad", "C1D", "CÁD"] { + assert_eq!( + RadrootsFoodCurrency::parse(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::PriceCurrencyInvalid + ); + } + } + + #[test] + fn quantity_is_positive_canonical_and_retains_its_unit() { + let quantity = RadrootsFoodQuantity::new("20.5", RadrootsFoodUnit::Pound).unwrap(); + assert_eq!(quantity.amount(), "20.5"); + assert_eq!(quantity.unit(), RadrootsFoodUnit::Pound); + assert_eq!( + RadrootsFoodQuantity::new("0", RadrootsFoodUnit::Pound).unwrap_err(), + RadrootsFoodAvailabilityError::QuantityZero + ); + assert_eq!( + RadrootsFoodQuantity::new("01", RadrootsFoodUnit::Pound).unwrap_err(), + RadrootsFoodAvailabilityError::QuantityInvalid + ); + } + + #[test] + fn status_is_exact_and_withdrawal_is_not_a_status() { + assert_eq!( + RadrootsFoodAvailabilityStatus::parse("active").unwrap(), + RadrootsFoodAvailabilityStatus::Active + ); + assert_eq!( + RadrootsFoodAvailabilityStatus::parse("sold").unwrap(), + RadrootsFoodAvailabilityStatus::Sold + ); + for invalid in ["", "Active", "withdrawn"] { + assert_eq!( + RadrootsFoodAvailabilityStatus::parse(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::StatusInvalid + ); + } + } + + #[test] + fn image_dimensions_are_canonical_nonzero_u32_values() { + let dimensions = RadrootsFoodImageDimensions::new(u32::MAX, 1).unwrap(); + assert_eq!(dimensions.width(), u32::MAX); + assert_eq!(dimensions.height(), 1); + assert_eq!(dimensions.to_string(), "4294967295x1"); + assert_eq!( + RadrootsFoodImageDimensions::parse("800x600").unwrap(), + RadrootsFoodImageDimensions::new(800, 600).unwrap() + ); + for invalid in [ + "", + "0x1", + "1x0", + "01x1", + "1x01", + "1X1", + "1x1x1", + "4294967296x1", + ] { + assert_eq!( + RadrootsFoodImageDimensions::parse(invalid).unwrap_err(), + RadrootsFoodAvailabilityError::ImageDimensionsInvalid + ); + } + } + + #[test] + fn details_enforce_quantity_unit_and_created_at() { + let mut parts = details_parts(Vec::new()); + parts.quantity = Some(RadrootsFoodQuantity::new("12", RadrootsFoodUnit::Kilogram).unwrap()); + assert_eq!( + RadrootsFoodAvailabilityDetails::new(parts).unwrap_err(), + RadrootsFoodAvailabilityError::QuantityInvalid + ); + + let details = RadrootsFoodAvailabilityDetails::new(details_parts(Vec::new())).unwrap(); + details.validate_created_at(100).unwrap(); + assert_eq!(details.identifier().as_str(), "nantes-carrots"); + assert_eq!(details.title().as_str(), "Nantes Carrots"); + assert_eq!(details.summary().as_str(), "Fresh bunches"); + assert_eq!(details.location().as_str(), "Central Saanich, BC"); + assert_eq!( + details.content().as_str(), + "Nantes carrots available this week." + ); + assert_eq!(details.price().amount(), "4"); + assert_eq!(details.price().currency().as_str(), "CAD"); + assert_eq!(details.quantity().unwrap().amount(), "24"); + assert_eq!(details.status(), RadrootsFoodAvailabilityStatus::Active); + assert!(details.images().is_empty()); + assert_eq!( + details.validate_created_at(99).unwrap_err().code(), + "food_published_at_future" + ); + } + + #[test] + fn details_bound_images_and_apply_url_before_digest_duplicate_precedence() { + let dimensions = RadrootsFoodImageDimensions::new(800, 600).unwrap(); + let images = (0..RADROOTS_FOOD_IMAGE_MAX_COUNT) + .map(|index| { + food_image( + "https://media.example", + index.to_be_bytes().as_slice(), + dimensions, + ) + }) + .collect::<Vec<_>>(); + assert_eq!( + RadrootsFoodAvailabilityDetails::new(details_parts(images)) + .unwrap() + .images() + .len(), + RADROOTS_FOOD_IMAGE_MAX_COUNT + ); + let too_many = (0..=RADROOTS_FOOD_IMAGE_MAX_COUNT) + .map(|index| { + food_image( + "https://media.example", + index.to_be_bytes().as_slice(), + dimensions, + ) + }) + .collect::<Vec<_>>(); + assert_eq!( + RadrootsFoodAvailabilityDetails::new(details_parts(too_many)).unwrap_err(), + RadrootsFoodAvailabilityError::ImageCountExceeded { + max: RADROOTS_FOOD_IMAGE_MAX_COUNT, + actual: RADROOTS_FOOD_IMAGE_MAX_COUNT + 1, + } + ); + + let image = food_image("https://media.example", b"carrot", dimensions); + assert_eq!( + RadrootsFoodAvailabilityDetails::new(details_parts(vec![image.clone(), image])) + .unwrap_err(), + RadrootsFoodAvailabilityError::ImageDuplicateUrl + ); + assert_eq!( + RadrootsFoodAvailabilityDetails::new(details_parts(vec![ + food_image("https://media.example", b"same", dimensions), + food_image("https://cache.example", b"same", dimensions), + ])) + .unwrap_err(), + RadrootsFoodAvailabilityError::ImageDuplicateDigest + ); + } + + fn details_parts( + images: Vec<RadrootsFoodAvailabilityImage>, + ) -> RadrootsFoodAvailabilityDetailsParts { + RadrootsFoodAvailabilityDetailsParts { + content: RadrootsFoodContent::new("Nantes carrots available this week.").unwrap(), + identifier: RadrootsFoodIdentifier::parse("nantes-carrots").unwrap(), + title: RadrootsFoodText::new("Nantes Carrots").unwrap(), + summary: RadrootsFoodText::new("Fresh bunches").unwrap(), + published_at: RadrootsFoodPublishedAt::new(100).unwrap(), + location: RadrootsFoodText::new("Central Saanich, BC").unwrap(), + price: RadrootsFoodPrice::new( + "4", + RadrootsFoodCurrency::parse("CAD").unwrap(), + RadrootsFoodUnit::Pound, + ) + .unwrap(), + quantity: Some(RadrootsFoodQuantity::new("24", RadrootsFoodUnit::Pound).unwrap()), + status: RadrootsFoodAvailabilityStatus::Active, + images, + } + } + + fn food_image( + origin: &str, + bytes: &[u8], + dimensions: RadrootsFoodImageDimensions, + ) -> RadrootsFoodAvailabilityImage { + let hash = RadrootsBlossomSha256::digest(bytes); + let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.webp")).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_200, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + RadrootsFoodAvailabilityImage::new( + RadrootsAuthoredImage::try_from(descriptor).unwrap(), + dimensions, + ) + } +} diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -13,6 +13,7 @@ pub mod account; pub mod app_data; pub mod article; pub mod calendar; +pub mod classified_listing; pub mod comment; pub mod contract; pub mod coop; @@ -28,6 +29,7 @@ pub mod farm_file; pub mod farm_workspace; pub mod file_metadata; pub mod follow; +pub mod food_availability; pub mod gcs; pub mod geochat; pub mod gift_wrap;