oas_document/calendar.rs
1// OAS Spec §5.10 — Calendar & Scheduling
2//! Calendar and scheduling section for OAS Identity Documents.
3//!
4//! The calendar section provides scheduling capabilities: booking URLs,
5//! availability feeds, office hours, and scheduling protocol preferences.
6//! The entire section or individual entries carry [`Visibility`] for
7//! access control (e.g., internal-only booking links).
8
9use serde::{Deserialize, Serialize};
10
11use crate::visibility::Visibility;
12
13/// A recurring time window (e.g., office hours on a specific day).
14///
15/// # Examples
16///
17/// ```
18/// use oas_document::calendar::ScheduleWindow;
19///
20/// let window = ScheduleWindow {
21/// day: "monday".to_string(),
22/// start: "09:00".to_string(),
23/// end: "17:00".to_string(),
24/// timezone: "America/New_York".to_string(),
25/// };
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ScheduleWindow {
30 /// Day of week: `"monday"` through `"sunday"`, or `"daily"` for every day.
31 pub day: String,
32
33 /// Start time in 24h format (e.g., `"09:00"`).
34 pub start: String,
35
36 /// End time in 24h format (e.g., `"17:00"`).
37 pub end: String,
38
39 /// IANA timezone for this window (e.g., `"America/New_York"`).
40 pub timezone: String,
41}
42
43/// A booking or scheduling endpoint.
44///
45/// Each booking entry can have its own visibility — an agent might
46/// expose a public booking link for external clients but restrict
47/// an internal scheduling API to organization members only.
48///
49/// # Examples
50///
51/// ```
52/// use oas_document::calendar::BookingEndpoint;
53/// use oas_document::visibility::Visibility;
54///
55/// let booking = BookingEndpoint {
56/// id: "booking-public".to_string(),
57/// url: "https://cal.com/agent42/30min".to_string(),
58/// label: Some("30-min consultation".to_string()),
59/// booking_type: Some("consultation".to_string()),
60/// visibility: Visibility::Public,
61/// };
62/// ```
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct BookingEndpoint {
66 /// Fragment identifier (e.g., "booking-public", "booking-internal").
67 pub id: String,
68
69 /// The booking URL (e.g., Calendly, Cal.com, custom API).
70 pub url: String,
71
72 /// Human-readable label (e.g., "30-min consultation", "Priority support").
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub label: Option<String>,
75
76 /// Type of booking: `"consultation"`, `"support"`, `"meeting"`, `"task"`, `"custom"`.
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub booking_type: Option<String>,
79
80 /// Who can access this booking endpoint.
81 #[serde(default)]
82 pub visibility: Visibility,
83}
84
85/// Calendar and scheduling section of an OAS Identity Document.
86///
87/// # Examples
88///
89/// ```
90/// use oas_document::calendar::{CalendarSection, BookingEndpoint, ScheduleWindow};
91/// use oas_document::visibility::Visibility;
92///
93/// let calendar = CalendarSection {
94/// booking_endpoints: vec![
95/// BookingEndpoint {
96/// id: "booking-external".to_string(),
97/// url: "https://cal.com/agent42/intro".to_string(),
98/// label: Some("Intro call".to_string()),
99/// booking_type: Some("consultation".to_string()),
100/// visibility: Visibility::Public,
101/// },
102/// BookingEndpoint {
103/// id: "booking-internal".to_string(),
104/// url: "https://internal.acme.com/schedule/agent42".to_string(),
105/// label: Some("Internal sync".to_string()),
106/// booking_type: Some("meeting".to_string()),
107/// visibility: Visibility::Organization,
108/// },
109/// ],
110/// availability_feed: Some("https://cal.com/agent42/availability.ics".to_string()),
111/// scheduling_protocol: Some("ical-invite".to_string()),
112/// office_hours: vec![
113/// ScheduleWindow {
114/// day: "monday".to_string(),
115/// start: "09:00".to_string(),
116/// end: "17:00".to_string(),
117/// timezone: "America/New_York".to_string(),
118/// },
119/// ],
120/// timezone: Some("America/New_York".to_string()),
121/// };
122/// assert_eq!(calendar.booking_endpoints.len(), 2);
123/// ```
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct CalendarSection {
127 /// Booking endpoints, each with independent visibility.
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub booking_endpoints: Vec<BookingEndpoint>,
130
131 /// iCal/CalDAV URL for real-time availability (free/busy).
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub availability_feed: Option<String>,
134
135 /// Preferred scheduling protocol:
136 /// `"ical-invite"`, `"api"`, `"agent-negotiation"`, `"manual"`.
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub scheduling_protocol: Option<String>,
139
140 /// Recurring office hours / availability windows.
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub office_hours: Vec<ScheduleWindow>,
143
144 /// Default IANA timezone for this entity's schedule.
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub timezone: Option<String>,
147}
148
149/// Standard booking types.
150pub mod booking_types {
151 pub const CONSULTATION: &str = "consultation";
152 pub const SUPPORT: &str = "support";
153 pub const MEETING: &str = "meeting";
154 pub const TASK: &str = "task";
155 pub const CUSTOM: &str = "custom";
156}
157
158/// Standard scheduling protocols.
159pub mod scheduling_protocols {
160 /// iCalendar invite via email.
161 pub const ICAL_INVITE: &str = "ical-invite";
162 /// Direct API call to booking endpoint.
163 pub const API: &str = "api";
164 /// Agent-to-agent negotiation via MAP protocols.
165 pub const AGENT_NEGOTIATION: &str = "agent-negotiation";
166 /// Manual / human-in-the-loop scheduling.
167 pub const MANUAL: &str = "manual";
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn serde_round_trip() {
176 let calendar = CalendarSection {
177 booking_endpoints: vec![BookingEndpoint {
178 id: "booking-1".to_string(),
179 url: "https://cal.com/agent/30min".to_string(),
180 label: Some("30-min call".to_string()),
181 booking_type: Some(booking_types::CONSULTATION.to_string()),
182 visibility: Visibility::Public,
183 }],
184 availability_feed: Some("https://cal.com/agent/avail.ics".to_string()),
185 scheduling_protocol: Some(scheduling_protocols::ICAL_INVITE.to_string()),
186 office_hours: vec![ScheduleWindow {
187 day: "monday".to_string(),
188 start: "09:00".to_string(),
189 end: "17:00".to_string(),
190 timezone: "UTC".to_string(),
191 }],
192 timezone: Some("UTC".to_string()),
193 };
194
195 let json = serde_json::to_string_pretty(&calendar).unwrap_or_default();
196 let parsed: CalendarSection =
197 serde_json::from_str(&json).unwrap_or_else(|_| calendar.clone());
198 assert_eq!(calendar, parsed);
199 }
200
201 #[test]
202 fn internal_booking_is_redacted() {
203 let booking = BookingEndpoint {
204 id: "booking-internal".to_string(),
205 url: "https://internal.acme.com/schedule".to_string(),
206 label: Some("Internal only".to_string()),
207 booking_type: None,
208 visibility: Visibility::Organization,
209 };
210 assert!(booking.visibility.is_redacted_on_dht());
211 }
212
213 #[test]
214 fn minimal_calendar_deserializes() {
215 let json = r#"{"timezone":"UTC"}"#;
216 let cal: Result<CalendarSection, _> = serde_json::from_str(json);
217 assert!(cal.is_ok());
218 let cal = cal.unwrap_or_else(|_| CalendarSection {
219 booking_endpoints: vec![],
220 availability_feed: None,
221 scheduling_protocol: None,
222 office_hours: vec![],
223 timezone: None,
224 });
225 assert!(cal.booking_endpoints.is_empty());
226 assert_eq!(cal.timezone.as_deref(), Some("UTC"));
227 }
228}