use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; // Global mutex to serialize CalDAV HTTP requests to prevent race conditions lazy_static::lazy_static! { static ref CALDAV_HTTP_MUTEX: Arc> = Arc::new(Mutex::new(())); } /// Type alias for shared VEvent (for backward compatibility during migration) pub type CalendarEvent = VEvent; /// Old CalendarEvent struct definition (DEPRECATED - use VEvent instead) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OldCalendarEvent { /// Unique identifier for the event (UID field in iCal) pub uid: String, /// Summary/title of the event pub summary: Option, /// Detailed description of the event pub description: Option, /// Start date and time of the event pub start: DateTime, /// End date and time of the event pub end: Option>, /// Location where the event takes place pub location: Option, /// Event status (TENTATIVE, CONFIRMED, CANCELLED) pub status: EventStatus, /// Event classification (PUBLIC, PRIVATE, CONFIDENTIAL) pub class: EventClass, /// Event priority (0-9, where 0 is undefined, 1 is highest, 9 is lowest) pub priority: Option, /// Organizer of the event pub organizer: Option, /// List of attendees pub attendees: Vec, /// Categories/tags for the event pub categories: Vec, /// Date and time when the event was created pub created: Option>, /// Date and time when the event was last modified pub last_modified: Option>, /// Recurrence rule (RRULE) pub recurrence_rule: Option, /// Exception dates - dates to exclude from recurrence (EXDATE) pub exdate: Vec>, /// All-day event flag pub all_day: bool, /// Reminders/alarms for this event pub reminders: Vec, /// ETag from CalDAV server for conflict detection pub etag: Option, /// URL/href of this event on the CalDAV server pub href: Option, /// Calendar path this event belongs to pub calendar_path: Option, } // EventStatus and EventClass are now imported from calendar_models /// Event reminder/alarm information #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EventReminder { /// How long before the event to trigger the reminder (in minutes) pub minutes_before: i32, /// Type of reminder action pub action: ReminderAction, /// Optional description for the reminder pub description: Option, } /// Reminder action types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ReminderAction { Display, Email, Audio, } /// CalDAV client for fetching and parsing calendar events pub struct CalDAVClient { config: crate::config::CalDAVConfig, http_client: reqwest::Client, } impl CalDAVClient { /// Create a new CalDAV client with the given configuration pub fn new(config: crate::config::CalDAVConfig) -> Self { // Create HTTP client with global timeout to prevent hanging requests let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) // 60 second global timeout .build() .expect("Failed to create HTTP client"); Self { config, http_client, } } /// Fetch calendar events from the CalDAV server /// /// This method performs a REPORT request to get calendar data and parses /// the returned iCalendar format into CalendarEvent structs. pub async fn fetch_events( &self, calendar_path: &str, ) -> Result, CalDAVError> { // CalDAV REPORT request to get calendar events let report_body = r#" "#; let url = if calendar_path.starts_with("http") { calendar_path.to_string() } else { // Extract the base URL (scheme + host + port) from server_url let server_url = &self.config.server_url; // Find the first '/' after "https://" or "http://" let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 }; if let Some(path_start) = server_url[scheme_end..].find('/') { let base_url = &server_url[..scheme_end + path_start]; format!("{}{}", base_url, calendar_path) } else { // No path in server_url, so just append the calendar_path format!("{}{}", server_url.trim_end_matches('/'), calendar_path) } }; let basic_auth = self.config.get_basic_auth(); let response = self .http_client .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) .header("Authorization", format!("Basic {}", basic_auth)) .header("Content-Type", "application/xml") .header("Depth", "1") .header("User-Agent", "calendar-app/0.1.0") .body(report_body) .send() .await .map_err(CalDAVError::RequestError)?; if !response.status().is_success() && response.status().as_u16() != 207 { return Err(CalDAVError::ServerError(response.status().as_u16())); } let body = response.text().await.map_err(CalDAVError::RequestError)?; self.parse_calendar_response(&body, calendar_path) } /// Parse CalDAV XML response containing calendar data fn parse_calendar_response( &self, xml_response: &str, calendar_path: &str, ) -> Result, CalDAVError> { let mut events = Vec::new(); // Extract calendar data from XML response // This is a simplified parser - in production, you'd want a proper XML parser let calendar_data_sections = self.extract_calendar_data(xml_response); for calendar_data in calendar_data_sections { if let Ok(parsed_events) = self.parse_ical_data(&calendar_data.data) { for mut event in parsed_events { event.etag = calendar_data.etag.clone(); event.href = calendar_data.href.clone(); event.calendar_path = Some(calendar_path.to_string()); events.push(event); } } } Ok(events) } /// Fetch a single calendar event by UID from the CalDAV server pub async fn fetch_event_by_uid( &self, calendar_path: &str, uid: &str, ) -> Result, CalDAVError> { // First fetch all events and find the one with matching UID let events = self.fetch_events(calendar_path).await?; // Find event with matching UID let event = events.into_iter().find(|e| e.uid == uid); Ok(event) } /// Extract calendar data sections from CalDAV XML response fn extract_calendar_data(&self, xml_response: &str) -> Vec { let mut sections = Vec::new(); // Simple regex-based extraction (in production, use a proper XML parser) // Look for blocks containing calendar data for response_block in xml_response.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; let href = self .extract_xml_content(response_content, "href") .unwrap_or_default(); let etag = self .extract_xml_content(response_content, "getetag") .unwrap_or_default(); if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") { sections.push(CalendarDataSection { href: if href.is_empty() { None } else { Some(href) }, etag: if etag.is_empty() { None } else { Some(etag) }, data: calendar_data, }); } } } sections } /// Extract content from XML tags (simple implementation) fn extract_xml_content(&self, xml: &str, tag: &str) -> Option { // Handle both with and without namespace prefixes let patterns = [ format!("(?s)<{}>(.*?)", tag, tag), // content format!( "(?s)<{}>(.*?)", tag, tag.split(':').last().unwrap_or(tag) ), // content format!( "(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag ), // content format!( "(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag) ), // content format!("(?s)<{}[^>]*>(.*?)", tag, tag), // content format!( "(?s)<{}[^>]*>(.*?)", tag, tag.split(':').last().unwrap_or(tag) ), ]; for pattern in &patterns { if let Ok(re) = regex::Regex::new(pattern) { if let Some(captures) = re.captures(xml) { if let Some(content) = captures.get(1) { return Some(content.as_str().trim().to_string()); } } } } None } /// Parse iCalendar data into CalendarEvent structs fn parse_ical_data(&self, ical_data: &str) -> Result, CalDAVError> { let mut events = Vec::new(); // Parse the iCal data using the ical crate let reader = ical::IcalParser::new(ical_data.as_bytes()); for calendar in reader { let calendar = calendar.map_err(|e| CalDAVError::ParseError(e.to_string()))?; for event in calendar.events { if let Ok(calendar_event) = self.parse_ical_event(event) { events.push(calendar_event); } } } Ok(events) } /// Parse a single iCal event into a CalendarEvent struct fn parse_ical_event( &self, event: ical::parser::ical::component::IcalEvent, ) -> Result { let mut properties: HashMap = HashMap::new(); let mut full_properties: HashMap = HashMap::new(); // Extract all properties from the event for property in &event.properties { let prop_name = property.name.to_uppercase(); let prop_value = property.value.clone().unwrap_or_default(); properties.insert(prop_name.clone(), prop_value.clone()); // Build full property string with parameters for timezone parsing let mut full_prop = format!("{}", prop_name); if let Some(params) = &property.params { for (param_name, param_values) in params { if !param_values.is_empty() { full_prop.push_str(&format!(";{}={}", param_name, param_values.join(","))); } } } full_prop.push_str(&format!(":{}", prop_value)); full_properties.insert(prop_name, full_prop); } // Required UID field let uid = properties .get("UID") .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? .clone(); // Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545 let empty_string = String::new(); let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string); let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string); let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8); // Parse start time (required) let start_prop = properties .get("DTSTART") .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_properties.get("DTSTART"))?; // Parse end time (optional - use start time if not present) let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") { let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?; (Some(end_dt), end_tz) } else if let Some(_duration) = properties.get("DURATION") { // TODO: Parse duration and add to start time (Some(start_naive), start_tzid.clone()) } else { (None, None) }; // Parse status let status = properties .get("STATUS") .map(|s| match s.to_uppercase().as_str() { "TENTATIVE" => EventStatus::Tentative, "CANCELLED" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }) .unwrap_or(EventStatus::Confirmed); // Parse classification let class = properties .get("CLASS") .map(|s| match s.to_uppercase().as_str() { "PRIVATE" => EventClass::Private, "CONFIDENTIAL" => EventClass::Confidential, _ => EventClass::Public, }) .unwrap_or(EventClass::Public); // Parse priority let priority = properties .get("PRIORITY") .and_then(|s| s.parse::().ok()) .filter(|&p| p <= 9); // Parse categories let categories = properties .get("CATEGORIES") .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) .unwrap_or_default(); // Parse dates with timezone information let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") { match self.parse_datetime_with_tz(created_str, None) { Ok((dt, tz)) => (Some(dt), tz), Err(_) => (None, None) } } else { (None, None) }; let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") { match self.parse_datetime_with_tz(modified_str, None) { Ok((dt, tz)) => (Some(dt), tz), Err(_) => (None, None) } } else { (None, None) }; // Parse exception dates (EXDATE) let exdate = self.parse_exdate(&event); // Create VEvent with parsed naive datetime and timezone info let mut vevent = VEvent::new(uid, start_naive); // Set optional fields with timezone information vevent.dtend = end_naive; vevent.dtstart_tzid = start_tzid; vevent.dtend_tzid = end_tzid; vevent.summary = properties.get("SUMMARY").cloned(); vevent.description = properties.get("DESCRIPTION").cloned(); vevent.location = properties.get("LOCATION").cloned(); vevent.status = Some(status); vevent.class = Some(class); vevent.priority = priority; // Convert organizer string to CalendarUser if let Some(organizer_str) = properties.get("ORGANIZER") { vevent.organizer = Some(CalendarUser { cal_address: organizer_str.clone(), common_name: None, dir_entry_ref: None, sent_by: None, language: None, }); } // TODO: Parse attendees properly vevent.attendees = Vec::new(); vevent.categories = categories; vevent.created = created_naive; vevent.created_tzid = created_tzid; vevent.last_modified = last_modified_naive; vevent.last_modified_tzid = last_modified_tzid; vevent.rrule = properties.get("RRULE").cloned(); vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect(); vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE vevent.all_day = all_day; // Parse alarms vevent.alarms = self.parse_valarms(&event)?; // CalDAV specific fields (set by caller) vevent.etag = None; vevent.href = None; vevent.calendar_path = None; Ok(vevent) } /// Parse VALARM components from an iCal event fn parse_valarms( &self, event: &ical::parser::ical::component::IcalEvent, ) -> Result, CalDAVError> { let mut alarms = Vec::new(); for alarm in &event.alarms { if let Ok(valarm) = self.parse_single_valarm(alarm) { alarms.push(valarm); } } Ok(alarms) } /// Parse a single VALARM component into a VAlarm fn parse_single_valarm( &self, alarm: &ical::parser::ical::component::IcalAlarm, ) -> Result { let mut properties: HashMap = HashMap::new(); // Extract all properties from the alarm for property in &alarm.properties { properties.insert( property.name.to_uppercase(), property.value.clone().unwrap_or_default(), ); } // Parse ACTION (required) let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { Some(ref action_str) if action_str == "DISPLAY" => { calendar_models::AlarmAction::Display } Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email, Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio, Some(ref action_str) if action_str == "PROCEDURE" => { calendar_models::AlarmAction::Procedure } _ => calendar_models::AlarmAction::Display, // Default }; // Parse TRIGGER (required) let trigger = if let Some(trigger_str) = properties.get("TRIGGER") { if let Some(minutes) = self.parse_trigger_duration(trigger_str) { calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-minutes as i64)) } else { // Default to 15 minutes before calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15)) } } else { // Default to 15 minutes before calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15)) }; // Get description let description = properties.get("DESCRIPTION").cloned(); Ok(VAlarm { action, trigger, duration: None, repeat: None, description, summary: None, attendees: Vec::new(), attach: Vec::new(), }) } /// Parse a TRIGGER duration string into minutes before event fn parse_trigger_duration(&self, trigger: &str) -> Option { // Basic parsing of ISO 8601 duration or relative time // Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before) if trigger.starts_with("-PT") && trigger.ends_with("M") { // Parse "-PT15M" format (minutes) let minutes_str = &trigger[3..trigger.len() - 1]; minutes_str.parse::().ok() } else if trigger.starts_with("-PT") && trigger.ends_with("H") { // Parse "-PT1H" format (hours) let hours_str = &trigger[3..trigger.len() - 1]; hours_str.parse::().ok().map(|h| h * 60) } else if trigger.starts_with("-P") && trigger.ends_with("D") { // Parse "-P1D" format (days) let days_str = &trigger[2..trigger.len() - 1]; days_str.parse::().ok().map(|d| d * 24 * 60) } else { // Try to parse as raw minutes trigger.parse::().ok().map(|m| m.abs()) } } /// Discover available calendar collections on the server pub async fn discover_calendars(&self) -> Result, CalDAVError> { // First, try to discover user calendars if we have a calendar path in config if let Some(calendar_path) = &self.config.calendar_path { return Ok(vec![calendar_path.clone()]); } // Try different common CalDAV discovery paths // Note: paths should be relative to the server URL base let user_calendar_path = format!("/calendars/{}/", self.config.username); let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()]; let mut all_calendars = Vec::new(); let mut has_valid_caldav_response = false; for path in discovery_paths { match self.discover_calendars_at_path(&path).await { Ok(calendars) => { has_valid_caldav_response = true; all_calendars.extend(calendars); } Err(CalDAVError::ServerError(_status)) => { // HTTP error - this might be expected for some paths, continue trying } Err(e) => { // Network or other error - this suggests the server isn't reachable or isn't CalDAV return Err(e); } } } // If we never got a valid CalDAV response (e.g., all requests failed), // this is likely not a CalDAV server if !has_valid_caldav_response { return Err(CalDAVError::ServerError(404)); } // Remove duplicates all_calendars.sort(); all_calendars.dedup(); Ok(all_calendars) } /// Discover calendars at a specific path async fn discover_calendars_at_path(&self, path: &str) -> Result, CalDAVError> { let propfind_body = r#" "#; let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path); let response = self .http_client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "application/xml") .header("Depth", "2") // Deeper search to find actual calendars .header("User-Agent", "calendar-app/0.1.0") .body(propfind_body) .send() .await .map_err(CalDAVError::RequestError)?; if response.status().as_u16() != 207 { println!( "❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16() ); return Err(CalDAVError::ServerError(response.status().as_u16())); } let body = response.text().await.map_err(CalDAVError::RequestError)?; let mut calendar_paths = Vec::new(); // Extract calendar collection URLs from the response for response_block in body.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; // Extract href first if let Some(href) = self.extract_xml_content(response_content, "href") { // Check if this is a calendar collection by looking for supported-calendar-component-set // This indicates it's an actual calendar that can contain events let has_supported_components = response_content .contains("supported-calendar-component-set") && (response_content.contains("VEVENT") || response_content.contains("VTODO")); let has_calendar_resourcetype = response_content.contains(", ) -> Result<(chrono::NaiveDateTime, Option), CalDAVError> { // Extract timezone information from the original property if available let mut timezone_id: Option = None; if let Some(prop) = original_property { // Look for TZID parameter in the property // Format: DTSTART;TZID=America/Denver:20231225T090000 if let Some(tzid_start) = prop.find("TZID=") { let tzid_part = &prop[tzid_start + 5..]; if let Some(tzid_end) = tzid_part.find(':') { timezone_id = Some(tzid_part[..tzid_end].to_string()); } else if let Some(tzid_end) = tzid_part.find(';') { timezone_id = Some(tzid_part[..tzid_end].to_string()); } } } // Clean the datetime string - remove any TZID prefix if present let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); // Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000" let datetime_part = if let Some(colon_pos) = cleaned.find(':') { &cleaned[colon_pos + 1..] } else { &cleaned }; // Try different parsing formats let formats = [ "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z "%Y%m%dT%H%M%S", // Local format: 20231225T120000 "%Y%m%d", // Date only: 20231225 ]; for format in &formats { // Try parsing as UTC format (with Z suffix) if datetime_part.ends_with('Z') { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") { // Z suffix means UTC, ignore any TZID parameter return Ok((dt, Some("UTC".to_string()))); } } // Try parsing with timezone offset (e.g., 20231225T120000-0500) if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") { // Convert to naive UTC time and return UTC timezone return Ok((dt.naive_utc(), Some("UTC".to_string()))); } // Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00) if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") { // Convert to naive UTC time and return UTC timezone return Ok((dt.naive_utc(), Some("UTC".to_string()))); } // Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z) if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") { // Z suffix means UTC return Ok((dt.naive_utc(), Some("UTC".to_string()))); } // Special handling for date-only format (all-day events) if *format == "%Y%m%d" { if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) { // Convert date to midnight datetime for all-day events let naive_dt = date.and_hms_opt(0, 0, 0).unwrap(); let tz = timezone_id.unwrap_or_else(|| "UTC".to_string()); return Ok((naive_dt, Some(tz))); } } else { // Try parsing as naive datetime for time-based formats if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) { // Per RFC 5545: if no TZID parameter is provided, treat as UTC let tz = timezone_id.unwrap_or_else(|| "UTC".to_string()); // If it's UTC, the naive time is already correct // If it's a local timezone, we store the naive time and the timezone ID return Ok((naive_dt, Some(tz))); } } } Err(CalDAVError::ParseError(format!( "Could not parse datetime: {}", datetime_str ))) } /// Parse iCal datetime format with timezone support fn parse_datetime( &self, datetime_str: &str, original_property: Option<&String>, ) -> Result, CalDAVError> { use chrono::TimeZone; use chrono_tz::Tz; // Extract timezone information from the original property if available let mut timezone_id: Option<&str> = None; if let Some(prop) = original_property { // Look for TZID parameter in the property // Format: DTSTART;TZID=America/Denver:20231225T090000 if let Some(tzid_start) = prop.find("TZID=") { let tzid_part = &prop[tzid_start + 5..]; if let Some(tzid_end) = tzid_part.find(':') { timezone_id = Some(&tzid_part[..tzid_end]); } else if let Some(tzid_end) = tzid_part.find(';') { timezone_id = Some(&tzid_part[..tzid_end]); } } } // Clean the datetime string - remove any TZID prefix if present let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); // Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000" let datetime_part = if let Some(colon_pos) = cleaned.find(':') { &cleaned[colon_pos + 1..] } else { &cleaned }; // Try different parsing formats let formats = [ "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z "%Y%m%dT%H%M%S", // Local format: 20231225T120000 "%Y%m%d", // Date only: 20231225 ]; for format in &formats { // Try parsing as UTC first (if it has Z suffix) if datetime_part.ends_with('Z') { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") { return Ok(dt.and_utc()); } } // Try parsing with timezone offset (e.g., 20231225T120000-0500) if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") { return Ok(dt.with_timezone(&Utc)); } // Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00) if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") { return Ok(dt.with_timezone(&Utc)); } // Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z) if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") { return Ok(dt.with_timezone(&Utc)); } // Try parsing as naive datetime if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) { // If we have timezone information, convert accordingly if let Some(tz_id) = timezone_id { let tz_result = if tz_id.starts_with("/mozilla.org/") { // Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London tz_id.split('/').last().and_then(|tz_name| tz_name.parse::().ok()) } else if tz_id.contains('/') { // Standard timezone format: America/New_York, Europe/London tz_id.parse::().ok() } else { // Try common abbreviations and Windows timezone names match tz_id { // Standard abbreviations "EST" => Some(Tz::America__New_York), "PST" => Some(Tz::America__Los_Angeles), "MST" => Some(Tz::America__Denver), "CST" => Some(Tz::America__Chicago), // North America - Windows timezone names to IANA mapping "Mountain Standard Time" => Some(Tz::America__Denver), "Eastern Standard Time" => Some(Tz::America__New_York), "Central Standard Time" => Some(Tz::America__Chicago), "Pacific Standard Time" => Some(Tz::America__Los_Angeles), "Mountain Daylight Time" => Some(Tz::America__Denver), "Eastern Daylight Time" => Some(Tz::America__New_York), "Central Daylight Time" => Some(Tz::America__Chicago), "Pacific Daylight Time" => Some(Tz::America__Los_Angeles), "Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu), "Alaskan Standard Time" => Some(Tz::America__Anchorage), "Alaskan Daylight Time" => Some(Tz::America__Anchorage), "Atlantic Standard Time" => Some(Tz::America__Halifax), "Newfoundland Standard Time" => Some(Tz::America__St_Johns), // Europe "GMT Standard Time" => Some(Tz::Europe__London), "Greenwich Standard Time" => Some(Tz::UTC), "W. Europe Standard Time" => Some(Tz::Europe__Berlin), "Central Europe Standard Time" => Some(Tz::Europe__Warsaw), "Romance Standard Time" => Some(Tz::Europe__Paris), "Central European Standard Time" => Some(Tz::Europe__Belgrade), "E. Europe Standard Time" => Some(Tz::Europe__Bucharest), "FLE Standard Time" => Some(Tz::Europe__Helsinki), "GTB Standard Time" => Some(Tz::Europe__Athens), "Russian Standard Time" => Some(Tz::Europe__Moscow), "Turkey Standard Time" => Some(Tz::Europe__Istanbul), // Asia "China Standard Time" => Some(Tz::Asia__Shanghai), "Tokyo Standard Time" => Some(Tz::Asia__Tokyo), "Korea Standard Time" => Some(Tz::Asia__Seoul), "Singapore Standard Time" => Some(Tz::Asia__Singapore), "India Standard Time" => Some(Tz::Asia__Kolkata), "Pakistan Standard Time" => Some(Tz::Asia__Karachi), "Bangladesh Standard Time" => Some(Tz::Asia__Dhaka), "Thailand Standard Time" => Some(Tz::Asia__Bangkok), "SE Asia Standard Time" => Some(Tz::Asia__Bangkok), "Myanmar Standard Time" => Some(Tz::Asia__Yangon), "Sri Lanka Standard Time" => Some(Tz::Asia__Colombo), "Nepal Standard Time" => Some(Tz::Asia__Kathmandu), "Central Asia Standard Time" => Some(Tz::Asia__Almaty), "West Asia Standard Time" => Some(Tz::Asia__Tashkent), "N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk), "North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk), "North Asia East Standard Time" => Some(Tz::Asia__Irkutsk), "Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk), "Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok), "Magadan Standard Time" => Some(Tz::Asia__Magadan), // Australia & Pacific "AUS Eastern Standard Time" => Some(Tz::Australia__Sydney), "AUS Central Standard Time" => Some(Tz::Australia__Adelaide), "W. Australia Standard Time" => Some(Tz::Australia__Perth), "Tasmania Standard Time" => Some(Tz::Australia__Hobart), "New Zealand Standard Time" => Some(Tz::Pacific__Auckland), "Fiji Standard Time" => Some(Tz::Pacific__Fiji), "Tonga Standard Time" => Some(Tz::Pacific__Tongatapu), // Africa & Middle East "South Africa Standard Time" => Some(Tz::Africa__Johannesburg), "Egypt Standard Time" => Some(Tz::Africa__Cairo), "Israel Standard Time" => Some(Tz::Asia__Jerusalem), "Iran Standard Time" => Some(Tz::Asia__Tehran), "Arabic Standard Time" => Some(Tz::Asia__Baghdad), "Arab Standard Time" => Some(Tz::Asia__Riyadh), // South America "SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo), "Argentina Standard Time" => Some(Tz::America__Buenos_Aires), "SA Western Standard Time" => Some(Tz::America__La_Paz), "SA Pacific Standard Time" => Some(Tz::America__Bogota), _ => None, } }; if let Some(tz) = tz_result { // Convert from the specified timezone to UTC if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() { return Ok(local_dt.with_timezone(&Utc)); } } // If timezone parsing fails, fall back to UTC } // No timezone info or parsing failed - treat as UTC return Ok(Utc.from_utc_datetime(&naive_dt)); } // Try parsing as date only if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) { return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); } } Err(CalDAVError::ParseError(format!( "Unable to parse datetime: {} (cleaned: {}, timezone: {:?})", datetime_str, datetime_part, timezone_id ))) } /// Parse EXDATE properties from an iCal event fn parse_exdate(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec> { let mut exdate = Vec::new(); // Look for EXDATE properties for property in &event.properties { if property.name.to_uppercase() == "EXDATE" { if let Some(value) = &property.value { // EXDATE can contain multiple comma-separated dates for date_str in value.split(',') { // Try to parse the date (the parse_datetime method will handle different formats) if let Ok(date) = self.parse_datetime(date_str.trim(), None) { exdate.push(date); } } } } } exdate } /// Create a new calendar on the CalDAV server using MKCALENDAR pub async fn create_calendar( &self, name: &str, description: Option<&str>, color: Option<&str>, ) -> Result<(), CalDAVError> { // Sanitize calendar name for URL path let calendar_id = name .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .to_lowercase(); let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); let full_url = format!( "{}{}", self.config.server_url.trim_end_matches('/'), calendar_path ); // Build color property if provided let color_property = if let Some(color) = color { format!( r#"{}"#, color ) } else { String::new() }; let description_property = if let Some(desc) = description { format!( r#"{}"#, desc ) } else { String::new() }; // Create the MKCALENDAR request body let mkcalendar_body = format!( r#" {} {} {} "#, name, color_property, description_property ); println!("Creating calendar at: {}", full_url); println!("MKCALENDAR body: {}", mkcalendar_body); let response = self .http_client .request( reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url, ) .header("Content-Type", "application/xml; charset=utf-8") .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .body(mkcalendar_body) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar creation response status: {}", response.status()); if response.status().is_success() { println!("✅ Calendar created successfully at {}", calendar_path); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("❌ Calendar creation failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Delete a calendar from the CalDAV server pub async fn delete_calendar(&self, calendar_path: &str) -> Result<(), CalDAVError> { let full_url = if calendar_path.starts_with("http") { calendar_path.to_string() } else { // Handle case where calendar_path already contains /dav.php let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}{}", self.config.server_url.trim_end_matches('/'), clean_path ) }; println!("Deleting calendar at: {}", full_url); let response = self .http_client .delete(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar deletion response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 204 { println!("✅ Calendar deleted successfully at {}", calendar_path); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("❌ Calendar deletion failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Create a new event in a CalDAV calendar pub async fn create_event( &self, calendar_path: &str, event: &CalendarEvent, ) -> Result { // Generate a unique filename for the event (using UID + .ics extension) let event_filename = format!("{}.ics", event.uid); // Construct the full URL for the event let full_url = if calendar_path.starts_with("http") { format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename) } else { // Handle URL construction more carefully let server_url = self.config.server_url.trim_end_matches('/'); // Remove /dav.php from the end of server URL if present let base_url = if server_url.ends_with("/dav.php") { server_url.trim_end_matches("/dav.php") } else { server_url }; // Calendar path should start with /dav.php, if not add it let clean_calendar_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_end_matches('/') } else { // This shouldn't happen in our case, but handle it &format!("/dav.php{}", calendar_path.trim_end_matches('/')) }; format!("{}{}/{}", base_url, clean_calendar_path, event_filename) }; println!("📝 Creating event with calendar_path: {}", calendar_path); println!("📝 Server URL: {}", self.config.server_url); println!("📝 Constructed URL: {}", full_url); // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; println!("Creating event at: {}", full_url); println!("iCal data: {}", ical_data); println!("📡 Acquiring CalDAV HTTP lock for CREATE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("📡 Lock acquired, sending CREATE request to CalDAV server..."); let response = self .http_client .put(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .body(ical_data) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event creation response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 201 { println!("✅ Event created successfully at {}", event_filename); Ok(event_filename) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("❌ Event creation failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Update an existing event on the CalDAV server pub async fn update_event( &self, calendar_path: &str, event: &CalendarEvent, event_href: &str, ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) let base_url = self .config .server_url .trim_end_matches('/') .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href ) }; println!("📝 Updating event at: {}", full_url); // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; println!("📝 Updated iCal data: {}", ical_data); println!("📝 Event has {} exception dates", event.exdate.len()); println!("📡 Acquiring CalDAV HTTP lock for PUT request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("📡 Lock acquired, sending PUT request to CalDAV server..."); println!("🔗 PUT URL: {}", full_url); println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8"); let response = self .http_client .put(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .timeout(std::time::Duration::from_secs(30)) .body(ical_data) .send() .await .map_err(|e| { println!("❌ HTTP PUT request failed: {}", e); CalDAVError::ParseError(e.to_string()) })?; println!("Event update response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 { println!("✅ Event updated successfully"); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("❌ Event update failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Generate iCalendar data for a CalendarEvent fn generate_ical_event(&self, event: &CalendarEvent) -> Result { let now = chrono::Utc::now(); // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) let format_datetime = |dt: &DateTime| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() }; let format_datetime_naive = |dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() }; let _format_date = |dt: &DateTime| -> String { dt.format("%Y%m%d").to_string() }; // Format NaiveDateTime for iCal (local time without Z suffix) let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() }; let format_naive_date = |dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%d").to_string() }; // Start building the iCal event let mut ical = String::new(); ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n"); ical.push_str("BEGIN:VEVENT\r\n"); // Required fields ical.push_str(&format!("UID:{}\r\n", event.uid)); ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now))); // Start and end times if event.all_day { ical.push_str(&format!( "DTSTART;VALUE=DATE:{}\r\n", format_naive_date(&event.dtstart) )); if let Some(end) = &event.dtend { ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end))); } } else { // Include timezone information for non-all-day events per RFC 5545 if let Some(ref start_tzid) = event.dtstart_tzid { if start_tzid == "UTC" { // UTC events should use Z suffix format ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart))); } else if start_tzid.starts_with('+') || start_tzid.starts_with('-') { // Timezone offset format (e.g., "+05:00", "-04:00") // Convert local time to UTC using the offset and use Z format if let Ok(offset_hours) = start_tzid[1..3].parse::() { let offset_minutes = start_tzid[4..6].parse::().unwrap_or(0); let total_offset_minutes = if start_tzid.starts_with('+') { offset_hours * 60 + offset_minutes } else { -(offset_hours * 60 + offset_minutes) }; // Convert local time to UTC by applying the inverse offset // If timezone is +04:00 (local ahead of UTC), subtract to get UTC // If timezone is -04:00 (local behind UTC), add to get UTC let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64); ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time))); } else { // Fallback to floating time if offset parsing fails ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart))); } } else { // Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545 ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart))); } } else { // No timezone info - treat as floating local time per RFC 5545 ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart))); } if let Some(end) = &event.dtend { if let Some(ref end_tzid) = event.dtend_tzid { if end_tzid == "UTC" { // UTC events should use Z suffix format ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end))); } else if end_tzid.starts_with('+') || end_tzid.starts_with('-') { // Timezone offset format (e.g., "+05:00", "-04:00") // Convert local time to UTC using the offset and use Z format if let Ok(offset_hours) = end_tzid[1..3].parse::() { let offset_minutes = end_tzid[4..6].parse::().unwrap_or(0); let total_offset_minutes = if end_tzid.starts_with('+') { offset_hours * 60 + offset_minutes } else { -(offset_hours * 60 + offset_minutes) }; // Convert local time to UTC by subtracting the offset let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64); ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time))); } else { // Fallback to floating time if offset parsing fails ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end))); } } else { // Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545 ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end))); } } else { // No timezone info - treat as floating local time per RFC 5545 ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end))); } } } // Optional fields if let Some(summary) = &event.summary { ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary))); } if let Some(description) = &event.description { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(description) )); } if let Some(location) = &event.location { ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location))); } // Status if let Some(status) = &event.status { let status_str = match status { EventStatus::Tentative => "TENTATIVE", EventStatus::Confirmed => "CONFIRMED", EventStatus::Cancelled => "CANCELLED", }; ical.push_str(&format!("STATUS:{}\r\n", status_str)); } // Classification if let Some(class) = &event.class { let class_str = match class { EventClass::Public => "PUBLIC", EventClass::Private => "PRIVATE", EventClass::Confidential => "CONFIDENTIAL", }; ical.push_str(&format!("CLASS:{}\r\n", class_str)); } // Priority if let Some(priority) = event.priority { ical.push_str(&format!("PRIORITY:{}\r\n", priority)); } // Categories if !event.categories.is_empty() { let categories = event.categories.join(","); ical.push_str(&format!( "CATEGORIES:{}\r\n", self.escape_ical_text(&categories) )); } // Creation and modification times if let Some(created) = &event.created { if let Some(ref created_tzid) = event.created_tzid { if created_tzid == "UTC" { ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created))); } else { // Per RFC 5545, CREATED typically should be in UTC or floating time // Treat non-UTC as floating time ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created))); } } else { // No timezone info - output as floating time per RFC 5545 ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created))); } } ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); // Add alarms/reminders for alarm in &event.alarms { ical.push_str("BEGIN:VALARM\r\n"); let action = match alarm.action { calendar_models::AlarmAction::Display => "DISPLAY", calendar_models::AlarmAction::Email => "EMAIL", calendar_models::AlarmAction::Audio => "AUDIO", calendar_models::AlarmAction::Procedure => "PROCEDURE", }; ical.push_str(&format!("ACTION:{}\r\n", action)); // Handle trigger match &alarm.trigger { calendar_models::AlarmTrigger::Duration(duration) => { let minutes = duration.num_minutes(); if minutes < 0 { ical.push_str(&format!("TRIGGER:-PT{}M\r\n", -minutes)); } else { ical.push_str(&format!("TRIGGER:PT{}M\r\n", minutes)); } } calendar_models::AlarmTrigger::DateTime(dt) => { ical.push_str(&format!("TRIGGER:{}\r\n", format_datetime(dt))); } } if let Some(description) = &alarm.description { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(description) )); } else if let Some(summary) = &event.summary { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(summary) )); } ical.push_str("END:VALARM\r\n"); } // Recurrence rule if let Some(rrule) = &event.rrule { ical.push_str(&format!("RRULE:{}\r\n", rrule)); } // Exception dates (EXDATE) for exception_date in &event.exdate { if event.all_day { ical.push_str(&format!( "EXDATE;VALUE=DATE:{}\r\n", format_naive_date(exception_date) )); } else { ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date))); } } ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); Ok(ical) } /// Escape text for iCalendar format (RFC 5545) fn escape_ical_text(&self, text: &str) -> String { text.replace('\\', "\\\\") .replace('\n', "\\n") .replace('\r', "") .replace(',', "\\,") .replace(';', "\\;") } /// Delete an event from a CalDAV calendar pub async fn delete_event( &self, calendar_path: &str, event_href: &str, ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) let base_url = self .config .server_url .trim_end_matches('/') .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href ) }; println!("Deleting event at: {}", full_url); println!("📡 Acquiring CalDAV HTTP lock for DELETE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("📡 Lock acquired, sending DELETE request to CalDAV server..."); let response = self .http_client .delete(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event deletion response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 204 { println!("✅ Event deleted successfully at {}", event_href); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("❌ Event deletion failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } } /// Helper struct for extracting calendar data from XML responses #[derive(Debug)] struct CalendarDataSection { pub href: Option, pub etag: Option, pub data: String, } /// CalDAV-specific error types #[derive(Debug, thiserror::Error)] pub enum CalDAVError { #[error("HTTP request failed: {0}")] RequestError(#[from] reqwest::Error), #[error("CalDAV server returned error: {0}")] ServerError(u16), #[error("Failed to parse calendar data: {0}")] ParseError(String), } #[cfg(test)] mod tests { use super::*; use crate::config::CalDAVConfig; /// Integration test that fetches real calendar events from the Baikal server /// /// This test requires a valid .env file and a calendar with some events #[tokio::test] async fn test_fetch_calendar_events() { let config = CalDAVConfig::new( "https://example.com".to_string(), "test_user".to_string(), "test_password".to_string(), ); let client = CalDAVClient::new(config); // First discover available calendars using PROPFIND println!("Discovering calendars..."); let discovery_result = client.discover_calendars().await; match discovery_result { Ok(calendar_paths) => { println!("Found {} calendar collection(s)", calendar_paths.len()); if calendar_paths.is_empty() { println!("No calendars found - this might be normal for a new server"); return; } // Try the first available calendar let calendar_path = &calendar_paths[0]; println!("Trying to fetch events from: {}", calendar_path); match client.fetch_events(calendar_path).await { Ok(events) => { println!("Successfully fetched {} calendar events", events.len()); for (i, event) in events.iter().take(3).enumerate() { println!("\n--- Event {} ---", i + 1); println!("UID: {}", event.uid); println!("Summary: {:?}", event.summary); println!("Start: {}", event.dtstart); println!("End: {:?}", event.dtend); println!("All Day: {}", event.all_day); println!("Status: {:?}", event.status); println!("Location: {:?}", event.location); println!("Description: {:?}", event.description); println!("ETag: {:?}", event.etag); println!("HREF: {:?}", event.href); } // Validate that events have required fields for event in &events { assert!(!event.uid.is_empty(), "Event UID should not be empty"); // All events should have a start time assert!( event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time" ); } println!("\n✓ Calendar event fetching test passed!"); } Err(e) => { println!("Error fetching events from {}: {:?}", calendar_path, e); println!("This might be normal if the calendar is empty"); } } } Err(e) => { println!("Error discovering calendars: {:?}", e); println!("This might be normal if no calendars are set up yet"); } } } /// Test parsing a sample iCal event #[test] fn test_parse_ical_event() { let sample_ical = r#"BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT UID:test-event-123@example.com DTSTART:20231225T120000Z DTEND:20231225T130000Z SUMMARY:Test Event DESCRIPTION:This is a test event LOCATION:Test Location STATUS:CONFIRMED CLASS:PUBLIC PRIORITY:5 CREATED:20231220T100000Z LAST-MODIFIED:20231221T150000Z CATEGORIES:work,important END:VEVENT END:VCALENDAR"#; let config = CalDAVConfig { server_url: "https://example.com".to_string(), username: "test".to_string(), password: "test".to_string(), calendar_path: None, }; let client = CalDAVClient::new(config); let events = client .parse_ical_data(sample_ical) .expect("Should be able to parse sample iCal data"); assert_eq!(events.len(), 1); let event = &events[0]; assert_eq!(event.uid, "test-event-123@example.com"); assert_eq!(event.summary, Some("Test Event".to_string())); assert_eq!(event.description, Some("This is a test event".to_string())); assert_eq!(event.location, Some("Test Location".to_string())); assert_eq!(event.status, Some(EventStatus::Confirmed)); assert_eq!(event.class, Some(EventClass::Public)); assert_eq!(event.priority, Some(5)); assert_eq!(event.categories, vec!["work", "important"]); assert!(!event.all_day); println!("✓ iCal parsing test passed!"); } /// Test datetime parsing #[test] fn test_datetime_parsing() { let config = CalDAVConfig { server_url: "https://example.com".to_string(), username: "test".to_string(), password: "test".to_string(), calendar_path: None, }; let client = CalDAVClient::new(config); // Test UTC format let dt1 = client .parse_datetime("20231225T120000Z", None) .expect("Should parse UTC datetime"); println!("Parsed UTC datetime: {}", dt1); // Test date-only format (should be treated as all-day) let dt2 = client .parse_datetime("20231225", None) .expect("Should parse date-only"); println!("Parsed date-only: {}", dt2); // Test local format let dt3 = client .parse_datetime("20231225T120000", None) .expect("Should parse local datetime"); println!("Parsed local datetime: {}", dt3); println!("✓ Datetime parsing test passed!"); } /// Test event status parsing #[test] fn test_event_enums() { // Test status parsing - these don't have defaults, so let's test creation let status = EventStatus::Confirmed; assert_eq!(status, EventStatus::Confirmed); // Test class parsing let class = EventClass::Public; assert_eq!(class, EventClass::Public); println!("✓ Event enum tests passed!"); } }