Skip to main content

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}