tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 386be6f987b18e771b37d9be11927343618894e5
parent 3d4dcc8992577e8c27c98615e0ac25eb14b44c02
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 21:14:07 -0700

nips: add listing projection contract

Diffstat:
Mcrates/tangle_nips/src/lib.rs | 306++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 304 insertions(+), 2 deletions(-)

diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs @@ -845,6 +845,176 @@ fn normalize_taxonomy_value(name: &str, value: &str) -> Result<String, String> { Ok(normalized) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListingProjection { + identity: ListingIdentity, + text: ListingText, + price: ListingPrice, + unit: ListingUnitTag, + fulfillment: ListingFulfillment, + status: ListingStatus, + location: ListingLocation, + taxonomy: ListingTaxonomy, +} + +impl ListingProjection { + pub fn identity(&self) -> &ListingIdentity { + &self.identity + } + + pub fn text(&self) -> &ListingText { + &self.text + } + + pub fn price(&self) -> &ListingPrice { + &self.price + } + + pub fn unit(&self) -> &ListingUnitTag { + &self.unit + } + + pub fn fulfillment(&self) -> &ListingFulfillment { + &self.fulfillment + } + + pub fn status(&self) -> &ListingStatus { + &self.status + } + + pub fn location(&self) -> &ListingLocation { + &self.location + } + + pub fn taxonomy(&self) -> &ListingTaxonomy { + &self.taxonomy + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListingProjectionRejection { + event_id: EventId, + reasons: Vec<String>, +} + +impl ListingProjectionRejection { + pub fn event_id(&self) -> &EventId { + &self.event_id + } + + pub fn reasons(&self) -> &[String] { + &self.reasons + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ListingProjectionEvaluation { + NotListing, + Eligible(Box<ListingProjection>), + Ineligible(ListingProjectionRejection), +} + +impl ListingProjectionEvaluation { + pub fn is_eligible(&self) -> bool { + matches!(self, Self::Eligible(_)) + } + + pub fn projection(&self) -> Option<&ListingProjection> { + match self { + Self::Eligible(projection) => Some(projection), + Self::NotListing | Self::Ineligible(_) => None, + } + } + + pub fn rejection(&self) -> Option<&ListingProjectionRejection> { + match self { + Self::Ineligible(rejection) => Some(rejection), + Self::NotListing | Self::Eligible(_) => None, + } + } +} + +pub fn evaluate_listing_projection(event: &Event) -> ListingProjectionEvaluation { + if listing_kind_for_event(event).is_none() { + return ListingProjectionEvaluation::NotListing; + } + let mut reasons = Vec::new(); + let identity = parse_listing_identity(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let text = parse_listing_text(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let price = parse_listing_price(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let unit = parse_listing_unit(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let fulfillment = parse_listing_fulfillment(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let status = parse_listing_status(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let location = parse_listing_location(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + let taxonomy = parse_listing_taxonomy(event) + .map_err(|reason| reasons.push(reason)) + .ok() + .flatten(); + if identity + .as_ref() + .is_some_and(|identity| identity.listing_kind() == ListingKind::Draft) + { + reasons.push("draft listing is not public projection eligible".to_owned()); + } + match ( + identity, + text, + price, + unit, + fulfillment, + status, + location, + taxonomy, + ) { + ( + Some(identity), + Some(text), + Some(price), + Some(unit), + Some(fulfillment), + Some(status), + Some(location), + Some(taxonomy), + ) if reasons.is_empty() => { + ListingProjectionEvaluation::Eligible(Box::new(ListingProjection { + identity, + text, + price, + unit, + fulfillment, + status, + location, + taxonomy, + })) + } + _ => ListingProjectionEvaluation::Ineligible(ListingProjectionRejection { + event_id: event.id().clone(), + reasons, + }), + } +} + fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { match event.unsigned().kind().as_u32() { NIP99_PUBLIC_LISTING_KIND => Some(ListingKind::Public), @@ -856,8 +1026,9 @@ fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { #[cfg(test)] mod tests { use super::{ - DeletionTarget, FulfillmentMethod, ListingEffectiveStatus, ListingKind, ListingUnit, - NIP99_PUBLIC_LISTING_KIND, matching_tags, optional_tag_value, optional_tag_values, + DeletionTarget, FulfillmentMethod, ListingEffectiveStatus, ListingKind, + ListingProjectionEvaluation, ListingUnit, NIP99_PUBLIC_LISTING_KIND, + evaluate_listing_projection, matching_tags, optional_tag_value, optional_tag_values, parse_deletion_request, parse_listing_fulfillment, parse_listing_identity, parse_listing_location, parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text, parse_listing_unit, parse_nip50_filter_search, parse_nip50_search, @@ -2040,6 +2211,122 @@ mod tests { ); } + #[test] + fn listing_projection_contract_accepts_complete_public_listing() { + let event = event_with_kind_tags_and_content( + u64::from(NIP99_PUBLIC_LISTING_KIND), + complete_listing_tags(), + "Sweet storage carrots.", + ); + + let evaluation = evaluate_listing_projection(&event); + + assert!(evaluation.is_eligible()); + assert_eq!(evaluation.rejection(), None); + let projection = evaluation.projection().expect("projection"); + + assert_eq!(projection.identity().d().as_str(), "listing-a"); + assert_eq!(projection.text().title(), "Carrot bunches"); + assert_eq!(projection.price().amount().raw(), "12.50"); + assert_eq!(projection.unit().unit(), ListingUnit::Lb); + assert_eq!(projection.unit().canonical(), "lb"); + assert!(projection.fulfillment().pickup_available()); + assert_eq!( + projection.status().effective_status(), + ListingEffectiveStatus::Active + ); + assert_eq!(projection.location().geohash4(), Some("c22y")); + assert_eq!( + projection.taxonomy().categories(), + &["vegetables".to_owned()] + ); + } + + #[test] + fn listing_projection_contract_ignores_non_listing_events() { + let event = event_with_kind_and_tags(1, complete_listing_tags()); + let evaluation = evaluate_listing_projection(&event); + + assert_eq!(evaluation, ListingProjectionEvaluation::NotListing); + assert!(!evaluation.is_eligible()); + assert_eq!(evaluation.projection(), None); + assert_eq!(evaluation.rejection(), None); + } + + #[test] + fn listing_projection_contract_rejects_draft_projection() { + let event = event_with_kind_and_tags(30_403, complete_listing_tags()); + let evaluation = evaluate_listing_projection(&event); + + assert!(!evaluation.is_eligible()); + let rejection = evaluation.rejection().expect("rejection"); + + assert_eq!(rejection.event_id(), event.id()); + assert_eq!( + rejection.reasons(), + &["draft listing is not public projection eligible".to_owned()] + ); + } + + #[test] + fn listing_projection_contract_accumulates_required_parser_failures() { + let event = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("d", &["listing-a"]).expect("d")], + ); + let evaluation = evaluate_listing_projection(&event); + + let rejection = evaluation.rejection().expect("rejection"); + + assert_eq!( + rejection.reasons(), + &[ + "tag `title` is required".to_owned(), + "tag `price` is required".to_owned(), + "tag `unit` is required".to_owned(), + "tag `fulfillment` is required".to_owned(), + ] + ); + } + + #[test] + fn listing_projection_contract_accumulates_optional_parser_failures() { + let mut tags = complete_listing_tags(); + tags.push(Tag::from_parts("location", &[""]).expect("location")); + tags.push(Tag::from_parts("category", &["vegetables", "extra"]).expect("category")); + let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags); + let evaluation = evaluate_listing_projection(&event); + + let rejection = evaluation.rejection().expect("rejection"); + + assert_eq!( + rejection.reasons(), + &[ + "listing location tag must not be empty".to_owned(), + "tag `category` must include exactly one value".to_owned(), + ] + ); + } + + #[test] + fn listing_projection_contract_accumulates_identity_and_status_failures() { + let mut tags = complete_listing_tags(); + tags.retain(|tag| tag.name().as_str() != "d"); + tags.push(Tag::from_parts("status", &["inactive"]).expect("status")); + let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags); + let evaluation = evaluate_listing_projection(&event); + + let rejection = evaluation.rejection().expect("rejection"); + + assert_eq!( + rejection.reasons(), + &[ + "tag `d` is required".to_owned(), + "listing status `inactive` is unsupported".to_owned(), + ] + ); + } + fn event_with_tags(tags: Vec<Tag>) -> Event { event_with_kind_and_tags(30_402, tags) } @@ -2061,4 +2348,19 @@ mod tests { SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), ) } + + fn complete_listing_tags() -> Vec<Tag> { + vec![ + Tag::from_parts("d", &["listing-a"]).expect("d"), + Tag::from_parts("title", &["Carrot bunches"]).expect("title"), + Tag::from_parts("price", &["12.50", "USD"]).expect("price"), + Tag::from_parts("unit", &["lb"]).expect("unit"), + Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"), + Tag::from_parts("g", &["c22yzug"]).expect("g"), + Tag::from_parts("category", &["vegetables"]).expect("category"), + Tag::from_parts("t", &["carrots"]).expect("topic"), + Tag::from_parts("practice", &["no spray"]).expect("practice"), + Tag::from_parts("certification", &["organic"]).expect("certification"), + ] + } }