The smokesignal.events web application
1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use icalendar::{Calendar, Component, Event as ICalEvent, EventLike};
4
5/// Generate an iCalendar (.ics) file content for an event
6///
7/// This is a shared helper used for both:
8/// - Email attachments (event summary notifications)
9/// - HTTP ICS export endpoints
10///
11/// # Arguments
12/// * `uid` - Unique identifier for the event (e.g., event ATURI or generated hash)
13/// * `name` - Event title/summary
14/// * `description` - Optional event description
15/// * `location` - Optional event location
16/// * `starts_at` - Event start time (UTC)
17/// * `ends_at` - Optional event end time (UTC)
18/// * `url` - Optional URL to the event page
19pub fn generate_event_ics(
20 uid: &str,
21 name: &str,
22 description: Option<&str>,
23 location: Option<&str>,
24 starts_at: DateTime<Utc>,
25 ends_at: Option<DateTime<Utc>>,
26 url: Option<&str>,
27) -> Result<String> {
28 let mut calendar = Calendar::new();
29 let mut ical_event = ICalEvent::new();
30
31 // Set required fields
32 ical_event.uid(uid);
33 ical_event.summary(name);
34 ical_event.starts(starts_at);
35
36 // Set optional fields
37 if let Some(desc) = description {
38 ical_event.description(desc);
39 }
40
41 if let Some(loc) = location {
42 ical_event.location(loc);
43 }
44
45 if let Some(end) = ends_at {
46 ical_event.ends(end);
47 }
48
49 if let Some(event_url) = url {
50 ical_event.url(event_url);
51 }
52
53 // Add event to calendar
54 calendar.push(ical_event);
55
56 Ok(calendar.to_string())
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use chrono::TimeZone;
63
64 #[test]
65 fn test_generate_basic_event() {
66 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap();
67
68 let ics = generate_event_ics(
69 "event123@smokesignal.events",
70 "Test Event",
71 None,
72 None,
73 start,
74 None,
75 None,
76 )
77 .unwrap();
78
79 assert!(ics.contains("BEGIN:VCALENDAR"));
80 assert!(ics.contains("BEGIN:VEVENT"));
81 assert!(ics.contains("UID:event123@smokesignal.events"));
82 assert!(ics.contains("SUMMARY:Test Event"));
83 assert!(ics.contains("DTSTART"));
84 assert!(ics.contains("END:VEVENT"));
85 assert!(ics.contains("END:VCALENDAR"));
86 }
87
88 #[test]
89 fn test_generate_full_event() {
90 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap();
91 let end = Utc.with_ymd_and_hms(2025, 1, 15, 17, 0, 0).unwrap();
92
93 let ics = generate_event_ics(
94 "event456@smokesignal.events",
95 "Community Meetup",
96 Some("Join us for a community meetup!"),
97 Some("123 Main St, City"),
98 start,
99 Some(end),
100 Some("https://smokesignal.events/events/456"),
101 )
102 .unwrap();
103
104 assert!(ics.contains("SUMMARY:Community Meetup"));
105 assert!(ics.contains("DESCRIPTION:Join us for a community meetup!"));
106 assert!(ics.contains("LOCATION:123 Main St"));
107 assert!(ics.contains("DTEND"));
108 assert!(ics.contains("URL:https://smokesignal.events/events/456"));
109 }
110
111 #[test]
112 fn test_special_characters_are_escaped() {
113 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap();
114
115 let ics = generate_event_ics(
116 "test",
117 "Event: Special; Chars,",
118 Some("Description\nwith newlines"),
119 Some("Location, with comma"),
120 start,
121 None,
122 None,
123 )
124 .unwrap();
125
126 // The icalendar crate should handle escaping automatically
127 assert!(ics.contains("BEGIN:VEVENT"));
128 assert!(ics.contains("END:VEVENT"));
129 }
130}