Skip to main content

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}