use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::datetime::format as datetime_format;
use crate::datetime::optional_format as optional_datetime_format;
use crate::lexicon::TypedBlob;
use crate::lexicon::app::bsky::richtext::facet::Facet;
use crate::lexicon::community::lexicon::location::LocationOrRef;
use crate::typed::{LexiconType, TypedLexicon};
pub const NSID: &str = "community.lexicon.calendar.event";
#[derive(Serialize, Deserialize, PartialEq, Clone, Default)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub enum Status {
#[default]
#[serde(rename = "community.lexicon.calendar.event#scheduled")]
Scheduled,
#[serde(rename = "community.lexicon.calendar.event#rescheduled")]
Rescheduled,
#[serde(rename = "community.lexicon.calendar.event#cancelled")]
Cancelled,
#[serde(rename = "community.lexicon.calendar.event#postponed")]
Postponed,
#[serde(rename = "community.lexicon.calendar.event#planned")]
Planned,
}
#[derive(Serialize, Deserialize, PartialEq, Clone, Default)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub enum Mode {
#[default]
#[serde(rename = "community.lexicon.calendar.event#inperson")]
InPerson,
#[serde(rename = "community.lexicon.calendar.event#virtual")]
Virtual,
#[serde(rename = "community.lexicon.calendar.event#hybrid")]
Hybrid,
}
pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri";
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct NamedUri {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
}
impl LexiconType for NamedUri {
fn lexicon_type() -> &'static str {
NAMED_URI_NSID
}
}
pub type TypedNamedUri = TypedLexicon<NamedUri>;
pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri";
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct EventLink {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
}
impl LexiconType for EventLink {
fn lexicon_type() -> &'static str {
EVENT_LINK_NSID
}
}
pub type TypedEventLink = TypedLexicon<EventLink>;
pub type EventLinks = Vec<TypedEventLink>;
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct AspectRatio {
pub width: u64,
pub height: u64,
}
pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media";
fn default_role() -> String {
"banner".to_string()
}
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct Media {
pub content: TypedBlob,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub alt: String,
#[serde(default = "default_role")]
pub role: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub aspect_ratio: Option<AspectRatio>,
}
impl LexiconType for Media {
fn lexicon_type() -> &'static str {
MEDIA_NSID
}
}
pub type TypedMedia = TypedLexicon<Media>;
pub type MediaList = Vec<TypedMedia>;
#[derive(Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
#[serde(untagged)]
pub enum EventLocation {
InlineUri(TypedNamedUri),
Location(LocationOrRef),
Unknown(serde_json::Value),
}
pub type EventLocations = Vec<EventLocation>;
#[derive(Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(any(debug_assertions, test), derive(Debug))]
pub struct Event {
pub name: String,
pub description: String,
#[serde(rename = "createdAt", with = "datetime_format")]
pub created_at: DateTime<Utc>,
#[serde(
rename = "startsAt",
skip_serializing_if = "Option::is_none",
default,
with = "optional_datetime_format"
)]
pub starts_at: Option<DateTime<Utc>>,
#[serde(
rename = "endsAt",
skip_serializing_if = "Option::is_none",
default,
with = "optional_datetime_format"
)]
pub ends_at: Option<DateTime<Utc>>,
#[serde(rename = "mode", skip_serializing_if = "Option::is_none", default)]
pub mode: Option<Mode>,
#[serde(rename = "status", skip_serializing_if = "Option::is_none", default)]
pub status: Option<Status>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub locations: EventLocations,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub uris: EventLinks,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub media: MediaList,
#[serde(skip_serializing_if = "Option::is_none")]
pub facets: Option<Vec<Facet>>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl LexiconType for Event {
fn lexicon_type() -> &'static str {
NSID
}
}
pub type TypedEvent = TypedLexicon<Event>;
#[cfg(test)]
mod tests {
use crate::lexicon::Blob;
use super::*;
use anyhow::Result;
#[test]
fn test_event_location_uri() {
let json_str = r#"{
"$type": "community.lexicon.calendar.event#uri",
"uri": "https://example.com/location",
"name": "Example"
}"#;
let location: EventLocation = serde_json::from_str(json_str).unwrap();
assert!(matches!(location, EventLocation::InlineUri(_)));
}
#[test]
fn test_typed_named_uri() -> Result<()> {
let test_json = r#"{"$type":"community.lexicon.calendar.event#uri","uri":"https://smokesignal.events/","name":"Smoke Signal"}"#;
let named_uri = NamedUri {
uri: "https://smokesignal.events/".to_string(),
name: Some("Smoke Signal".to_string()),
};
let typed_uri = TypedLexicon::new(named_uri);
let serialized = serde_json::to_value(&typed_uri)?;
let expected: serde_json::Value = serde_json::from_str(test_json)?;
assert_eq!(serialized, expected);
let deserialized: TypedNamedUri = serde_json::from_str(test_json).unwrap();
assert_eq!(deserialized.inner.uri, "https://smokesignal.events/");
assert_eq!(deserialized.inner.name, Some("Smoke Signal".to_string()));
Ok(())
}
#[test]
fn test_typed_event() -> Result<()> {
use chrono::TimeZone;
let event = Event {
name: "Test Event".to_string(),
description: "A test event".to_string(),
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(),
starts_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap()),
ends_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 16, 0, 0).unwrap()),
mode: Some(Mode::Hybrid),
status: Some(Status::Scheduled),
locations: vec![],
uris: vec![],
media: vec![],
facets: None,
extra: HashMap::new(),
};
let typed_event = TypedLexicon::new(event.clone());
let json = serde_json::to_value(&typed_event)?;
assert_eq!(json["$type"], "community.lexicon.calendar.event");
assert_eq!(json["name"], "Test Event");
assert_eq!(json["description"], "A test event");
let json_str = r#"{
"$type": "community.lexicon.calendar.event",
"name": "Deserialized Event",
"description": "Event from JSON",
"createdAt": "2025-01-01T12:00:00Z",
"startsAt": "2025-01-15T14:00:00Z",
"endsAt": "2025-01-15T16:00:00Z",
"mode": "community.lexicon.calendar.event#hybrid",
"status": "community.lexicon.calendar.event#scheduled"
}"#;
let deserialized: TypedEvent = serde_json::from_str(json_str)?;
assert_eq!(deserialized.inner.name, "Deserialized Event");
assert_eq!(deserialized.inner.description, "Event from JSON");
assert!(deserialized.has_type_field());
Ok(())
}
#[test]
fn test_typed_event_link() -> Result<()> {
let event_link = EventLink {
uri: "https://example.com/event".to_string(),
name: Some("Example Event".to_string()),
};
let typed_link = TypedLexicon::new(event_link);
let json = serde_json::to_value(&typed_link)?;
assert_eq!(json["$type"], "community.lexicon.calendar.event#uri");
assert_eq!(json["uri"], "https://example.com/event");
assert_eq!(json["name"], "Example Event");
let json_str = r#"{
"$type": "community.lexicon.calendar.event#uri",
"uri": "https://test.com",
"name": "Test Link"
}"#;
let deserialized: TypedEventLink = serde_json::from_str(json_str)?;
assert_eq!(deserialized.inner.uri, "https://test.com");
assert_eq!(deserialized.inner.name, Some("Test Link".to_string()));
assert!(deserialized.has_type_field());
Ok(())
}
#[test]
fn test_typed_media() -> Result<()> {
let media = Media {
content: TypedLexicon::new(Blob {
ref_: crate::lexicon::Link {
link: "bafkreiblob123".to_string(),
},
mime_type: "image/jpeg".to_string(),
size: 12345,
}),
alt: "Test image".to_string(),
role: "banner".to_string(),
aspect_ratio: Some(AspectRatio {
width: 1920,
height: 1080,
}),
};
let typed_media = TypedLexicon::new(media);
let json = serde_json::to_value(&typed_media)?;
assert_eq!(json["$type"], "community.lexicon.calendar.event#media");
assert_eq!(json["alt"], "Test image");
assert_eq!(json["role"], "banner");
assert_eq!(json["content"]["$type"], "blob");
assert_eq!(json["aspect_ratio"]["width"], 1920);
assert_eq!(json["aspect_ratio"]["height"], 1080);
let json_str = r#"{
"$type": "community.lexicon.calendar.event#media",
"content": {
"$type": "blob",
"ref": {
"$link": "bafkreitest456"
},
"mimeType": "image/png",
"size": 54321
},
"alt": "Another test",
"role": "thumbnail"
}"#;
let deserialized: TypedMedia = serde_json::from_str(json_str)?;
assert_eq!(deserialized.inner.alt, "Another test");
assert_eq!(deserialized.inner.role, "thumbnail");
assert_eq!(deserialized.inner.content.inner.mime_type, "image/png");
assert!(deserialized.inner.aspect_ratio.is_none());
assert!(deserialized.has_type_field());
Ok(())
}
#[test]
fn test_event_with_typed_fields() -> Result<()> {
use chrono::TimeZone;
let event_link = EventLink {
uri: "https://event.com".to_string(),
name: Some("Event Website".to_string()),
};
let media = Media {
content: TypedLexicon::new(Blob {
ref_: crate::lexicon::Link {
link: "bafkreimedia".to_string(),
},
mime_type: "image/jpeg".to_string(),
size: 99999,
}),
alt: "Event poster".to_string(),
role: "poster".to_string(),
aspect_ratio: None,
};
let event = Event {
name: "Complex Event".to_string(),
description: "Event with typed fields".to_string(),
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(),
starts_at: None,
ends_at: None,
mode: None,
status: None,
locations: vec![],
uris: vec![TypedLexicon::new(event_link)],
media: vec![TypedLexicon::new(media)],
facets: None,
extra: HashMap::new(),
};
let typed_event = TypedLexicon::new(event);
let json = serde_json::to_value(&typed_event)?;
assert_eq!(json["$type"], "community.lexicon.calendar.event");
assert_eq!(json["name"], "Complex Event");
assert_eq!(
json["uris"][0]["$type"],
"community.lexicon.calendar.event#uri"
);
assert_eq!(json["uris"][0]["uri"], "https://event.com");
assert_eq!(
json["media"][0]["$type"],
"community.lexicon.calendar.event#media"
);
assert_eq!(json["media"][0]["alt"], "Event poster");
Ok(())
}
#[test]
fn test_event_with_address_location() -> Result<()> {
let json_str = r#"{
"$type": "community.lexicon.calendar.event",
"name": "Office Meetup",
"description": "Team gathering",
"createdAt": "2025-06-01T10:00:00Z",
"locations": [
{
"$type": "community.lexicon.location.address",
"country": "USA",
"region": "California",
"locality": "San Francisco",
"street": "123 Main St"
}
]
}"#;
let event: TypedEvent = serde_json::from_str(json_str)?;
assert_eq!(event.inner.locations.len(), 1);
assert!(matches!(
&event.inner.locations[0],
EventLocation::Location(LocationOrRef::InlineAddress(_))
));
Ok(())
}
#[test]
fn test_event_with_geo_location() -> Result<()> {
let json_str = r#"{
"$type": "community.lexicon.calendar.event",
"name": "Park Picnic",
"description": "Outdoor event",
"createdAt": "2025-06-01T10:00:00Z",
"locations": [
{
"$type": "community.lexicon.location.geo",
"latitude": "37.7749",
"longitude": "-122.4194",
"name": "Golden Gate Park"
}
]
}"#;
let event: TypedEvent = serde_json::from_str(json_str)?;
assert_eq!(event.inner.locations.len(), 1);
assert!(matches!(
&event.inner.locations[0],
EventLocation::Location(LocationOrRef::InlineGeo(_))
));
Ok(())
}
#[test]
fn test_event_with_uri_location() -> Result<()> {
let json_str = r#"{
"$type": "community.lexicon.calendar.event",
"name": "Virtual Meetup",
"description": "Online event",
"createdAt": "2025-06-01T10:00:00Z",
"locations": [
{
"$type": "community.lexicon.calendar.event#uri",
"uri": "https://meet.example.com/room",
"name": "Meeting Room"
}
]
}"#;
let event: TypedEvent = serde_json::from_str(json_str)?;
assert_eq!(event.inner.locations.len(), 1);
assert!(matches!(
&event.inner.locations[0],
EventLocation::InlineUri(_)
));
Ok(())
}
#[test]
fn test_event_with_mixed_locations() -> Result<()> {
let json_str = r#"{
"$type": "community.lexicon.calendar.event",
"name": "Hybrid Conference",
"description": "In-person and online",
"createdAt": "2025-06-01T10:00:00Z",
"mode": "community.lexicon.calendar.event#hybrid",
"locations": [
{
"$type": "community.lexicon.location.address",
"country": "USA",
"locality": "Austin"
},
{
"$type": "community.lexicon.calendar.event#uri",
"uri": "https://stream.example.com/live"
},
{
"$type": "community.lexicon.location.geo",
"latitude": "30.2672",
"longitude": "-97.7431"
},
{
"$type": "community.lexicon.location.hthree",
"value": "8a2a1072b59ffff"
},
{
"$type": "community.lexicon.location.fsq",
"fsq_place_id": "4a27f3d4f964a520a4891fe3"
},
{
"$type": "some.future.location.type",
"data": "opaque"
}
]
}"#;
let event: TypedEvent = serde_json::from_str(json_str)?;
assert_eq!(event.inner.locations.len(), 6);
assert!(matches!(
&event.inner.locations[0],
EventLocation::Location(LocationOrRef::InlineAddress(_))
));
assert!(matches!(
&event.inner.locations[1],
EventLocation::InlineUri(_)
));
assert!(matches!(
&event.inner.locations[2],
EventLocation::Location(LocationOrRef::InlineGeo(_))
));
assert!(matches!(
&event.inner.locations[3],
EventLocation::Location(LocationOrRef::InlineHthree(_))
));
assert!(matches!(
&event.inner.locations[4],
EventLocation::Location(LocationOrRef::InlineFsq(_))
));
assert!(matches!(
&event.inner.locations[5],
EventLocation::Location(LocationOrRef::Unknown(_))
));
Ok(())
}
#[test]
fn test_event_with_malformed_created_at() {
let json_str = r#"{
"mode": "community.lexicon.calendar.event#inperson",
"name": "Lunch at Copelands!",
"uris": [
{
"uri": "lsdfaopkljdfs/940340cce37871e3f224f.jpg",
"name": "Event Image"
}
],
"$type": "community.lexicon.calendar.event",
"endsAt": null,
"status": "community.lexicon.calendar.event#scheduled",
"startsAt": "2025-04-12T16:00:00.000Z",
"createdAt": {},
"locations": [
{
"lat": 33.87734358679921,
"lon": -84.45593029260637,
"type": "community.lexicon.location.geo",
"description": "Copeland's, 3101, Cobb Parkway South, Riverwood, Atlanta, Cobb County, Georgia, 30339, United States"
}
],
"description": "Lunch!! first one for 2025"
}"#;
let result = serde_json::from_str::<TypedEvent>(json_str);
assert!(
result.is_err(),
"Malformed createdAt (empty object) should fail deserialization"
);
}
}