event_matcher/models.rs
1//! Data models for events, following schema.org/Event.
2//!
3//! This module is intentionally **logic-free**: it defines the types that
4//! flow through the matching engine but contains no matching code itself.
5//! See [`crate::matcher`] for the engine and [`crate::normalizer`] for the
6//! text transformations that the matcher applies to these fields.
7//!
8//! The shape of [`Event`] mirrors the properties listed at
9//! <https://schema.org/Event>. Field names use Rust conventions
10//! (`snake_case`) but the semantics match the schema.org definitions one
11//! for one — a record produced from a `schema:Event` JSON-LD payload can
12//! be loaded with a thin field-by-field mapping.
13//!
14//! All public types here are `Serialize + Deserialize` so they round-trip
15//! through JSON, `MessagePack`, or any other `serde` format.
16//!
17//! ## Building an event
18//!
19//! Prefer [`Event::builder`] over constructing the struct literal — the
20//! builder accepts `impl Into<String>` so call-sites can pass `&str`,
21//! `String`, or owned values interchangeably.
22//!
23//! ```
24//! use event_matcher::Event;
25//!
26//! let e = Event::builder()
27//! .name("Glastonbury Festival 2024")
28//! .add_alternate_name("Glasto 2024")
29//! .start_date("2024-06-26T09:00:00Z")
30//! .end_date("2024-06-30T23:59:00Z")
31//! .build();
32//!
33//! assert_eq!(e.name.as_deref(), Some("Glastonbury Festival 2024"));
34//! ```
35
36use serde::{Deserialize, Serialize};
37
38/// Postal address used as supporting evidence in event matcher.
39///
40/// All fields are `Option<String>` so partial addresses are first-class —
41/// a record with only a postcode is still useful for matching.
42///
43/// The matcher does **not** weight every component equally; see
44/// [`crate::matcher::MatchingEngine`] for the weighted comparison rules.
45///
46/// # Example
47///
48/// ```
49/// use event_matcher::Address;
50///
51/// let mut addr = Address::new();
52/// addr.line1 = Some("Worthy Farm".into());
53/// addr.city = Some("Pilton".into());
54/// addr.postcode = Some("BA4 4BY".into());
55///
56/// assert_eq!(addr.postcode.as_deref(), Some("BA4 4BY"));
57/// assert!(addr.country.is_none());
58/// ```
59///
60/// `Address` is JSON round-trippable.
61///
62/// ```
63/// # use event_matcher::Address;
64/// let mut a = Address::new();
65/// a.postcode = Some("BA4 4BY".into());
66///
67/// let json = serde_json::to_string(&a).unwrap();
68/// let back: Address = serde_json::from_str(&json).unwrap();
69/// assert_eq!(a, back);
70/// ```
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct Address {
74 /// First line — typically house number and street, e.g. `"Worthy Farm"`.
75 pub line1: Option<String>,
76 /// Second line — typically flat, locality, or care-of details.
77 pub line2: Option<String>,
78 /// Town or city, e.g. `"Pilton"`.
79 pub city: Option<String>,
80 /// County or administrative region, e.g. `"Somerset"`.
81 pub county: Option<String>,
82 /// Postal code, e.g. `"BA4 4BY"`. Compared after whitespace normalisation.
83 pub postcode: Option<String>,
84 /// Country, e.g. `"England"` or `"United Kingdom"`.
85 pub country: Option<String>,
86}
87
88impl Address {
89 /// Construct an empty address with every field set to `None`.
90 ///
91 /// # Example
92 ///
93 /// ```
94 /// use event_matcher::Address;
95 ///
96 /// let a = Address::new();
97 /// assert!(a.line1.is_none());
98 /// assert!(a.postcode.is_none());
99 /// ```
100 #[must_use]
101 pub fn new() -> Self {
102 Self {
103 line1: None,
104 line2: None,
105 city: None,
106 county: None,
107 postcode: None,
108 country: None,
109 }
110 }
111
112 /// Fluent setter for `line1`.
113 #[must_use]
114 pub fn with_line1(mut self, value: impl Into<String>) -> Self {
115 self.line1 = Some(value.into());
116 self
117 }
118
119 /// Fluent setter for `line2`.
120 #[must_use]
121 pub fn with_line2(mut self, value: impl Into<String>) -> Self {
122 self.line2 = Some(value.into());
123 self
124 }
125
126 /// Fluent setter for `city`.
127 #[must_use]
128 pub fn with_city(mut self, value: impl Into<String>) -> Self {
129 self.city = Some(value.into());
130 self
131 }
132
133 /// Fluent setter for `county`.
134 #[must_use]
135 pub fn with_county(mut self, value: impl Into<String>) -> Self {
136 self.county = Some(value.into());
137 self
138 }
139
140 /// Fluent setter for `postcode`.
141 #[must_use]
142 pub fn with_postcode(mut self, value: impl Into<String>) -> Self {
143 self.postcode = Some(value.into());
144 self
145 }
146
147 /// Fluent setter for `country`.
148 #[must_use]
149 pub fn with_country(mut self, value: impl Into<String>) -> Self {
150 self.country = Some(value.into());
151 self
152 }
153}
154
155impl Default for Address {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161/// Where an event takes place — corresponds to schema.org `Event.location`.
162///
163/// Schema.org accepts `Place`, `PostalAddress`, `Text`, or `VirtualLocation`
164/// as a value for `Event.location`. This crate models all four flavours
165/// inside one struct so a single `Location` can describe a purely
166/// physical venue, a purely virtual one, or a hybrid:
167///
168/// - `venue_name` carries the textual name of the venue (`Place.name`),
169/// e.g. `"Worthy Farm"` or `"Madison Square Garden"`.
170/// - `address` carries the postal address (`PostalAddress`).
171/// - `latitude` / `longitude` carry geographic coordinates (`Place.geo`).
172/// - `virtual_url` carries the join URL for `VirtualLocation`.
173///
174/// Every field is optional. A `Location` with all fields `None` is
175/// equivalent to no location at all and is skipped by the matcher.
176///
177/// # Example
178///
179/// ```
180/// use event_matcher::{Address, Location};
181///
182/// let l = Location::new()
183/// .with_venue_name("Worthy Farm")
184/// .with_address(Address::new().with_postcode("BA4 4BY"))
185/// .with_latitude(51.150_3)
186/// .with_longitude(-2.586_2);
187///
188/// assert_eq!(l.venue_name.as_deref(), Some("Worthy Farm"));
189/// assert_eq!(l.latitude, Some(51.150_3));
190/// ```
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[non_exhaustive]
193pub struct Location {
194 /// Textual name of the venue, e.g. `"Madison Square Garden"`.
195 pub venue_name: Option<String>,
196 /// Postal address of the physical venue.
197 pub address: Option<Address>,
198 /// Latitude in decimal degrees. Conventionally `[-90.0, 90.0]`.
199 /// Values outside that range or non-finite values are stored as
200 /// supplied for round-trip honesty, but the coordinates scorer treats
201 /// them as missing.
202 pub latitude: Option<f64>,
203 /// Longitude in decimal degrees. Conventionally `[-180.0, 180.0]`.
204 pub longitude: Option<f64>,
205 /// Join URL for a virtual event (schema.org `VirtualLocation.url`),
206 /// e.g. `"https://zoom.us/j/123456"`. Compared by exact equality
207 /// after trimming whitespace.
208 pub virtual_url: Option<String>,
209}
210
211impl Location {
212 /// Construct an empty location with every field set to `None`.
213 #[must_use]
214 pub fn new() -> Self {
215 Self {
216 venue_name: None,
217 address: None,
218 latitude: None,
219 longitude: None,
220 virtual_url: None,
221 }
222 }
223
224 /// Fluent setter for `venue_name`.
225 #[must_use]
226 pub fn with_venue_name(mut self, value: impl Into<String>) -> Self {
227 self.venue_name = Some(value.into());
228 self
229 }
230
231 /// Fluent setter for `address`.
232 #[must_use]
233 pub fn with_address(mut self, value: Address) -> Self {
234 self.address = Some(value);
235 self
236 }
237
238 /// Fluent setter for `latitude`.
239 #[must_use]
240 pub fn with_latitude(mut self, value: f64) -> Self {
241 self.latitude = Some(value);
242 self
243 }
244
245 /// Fluent setter for `longitude`.
246 #[must_use]
247 pub fn with_longitude(mut self, value: f64) -> Self {
248 self.longitude = Some(value);
249 self
250 }
251
252 /// Fluent setter for `virtual_url`.
253 #[must_use]
254 pub fn with_virtual_url(mut self, value: impl Into<String>) -> Self {
255 self.virtual_url = Some(value.into());
256 self
257 }
258}
259
260impl Default for Location {
261 fn default() -> Self {
262 Self::new()
263 }
264}
265
266/// Schema.org Event subtypes — the kind of event.
267///
268/// The variants mirror the direct subtypes of `schema:Event` listed at
269/// <https://schema.org/Event>. The catch-all [`EventCategory::Other`]
270/// variant lets callers express categories that the crate does not
271/// enumerate; two `Other` values match iff their carried string is
272/// structurally equal (`PartialEq`).
273///
274/// The enum is `#[non_exhaustive]` so future variants can be added without
275/// breaking `SemVer` for downstream pattern matches.
276///
277/// # Example
278///
279/// ```
280/// use event_matcher::EventCategory;
281///
282/// assert_eq!(EventCategory::MusicEvent, EventCategory::MusicEvent);
283/// assert_ne!(EventCategory::MusicEvent, EventCategory::ComedyEvent);
284/// assert_eq!(
285/// EventCategory::Other("WeddingEvent".into()),
286/// EventCategory::Other("WeddingEvent".into())
287/// );
288/// ```
289#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
290#[non_exhaustive]
291pub enum EventCategory {
292 /// Business event: trade show, networking, professional gathering.
293 BusinessEvent,
294 /// Event aimed primarily at children.
295 ChildrensEvent,
296 /// Stand-up comedy or comedic performance.
297 ComedyEvent,
298 /// Multi-session conference (schema.org `ConferenceEvent`).
299 ConferenceEvent,
300 /// Specific delivery of a course (schema.org `CourseInstance`).
301 CourseInstance,
302 /// Dance performance or social dance.
303 DanceEvent,
304 /// Delivery of goods to a customer (schema.org `DeliveryEvent`).
305 DeliveryEvent,
306 /// General educational event: lecture, workshop, training session.
307 EducationEvent,
308 /// Recurring series sharing an identity (schema.org `EventSeries`).
309 EventSeries,
310 /// Exhibition such as a museum show or trade exhibition.
311 ExhibitionEvent,
312 /// Festival, fair, or similar multi-attraction celebration.
313 Festival,
314 /// Food-centred event: tasting, supper club, food fair.
315 FoodEvent,
316 /// Hackathon or coding marathon.
317 Hackathon,
318 /// Literary event: reading, book launch, signing.
319 LiteraryEvent,
320 /// Concert, recital, or other musical performance.
321 MusicEvent,
322 /// Performing-arts event (catch-all for live performance).
323 PerformingArtsEvent,
324 /// Release of a creative work (schema.org `PublicationEvent`).
325 PublicationEvent,
326 /// Sale, auction, or other commercial offer-based event.
327 SaleEvent,
328 /// Screening of a film or video work.
329 ScreeningEvent,
330 /// Social event: party, mixer, meetup.
331 SocialEvent,
332 /// Sporting event or competition.
333 SportsEvent,
334 /// Stage play or other theatrical performance.
335 TheaterEvent,
336 /// Visual-arts event: gallery opening, art-walk.
337 VisualArtsEvent,
338 /// Catch-all for categories not enumerated above. The carried string
339 /// participates in equality, so `Other("foo")` does not match
340 /// `Other("bar")`.
341 Other(String),
342}
343
344/// Schema.org `EventStatusType` — the lifecycle state of an event.
345///
346/// The variants are the five enumeration values defined at
347/// <https://schema.org/EventStatusType>.
348///
349/// The enum is `#[non_exhaustive]` so future variants can be added without
350/// breaking `SemVer` for downstream pattern matches.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
352#[non_exhaustive]
353pub enum EventStatus {
354 /// `schema:EventScheduled` — the event is confirmed and on track.
355 EventScheduled,
356 /// `schema:EventCancelled` — the event will not take place.
357 EventCancelled,
358 /// `schema:EventPostponed` — the event has been postponed, no new date yet.
359 EventPostponed,
360 /// `schema:EventRescheduled` — the event has been moved to a new date.
361 EventRescheduled,
362 /// `schema:EventMovedOnline` — the event has moved to a virtual format.
363 EventMovedOnline,
364}
365
366/// Schema.org `EventAttendanceModeEnumeration` — physical, virtual, or both.
367///
368/// Defined at <https://schema.org/EventAttendanceModeEnumeration>.
369///
370/// The enum is `#[non_exhaustive]` so future variants can be added without
371/// breaking `SemVer` for downstream pattern matches.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
373#[non_exhaustive]
374pub enum EventAttendanceMode {
375 /// Physical attendance only.
376 OfflineEventAttendanceMode,
377 /// Virtual attendance only.
378 OnlineEventAttendanceMode,
379 /// Hybrid event with both physical and virtual attendance.
380 MixedEventAttendanceMode,
381}
382
383/// Issuer / scheme of an [`EventId`].
384///
385/// Identifiers from different schemes never match each other, even when
386/// their string values happen to coincide — see [`EventId`].
387///
388/// The enum is `#[non_exhaustive]` so future variants can be added without
389/// breaking `SemVer` for downstream pattern matches.
390#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
391#[non_exhaustive]
392pub enum EventIdScheme {
393 /// Wikidata QID for the event (e.g. `Q15290`).
394 Wikidata,
395 /// Eventbrite event identifier.
396 Eventbrite,
397 /// Meetup.com event identifier.
398 Meetup,
399 /// Ticketmaster event identifier.
400 Ticketmaster,
401 /// Songkick concert / festival identifier.
402 Songkick,
403 /// Bandsintown event identifier.
404 Bandsintown,
405 /// Facebook event identifier.
406 Facebook,
407 /// Luma (lu.ma) event identifier.
408 Luma,
409 /// Google Calendar event identifier.
410 GoogleCalendar,
411 /// iCalendar `UID` property (RFC 5545).
412 ICalendarUid,
413 /// Catch-all for schemes not enumerated above. The carried string
414 /// participates in equality.
415 Other(String),
416}
417
418/// External identifier for an event, scoped by its issuing scheme.
419///
420/// Two `EventId` values are equal iff both their [`EventIdScheme`] and
421/// their `value` are equal. Equality is structural — no per-scheme
422/// canonicalisation is performed.
423///
424/// # Example
425///
426/// ```
427/// use event_matcher::{EventId, EventIdScheme};
428///
429/// let a = EventId::new(EventIdScheme::Eventbrite, "123456789").unwrap();
430/// let b = EventId::new(EventIdScheme::Eventbrite, " 123456789 ").unwrap();
431/// assert_eq!(a, b, "values are trimmed at construction");
432/// assert!(EventId::new(EventIdScheme::Eventbrite, "").is_none());
433/// ```
434#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
435pub struct EventId {
436 /// The issuing scheme.
437 pub scheme: EventIdScheme,
438 /// The identifier value, trimmed of surrounding whitespace.
439 pub value: String,
440}
441
442impl EventId {
443 /// Construct an [`EventId`], trimming the value of surrounding
444 /// whitespace. Returns `None` if the trimmed value is empty.
445 ///
446 /// No further normalisation is applied — different schemes have
447 /// different rules and the crate makes no assumptions.
448 ///
449 /// # Example
450 ///
451 /// ```
452 /// use event_matcher::{EventId, EventIdScheme};
453 ///
454 /// let id = EventId::new(EventIdScheme::Wikidata, " Q15290 ").unwrap();
455 /// assert_eq!(id.value, "Q15290");
456 ///
457 /// assert!(EventId::new(EventIdScheme::Wikidata, " ").is_none());
458 /// ```
459 pub fn new(scheme: EventIdScheme, value: impl Into<String>) -> Option<Self> {
460 let trimmed = value.into().trim().to_string();
461 if trimmed.is_empty() {
462 None
463 } else {
464 Some(Self {
465 scheme,
466 value: trimmed,
467 })
468 }
469 }
470}
471
472/// Core event data structure — corresponds to schema.org `Event`.
473///
474/// Every field is optional (or defaults to empty). The matcher tolerates
475/// missing data field-by-field — a `None` value never penalises an event.
476/// See [`crate::matcher::MatchingEngine::match_events`] for how missing
477/// fields affect the weighted score.
478///
479/// Construct via [`Event::builder`] rather than struct literal syntax so
480/// the call-site stays compact and forward-compatible if fields are added.
481///
482/// # Field mapping to schema.org
483///
484/// | Field | Schema.org property |
485/// |---|---|
486/// | `name` | `schema:name` |
487/// | `alternate_names` | `schema:alternateName` |
488/// | `description` | `schema:description` |
489/// | `url` | `schema:url` |
490/// | `event_ids` | `schema:identifier` |
491/// | `category` | (closest match to the subtype of `schema:Event`) |
492/// | `keywords` | `schema:keywords` |
493/// | `in_language` | `schema:inLanguage` |
494/// | `start_date` | `schema:startDate` |
495/// | `end_date` | `schema:endDate` |
496/// | `door_time` | `schema:doorTime` |
497/// | `previous_start_date` | `schema:previousStartDate` |
498/// | `event_status` | `schema:eventStatus` |
499/// | `event_attendance_mode` | `schema:eventAttendanceMode` |
500/// | `location` | `schema:location` |
501/// | `organizer` | `schema:organizer` |
502/// | `performers` | `schema:performer` |
503/// | `maximum_attendee_capacity` | `schema:maximumAttendeeCapacity` |
504/// | `maximum_physical_attendee_capacity` | `schema:maximumPhysicalAttendeeCapacity` |
505/// | `maximum_virtual_attendee_capacity` | `schema:maximumVirtualAttendeeCapacity` |
506/// | `is_accessible_for_free` | `schema:isAccessibleForFree` |
507/// | `typical_age_range` | `schema:typicalAgeRange` |
508/// | `super_event_id` | `schema:superEvent` (id only) |
509///
510/// # Example
511///
512/// ```
513/// use event_matcher::Event;
514///
515/// let e = Event::builder()
516/// .name("RustConf 2024")
517/// .add_alternate_name("RustConf '24")
518/// .start_date("2024-09-10T09:00:00Z")
519/// .end_date("2024-09-13T17:00:00Z")
520/// .organizer("Rust Foundation")
521/// .build();
522///
523/// assert_eq!(e.name.as_deref(), Some("RustConf 2024"));
524/// assert_eq!(e.alternate_names, vec!["RustConf '24".to_string()]);
525/// ```
526///
527/// `Event` round-trips through `serde`.
528///
529/// ```
530/// # use event_matcher::Event;
531/// let e = Event::builder().name("RustConf").build();
532/// let json = serde_json::to_string(&e).unwrap();
533/// let back: Event = serde_json::from_str(&json).unwrap();
534/// assert_eq!(e, back);
535/// ```
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
537#[non_exhaustive]
538pub struct Event {
539 /// Primary canonical name of the event, e.g. `"Glastonbury Festival 2024"`.
540 pub name: Option<String>,
541
542 /// Aliases, translations, or alternative titles of the event, e.g.
543 /// `vec!["Glasto 2024".into()]`. Default empty.
544 pub alternate_names: Vec<String>,
545
546 /// Free-text description of the event (`schema:description`).
547 pub description: Option<String>,
548
549 /// Canonical URL for the event (`schema:url`).
550 pub url: Option<String>,
551
552 /// External scheme-scoped identifiers for the event. Sharing any one
553 /// `(scheme, value)` pair across two events is a deterministic match.
554 pub event_ids: Vec<EventId>,
555
556 /// Local identifier issued by the originating system. Not normalised —
557 /// different organisations may issue colliding values.
558 pub local_id: Option<String>,
559
560 /// Coarse-grained category of the event (see [`EventCategory`]).
561 pub category: Option<EventCategory>,
562
563 /// Free-form descriptive tags (`schema:keywords`).
564 pub keywords: Vec<String>,
565
566 /// Content language as an IETF BCP-47 tag, e.g. `"en-GB"`, `"fr"`.
567 /// Stored as supplied; compared case-insensitively after trim.
568 pub in_language: Option<String>,
569
570 /// Expected demographic age range, e.g. `"7-9"` (`schema:typicalAgeRange`).
571 pub typical_age_range: Option<String>,
572
573 /// Start date or date-time in ISO 8601 form. Examples:
574 /// `"2024-06-26"`, `"2024-06-26T09:00:00Z"`, `"2024-06-26T09:00:00+01:00"`.
575 pub start_date: Option<String>,
576
577 /// End date or date-time in ISO 8601 form.
578 pub end_date: Option<String>,
579
580 /// Door-opening time (`schema:doorTime`) in ISO 8601 form.
581 pub door_time: Option<String>,
582
583 /// Original start date prior to rescheduling (`schema:previousStartDate`).
584 pub previous_start_date: Option<String>,
585
586 /// Current status — scheduled, cancelled, postponed, rescheduled, or
587 /// moved online (see [`EventStatus`]).
588 pub event_status: Option<EventStatus>,
589
590 /// Physical, virtual, or mixed (see [`EventAttendanceMode`]).
591 pub event_attendance_mode: Option<EventAttendanceMode>,
592
593 /// Where the event takes place — venue, address, coordinates, and/or
594 /// virtual URL (see [`Location`]).
595 pub location: Option<Location>,
596
597 /// Country code in ISO 3166-1 alpha-2 form, e.g. `"GB"`, `"US"`,
598 /// `"FR"`. Convenient quick lookup when [`Location::address`] is
599 /// absent. Stored as supplied — no canonicalisation is performed at
600 /// construction time. The matcher compares case-insensitively after
601 /// trimming whitespace.
602 pub country_code_as_iso_3166_1_alpha_2: Option<String>,
603
604 /// Name of the organiser (person or organisation). Single string for
605 /// simplicity; callers needing multiple organisers should join them
606 /// with `", "` or pre-pick a canonical organiser.
607 pub organizer: Option<String>,
608
609 /// Performer names — actors, bands, speakers, athletes
610 /// (`schema:performer`). Compared as a best-of cartesian product.
611 pub performers: Vec<String>,
612
613 /// Total expected capacity (`schema:maximumAttendeeCapacity`).
614 pub maximum_attendee_capacity: Option<u32>,
615
616 /// Offline-attendance capacity (`schema:maximumPhysicalAttendeeCapacity`).
617 pub maximum_physical_attendee_capacity: Option<u32>,
618
619 /// Online-attendance capacity (`schema:maximumVirtualAttendeeCapacity`).
620 pub maximum_virtual_attendee_capacity: Option<u32>,
621
622 /// True if the event is free to attend (`schema:isAccessibleForFree`).
623 pub is_accessible_for_free: Option<bool>,
624
625 /// Identifier of the parent event (`schema:superEvent`). Stored as a
626 /// free-form string so callers can carry whichever scheme they need.
627 pub super_event_id: Option<String>,
628}
629
630impl Event {
631 /// Begin constructing an [`Event`] with the [`EventBuilder`].
632 ///
633 /// All fields default to `None` / empty until a setter is called.
634 ///
635 /// # Example
636 ///
637 /// ```
638 /// use event_matcher::Event;
639 ///
640 /// let e = Event::builder()
641 /// .name("RustConf 2024")
642 /// .build();
643 ///
644 /// assert_eq!(e.name.as_deref(), Some("RustConf 2024"));
645 /// ```
646 #[must_use]
647 pub fn builder() -> EventBuilder {
648 EventBuilder::default()
649 }
650
651 /// Validate that the event carries a primary name.
652 ///
653 /// Returns `Ok(())` if `name` is set.
654 ///
655 /// This is **not** invoked automatically by the matcher — call it at the
656 /// system boundary when you ingest data, not on every comparison.
657 ///
658 /// # Errors
659 ///
660 /// Returns [`crate::MatchingError::MissingField`] if `name` is `None`.
661 ///
662 /// # Example
663 ///
664 /// ```
665 /// use event_matcher::Event;
666 ///
667 /// assert!(Event::builder().name("RustConf").build().validate().is_ok());
668 /// assert!(Event::builder().build().validate().is_err());
669 /// ```
670 pub fn validate(&self) -> crate::Result<()> {
671 if self.name.is_none() {
672 return Err(crate::MatchingError::MissingField(
673 "name is required".to_string(),
674 ));
675 }
676 Ok(())
677 }
678}
679
680/// Fluent builder for [`Event`].
681///
682/// All string setters accept `impl Into<String>` so call-sites may pass
683/// `&str`, `String`, or `&String` interchangeably without explicit
684/// conversion.
685///
686/// # Example
687///
688/// ```
689/// use event_matcher::{Event, EventBuilder, EventCategory};
690///
691/// let e: Event = EventBuilder::default()
692/// .name(String::from("RustConf 2024"))
693/// .add_alternate_name("RustConf '24")
694/// .start_date("2024-09-10T09:00:00Z")
695/// .category(EventCategory::ConferenceEvent)
696/// .build();
697///
698/// assert_eq!(e.name.as_deref(), Some("RustConf 2024"));
699/// assert_eq!(e.category, Some(EventCategory::ConferenceEvent));
700/// ```
701#[derive(Default)]
702pub struct EventBuilder {
703 name: Option<String>,
704 alternate_names: Vec<String>,
705 description: Option<String>,
706 url: Option<String>,
707 event_ids: Vec<EventId>,
708 local_id: Option<String>,
709 category: Option<EventCategory>,
710 keywords: Vec<String>,
711 in_language: Option<String>,
712 typical_age_range: Option<String>,
713 start_date: Option<String>,
714 end_date: Option<String>,
715 door_time: Option<String>,
716 previous_start_date: Option<String>,
717 event_status: Option<EventStatus>,
718 event_attendance_mode: Option<EventAttendanceMode>,
719 location: Option<Location>,
720 country_code_as_iso_3166_1_alpha_2: Option<String>,
721 organizer: Option<String>,
722 performers: Vec<String>,
723 maximum_attendee_capacity: Option<u32>,
724 maximum_physical_attendee_capacity: Option<u32>,
725 maximum_virtual_attendee_capacity: Option<u32>,
726 is_accessible_for_free: Option<bool>,
727 super_event_id: Option<String>,
728}
729
730impl EventBuilder {
731 /// Set the primary canonical name.
732 #[must_use]
733 pub fn name<S: Into<String>>(mut self, value: S) -> Self {
734 self.name = Some(value.into());
735 self
736 }
737
738 /// Replace the entire list of alternate names.
739 #[must_use]
740 pub fn alternate_names(mut self, value: Vec<String>) -> Self {
741 self.alternate_names = value;
742 self
743 }
744
745 /// Append a single alternate name.
746 #[must_use]
747 pub fn add_alternate_name<S: Into<String>>(mut self, value: S) -> Self {
748 self.alternate_names.push(value.into());
749 self
750 }
751
752 /// Set the free-text description.
753 #[must_use]
754 pub fn description<S: Into<String>>(mut self, value: S) -> Self {
755 self.description = Some(value.into());
756 self
757 }
758
759 /// Set the canonical URL.
760 #[must_use]
761 pub fn url<S: Into<String>>(mut self, value: S) -> Self {
762 self.url = Some(value.into());
763 self
764 }
765
766 /// Replace the entire list of external event IDs.
767 #[must_use]
768 pub fn event_ids(mut self, value: Vec<EventId>) -> Self {
769 self.event_ids = value;
770 self
771 }
772
773 /// Append a single external event ID.
774 #[must_use]
775 pub fn add_event_id(mut self, value: EventId) -> Self {
776 self.event_ids.push(value);
777 self
778 }
779
780 /// Set the local identifier.
781 #[must_use]
782 pub fn local_id<S: Into<String>>(mut self, value: S) -> Self {
783 self.local_id = Some(value.into());
784 self
785 }
786
787 /// Set the event's category.
788 #[must_use]
789 pub fn category(mut self, value: EventCategory) -> Self {
790 self.category = Some(value);
791 self
792 }
793
794 /// Replace the entire list of keywords.
795 #[must_use]
796 pub fn keywords(mut self, value: Vec<String>) -> Self {
797 self.keywords = value;
798 self
799 }
800
801 /// Append a single keyword.
802 #[must_use]
803 pub fn add_keyword<S: Into<String>>(mut self, value: S) -> Self {
804 self.keywords.push(value.into());
805 self
806 }
807
808 /// Set the BCP-47 language tag.
809 #[must_use]
810 pub fn in_language<S: Into<String>>(mut self, value: S) -> Self {
811 self.in_language = Some(value.into());
812 self
813 }
814
815 /// Set the typical-age-range string.
816 #[must_use]
817 pub fn typical_age_range<S: Into<String>>(mut self, value: S) -> Self {
818 self.typical_age_range = Some(value.into());
819 self
820 }
821
822 /// Set the start date or date-time in ISO 8601 form.
823 #[must_use]
824 pub fn start_date<S: Into<String>>(mut self, value: S) -> Self {
825 self.start_date = Some(value.into());
826 self
827 }
828
829 /// Set the end date or date-time in ISO 8601 form.
830 #[must_use]
831 pub fn end_date<S: Into<String>>(mut self, value: S) -> Self {
832 self.end_date = Some(value.into());
833 self
834 }
835
836 /// Set the door-opening time in ISO 8601 form.
837 #[must_use]
838 pub fn door_time<S: Into<String>>(mut self, value: S) -> Self {
839 self.door_time = Some(value.into());
840 self
841 }
842
843 /// Set the previous-start-date for rescheduled events.
844 #[must_use]
845 pub fn previous_start_date<S: Into<String>>(mut self, value: S) -> Self {
846 self.previous_start_date = Some(value.into());
847 self
848 }
849
850 /// Set the event lifecycle status.
851 #[must_use]
852 pub fn event_status(mut self, value: EventStatus) -> Self {
853 self.event_status = Some(value);
854 self
855 }
856
857 /// Set the attendance mode (offline / online / mixed).
858 #[must_use]
859 pub fn event_attendance_mode(mut self, value: EventAttendanceMode) -> Self {
860 self.event_attendance_mode = Some(value);
861 self
862 }
863
864 /// Set the event's location.
865 #[must_use]
866 pub fn location(mut self, value: Location) -> Self {
867 self.location = Some(value);
868 self
869 }
870
871 /// Set the country code in ISO 3166-1 alpha-2 form.
872 #[must_use]
873 pub fn country_code_as_iso_3166_1_alpha_2<S: Into<String>>(mut self, value: S) -> Self {
874 self.country_code_as_iso_3166_1_alpha_2 = Some(value.into());
875 self
876 }
877
878 /// Set the organiser name.
879 #[must_use]
880 pub fn organizer<S: Into<String>>(mut self, value: S) -> Self {
881 self.organizer = Some(value.into());
882 self
883 }
884
885 /// Replace the entire list of performer names.
886 #[must_use]
887 pub fn performers(mut self, value: Vec<String>) -> Self {
888 self.performers = value;
889 self
890 }
891
892 /// Append a single performer name.
893 #[must_use]
894 pub fn add_performer<S: Into<String>>(mut self, value: S) -> Self {
895 self.performers.push(value.into());
896 self
897 }
898
899 /// Set the total expected attendee capacity.
900 #[must_use]
901 pub fn maximum_attendee_capacity(mut self, value: u32) -> Self {
902 self.maximum_attendee_capacity = Some(value);
903 self
904 }
905
906 /// Set the offline-attendance capacity.
907 #[must_use]
908 pub fn maximum_physical_attendee_capacity(mut self, value: u32) -> Self {
909 self.maximum_physical_attendee_capacity = Some(value);
910 self
911 }
912
913 /// Set the online-attendance capacity.
914 #[must_use]
915 pub fn maximum_virtual_attendee_capacity(mut self, value: u32) -> Self {
916 self.maximum_virtual_attendee_capacity = Some(value);
917 self
918 }
919
920 /// Set the "free to attend" flag.
921 #[must_use]
922 pub fn is_accessible_for_free(mut self, value: bool) -> Self {
923 self.is_accessible_for_free = Some(value);
924 self
925 }
926
927 /// Set the parent event identifier.
928 #[must_use]
929 pub fn super_event_id<S: Into<String>>(mut self, value: S) -> Self {
930 self.super_event_id = Some(value.into());
931 self
932 }
933
934 /// Consume the builder and produce the [`Event`].
935 #[must_use]
936 pub fn build(self) -> Event {
937 Event {
938 name: self.name,
939 alternate_names: self.alternate_names,
940 description: self.description,
941 url: self.url,
942 event_ids: self.event_ids,
943 local_id: self.local_id,
944 category: self.category,
945 keywords: self.keywords,
946 in_language: self.in_language,
947 typical_age_range: self.typical_age_range,
948 start_date: self.start_date,
949 end_date: self.end_date,
950 door_time: self.door_time,
951 previous_start_date: self.previous_start_date,
952 event_status: self.event_status,
953 event_attendance_mode: self.event_attendance_mode,
954 location: self.location,
955 country_code_as_iso_3166_1_alpha_2: self.country_code_as_iso_3166_1_alpha_2,
956 organizer: self.organizer,
957 performers: self.performers,
958 maximum_attendee_capacity: self.maximum_attendee_capacity,
959 maximum_physical_attendee_capacity: self.maximum_physical_attendee_capacity,
960 maximum_virtual_attendee_capacity: self.maximum_virtual_attendee_capacity,
961 is_accessible_for_free: self.is_accessible_for_free,
962 super_event_id: self.super_event_id,
963 }
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn address_new_is_all_none() {
973 let a = Address::new();
974 assert!(a.line1.is_none());
975 assert!(a.line2.is_none());
976 assert!(a.city.is_none());
977 assert!(a.county.is_none());
978 assert!(a.postcode.is_none());
979 assert!(a.country.is_none());
980 }
981
982 #[test]
983 fn address_default_matches_new() {
984 assert_eq!(Address::default(), Address::new());
985 }
986
987 #[test]
988 fn address_fluent_builders_chain() {
989 let a = Address::new()
990 .with_line1("Worthy Farm")
991 .with_city("Pilton")
992 .with_postcode("BA4 4BY")
993 .with_country("United Kingdom");
994 assert_eq!(a.line1.as_deref(), Some("Worthy Farm"));
995 assert_eq!(a.city.as_deref(), Some("Pilton"));
996 assert_eq!(a.postcode.as_deref(), Some("BA4 4BY"));
997 assert_eq!(a.country.as_deref(), Some("United Kingdom"));
998 assert!(a.line2.is_none());
999 assert!(a.county.is_none());
1000 }
1001
1002 #[test]
1003 fn address_round_trips_through_serde() {
1004 let mut a = Address::new();
1005 a.line1 = Some("Worthy Farm".into());
1006 a.postcode = Some("BA4 4BY".into());
1007 let json = serde_json::to_string(&a).expect("serialise");
1008 let back: Address = serde_json::from_str(&json).expect("deserialise");
1009 assert_eq!(a, back);
1010 }
1011
1012 #[test]
1013 fn location_new_is_all_none() {
1014 let l = Location::new();
1015 assert!(l.venue_name.is_none());
1016 assert!(l.address.is_none());
1017 assert!(l.latitude.is_none());
1018 assert!(l.longitude.is_none());
1019 assert!(l.virtual_url.is_none());
1020 }
1021
1022 #[test]
1023 fn location_default_matches_new() {
1024 assert_eq!(Location::default(), Location::new());
1025 }
1026
1027 #[test]
1028 fn location_fluent_builders_chain() {
1029 let l = Location::new()
1030 .with_venue_name("Worthy Farm")
1031 .with_latitude(51.150_3)
1032 .with_longitude(-2.586_2);
1033 assert_eq!(l.venue_name.as_deref(), Some("Worthy Farm"));
1034 assert_eq!(l.latitude, Some(51.150_3));
1035 assert_eq!(l.longitude, Some(-2.586_2));
1036 }
1037
1038 #[test]
1039 fn event_builder_starts_empty() {
1040 let e = Event::builder().build();
1041 assert!(e.name.is_none());
1042 assert!(e.alternate_names.is_empty());
1043 assert!(e.description.is_none());
1044 assert!(e.url.is_none());
1045 assert!(e.event_ids.is_empty());
1046 assert!(e.local_id.is_none());
1047 assert!(e.category.is_none());
1048 assert!(e.keywords.is_empty());
1049 assert!(e.in_language.is_none());
1050 assert!(e.start_date.is_none());
1051 assert!(e.end_date.is_none());
1052 assert!(e.location.is_none());
1053 assert!(e.organizer.is_none());
1054 assert!(e.performers.is_empty());
1055 assert!(e.maximum_attendee_capacity.is_none());
1056 assert!(e.is_accessible_for_free.is_none());
1057 assert!(e.super_event_id.is_none());
1058 }
1059
1060 #[test]
1061 fn event_builder_accepts_all_fields() {
1062 let e = Event::builder()
1063 .name("RustConf 2024")
1064 .add_alternate_name("RustConf '24")
1065 .description("Annual Rust conference")
1066 .url("https://rustconf.com/2024")
1067 .start_date("2024-09-10T09:00:00Z")
1068 .end_date("2024-09-13T17:00:00Z")
1069 .door_time("2024-09-10T08:30:00Z")
1070 .organizer("Rust Foundation")
1071 .add_performer("Niko Matsakis")
1072 .category(EventCategory::ConferenceEvent)
1073 .event_status(EventStatus::EventScheduled)
1074 .event_attendance_mode(EventAttendanceMode::MixedEventAttendanceMode)
1075 .country_code_as_iso_3166_1_alpha_2("US")
1076 .maximum_attendee_capacity(1000)
1077 .is_accessible_for_free(false)
1078 .in_language("en")
1079 .typical_age_range("18-99")
1080 .add_keyword("rust")
1081 .build();
1082 assert_eq!(e.name.as_deref(), Some("RustConf 2024"));
1083 assert_eq!(e.alternate_names.len(), 1);
1084 assert_eq!(e.start_date.as_deref(), Some("2024-09-10T09:00:00Z"));
1085 assert_eq!(e.organizer.as_deref(), Some("Rust Foundation"));
1086 assert_eq!(e.category, Some(EventCategory::ConferenceEvent));
1087 assert_eq!(e.event_status, Some(EventStatus::EventScheduled));
1088 assert_eq!(e.maximum_attendee_capacity, Some(1000));
1089 assert_eq!(e.is_accessible_for_free, Some(false));
1090 }
1091
1092 #[test]
1093 fn event_builder_accepts_str_and_string() {
1094 let e = Event::builder()
1095 .name("RustConf")
1096 .add_alternate_name(String::from("RustConf '24"))
1097 .build();
1098 assert_eq!(e.name.as_deref(), Some("RustConf"));
1099 assert_eq!(e.alternate_names, vec!["RustConf '24".to_string()]);
1100 }
1101
1102 #[test]
1103 fn event_validate_requires_a_name() {
1104 assert!(Event::builder().name("RustConf").build().validate().is_ok());
1105 let err = Event::builder()
1106 .build()
1107 .validate()
1108 .expect_err("should be missing");
1109 assert!(matches!(err, crate::MatchingError::MissingField(_)));
1110 }
1111
1112 #[test]
1113 fn event_round_trips_through_serde() {
1114 let e = Event::builder()
1115 .name("Glastonbury Festival 2024")
1116 .add_alternate_name("Glasto 2024")
1117 .start_date("2024-06-26T09:00:00Z")
1118 .end_date("2024-06-30T23:59:00Z")
1119 .category(EventCategory::Festival)
1120 .add_event_id(EventId::new(EventIdScheme::Wikidata, "Q15290").unwrap())
1121 .country_code_as_iso_3166_1_alpha_2("GB")
1122 .build();
1123 let json = serde_json::to_string(&e).expect("serialise");
1124 let back: Event = serde_json::from_str(&json).expect("deserialise");
1125 assert_eq!(e, back);
1126 }
1127
1128 #[test]
1129 fn alternate_names_setter_replaces_vec() {
1130 let e = Event::builder()
1131 .alternate_names(vec!["X".into(), "Y".into()])
1132 .build();
1133 assert_eq!(e.alternate_names, vec!["X".to_string(), "Y".to_string()]);
1134 }
1135
1136 #[test]
1137 fn event_id_trims_value() {
1138 let id = EventId::new(EventIdScheme::Eventbrite, " abc ").unwrap();
1139 assert_eq!(id.value, "abc");
1140 }
1141
1142 #[test]
1143 fn event_id_rejects_empty() {
1144 assert!(EventId::new(EventIdScheme::Eventbrite, "").is_none());
1145 assert!(EventId::new(EventIdScheme::Eventbrite, " ").is_none());
1146 }
1147
1148 #[test]
1149 fn event_id_equality_is_scheme_scoped() {
1150 let g = EventId::new(EventIdScheme::Eventbrite, "X").unwrap();
1151 let o = EventId::new(EventIdScheme::Meetup, "X").unwrap();
1152 assert_ne!(g, o);
1153 }
1154
1155 #[test]
1156 fn event_id_scheme_other_compares_by_string() {
1157 let a = EventId::new(EventIdScheme::Other("foo".into()), "1").unwrap();
1158 let b = EventId::new(EventIdScheme::Other("bar".into()), "1").unwrap();
1159 let c = EventId::new(EventIdScheme::Other("foo".into()), "1").unwrap();
1160 assert_ne!(a, b);
1161 assert_eq!(a, c);
1162 }
1163
1164 #[test]
1165 fn category_other_compares_by_string() {
1166 assert_ne!(
1167 EventCategory::Other("foo".into()),
1168 EventCategory::Other("bar".into())
1169 );
1170 assert_eq!(
1171 EventCategory::Other("foo".into()),
1172 EventCategory::Other("foo".into())
1173 );
1174 }
1175
1176 #[test]
1177 fn event_status_roundtrips_through_serde() {
1178 let s = EventStatus::EventCancelled;
1179 let json = serde_json::to_string(&s).expect("serialise");
1180 let back: EventStatus = serde_json::from_str(&json).expect("deserialise");
1181 assert_eq!(s, back);
1182 }
1183
1184 #[test]
1185 fn attendance_mode_roundtrips_through_serde() {
1186 let m = EventAttendanceMode::MixedEventAttendanceMode;
1187 let json = serde_json::to_string(&m).expect("serialise");
1188 let back: EventAttendanceMode = serde_json::from_str(&json).expect("deserialise");
1189 assert_eq!(m, back);
1190 }
1191}