Files
calendar/backend/src/calendar.rs
Connor Johnstone 8329244c69
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m9s
Fix authentication validation to properly reject invalid CalDAV servers
- Backend: Enhance CalDAV discovery to require at least one valid 207 response
- Backend: Fail authentication if no valid CalDAV endpoints are found
- Frontend: Add token verification on app startup to validate stored tokens
- Frontend: Clear invalid tokens when login fails or token verification fails
- Frontend: Prevent users with invalid tokens from accessing calendar page

This resolves the issue where invalid servers (like google.com) were incorrectly
accepted as valid CalDAV servers, and ensures proper authentication flow.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 16:06:18 -04:00

1630 lines
62 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Mutex<()>> = 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<String>,
/// Detailed description of the event
pub description: Option<String>,
/// Start date and time of the event
pub start: DateTime<Utc>,
/// End date and time of the event
pub end: Option<DateTime<Utc>>,
/// Location where the event takes place
pub location: Option<String>,
/// 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<u8>,
/// Organizer of the event
pub organizer: Option<String>,
/// List of attendees
pub attendees: Vec<String>,
/// Categories/tags for the event
pub categories: Vec<String>,
/// Date and time when the event was created
pub created: Option<DateTime<Utc>>,
/// Date and time when the event was last modified
pub last_modified: Option<DateTime<Utc>>,
/// Recurrence rule (RRULE)
pub recurrence_rule: Option<String>,
/// Exception dates - dates to exclude from recurrence (EXDATE)
pub exdate: Vec<DateTime<Utc>>,
/// All-day event flag
pub all_day: bool,
/// Reminders/alarms for this event
pub reminders: Vec<EventReminder>,
/// ETag from CalDAV server for conflict detection
pub etag: Option<String>,
/// URL/href of this event on the CalDAV server
pub href: Option<String>,
/// Calendar path this event belongs to
pub calendar_path: Option<String>,
}
// 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<String>,
}
/// 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<Vec<CalendarEvent>, CalDAVError> {
// CalDAV REPORT request to get calendar events
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>"#;
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();
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
println!("🌐 REPORT URL: {}", url);
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<Vec<CalendarEvent>, 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<Option<CalendarEvent>, 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<CalendarDataSection> {
let mut sections = Vec::new();
// Simple regex-based extraction (in production, use a proper XML parser)
// Look for <d:response> blocks containing calendar data
for response_block in xml_response.split("<d:response>").skip(1) {
if let Some(end_pos) = response_block.find("</d:response>") {
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<String> {
// Handle both with and without namespace prefixes
let patterns = [
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
format!(
"(?s)<{}>(.*?)</.*:{}>",
tag,
tag.split(':').last().unwrap_or(tag)
), // <tag>content</ns:tag>
format!(
"(?s)<.*:{}>(.*?)</{}>",
tag.split(':').last().unwrap_or(tag),
tag
), // <ns:tag>content</tag>
format!(
"(?s)<.*:{}>(.*?)</.*:{}>",
tag.split(':').last().unwrap_or(tag),
tag.split(':').last().unwrap_or(tag)
), // <ns:tag>content</ns:tag>
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
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<Vec<CalendarEvent>, 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<CalendarEvent, CalDAVError> {
let mut properties: HashMap<String, String> = HashMap::new();
let mut full_properties: HashMap<String, String> = 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();
// Parse start time (required)
let start = properties
.get("DTSTART")
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
// Parse end time (optional - use start time if not present)
let end = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?)
} else if let Some(_duration) = properties.get("DURATION") {
// TODO: Parse duration and add to start time
Some(start)
} else {
None
};
// Determine if it's an all-day event by checking for VALUE=DATE parameter
let empty_string = String::new();
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
// 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::<u8>().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
let created = properties
.get("CREATED")
.and_then(|s| self.parse_datetime(s, None).ok());
let last_modified = properties
.get("LAST-MODIFIED")
.and_then(|s| self.parse_datetime(s, None).ok());
// Parse exception dates (EXDATE)
let exdate = self.parse_exdate(&event);
// Create VEvent with required fields
let mut vevent = VEvent::new(uid, start);
// Set optional fields
vevent.dtend = end;
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;
vevent.last_modified = last_modified;
vevent.rrule = properties.get("RRULE").cloned();
vevent.exdate = 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<Vec<VAlarm>, 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<VAlarm, CalDAVError> {
let mut properties: HashMap<String, String> = 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<i32> {
// 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::<i32>().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::<i32>().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::<i32>().ok().map(|d| d * 24 * 60)
} else {
// Try to parse as raw minutes
trigger.parse::<i32>().ok().map(|m| m.abs())
}
}
/// Discover available calendar collections on the server
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
// First, try to discover user calendars if we have a calendar path in config
if let Some(calendar_path) = &self.config.calendar_path {
println!("Using configured calendar path: {}", calendar_path);
return Ok(vec![calendar_path.clone()]);
}
println!("No calendar path configured, discovering calendars...");
// 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 {
println!("Trying discovery path: {}", path);
match self.discover_calendars_at_path(&path).await {
Ok(calendars) => {
println!("Found {} calendar(s) at {}", calendars.len(), path);
has_valid_caldav_response = true;
all_calendars.extend(calendars);
}
Err(CalDAVError::ServerError(status)) => {
// HTTP error - this might be expected for some paths, continue trying
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
}
Err(e) => {
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
println!("Discovery failed for path {}: {:?}", path, e);
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<Vec<String>, CalDAVError> {
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype />
<d:displayname />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>"#;
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)?;
println!("Discovery response for {}: {}", path, body);
let mut calendar_paths = Vec::new();
// Extract calendar collection URLs from the response
for response_block in body.split("<d:response>").skip(1) {
if let Some(end_pos) = response_block.find("</d:response>") {
let response_content = &response_block[..end_pos];
// Extract href first
if let Some(href) = self.extract_xml_content(response_content, "href") {
println!("🔍 Checking resource: {}", 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("<cal:calendar")
|| response_content.contains("<c:calendar");
let is_calendar = has_supported_components || has_calendar_resourcetype;
// Also check resourcetype for collection
let has_collection = response_content.contains("<d:collection")
|| response_content.contains("<collection");
if is_calendar && has_collection {
// Exclude system directories like inbox, outbox, and root calendar directories
if !href.contains("/inbox/")
&& !href.contains("/outbox/")
&& !href.ends_with("/calendars/")
&& href.ends_with('/')
{
println!("📅 Found calendar collection: {}", href);
calendar_paths.push(href);
} else {
println!("❌ Skipping system/root directory: {}", href);
}
} else {
println!(" Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
href, is_calendar, has_collection);
}
}
}
}
Ok(calendar_paths)
}
/// Parse iCal datetime format with timezone support
fn parse_datetime(
&self,
datetime_str: &str,
original_property: Option<&String>,
) -> Result<DateTime<Utc>, 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::<Tz>().ok())
} else if tz_id.contains('/') {
// Standard timezone format: America/New_York, Europe/London
tz_id.parse::<Tz>().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<DateTime<Utc>> {
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::<String>()
.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#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#,
color
)
} else {
String::new()
};
let description_property = if let Some(desc) = description {
format!(
r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#,
desc
)
} else {
String::new()
};
// Create the MKCALENDAR request body
let mkcalendar_body = format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
<d:set>
<d:prop>
<d:displayname>{}</d:displayname>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/>
</c:supported-calendar-component-set>
{}
{}
</d:prop>
</d:set>
</c:mkcalendar>"#,
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<String, CalDAVError> {
// 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<String, CalDAVError> {
let now = chrono::Utc::now();
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
let format_datetime =
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
let format_date = |dt: &DateTime<Utc>| -> 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_date(&event.dtstart)
));
if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
}
} else {
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND:{}\r\n", format_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 {
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(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_date(exception_date)
));
} else {
ical.push_str(&format!("EXDATE:{}\r\n", format_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<String>,
pub etag: Option<String>,
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!");
}
}