oas-document 1.1.1

OAS Identity Document types and validation for the Open Agent Specification
Documentation
// OAS Spec §5.10 — Calendar & Scheduling
//! Calendar and scheduling section for OAS Identity Documents.
//!
//! The calendar section provides scheduling capabilities: booking URLs,
//! availability feeds, office hours, and scheduling protocol preferences.
//! The entire section or individual entries carry [`Visibility`] for
//! access control (e.g., internal-only booking links).

use serde::{Deserialize, Serialize};

use crate::visibility::Visibility;

/// A recurring time window (e.g., office hours on a specific day).
///
/// # Examples
///
/// ```
/// use oas_document::calendar::ScheduleWindow;
///
/// let window = ScheduleWindow {
///     day: "monday".to_string(),
///     start: "09:00".to_string(),
///     end: "17:00".to_string(),
///     timezone: "America/New_York".to_string(),
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleWindow {
    /// Day of week: `"monday"` through `"sunday"`, or `"daily"` for every day.
    pub day: String,

    /// Start time in 24h format (e.g., `"09:00"`).
    pub start: String,

    /// End time in 24h format (e.g., `"17:00"`).
    pub end: String,

    /// IANA timezone for this window (e.g., `"America/New_York"`).
    pub timezone: String,
}

/// A booking or scheduling endpoint.
///
/// Each booking entry can have its own visibility — an agent might
/// expose a public booking link for external clients but restrict
/// an internal scheduling API to organization members only.
///
/// # Examples
///
/// ```
/// use oas_document::calendar::BookingEndpoint;
/// use oas_document::visibility::Visibility;
///
/// let booking = BookingEndpoint {
///     id: "booking-public".to_string(),
///     url: "https://cal.com/agent42/30min".to_string(),
///     label: Some("30-min consultation".to_string()),
///     booking_type: Some("consultation".to_string()),
///     visibility: Visibility::Public,
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BookingEndpoint {
    /// Fragment identifier (e.g., "booking-public", "booking-internal").
    pub id: String,

    /// The booking URL (e.g., Calendly, Cal.com, custom API).
    pub url: String,

    /// Human-readable label (e.g., "30-min consultation", "Priority support").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,

    /// Type of booking: `"consultation"`, `"support"`, `"meeting"`, `"task"`, `"custom"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub booking_type: Option<String>,

    /// Who can access this booking endpoint.
    #[serde(default)]
    pub visibility: Visibility,
}

/// Calendar and scheduling section of an OAS Identity Document.
///
/// # Examples
///
/// ```
/// use oas_document::calendar::{CalendarSection, BookingEndpoint, ScheduleWindow};
/// use oas_document::visibility::Visibility;
///
/// let calendar = CalendarSection {
///     booking_endpoints: vec![
///         BookingEndpoint {
///             id: "booking-external".to_string(),
///             url: "https://cal.com/agent42/intro".to_string(),
///             label: Some("Intro call".to_string()),
///             booking_type: Some("consultation".to_string()),
///             visibility: Visibility::Public,
///         },
///         BookingEndpoint {
///             id: "booking-internal".to_string(),
///             url: "https://internal.acme.com/schedule/agent42".to_string(),
///             label: Some("Internal sync".to_string()),
///             booking_type: Some("meeting".to_string()),
///             visibility: Visibility::Organization,
///         },
///     ],
///     availability_feed: Some("https://cal.com/agent42/availability.ics".to_string()),
///     scheduling_protocol: Some("ical-invite".to_string()),
///     office_hours: vec![
///         ScheduleWindow {
///             day: "monday".to_string(),
///             start: "09:00".to_string(),
///             end: "17:00".to_string(),
///             timezone: "America/New_York".to_string(),
///         },
///     ],
///     timezone: Some("America/New_York".to_string()),
/// };
/// assert_eq!(calendar.booking_endpoints.len(), 2);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarSection {
    /// Booking endpoints, each with independent visibility.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub booking_endpoints: Vec<BookingEndpoint>,

    /// iCal/CalDAV URL for real-time availability (free/busy).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub availability_feed: Option<String>,

    /// Preferred scheduling protocol:
    /// `"ical-invite"`, `"api"`, `"agent-negotiation"`, `"manual"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scheduling_protocol: Option<String>,

    /// Recurring office hours / availability windows.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub office_hours: Vec<ScheduleWindow>,

    /// Default IANA timezone for this entity's schedule.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timezone: Option<String>,
}

/// Standard booking types.
pub mod booking_types {
    pub const CONSULTATION: &str = "consultation";
    pub const SUPPORT: &str = "support";
    pub const MEETING: &str = "meeting";
    pub const TASK: &str = "task";
    pub const CUSTOM: &str = "custom";
}

/// Standard scheduling protocols.
pub mod scheduling_protocols {
    /// iCalendar invite via email.
    pub const ICAL_INVITE: &str = "ical-invite";
    /// Direct API call to booking endpoint.
    pub const API: &str = "api";
    /// Agent-to-agent negotiation via MAP protocols.
    pub const AGENT_NEGOTIATION: &str = "agent-negotiation";
    /// Manual / human-in-the-loop scheduling.
    pub const MANUAL: &str = "manual";
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn serde_round_trip() {
        let calendar = CalendarSection {
            booking_endpoints: vec![BookingEndpoint {
                id: "booking-1".to_string(),
                url: "https://cal.com/agent/30min".to_string(),
                label: Some("30-min call".to_string()),
                booking_type: Some(booking_types::CONSULTATION.to_string()),
                visibility: Visibility::Public,
            }],
            availability_feed: Some("https://cal.com/agent/avail.ics".to_string()),
            scheduling_protocol: Some(scheduling_protocols::ICAL_INVITE.to_string()),
            office_hours: vec![ScheduleWindow {
                day: "monday".to_string(),
                start: "09:00".to_string(),
                end: "17:00".to_string(),
                timezone: "UTC".to_string(),
            }],
            timezone: Some("UTC".to_string()),
        };

        let json = serde_json::to_string_pretty(&calendar).unwrap_or_default();
        let parsed: CalendarSection =
            serde_json::from_str(&json).unwrap_or_else(|_| calendar.clone());
        assert_eq!(calendar, parsed);
    }

    #[test]
    fn internal_booking_is_redacted() {
        let booking = BookingEndpoint {
            id: "booking-internal".to_string(),
            url: "https://internal.acme.com/schedule".to_string(),
            label: Some("Internal only".to_string()),
            booking_type: None,
            visibility: Visibility::Organization,
        };
        assert!(booking.visibility.is_redacted_on_dht());
    }

    #[test]
    fn minimal_calendar_deserializes() {
        let json = r#"{"timezone":"UTC"}"#;
        let cal: Result<CalendarSection, _> = serde_json::from_str(json);
        assert!(cal.is_ok());
        let cal = cal.unwrap_or_else(|_| CalendarSection {
            booking_endpoints: vec![],
            availability_feed: None,
            scheduling_protocol: None,
            office_hours: vec![],
            timezone: None,
        });
        assert!(cal.booking_endpoints.is_empty());
        assert_eq!(cal.timezone.as_deref(), Some("UTC"));
    }
}