Files
calendar/backend/src/calendar.rs
Connor Johnstone f9c87369e5 Implement complete calendar creation functionality
Add full end-to-end calendar creation feature including:
- Create Calendar button in sidebar footer
- Modal form with name, description, and color picker (16 predefined colors in 4x4 grid)
- Form validation and error handling with loading states
- Backend API endpoint for calendar creation with authentication
- CalDAV MKCALENDAR protocol implementation with proper XML generation
- Real-time calendar list refresh after successful creation
- Responsive design for mobile and desktop

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 21:21:30 -04:00

853 lines
32 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 chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Represents a calendar event with all its properties
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarEvent {
/// 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>,
/// 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>,
}
/// Event status enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
/// Event classification enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
/// 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 {
Self {
config,
http_client: reqwest::Client::new(),
}
}
/// 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();
// Extract all properties from the event
for property in &event.properties {
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
}
// 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, 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, 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
let all_day = properties.get("DTSTART")
.map(|s| !s.contains("T"))
.unwrap_or(false);
// 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_default();
// 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_default();
// 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());
Ok(CalendarEvent {
uid,
summary: properties.get("SUMMARY").cloned(),
description: properties.get("DESCRIPTION").cloned(),
start,
end,
location: properties.get("LOCATION").cloned(),
status,
class,
priority,
organizer: properties.get("ORGANIZER").cloned(),
attendees: Vec::new(), // TODO: Parse attendees
categories,
created,
last_modified,
recurrence_rule: properties.get("RRULE").cloned(),
all_day,
reminders: self.parse_alarms(&event)?,
etag: None, // Set by caller
href: None, // Set by caller
calendar_path: None, // Set by caller
})
}
/// Parse VALARM components from an iCal event
fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<EventReminder>, CalDAVError> {
let mut reminders = Vec::new();
for alarm in &event.alarms {
if let Ok(reminder) = self.parse_single_alarm(alarm) {
reminders.push(reminder);
}
}
Ok(reminders)
}
/// Parse a single VALARM component into an EventReminder
fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, 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" => ReminderAction::Display,
Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email,
Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio,
_ => ReminderAction::Display, // Default
};
// Parse TRIGGER (required)
let minutes_before = if let Some(trigger) = properties.get("TRIGGER") {
self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes
} else {
15 // Default 15 minutes
};
// Get description
let description = properties.get("DESCRIPTION").cloned();
Ok(EventReminder {
minutes_before,
action,
description,
})
}
/// 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();
for path in discovery_paths {
println!("Trying discovery path: {}", path);
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
println!("Found {} calendar(s) at {}", calendars.len(), path);
all_calendars.extend(calendars);
}
}
// 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
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
use chrono::TimeZone;
// Handle different iCal datetime formats
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
// 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 {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
return Ok(Utc.from_utc_datetime(&dt));
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
}
}
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
}
/// 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()))
}
}
}
/// 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),
#[error("Configuration error: {0}")]
ConfigError(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::from_env()
.expect("Failed to load CalDAV config from environment");
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.start);
println!("End: {:?}", event.end);
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.start > 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,
tasks_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, EventStatus::Confirmed);
assert_eq!(event.class, 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,
tasks_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
assert_eq!(EventStatus::default(), EventStatus::Confirmed);
// Test class parsing
assert_eq!(EventClass::default(), EventClass::Public);
println!("✓ Event enum tests passed!");
}