1use thiserror::Error;
32
33use crate::event::{
34 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
35 SingleLetterTag, Tag, TagKind, Tags,
36};
37use crate::types::{
38 ImageDimensions, ImageError, RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError,
39};
40
41pub const KIND_CLASSIFIED_LISTING: Kind = Kind::CLASSIFIED_LISTING;
43
44pub const KIND_CLASSIFIED_LISTING_DRAFT: Kind = Kind::CLASSIFIED_LISTING_DRAFT;
46
47const TITLE_TAG: &str = "title";
48const SUMMARY_TAG: &str = "summary";
49const PUBLISHED_AT_TAG: &str = "published_at";
50const IMAGE_TAG: &str = "image";
51const LOCATION_TAG: &str = "location";
52const PRICE_TAG: &str = "price";
53const STATUS_TAG: &str = "status";
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub enum ListingStatus {
59 Active,
61 Sold,
63 Custom(String),
65}
66
67impl ListingStatus {
68 #[must_use]
74 #[expect(
75 clippy::missing_const_for_fn,
76 reason = "`Self::Custom` borrows from a heap `String`"
77 )]
78 pub fn as_str(&self) -> &str {
79 match self {
80 Self::Active => "active",
81 Self::Sold => "sold",
82 Self::Custom(s) => s.as_str(),
83 }
84 }
85
86 #[must_use]
89 pub fn parse(token: &str) -> Self {
90 match token {
91 "active" => Self::Active,
92 "sold" => Self::Sold,
93 _ => Self::Custom(token.to_owned()),
94 }
95 }
96}
97
98pub type PriceFrequency = String;
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Price {
105 pub amount: String,
108 pub currency: String,
110 pub frequency: Option<PriceFrequency>,
113}
114
115impl Price {
116 #[must_use]
118 pub fn new(amount: impl Into<String>, currency: impl Into<String>) -> Self {
119 Self {
120 amount: amount.into(),
121 currency: currency.into(),
122 frequency: None,
123 }
124 }
125
126 #[must_use]
128 pub fn frequency(mut self, frequency: impl Into<PriceFrequency>) -> Self {
129 self.frequency = Some(frequency.into());
130 self
131 }
132
133 #[must_use]
135 pub fn to_tag(&self) -> Tag {
136 let head = TagKind::from_wire(PRICE_TAG);
137 self.frequency.as_ref().map_or_else(
138 || Tag::with(&head, [self.amount.clone(), self.currency.clone()]),
139 |freq| {
140 Tag::with(
141 &head,
142 [self.amount.clone(), self.currency.clone(), freq.clone()],
143 )
144 },
145 )
146 }
147
148 pub fn from_tag(tag: &Tag) -> Result<Self, ListingError> {
156 if tag.name() != PRICE_TAG {
157 return Err(ListingError::WrongPriceTag);
158 }
159 let amount = tag.get(1).ok_or(ListingError::MalformedPrice)?.to_owned();
160 let currency = tag.get(2).ok_or(ListingError::MalformedPrice)?.to_owned();
161 let frequency = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
162 Ok(Self {
163 amount,
164 currency,
165 frequency,
166 })
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Image {
174 pub url: Url,
176 pub dim: Option<ImageDimensions>,
178}
179
180impl Image {
181 #[must_use]
183 pub const fn new(url: Url) -> Self {
184 Self { url, dim: None }
185 }
186
187 #[must_use]
189 pub const fn dim(mut self, dim: ImageDimensions) -> Self {
190 self.dim = Some(dim);
191 self
192 }
193
194 #[must_use]
196 pub fn to_tag(&self) -> Tag {
197 let head = TagKind::from_wire(IMAGE_TAG);
198 self.dim.map_or_else(
199 || Tag::with(&head, [self.url.as_str().to_owned()]),
200 |dim| Tag::with(&head, [self.url.as_str().to_owned(), dim.to_string()]),
201 )
202 }
203
204 pub fn from_tag(tag: &Tag) -> Result<Self, ListingError> {
213 if tag.name() != IMAGE_TAG {
214 return Err(ListingError::WrongImageTag);
215 }
216 let url_str = tag.get(1).ok_or(ListingError::MalformedImage)?;
217 let url = Url::parse(url_str)?;
218 let dim = match tag.get(2) {
219 Some(d) if !d.is_empty() => Some(d.parse::<ImageDimensions>()?),
220 _ => None,
221 };
222 Ok(Self { url, dim })
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Default)]
228pub struct Listing {
229 pub identifier: String,
231 pub content: String,
233 pub title: Option<String>,
235 pub summary: Option<String>,
237 pub published_at: Option<Timestamp>,
239 pub location: Option<String>,
241 pub geohash: Option<String>,
243 pub price: Option<Price>,
245 pub status: Option<ListingStatus>,
247 pub hashtags: Vec<String>,
249 pub images: Vec<Image>,
251 pub event_refs: Vec<EventReference>,
253 pub address_refs: Vec<AddressReference>,
255 pub extra_tags: Vec<Tag>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct EventReference {
262 pub id: EventId,
264 pub relay_hint: Option<RelayUrl>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct AddressReference {
271 pub coordinate: Coordinate,
273 pub relay_hint: Option<RelayUrl>,
275}
276
277impl Listing {
278 #[must_use]
280 pub fn new(identifier: impl Into<String>) -> Self {
281 Self {
282 identifier: identifier.into(),
283 ..Self::default()
284 }
285 }
286
287 #[must_use]
289 pub fn content(mut self, content: impl Into<String>) -> Self {
290 self.content = content.into();
291 self
292 }
293
294 #[must_use]
296 pub fn title(mut self, title: impl Into<String>) -> Self {
297 self.title = Some(title.into());
298 self
299 }
300
301 #[must_use]
303 pub fn summary(mut self, summary: impl Into<String>) -> Self {
304 self.summary = Some(summary.into());
305 self
306 }
307
308 #[must_use]
310 pub const fn published_at(mut self, published_at: Timestamp) -> Self {
311 self.published_at = Some(published_at);
312 self
313 }
314
315 #[must_use]
317 pub fn location(mut self, location: impl Into<String>) -> Self {
318 self.location = Some(location.into());
319 self
320 }
321
322 #[must_use]
324 pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
325 self.geohash = Some(geohash.into());
326 self
327 }
328
329 #[must_use]
331 pub fn price(mut self, price: Price) -> Self {
332 self.price = Some(price);
333 self
334 }
335
336 #[must_use]
338 pub fn status(mut self, status: ListingStatus) -> Self {
339 self.status = Some(status);
340 self
341 }
342
343 #[must_use]
345 pub fn hashtag(mut self, hashtag: impl AsRef<str>) -> Self {
346 self.hashtags.push(hashtag.as_ref().to_lowercase());
347 self
348 }
349
350 #[must_use]
352 pub fn image(mut self, image: Image) -> Self {
353 self.images.push(image);
354 self
355 }
356
357 #[must_use]
359 pub fn event_ref(mut self, reference: EventReference) -> Self {
360 self.event_refs.push(reference);
361 self
362 }
363
364 #[must_use]
366 pub fn address_ref(mut self, reference: AddressReference) -> Self {
367 self.address_refs.push(reference);
368 self
369 }
370
371 #[must_use]
373 pub fn coordinate(&self, author: crate::PublicKey, kind: Kind) -> Coordinate {
374 Coordinate::new(kind, author, self.identifier.clone())
375 }
376
377 pub fn from_event(event: &Event) -> Result<Self, ListingError> {
387 if event.kind != KIND_CLASSIFIED_LISTING && event.kind != KIND_CLASSIFIED_LISTING_DRAFT {
388 return Err(ListingError::WrongKind(event.kind));
389 }
390 let identifier = d_value(&event.tags)
391 .ok_or(ListingError::MissingIdentifier)?
392 .to_owned();
393 let mut listing = Self {
394 identifier,
395 content: event.content.clone(),
396 ..Self::default()
397 };
398 for tag in &event.tags {
399 absorb_tag(tag, &mut listing)?;
400 }
401 Ok(listing)
402 }
403}
404
405fn absorb_tag(tag: &Tag, listing: &mut Listing) -> Result<(), ListingError> {
406 match tag.kind() {
407 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
408 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
409 if let Some(t) = tag.get(1) {
410 listing.hashtags.push(t.to_owned());
411 }
412 }
413 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::G => {
414 listing.geohash = tag.get(1).map(str::to_owned);
415 }
416 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
417 listing.event_refs.push(parse_event_ref(tag)?);
418 }
419 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
420 listing.address_refs.push(parse_address_ref(tag)?);
421 }
422 _ if tag.name() == TITLE_TAG => listing.title = tag.get(1).map(str::to_owned),
423 _ if tag.name() == SUMMARY_TAG => listing.summary = tag.get(1).map(str::to_owned),
424 _ if tag.name() == PUBLISHED_AT_TAG => {
425 if let Some(raw) = tag.get(1) {
426 listing.published_at = Some(raw.parse::<Timestamp>()?);
427 }
428 }
429 _ if tag.name() == LOCATION_TAG => listing.location = tag.get(1).map(str::to_owned),
430 _ if tag.name() == STATUS_TAG => {
431 listing.status = tag.get(1).map(ListingStatus::parse);
432 }
433 _ if tag.name() == PRICE_TAG => listing.price = Some(Price::from_tag(tag)?),
434 _ if tag.name() == IMAGE_TAG => listing.images.push(Image::from_tag(tag)?),
435 _ => listing.extra_tags.push(tag.clone()),
436 }
437 Ok(())
438}
439
440fn parse_event_ref(tag: &Tag) -> Result<EventReference, ListingError> {
441 let id_hex = tag.get(1).ok_or(ListingError::MalformedEventRef)?;
442 let id = EventId::parse(id_hex)?;
443 let relay_hint = match tag.get(2) {
444 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
445 _ => None,
446 };
447 Ok(EventReference { id, relay_hint })
448}
449
450fn parse_address_ref(tag: &Tag) -> Result<AddressReference, ListingError> {
451 let coord_str = tag.get(1).ok_or(ListingError::MalformedAddressRef)?;
452 let coordinate = Coordinate::parse(coord_str)?;
453 let relay_hint = match tag.get(2) {
454 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
455 _ => None,
456 };
457 Ok(AddressReference {
458 coordinate,
459 relay_hint,
460 })
461}
462
463fn d_value(tags: &Tags) -> Option<&str> {
464 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
465 tags.find_first(&head).and_then(|tag| tag.get(1))
466}
467
468#[derive(Debug, Error)]
470#[non_exhaustive]
471pub enum ListingError {
472 #[error("expected kind 30402 or 30403 (classified listing), got kind {}", .0.as_u16())]
474 WrongKind(Kind),
475 #[error("NIP-99 listing missing `d` tag")]
477 MissingIdentifier,
478 #[error("expected `price` tag")]
480 WrongPriceTag,
481 #[error("`price` tag missing amount or currency")]
483 MalformedPrice,
484 #[error("expected `image` tag")]
486 WrongImageTag,
487 #[error("`image` tag missing URL")]
489 MalformedImage,
490 #[error("`e` reference tag missing event id")]
492 MalformedEventRef,
493 #[error("`a` reference tag missing coordinate")]
495 MalformedAddressRef,
496 #[error(transparent)]
498 InvalidEventId(#[from] EventIdError),
499 #[error(transparent)]
501 InvalidCoordinate(#[from] CoordinateError),
502 #[error(transparent)]
504 InvalidRelayUrl(#[from] RelayUrlError),
505 #[error(transparent)]
507 InvalidUrl(#[from] UrlError),
508 #[error(transparent)]
510 InvalidDim(#[from] ImageError),
511 #[error(transparent)]
513 InvalidTimestamp(#[from] TimestampError),
514}
515
516impl EventBuilder {
517 #[must_use]
523 pub fn classified_listing(listing: &Listing, kind: Kind) -> Self {
524 let mut builder = Self::new(kind, listing.content.clone());
525 builder = builder.tag(Tag::d(&listing.identifier));
526 if let Some(title) = &listing.title {
527 builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
528 }
529 if let Some(summary) = &listing.summary {
530 builder = builder.tag(Tag::with(
531 &TagKind::from_wire(SUMMARY_TAG),
532 [summary.clone()],
533 ));
534 }
535 if let Some(ts) = listing.published_at {
536 builder = builder.tag(Tag::with(
537 &TagKind::from_wire(PUBLISHED_AT_TAG),
538 [ts.as_secs().to_string()],
539 ));
540 }
541 for hashtag in &listing.hashtags {
542 builder = builder.tag(Tag::t(hashtag));
543 }
544 for image in &listing.images {
545 builder = builder.tag(image.to_tag());
546 }
547 if let Some(location) = &listing.location {
548 builder = builder.tag(Tag::with(
549 &TagKind::from_wire(LOCATION_TAG),
550 [location.clone()],
551 ));
552 }
553 if let Some(geohash) = &listing.geohash {
554 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
555 builder = builder.tag(Tag::with(&head, [geohash.clone()]));
556 }
557 if let Some(price) = &listing.price {
558 builder = builder.tag(price.to_tag());
559 }
560 if let Some(status) = &listing.status {
561 builder = builder.tag(Tag::with(
562 &TagKind::from_wire(STATUS_TAG),
563 [status.as_str().to_owned()],
564 ));
565 }
566 for r in &listing.event_refs {
567 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
568 builder = builder.tag(r.relay_hint.as_ref().map_or_else(
569 || Tag::with(&head, [r.id.to_hex()]),
570 |relay| Tag::with(&head, [r.id.to_hex(), relay.as_str().to_owned()]),
571 ));
572 }
573 for r in &listing.address_refs {
574 builder = builder.tag(r.relay_hint.as_ref().map_or_else(
575 || Tag::a(&r.coordinate),
576 |relay| Tag::a_with_relay(&r.coordinate, relay),
577 ));
578 }
579 for tag in &listing.extra_tags {
580 builder = builder.tag(tag.clone());
581 }
582 builder
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::Keys;
590
591 fn keys() -> Keys {
592 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
593 }
594
595 #[test]
596 fn round_trip_minimal_listing() {
597 let listing = Listing::new("lorem-ipsum").content("**markdown**");
598 let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
599 .sign_with_keys(&keys())
600 .unwrap();
601 let parsed = Listing::from_event(&event).unwrap();
602 assert_eq!(parsed, listing);
603 }
604
605 #[test]
606 fn round_trip_full_listing() {
607 let listing = Listing::new("lorem-ipsum")
608 .content("Lorem ipsum body.")
609 .title("Lorem Ipsum")
610 .summary("Brief")
611 .published_at(Timestamp::from_secs(1_296_962_229))
612 .location("NYC")
613 .geohash("dr5regw3p")
614 .price(Price::new("100", "USD"))
615 .status(ListingStatus::Active)
616 .hashtag("ELECTRONICS")
617 .image(
618 Image::new(Url::parse("https://example.com/p.jpg").unwrap())
619 .dim("256x256".parse().unwrap()),
620 )
621 .event_ref(EventReference {
622 id: EventId::from_byte_array([0x7f; 32]),
623 relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
624 })
625 .address_ref(AddressReference {
626 coordinate: Coordinate::new(
627 Kind::new(30_023),
628 *keys().public_key(),
629 "post".to_owned(),
630 ),
631 relay_hint: Some(RelayUrl::parse("wss://relay.nostr/").unwrap()),
632 });
633 let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
634 .sign_with_keys(&keys())
635 .unwrap();
636 let parsed = Listing::from_event(&event).unwrap();
637 assert_eq!(parsed.hashtags, vec!["electronics".to_owned()]);
639 let expected = Listing {
640 hashtags: vec!["electronics".to_owned()],
641 ..listing
642 };
643 assert_eq!(parsed, expected);
644 }
645
646 #[test]
647 fn round_trip_draft() {
648 let listing = Listing::new("draft-1").content("hidden");
649 let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING_DRAFT)
650 .sign_with_keys(&keys())
651 .unwrap();
652 let parsed = Listing::from_event(&event).unwrap();
653 assert_eq!(parsed, listing);
654 assert_eq!(event.kind, KIND_CLASSIFIED_LISTING_DRAFT);
655 }
656
657 #[test]
658 fn price_with_frequency_round_trips() {
659 let price = Price::new("15", "EUR").frequency("month");
660 let tag = price.to_tag();
661 let parsed = Price::from_tag(&tag).unwrap();
662 assert_eq!(parsed, price);
663 }
664
665 #[test]
666 fn status_parses_unknown_tokens_as_custom() {
667 let listing = Listing::new("status-test")
668 .content("…")
669 .status(ListingStatus::Custom("expired".into()));
670 let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
671 .sign_with_keys(&keys())
672 .unwrap();
673 let parsed = Listing::from_event(&event).unwrap();
674 assert_eq!(parsed.status, Some(ListingStatus::Custom("expired".into())));
675 }
676
677 #[test]
678 fn wrong_kind_is_rejected() {
679 let event = EventBuilder::text_note("nope")
680 .sign_with_keys(&keys())
681 .unwrap();
682 assert!(matches!(
683 Listing::from_event(&event),
684 Err(ListingError::WrongKind(_))
685 ));
686 }
687
688 #[test]
689 fn missing_identifier_is_rejected() {
690 let event = EventBuilder::new(KIND_CLASSIFIED_LISTING, "")
691 .sign_with_keys(&keys())
692 .unwrap();
693 assert!(matches!(
694 Listing::from_event(&event),
695 Err(ListingError::MissingIdentifier)
696 ));
697 }
698
699 #[test]
700 fn malformed_price_is_rejected() {
701 let event = EventBuilder::new(KIND_CLASSIFIED_LISTING, "")
702 .tag(Tag::d("listing-1"))
703 .tag(Tag::with(&TagKind::from_wire(PRICE_TAG), ["100"]))
704 .sign_with_keys(&keys())
705 .unwrap();
706 assert!(matches!(
707 Listing::from_event(&event),
708 Err(ListingError::MalformedPrice)
709 ));
710 }
711
712 #[test]
713 fn extra_tags_are_preserved() {
714 let custom = Tag::with(&TagKind::Custom("note".to_owned()), ["preserve me"]);
715 let listing = Listing::new("listing-x").content("body");
716 let mut builder = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING);
717 builder = builder.tag(custom.clone());
718 let event = builder.sign_with_keys(&keys()).unwrap();
719 let parsed = Listing::from_event(&event).unwrap();
720 assert_eq!(parsed.extra_tags, vec![custom]);
721 }
722}