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:
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;