diff --git a/.gitignore b/.gitignore index 5ea9fdd..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ /target -config/config.toml diff --git a/Cargo.lock b/Cargo.lock index f828366..759b5b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,9 +211,7 @@ dependencies = [ "chrono-tz", "clap", "config", - "icalendar", "quick-xml", - "regex", "reqwest", "serde", "serde_json", @@ -335,7 +333,7 @@ dependencies = [ "async-trait", "json5", "lazy_static", - "nom 7.1.3", + "nom", "pathdiff", "ron", "rust-ini", @@ -701,18 +699,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icalendar" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85aad69a5625006d09c694c0cd811f3655363444e692b2a9ce410c712ec1ff96" -dependencies = [ - "chrono", - "iso8601", - "nom 7.1.3", - "uuid", -] - [[package]] name = "icu_collections" version = "2.0.0" @@ -853,15 +839,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "iso8601" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" -dependencies = [ - "nom 8.0.0", -] - [[package]] name = "itoa" version = "1.0.15" @@ -1008,15 +985,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" diff --git a/Cargo.toml b/Cargo.toml index 1dfcd37..89cf893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,16 +18,6 @@ tokio = { version = "1.0", features = ["full"] } # HTTP client reqwest = { version = "0.11", features = ["json", "rustls-tls"] } -# Regular expressions -regex = "1.10" - -# CalDAV client library -# minicaldav = { git = "https://github.com/julianolf/minicaldav", version = "0.8.0" } -# Using direct HTTP implementation instead of minicaldav library - -# iCalendar parsing -icalendar = "0.15" - # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 064a0a9..a96fb60 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -15,31 +15,51 @@ The application is built with a modular architecture using Rust's strong type sy - Environment variable support - Command-line argument overrides - Configuration validation -- **Key Types**: `Config`, `ServerConfig`, `CalendarConfig`, `FilterConfig`, `SyncConfig` +- **Key Types**: `Config`, `ServerConfig`, `CalendarConfig`, `SyncConfig` -#### 2. **CalDAV Client** (`src/minicaldav_client.rs`) -- **Purpose**: Handle CalDAV protocol operations with multiple CalDAV servers +#### 2. **CalDAV Client** (`src/caldav_client.rs`) +- **Purpose**: Handle CalDAV protocol operations with Zoho and Nextcloud - **Features**: - HTTP client with authentication - - Multiple CalDAV approaches (9 different methods) - Calendar discovery via PROPFIND - - Event retrieval via REPORT requests and individual .ics file fetching - - Multi-status response parsing - - Zoho-specific implementation support -- **Key Types**: `RealCalDavClient`, `CalendarInfo`, `CalendarEvent` + - Event retrieval via REPORT requests + - Event creation via PUT requests +- **Key Types**: `CalDavClient`, `CalendarInfo`, `CalDavEventInfo` -#### 3. **Sync Engine** (`src/real_sync.rs`) +#### 3. **Event Model** (`src/event.rs`) +- **Purpose**: Represent calendar events and handle parsing +- **Features**: + - iCalendar data parsing + - Timezone-aware datetime handling + - Event filtering and validation +- **Key Types**: `Event`, `EventBuilder`, `EventFilter` + +#### 4. **Timezone Handler** (`src/timezone.rs`) +- **Purpose**: Manage timezone conversions and datetime operations +- **Features**: + - Convert between different timezones + - Parse timezone information from iCalendar data + - Handle DST transitions +- **Key Types**: `TimezoneHandler`, `TimeZoneInfo` + +#### 5. **Calendar Filter** (`src/calendar_filter.rs`) +- **Purpose**: Filter calendars and events based on user criteria +- **Features**: + - Calendar name filtering + - Regex pattern matching + - Event date range filtering +- **Key Types**: `CalendarFilter`, `FilterRule`, `EventFilter` + +#### 6. **Sync Engine** (`src/sync.rs`) - **Purpose**: Coordinate the synchronization process - **Features**: - - Pull events from CalDAV servers - - Event processing and filtering + - Pull events from Zoho + - Push events to Nextcloud + - Conflict resolution - Progress tracking - - Statistics reporting - - Timezone-aware event storage -- **Key Types**: `SyncEngine`, `SyncResult`, `SyncEvent`, `SyncStats` -- **Recent Enhancement**: Added `start_tzid` and `end_tzid` fields to `SyncEvent` for timezone preservation +- **Key Types**: `SyncEngine`, `SyncResult`, `SyncStats` -#### 4. **Error Handling** (`src/error.rs`) +#### 7. **Error Handling** (`src/error.rs`) - **Purpose**: Comprehensive error management - **Features**: - Custom error types @@ -47,70 +67,38 @@ The application is built with a modular architecture using Rust's strong type sy - User-friendly error messages - **Key Types**: `CalDavError`, `CalDavResult` -#### 5. **Main Application** (`src/main.rs`) -- **Purpose**: Command-line interface and application orchestration -- **Features**: - - CLI argument parsing - - Configuration loading and overrides - - Debug logging setup - - Command routing (list-events, list-calendars, sync) - - Approach-specific testing - - Timezone-aware event display -- **Key Commands**: `--list-events`, `--list-calendars`, `--approach`, `--calendar-url` -- **Recent Enhancement**: Added timezone information to event listing output for debugging - ## Design Decisions ### 1. **Selective Calendar Import** -The application allows users to select specific calendars to import from, consolidating all events into a single data structure. This design choice: +The application allows users to select specific Zoho calendars to import from, consolidating all events into a single Nextcloud calendar. This design choice: - **Reduces complexity** compared to bidirectional sync -- **Provides clear data flow** (CalDAV server β†’ Application) +- **Provides clear data flow** (Zoho β†’ Nextcloud) - **Minimizes sync conflicts** - **Matches user requirements** exactly -### 2. **Multi-Approach CalDAV Strategy** -The application implements 9 different CalDAV approaches to ensure compatibility with various server implementations: -- **Standard CalDAV Methods**: REPORT, PROPFIND, GET -- **Zoho-Specific Methods**: Custom endpoints for Zoho Calendar -- **Fallback Mechanisms**: Multiple approaches ensure at least one works -- **Debugging Support**: Individual approach testing with `--approach` parameter +### 2. **Timezone Handling** +All events are converted to UTC internally for consistency, while preserving original timezone information: -### 3. **CalendarEvent Structure** -The application uses a timezone-aware event structure that includes comprehensive metadata: ```rust -pub struct CalendarEvent { +pub struct Event { pub id: String, pub summary: String, - pub description: Option, pub start: DateTime, pub end: DateTime, - pub location: Option, - pub status: Option, - pub etag: Option, - // Enhanced timezone information (recently added) - pub start_tzid: Option, // Timezone ID for start time - pub end_tzid: Option, // Timezone ID for end time - pub original_start: Option, // Original datetime string from iCalendar - pub original_end: Option, // Original datetime string from iCalendar - // Additional metadata - pub href: String, - pub created: Option>, - pub last_modified: Option>, - pub sequence: i32, - pub transparency: Option, - pub uid: Option, - pub recurrence_id: Option>, + pub original_timezone: Option, + pub source_calendar: String, } ``` -### 4. **Configuration Hierarchy** +### 3. **Configuration Hierarchy** Configuration is loaded in priority order: 1. **Command line arguments** (highest priority) 2. **User config file** (`config/config.toml`) -3. **Environment variables** -4. **Hardcoded defaults** (lowest priority) +3. **Default config file** (`config/default.toml`) +4. **Environment variables** +5. **Hardcoded defaults** (lowest priority) ### 4. **Error Handling Strategy** Uses `thiserror` for custom error types and `anyhow` for error propagation: @@ -145,136 +133,118 @@ pub enum CalDavError { ### 2. **Calendar Discovery** ``` -1. Connect to CalDAV server and authenticate -2. Send PROPFIND request to discover calendars -3. Parse calendar list and metadata -4. Select target calendar based on configuration +1. Connect to Zoho CalDAV server +2. Authenticate with app password +3. Send PROPFIND request to discover calendars +4. Parse calendar list and metadata +5. Apply user filters to select calendars ``` ### 3. **Event Synchronization** ``` -1. Connect to CalDAV server and authenticate -2. Discover calendar collections using PROPFIND -3. Select target calendar based on configuration -4. Apply CalDAV approaches to retrieve events: - - Try REPORT queries with time-range filters - - Fall back to PROPFIND with href discovery - - Fetch individual .ics files for event details -5. Parse iCalendar data into CalendarEvent objects -6. Convert timestamps to UTC with timezone preservation -7. Apply event filters (duration, status, patterns) -8. Report sync statistics and event summary +1. Query selected Zoho calendars for events (next week) +2. Parse iCalendar data into Event objects +3. Convert timestamps to UTC with timezone preservation +4. Apply event filters (duration, status, patterns) +5. Connect to Nextcloud CalDAV server +6. Create target calendar if needed +7. Upload events to Nextcloud calendar +8. Report sync statistics ``` ## Key Algorithms -### 1. **Multi-Approach CalDAV Strategy** -The application implements a robust fallback system with 9 different approaches: +### 1. **Calendar Filtering** ```rust -impl RealCalDavClient { - pub async fn get_events_with_approach(&self, approach: &str) -> CalDavResult> { - match approach { - "report-simple" => self.report_simple().await, - "report-filter" => self.report_with_filter().await, - "propfind-depth" => self.propfind_with_depth().await, - "simple-propfind" => self.simple_propfind().await, - "multiget" => self.multiget_events().await, - "ical-export" => self.ical_export().await, - "zoho-export" => self.zoho_export().await, - "zoho-events-list" => self.zoho_events_list().await, - "zoho-events-direct" => self.zoho_events_direct().await, - _ => Err(CalDavError::InvalidApproach(approach.to_string())), +impl CalendarFilter { + pub fn should_import_calendar(&self, calendar_name: &str) -> bool { + // Check exact matches + if self.selected_names.contains(&calendar_name.to_string()) { + return true; } + + // Check regex patterns + for pattern in &self.regex_patterns { + if pattern.is_match(calendar_name) { + return true; + } + } + + false } } ``` -### 2. **Individual Event Fetching** -For servers that don't support REPORT queries, the application fetches individual .ics files: +### 2. **Timezone Conversion** ```rust -async fn fetch_single_event(&self, event_url: &str, calendar_href: &str) -> Result> { - let response = self.client - .get(event_url) - .header("User-Agent", "caldav-sync/0.1.0") - .header("Accept", "text/calendar") - .send() - .await?; - - // Parse iCalendar data and return CalendarEvent +impl TimezoneHandler { + pub fn convert_to_utc(&self, dt: DateTime, timezone: &str) -> CalDavResult> { + let tz = self.get_timezone(timezone)?; + let local_dt = dt.with_timezone(&tz); + Ok(local_dt.with_timezone(&Utc)) + } } ``` -### 3. **Multi-Status Response Parsing** +### 3. **Event Processing** ```rust -async fn parse_multistatus_response(&self, xml: &str, calendar_href: &str) -> Result> { - let mut events = Vec::new(); - - // Parse multi-status response - let mut start_pos = 0; - while let Some(response_start) = xml[start_pos..].find("") { - // Extract href and fetch individual events - // ... parsing logic +impl SyncEngine { + pub async fn sync_calendar(&mut self, calendar: &CalendarInfo) -> CalDavResult { + // 1. Fetch events from Zoho + let zoho_events = self.fetch_zoho_events(calendar).await?; + + // 2. Filter and process events + let processed_events = self.process_events(zoho_events)?; + + // 3. Upload to Nextcloud + let upload_results = self.upload_to_nextcloud(processed_events).await?; + + // 4. Return sync statistics + Ok(SyncResult::from_upload_results(upload_results)) } - - Ok(events) } ``` ## Configuration Schema -### Working Configuration Structure +### Complete Configuration Structure ```toml -# CalDAV Server Configuration +# Zoho Configuration (Source) +[zoho] +server_url = "https://caldav.zoho.com/caldav" +username = "your-zoho-email@domain.com" +password = "your-zoho-app-password" +selected_calendars = ["Work Calendar", "Personal Calendar"] + +# Nextcloud Configuration (Target) +[nextcloud] +server_url = "https://your-nextcloud-domain.com" +username = "your-nextcloud-username" +password = "your-nextcloud-app-password" +target_calendar = "Imported-Zoho-Events" +create_if_missing = true + +# General Settings [server] -# CalDAV server URL (Zoho in this implementation) -url = "https://calendar.zoho.com/caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Username for authentication -username = "your-email@domain.com" -# Password for authentication (use app-specific password) -password = "your-app-password" -# Whether to use HTTPS (recommended) -use_https = true -# Request timeout in seconds timeout = 30 -# Calendar Configuration [calendar] -# Calendar name/path on the server -name = "caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Calendar display name (optional) -display_name = "Your Calendar Name" -# Calendar color in hex format (optional) -color = "#4285F4" -# Default timezone for the calendar +color = "#3174ad" timezone = "UTC" -# Whether this calendar is enabled for synchronization -enabled = true -# Sync Configuration [sync] -# Synchronization interval in seconds (300 = 5 minutes) interval = 300 -# Whether to perform synchronization on startup sync_on_startup = true -# Maximum number of retry attempts for failed operations -max_retries = 3 -# Delay between retry attempts in seconds -retry_delay = 5 -# Whether to delete local events that are missing on server -delete_missing = false -# Date range configuration -date_range = { days_ahead = 30, days_back = 30, sync_all_events = false } +weeks_ahead = 1 +dry_run = false -# Optional filtering configuration +# Optional Filtering [filters] -# Keywords to filter events by (events containing any of these will be included) -# keywords = ["work", "meeting", "project"] -# Keywords to exclude (events containing any of these will be excluded) -# exclude_keywords = ["personal", "holiday", "cancelled"] -# Minimum event duration in minutes min_duration_minutes = 5 -# Maximum event duration in hours max_duration_hours = 24 +exclude_patterns = ["Cancelled:", "BLOCKED"] +include_status = ["confirmed", "tentative"] +exclude_status = ["cancelled"] ``` ## Dependencies and External Libraries @@ -376,385 +346,25 @@ pub async fn fetch_events(&self, calendar: &CalendarInfo) -> CalDavResult>, - pub imported_events: HashMap, // source_uid β†’ target_href - pub deleted_events: HashSet, // Deleted source events - } - ``` - -**Phase 2: Import Logic (2-3 days)** -1. **Import Pipeline Algorithm** - ```rust - async fn import_events(&mut self) -> Result { - // 1. Fetch source events - let source_events = self.source_client.get_events(...).await?; - - // 2. Fetch target events - let target_events = self.target_client.get_events(...).await?; - - // 3. Process each source event (source wins) - for source_event in source_events { - if let Some(target_href) = self.import_state.imported_events.get(&source_event.uid) { - // UPDATE: Overwrite target with source data - self.update_target_event(source_event, target_href).await?; - } else { - // CREATE: New event in target - self.create_target_event(source_event).await?; - } - } - - // 4. DELETE: Remove orphaned target events - self.delete_orphaned_events(source_events, target_events).await?; - } - ``` - -2. **Target Calendar Management** - - Validate target calendar exists before import - - Set calendar properties (color, name, timezone) - - Fail fast if target calendar is not found - - Auto-creation as future enhancement (nice-to-have) - -3. **Event Transformation** - - Convert between iCalendar formats if needed - - Preserve timezone information - - Handle UID mapping for future updates - -**Phase 3: CLI & User Experience (1-2 days)** -1. **Import Commands** - ```bash - # Import events (dry run by default) - cargo run -- --import-events --dry-run - - # Execute actual import - cargo run -- --import-events --target-calendar "Imported-Zoho-Events" - - # List import status - cargo run -- --import-status - ``` - -2. **Progress Reporting** - - Real-time import progress - - Summary statistics (created/updated/deleted) - - Error reporting and recovery - -3. **Configuration Examples** - ```toml - [source] - server_url = "https://caldav.zoho.com/caldav" - username = "user@zoho.com" - password = "zoho-app-password" - - [target] - server_url = "https://nextcloud.example.com" - username = "nextcloud-user" - password = "nextcloud-app-password" - - [source_calendar] - name = "Work Calendar" - - [target_calendar] - name = "Imported-Work-Events" - create_if_missing = true - color = "#3174ad" - - [import] - overwrite_existing = true # Source always wins - delete_missing = true # Remove events not in source - dry_run = false - batch_size = 50 - ``` - -#### **Key Implementation Principles** - -1. **Source is Always Truth**: Source server data overwrites target -2. **Unidirectional Flow**: No bidirectional sync complexity -3. **Robust Error Handling**: Continue import even if some events fail -4. **Progress Visibility**: Clear reporting of import operations -5. **Configuration Flexibility**: Support for any CalDAV source/target - -#### **Estimated Timeline** -- **Phase 1**: 2-3 days (Core infrastructure) -- **Phase 2**: 2-3 days (Import logic) -- **Phase 3**: 1-2 days (CLI & UX) -- **Total**: 5-8 days for complete implementation - -#### **Success Criteria** -- Successfully import events from Zoho to Nextcloud -- Handle timezone preservation during import -- Provide clear progress reporting -- Support dry-run mode for preview -- Handle large calendars (1000+ events) efficiently - -This plan provides a clear roadmap for implementing the unidirectional event import feature while maintaining the simplicity and reliability of the current codebase. - -### πŸŽ‰ **Final Status** - -**The CalDAV Calendar Synchronizer is PRODUCTION READY and fully functional.** - -- βœ… **Authentication**: Working -- βœ… **Calendar Discovery**: Working -- βœ… **Event Retrieval**: Working (265+ events) -- βœ… **Multi-Approach Fallback**: Working -- βœ… **CLI Interface**: Complete -- βœ… **Configuration Management**: Complete -- βœ… **Error Handling**: Robust -- βœ… **Documentation**: Comprehensive - -The application successfully solved the original problem of retrieving zero events from Zoho Calendar and now provides a reliable, scalable solution for CalDAV calendar synchronization. - -## TODO List and Status Tracking - -### 🎯 Current Development Status - -The CalDAV Calendar Synchronizer is **PRODUCTION READY** with recent enhancements to the `fetch_single_event` functionality and timezone handling. - -### βœ… Recently Completed Tasks (Latest Development Cycle) - -#### 1. **fetch_single_event Debugging and Enhancement** -- **βœ… Located and analyzed the function** in `src/minicaldav_client.rs` (lines 584-645) -- **βœ… Fixed critical bug**: Missing approach name for approach 5 causing potential runtime issues -- **βœ… Enhanced datetime parsing**: Added support for multiple iCalendar formats: - - UTC times with 'Z' suffix (YYYYMMDDTHHMMSSZ) - - Local times without timezone (YYYYMMDDTHHMMSS) - - Date-only values (YYYYMMDD) -- **βœ… Added debug logging**: Enhanced error reporting for failed datetime parsing -- **βœ… Implemented iCalendar line unfolding**: Proper handling of folded long lines in iCalendar files - -#### 2. **Zoho Compatibility Improvements** -- **βœ… Made Zoho-compatible approach default**: Reordered approaches so Zoho-specific headers are tried first -- **βœ… Enhanced HTTP headers**: Uses `Accept: text/calendar` and `User-Agent: curl/8.16.0` for optimal Zoho compatibility - -#### 3. **Timezone Information Preservation** -- **βœ… Enhanced CalendarEvent struct** with new timezone-aware fields: - - `start_tzid: Option` - Timezone ID for start time - - `end_tzid: Option` - Timezone ID for end time - - `original_start: Option` - Original datetime string from iCalendar - - `original_end: Option` - Original datetime string from iCalendar -- **βœ… Added TZID parameter parsing**: Handles properties like `DTSTART;TZID=America/New_York:20240315T100000` -- **βœ… Updated all mock event creation** to include timezone information - -#### 4. **Code Quality and Testing** -- **βœ… Verified compilation**: All changes compile successfully with only minor warnings -- **βœ… Updated all struct implementations**: All CalendarEvent creation points updated with new fields -- **βœ… Maintained backward compatibility**: Existing functionality remains intact - -#### 5. **--list-events Debugging and Enhancement (Latest Development Cycle)** -- **βœ… Time-range format investigation**: Analyzed and resolved the `T000000Z` vs. full time format issue in CalDAV queries -- **βœ… Simplified CalDAV approaches**: Removed all 8 alternative approaches, keeping only the standard `calendar-query` method for cleaner debugging -- **βœ… Removed debug event limits**: Eliminated the 3-item limitation in `parse_propfind_response()` to allow processing of all events -- **βœ… Enhanced timezone display**: Added timezone information to `--list-events` output for easier debugging: - - Updated `SyncEvent` struct with `start_tzid` and `end_tzid` fields - - Modified event display in `main.rs` to show timezone IDs - - Output format: `Event Name (2024-01-15 14:00 America/New_York to 2024-01-15 15:00 America/New_York)` -- **βœ… Reverted time-range format**: Changed from date-only (`%Y%m%d`) back to midnight format (`%Y%m%dT000000Z`) per user request -- **βœ… Verified complete event retrieval**: Now processes and displays all events returned by the CalDAV server without artificial limitations - -### πŸ”„ Current TODO Items - -#### High Priority -- [ ] **Test enhanced functionality**: Run real sync operations to verify Zoho compatibility improvements -- [ ] **Performance testing**: Validate timezone handling with real-world calendar data -- [ ] **Documentation updates**: Update API documentation to reflect new timezone fields - -#### Medium Priority -- [ ] **Additional CalDAV server testing**: Test with non-Zoho servers to ensure enhanced parsing is robust -- [ ] **Error handling refinement**: Add more specific error messages for timezone parsing failures -- [ ] **Unit test expansion**: Add tests for the new timezone parsing and line unfolding functionality - -#### Low Priority -- [ ] **Configuration schema update**: Consider adding timezone preference options to config -- [x] **CLI enhancements**: βœ… **COMPLETED** - Added timezone information display to event listing commands -- [ ] **Integration with calendar filters**: Update filtering logic to consider timezone information - -### πŸ“… Next Development Steps - -#### Immediate (Next 1-2 weeks) -1. **Real-world validation**: Run comprehensive tests with actual Zoho Calendar data -2. **Performance profiling**: Ensure timezone preservation doesn't impact performance -3. **Bug monitoring**: Watch for any timezone-related parsing issues in production - -#### Short-term (Next month) -1. **Enhanced filtering**: Leverage timezone information for smarter event filtering -2. **Export improvements**: Add timezone-aware export options -3. **Cross-platform testing**: Test with various CalDAV implementations - -#### Long-term (Next 3 months) -1. **Bidirectional sync preparation**: Use timezone information for accurate conflict resolution -2. **Multi-calendar timezone handling**: Handle events from different timezones across multiple calendars -3. **User timezone preferences**: Allow users to specify their preferred timezone for display - -### πŸ” Technical Debt and Improvements - -#### Identified Areas for Future Enhancement -1. **XML parsing**: Consider using a more robust XML library for CalDAV responses -2. **Timezone database**: Integrate with tz database for better timezone validation -3. **Error recovery**: Add fallback mechanisms for timezone parsing failures -4. **Memory optimization**: Optimize large calendar processing with timezone data - -#### Code Quality Improvements -1. **Documentation**: Ensure all new functions have proper documentation -2. **Test coverage**: Aim for >90% test coverage for new timezone functionality -3. **Performance benchmarks**: Establish baseline performance metrics - -### πŸ“Š Success Metrics - -#### Current Status -- **βœ… Code compilation**: All changes compile without errors -- **βœ… Backward compatibility**: Existing functionality preserved -- **βœ… Enhanced functionality**: Timezone information preservation added -- **πŸ”„ Testing**: Real-world testing pending - -#### Success Criteria for Next Release -- **Target**: Successful retrieval and parsing of timezone-aware events from Zoho -- **Metric**: >95% success rate for events with timezone information -- **Performance**: No significant performance degradation (<5% slower) -- **Compatibility**: Maintain compatibility with existing CalDAV servers +- Real-time sync notifications ## Build and Development diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 3a732ee..0000000 --- a/TESTING.md +++ /dev/null @@ -1,887 +0,0 @@ -# Testing Documentation - -## Table of Contents - -1. [Overview](#overview) -2. [Test Architecture](#test-architecture) -3. [Test Categories](#test-categories) -4. [Test Configuration](#test-configuration) -5. [Running Tests](#running-tests) -6. [Test Results Analysis](#test-results-analysis) -7. [Mock Data](#mock-data) -8. [Performance Testing](#performance-testing) -9. [Error Handling Tests](#error-handling-tests) -10. [Integration Testing](#integration-testing) -11. [Troubleshooting](#troubleshooting) -12. [Best Practices](#best-practices) - -## Overview - -This document describes the comprehensive testing framework for the CalDAV Sync library. The test suite validates calendar discovery, event retrieval, data parsing, error handling, and integration across all components. - -### Test Statistics - -- **Library Tests**: 74 total tests (67 passed, 7 failed) -- **Integration Tests**: 17 total tests (15 passed, 2 failed) -- **Success Rate**: 88% integration tests passing -- **Coverage**: Calendar discovery, event parsing, filtering, timezone handling, error management - -## Test Architecture - -### Test Structure - -``` -src/ -β”œβ”€β”€ lib.rs # Main library with integration tests -β”œβ”€β”€ caldav_client.rs # Core CalDAV client with comprehensive test suite -β”œβ”€β”€ event.rs # Event handling with unit tests -β”œβ”€β”€ sync.rs # Sync engine with state management tests -β”œβ”€β”€ timezone.rs # Timezone handling with validation tests -β”œβ”€β”€ calendar_filter.rs # Filtering system with unit tests -β”œβ”€β”€ error.rs # Error types and handling tests -└── config.rs # Configuration management tests - -tests/ -└── integration_tests.rs # Cross-module integration tests -``` - -### Test Design Philosophy - -1. **Unit Testing**: Individual component validation -2. **Integration Testing**: Cross-module functionality validation -3. **Mock Data Testing**: Realistic CalDAV response simulation -4. **Performance Testing**: Large-scale data handling validation -5. **Error Resilience Testing**: Edge case and failure scenario validation - -## Test Categories - -### 1. Library Tests (`cargo test --lib`) - -#### Calendar Discovery Tests -- **Location**: `src/caldav_client.rs` - `calendar_discovery` module -- **Purpose**: Validate calendar listing and metadata extraction -- **Key Tests**: - - `test_calendar_client_creation` - Client initialization - - `test_calendar_parsing_empty_xml` - Empty response handling - - `test_calendar_info_structure` - Calendar metadata validation - - `test_calendar_info_serialization` - Data serialization - -#### Event Retrieval Tests -- **Location**: `src/caldav_client.rs` - `event_retrieval` module -- **Purpose**: Validate event parsing and data extraction -- **Key Tests**: - - `test_event_parsing_single_event` - Single event parsing - - `test_event_parsing_multiple_events` - Multiple event parsing - - `test_datetime_parsing` - Datetime format validation - - `test_simple_ical_parsing` - iCalendar data parsing - - `test_ical_parsing_missing_fields` - Incomplete data handling - -#### Integration Tests (Client Level) -- **Location**: `src/caldav_client.rs` - `integration` module -- **Purpose**: Validate end-to-end client workflows -- **Key Tests**: - - `test_mock_calendar_workflow` - Calendar discovery workflow - - `test_mock_event_workflow` - Event retrieval workflow - - `test_url_handling` - URL normalization - - `test_client_with_real_config` - Real configuration handling - -#### Error Handling Tests -- **Location**: `src/caldav_client.rs` - `error_handling` module -- **Purpose**: Validate error scenarios and recovery -- **Key Tests**: - - `test_malformed_xml_handling` - Invalid XML response handling - - `test_network_timeout_simulation` - Timeout scenarios - - `test_invalid_datetime_formats` - Malformed datetime handling - -#### Performance Tests -- **Location**: `src/caldav_client.rs` - `performance` module -- **Purpose**: Validate large-scale data handling -- **Key Tests**: - - `test_large_event_parsing` - 100+ event parsing performance - - `test_memory_usage` - Memory efficiency validation - -#### Sync Engine Tests -- **Location**: `src/sync.rs` -- **Purpose**: Validate sync state management and import functionality -- **Key Tests**: - - `test_sync_state_creation` - Sync state initialization - - `test_import_state_management` - Import state handling - - `test_filter_integration` - Filter and sync integration - -#### Timezone Tests -- **Location**: `src/timezone.rs` -- **Purpose**: Validate timezone conversion and formatting -- **Key Tests**: - - `test_timezone_handler_creation` - Handler initialization - - `test_utc_datetime_parsing` - UTC datetime handling - - `test_ical_formatting` - iCalendar timezone formatting - -### 2. Integration Tests (`cargo test --test integration_tests`) - -#### Configuration Tests -- **Location**: `tests/integration_tests.rs` - `config_tests` module -- **Purpose**: Validate configuration management across modules -- **Key Tests**: - - `test_default_config` - Default configuration validation - - `test_config_validation` - Configuration validation logic - -#### Event Tests -- **Location**: `tests/integration_tests.rs` - `event_tests` module -- **Purpose**: Validate event creation and serialization -- **Key Tests**: - - `test_event_creation` - Event structure validation - - `test_all_day_event` - All-day event handling - - `test_event_to_ical` - Event serialization - -#### Filter Tests -- **Location**: `tests/integration_tests.rs` - `filter_tests` module -- **Purpose**: Validate filtering system integration -- **Key Tests**: - - `test_date_range_filter` - Date range filtering - - `test_keyword_filter` - Keyword-based filtering - - `test_calendar_filter` - Calendar-level filtering - - `test_filter_builder` - Filter composition - -#### Timezone Tests -- **Location**: `tests/integration_tests.rs` - `timezone_tests` module -- **Purpose**: Validate timezone handling in integration context -- **Key Tests**: - - `test_timezone_handler_creation` - Cross-module timezone handling - - `test_timezone_validation` - Timezone validation - - `test_ical_formatting` - Integration-level formatting - -#### Error Tests -- **Location**: `tests/integration_tests.rs` - `error_tests` module -- **Purpose**: Validate error handling across modules -- **Key Tests**: - - `test_error_retryable` - Error retry logic - - `test_error_classification` - Error type classification - -## Test Configuration - -### Test Dependencies - -```toml -[dev-dependencies] -tokio-test = "0.4" -tempfile = "3.0" -``` - -### Environment Variables - -```bash -# Enable detailed test output -RUST_BACKTRACE=1 - -# Enable logging during tests -RUST_LOG=debug - -# Run tests with specific logging -RUST_LOG=caldav_sync=debug -``` - -### Test Configuration Files - -Test configurations are embedded in the test modules: - -```rust -/// Test server configuration for unit tests -fn create_test_server_config() -> ServerConfig { - ServerConfig { - url: "https://caldav.test.com".to_string(), - username: "test_user".to_string(), - password: "test_pass".to_string(), - timeout: Duration::from_secs(30), - } -} -``` - -## Running Tests - -### Basic Test Commands - -```bash -# Run all library tests -cargo test --lib - -# Run all integration tests -cargo test --test integration_tests - -# Run all tests (library + integration) -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run tests with specific logging -RUST_LOG=debug cargo test --verbose -``` - -### Running Specific Test Modules - -```bash -# Calendar discovery tests -cargo test --lib caldav_client::tests::calendar_discovery - -# Event retrieval tests -cargo test --lib caldav_client::tests::event_retrieval - -# Integration tests -cargo test --lib caldav_client::tests::integration - -# Error handling tests -cargo test --lib caldav_client::tests::error_handling - -# Performance tests -cargo test --lib caldav_client::tests::performance - -# Sync engine tests -cargo test --lib sync::tests - -# Timezone tests -cargo test --lib timezone::tests -``` - -### Running Individual Tests - -```bash -# Specific test with full path -cargo test --lib caldav_client::tests::calendar_discovery::test_calendar_info_structure - -# Test by pattern matching -cargo test --lib test_calendar_parsing - -# Integration test by module -cargo test --test integration_tests config_tests - -# Specific integration test -cargo test --test integration_tests config_tests::test_config_validation -``` - -### Performance Testing Commands - -```bash -# Run performance tests -cargo test --lib caldav_client::tests::performance - -# Run with release optimizations for performance testing -cargo test --lib --release caldav_client::tests::performance - -# Run performance tests with output capture -cargo test --lib -- --nocapture caldav_client::tests::performance -``` - -### Debug Testing Commands - -```bash -# Run tests with backtrace on failure -RUST_BACKTRACE=1 cargo test - -# Run tests with full backtrace -RUST_BACKTRACE=full cargo test - -# Run tests with logging -RUST_LOG=debug cargo test --lib - -# Run specific test with logging -RUST_LOG=caldav_sync::caldav_client=debug cargo test --lib test_event_parsing -``` - -## Test Results Analysis - -### Current Test Status - -#### Library Tests (`cargo test --lib`) -- **Total Tests**: 74 -- **Passed**: 67 (90.5%) -- **Failed**: 7 (9.5%) -- **Execution Time**: ~0.11s - -#### Integration Tests (`cargo test --test integration_tests`) -- **Total Tests**: 17 -- **Passed**: 15 (88.2%) -- **Failed**: 2 (11.8%) -- **Execution Time**: ~0.00s - -### Expected Failures - -#### Library Test Failures (7) -1. **Event Parsing Tests** (5 failures) - Placeholder XML parsing implementations -2. **URL Handling Test** (1 failure) - URL normalization needs implementation -3. **Datetime Parsing Test** (1 failure) - Uses current time fallback instead of parsing - -#### Integration Test Failures (2) -1. **Default Config Test** - Expected failure due to empty username validation -2. **Full Workflow Test** - Expected failure due to empty username validation - -### Test Coverage Analysis - -**βœ… Fully Validated Components:** -- Calendar discovery and metadata parsing -- Event structure creation and validation -- Error classification and handling -- Timezone conversion and formatting -- Filter system functionality -- Sync state management -- Configuration validation logic - -**⚠️ Partially Implemented (Expected Failures):** -- XML parsing for CalDAV responses -- URL normalization for CalDAV endpoints -- Datetime parsing from iCalendar data - -## Mock Data - -### Calendar XML Mock - -```rust -const MOCK_CALENDAR_XML: &str = r#" - - - /calendars/testuser/calendar1/ - - - Work Calendar - #3174ad - Work related events - - - - - - - -"#; -``` - -### Event XML Mock - -```rust -const MOCK_EVENTS_XML: &str = r#" - - - /calendars/testuser/work/1234567890.ics - - - "1234567890-1" - BEGIN:VCALENDAR -BEGIN:VEVENT -UID:1234567890 -SUMMARY:Team Meeting -DESCRIPTION:Weekly team sync to discuss project progress -LOCATION:Conference Room A -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -STATUS:CONFIRMED -END:VEVENT -END:VCALENDAR - - - - -"#; -``` - -### Test Event Data - -```rust -fn create_test_event() -> Event { - let start = Utc::now(); - let end = start + Duration::hours(1); - Event::new("Test Event".to_string(), start, end) -} -``` - -## Performance Testing - -### Large Event Parsing Test - -```rust -#[test] -fn test_large_event_parsing() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let mut large_xml = String::new(); - - // Generate 100 test events - for i in 0..100 { - large_xml.push_str(&format!(r#" - - /calendars/test/event{}.ics - - - BEGIN:VCALENDAR -BEGIN:VEVENT -UID:event{} -SUMMARY:Event {} -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -END:VEVENT -END:VCALENDAR - - - - "#, i, i, i)); - } - - let start = Instant::now(); - let result = client.parse_events(&large_xml).unwrap(); - let duration = start.elapsed(); - - assert_eq!(result.len(), 100); - assert!(duration.as_millis() < 1000); // Should complete in < 1 second -} -``` - -### Memory Usage Test - -```rust -#[test] -fn test_memory_usage() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Parse 20 events and check memory efficiency - let events = client.parse_events(MOCK_EVENTS_XML).unwrap(); - assert_eq!(events.len(), 20); - - // Verify no memory leaks in event parsing - for event in &events { - assert!(!event.summary.is_empty()); - assert!(event.start <= event.end); - } -} -``` - -## Error Handling Tests - -### Network Error Simulation - -```rust -#[test] -fn test_network_timeout_simulation() { - let config = ServerConfig { - timeout: Duration::from_millis(1), // Very short timeout - ..create_test_server_config() - }; - - let client = CalDavClient::new(config).unwrap(); - // This should timeout and return a network error - let result = client.list_calendars(); - assert!(result.is_err()); - - match result.unwrap_err() { - CalDavError::Network(_) => { - // Expected error type - } - _ => panic!("Expected network error"), - } -} -``` - -### Malformed XML Handling - -```rust -#[test] -fn test_malformed_xml_handling() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let malformed_xml = r#""#; - - let result = client.parse_calendar_list(malformed_xml); - assert!(result.is_err()); - - // Should handle gracefully without panic - match result.unwrap_err() { - CalDavError::XmlParsing(_) => { - // Expected error type - } - _ => panic!("Expected XML parsing error"), - } -} -``` - -### Invalid Datetime Formats - -```rust -#[test] -fn test_invalid_datetime_formats() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test various invalid datetime formats - let invalid_datetimes = vec![ - "invalid-datetime", - "2024-13-45T25:99:99Z", // Invalid date/time - "", // Empty string - "20241015T140000", // Missing Z suffix - ]; - - for invalid_dt in invalid_datetimes { - let result = client.parse_datetime(invalid_dt); - // Should handle gracefully with fallback - assert!(result.is_ok()); - } -} -``` - -## Integration Testing - -### Full Workflow Test - -```rust -#[test] -fn test_full_workflow() -> CalDavResult<()> { - // Initialize library - caldav_sync::init()?; - - // Create configuration - let config = Config::default(); - - // Validate configuration (should fail with empty credentials) - assert!(config.validate().is_err()); - - // Create test events - let event1 = caldav_sync::event::Event::new( - "Test Meeting".to_string(), - Utc::now(), - Utc::now() + chrono::Duration::hours(1), - ); - - let event2 = caldav_sync::event::Event::new_all_day( - "Test Holiday".to_string(), - chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(), - ); - - // Test event serialization - let ical1 = event1.to_ical()?; - let ical2 = event2.to_ical()?; - - assert!(!ical1.is_empty()); - assert!(!ical2.is_empty()); - assert!(ical1.contains("SUMMARY:Test Meeting")); - assert!(ical2.contains("SUMMARY:Test Holiday")); - - // Test filtering - let filter = caldav_sync::calendar_filter::FilterBuilder::new() - .keywords(vec!["test".to_string()]) - .build(); - - assert!(filter.matches_event(&event1)); - assert!(filter.matches_event(&event2)); - - Ok(()) -} -``` - -### Cross-Module Integration Test - -```rust -#[test] -fn test_sync_engine_filter_integration() { - let config = create_test_server_config(); - let sync_engine = SyncEngine::new(config); - - // Create test filter - let filter = FilterBuilder::new() - .date_range(start_date, end_date) - .keywords(vec!["meeting".to_string()]) - .build(); - - // Test filter integration with sync engine - let filtered_events = sync_engine.filter_events(&test_events, &filter); - assert!(!filtered_events.is_empty()); - - // Verify all filtered events match criteria - for event in &filtered_events { - assert!(filter.matches_event(event)); - } -} -``` - -## Troubleshooting - -### Common Test Issues - -#### 1. Configuration Validation Failures - -**Issue**: Tests fail with "Username cannot be empty" error - -**Solution**: This is expected behavior for tests using default configuration - -```bash -# Run specific tests that don't require valid credentials -cargo test --lib caldav_client::tests::calendar_discovery -cargo test --lib caldav_client::tests::event_retrieval -``` - -#### 2. XML Parsing Failures - -**Issue**: Event parsing tests fail with 0 events parsed - -**Solution**: These are expected failures due to placeholder implementations - -```bash -# Run tests that don't depend on XML parsing -cargo test --lib caldav_client::tests::calendar_discovery -cargo test --lib caldav_client::tests::error_handling -cargo test --lib sync::tests -``` - -#### 3. Import/Module Resolution Errors - -**Issue**: Tests fail to compile with import errors - -**Solution**: Ensure all required dependencies are in scope - -```rust -use caldav_sync::{Config, CalDavResult}; -use chrono::{Utc, DateTime}; -use caldav_sync::event::{Event, EventStatus}; -``` - -#### 4. Performance Test Timeouts - -**Issue**: Performance tests take too long or timeout - -**Solution**: Run with optimized settings - -```bash -# Run performance tests in release mode -cargo test --lib --release caldav_client::tests::performance - -# Or increase timeout in test configuration -export CALDAV_TEST_TIMEOUT=30 -``` - -### Debug Tips - -#### Enable Detailed Logging - -```bash -# Run with debug logging -RUST_LOG=debug cargo test --lib --verbose - -# Focus on specific module logging -RUST_LOG=caldav_sync::caldav_client=debug cargo test --lib test_event_parsing -``` - -#### Use Backtrace for Failures - -```bash -# Enable backtrace for detailed failure information -RUST_BACKTRACE=1 cargo test - -# Full backtrace for maximum detail -RUST_BACKTRACE=full cargo test -``` - -#### Run Single Tests for Debugging - -```bash -# Run a specific test with output -cargo test --lib -- --nocapture test_calendar_info_structure - -# Run with specific test pattern -cargo test --lib test_parsing -``` - -## Best Practices - -### Test Writing Guidelines - -#### 1. Use Descriptive Test Names - -```rust -// Good -#[test] -fn test_calendar_parsing_with_missing_display_name() { - // Test implementation -} - -// Avoid -#[test] -fn test_calendar_1() { - // Unclear test purpose -} -``` - -#### 2. Include Assertive Test Cases - -```rust -#[test] -fn test_event_creation() { - let start = Utc::now(); - let end = start + Duration::hours(1); - let event = Event::new("Test Event".to_string(), start, end); - - // Specific assertions - assert_eq!(event.summary, "Test Event"); - assert_eq!(event.start, start); - assert_eq!(event.end, end); - assert!(!event.all_day); - assert!(event.start < event.end); -} -``` - -#### 3. Use Mock Data Consistently - -```rust -// Define mock data once -const TEST_CALENDAR_NAME: &str = "Test Calendar"; -const TEST_EVENT_SUMMARY: &str = "Test Event"; - -// Reuse across tests -#[test] -fn test_calendar_creation() { - let calendar = CalendarInfo::new(TEST_CALENDAR_NAME.to_string()); - assert_eq!(calendar.display_name, TEST_CALENDAR_NAME); -} -``` - -#### 4. Test Both Success and Failure Cases - -```rust -#[test] -fn test_config_validation() { - // Test valid configuration - let valid_config = create_valid_config(); - assert!(valid_config.validate().is_ok()); - - // Test invalid configuration - let invalid_config = create_invalid_config(); - assert!(invalid_config.validate().is_err()); -} -``` - -### Test Organization - -#### 1. Group Related Tests - -```rust -#[cfg(test)] -mod calendar_discovery { - use super::*; - - #[test] - fn test_calendar_parsing() { /* ... */ } - - #[test] - fn test_calendar_validation() { /* ... */ } -} -``` - -#### 2. Use Test Helpers - -```rust -fn create_test_server_config() -> ServerConfig { - ServerConfig { - url: "https://caldav.test.com".to_string(), - username: "test_user".to_string(), - password: "test_pass".to_string(), - timeout: Duration::from_secs(30), - } -} - -#[test] -fn test_client_creation() { - let config = create_test_server_config(); - let client = CalDavClient::new(config); - assert!(client.is_ok()); -} -``` - -#### 3. Document Test Purpose - -```rust -/// Tests that calendar parsing correctly extracts metadata from CalDAV XML responses -/// including display name, description, color, and supported components. -#[test] -fn test_calendar_metadata_extraction() { - // Test implementation with comments explaining each step -} -``` - -### Continuous Integration - -#### GitHub Actions Example - -```yaml -name: Test Suite - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - - name: Run library tests - run: cargo test --lib --verbose - - - name: Run integration tests - run: cargo test --test integration_tests --verbose - - - name: Run performance tests - run: cargo test --lib --release caldav_client::tests::performance -``` - -### Test Data Management - -#### 1. External Test Data - -```rust -// For large test data files -#[cfg(test)] -mod tests { - use std::fs; - - fn load_test_data(filename: &str) -> String { - fs::read_to_string(format!("tests/data/{}", filename)) - .expect("Failed to read test data file") - } - - #[test] - fn test_large_calendar_response() { - let xml_data = load_test_data("large_calendar_response.xml"); - let result = parse_calendar_list(&xml_data); - assert!(result.is_ok()); - } -} -``` - -#### 2. Generated Test Data - -```rust -fn generate_test_events(count: usize) -> Vec { - let mut events = Vec::new(); - for i in 0..count { - let start = Utc::now() + Duration::days(i as i64); - let event = Event::new( - format!("Test Event {}", i), - start, - start + Duration::hours(1), - ); - events.push(event); - } - events -} -``` - ---- - -## Conclusion - -This comprehensive testing framework provides confidence in the CalDAV Sync library's functionality, reliability, and performance. The test suite validates: - -- **Core Functionality**: Calendar discovery, event parsing, and data management -- **Error Resilience**: Robust handling of network errors, malformed data, and edge cases -- **Performance**: Efficient handling of large datasets and memory management -- **Integration**: Seamless operation across all library components - -The failing tests are expected due to placeholder implementations and demonstrate that the validation logic is working correctly. As development progresses, these placeholders will be implemented to achieve 100% test coverage. - -For questions or issues with testing, refer to the [Troubleshooting](#troubleshooting) section or create an issue in the project repository. diff --git a/config/config.toml b/config/config.toml deleted file mode 100644 index e2c65d9..0000000 --- a/config/config.toml +++ /dev/null @@ -1,51 +0,0 @@ -# CalDAV Configuration for Zoho Sync -# This matches the Rust application's expected configuration structure - -[server] -# CalDAV server URL (Zoho) -url = "https://calendar.zoho.com/caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Username for authentication -username = "alvaro.soliverez@collabora.com" -# Password for authentication (use app-specific password) -password = "1vSf8KZzYtkP" -# Whether to use HTTPS (recommended) -use_https = true -# Request timeout in seconds -timeout = 30 - -[calendar] -# Calendar name/path on the server -name = "caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Calendar display name (optional) -display_name = "Alvaro.soliverez@collabora.com" -# Calendar color in hex format (optional) -color = "#4285F4" -# Default timezone for the calendar -timezone = "UTC" -# Whether this calendar is enabled for synchronization -enabled = true - -[sync] -# Synchronization interval in seconds (300 = 5 minutes) -interval = 300 -# Whether to perform synchronization on startup -sync_on_startup = true -# Maximum number of retry attempts for failed operations -max_retries = 3 -# Delay between retry attempts in seconds -retry_delay = 5 -# Whether to delete local events that are missing on server -delete_missing = false -# Date range configuration -date_range = { days_ahead = 30, days_back = 30, sync_all_events = false } - -# Optional filtering configuration -[filters] -# Keywords to filter events by (events containing any of these will be included) -# keywords = ["work", "meeting", "project"] -# Keywords to exclude (events containing any of these will be excluded) -# exclude_keywords = ["personal", "holiday", "cancelled"] -# Minimum event duration in minutes -min_duration_minutes = 5 -# Maximum event duration in hours -max_duration_hours = 24 diff --git a/config/default.toml b/config/default.toml index 2354f61..f700218 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,88 +1,54 @@ # Default CalDAV Sync Configuration -# This file provides default values for CalDAV synchronization +# This file provides default values for the Zoho to Nextcloud calendar sync -# Source Server Configuration (Primary CalDAV server) -[server] -# CalDAV server URL (example: Zoho, Google Calendar, etc.) -url = "https://caldav.example.com/" -# Username for authentication +# Zoho Configuration (Source) +[zoho] +server_url = "https://caldav.zoho.com/caldav" username = "" -# Password for authentication (use app-specific password) password = "" -# Whether to use HTTPS (recommended) -use_https = true +selected_calendars = [] + +# Nextcloud Configuration (Target) +[nextcloud] +server_url = "" +username = "" +password = "" +target_calendar = "Imported-Zoho-Events" +create_if_missing = true + +[server] # Request timeout in seconds timeout = 30 -# Source Calendar Configuration [calendar] -# Calendar name/path on the server -name = "calendar" -# Calendar display name (optional - will be discovered from server if not specified) -display_name = "" -# Calendar color in hex format (optional - will be discovered from server if not specified) +# Calendar color in hex format color = "#3174ad" -# Calendar timezone (optional - will be discovered from server if not specified) -timezone = "" -# Whether this calendar is enabled for synchronization -enabled = true +# Default timezone for processing +timezone = "UTC" -# Synchronization Configuration [sync] # Synchronization interval in seconds (300 = 5 minutes) interval = 300 # Whether to perform synchronization on startup sync_on_startup = true -# Maximum number of retry attempts for failed operations +# Number of weeks ahead to sync +weeks_ahead = 1 +# Whether to run in dry-run mode (preview changes only) +dry_run = false + +# Performance settings max_retries = 3 -# Delay between retry attempts in seconds retry_delay = 5 -# Whether to delete local events that are missing on server -delete_missing = false -# Date range configuration -[sync.date_range] -# Number of days ahead to sync -days_ahead = 7 -# Number of days in the past to sync -days_back = 0 -# Whether to sync all events regardless of date -sync_all_events = false # Optional filtering configuration # [filters] -# # Start date filter (ISO 8601 format) -# start_date = "2024-01-01T00:00:00Z" -# # End date filter (ISO 8601 format) -# end_date = "2024-12-31T23:59:59Z" -# # Event types to include +# # Event types to include (leave empty for all) # event_types = ["meeting", "appointment"] -# # Keywords to filter events by (events containing any of these will be included) +# # Keywords to filter events by # keywords = ["work", "meeting", "project"] -# # Keywords to exclude (events containing any of these will be excluded) +# # Keywords to exclude # exclude_keywords = ["personal", "holiday", "cancelled"] - -# Optional Import Configuration (for unidirectional sync to target server) -# Uncomment and configure this section to enable import functionality -# [import] -# # Target server configuration -# [import.target_server] -# url = "https://nextcloud.example.com/remote.php/dav/" -# username = "" -# password = "" -# use_https = true -# timeout = 30 -# -# # Target calendar configuration -# [import.target_calendar] -# name = "Imported-Events" -# display_name = "Imported from Source" -# color = "#FF6B6B" -# timezone = "UTC" -# enabled = true -# -# # Import behavior settings -# overwrite_existing = true # Source always wins -# delete_missing = false # Don't delete events missing from source -# dry_run = false # Set to true for preview mode -# batch_size = 50 # Number of events to process in each batch -# create_target_calendar = true # Create target calendar if it doesn't exist +# # Minimum event duration in minutes +# min_duration_minutes = 5 +# # Maximum event duration in hours +# max_duration_hours = 24 diff --git a/config/example.toml b/config/example.toml index 5002737..76613ea 100644 --- a/config/example.toml +++ b/config/example.toml @@ -1,96 +1,117 @@ # CalDAV Configuration Example -# This file demonstrates how to configure CalDAV synchronization +# This file demonstrates how to configure Zoho and Nextcloud CalDAV connections # Copy and modify this example for your specific setup -# Source Server Configuration (e.g., Zoho Calendar) -[server] -# CalDAV server URL -url = "https://calendar.zoho.com/caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Username for authentication -username = "your-email@domain.com" -# Password for authentication (use app-specific password) -password = "your-app-password" -# Whether to use HTTPS (recommended) -use_https = true -# Request timeout in seconds -timeout = 30 +# Global settings +global: + log_level: "info" + sync_interval: 300 # seconds (5 minutes) + conflict_resolution: "latest" # or "manual" or "local" or "remote" + timezone: "UTC" -# Source Calendar Configuration -[calendar] -# Calendar name/path on the server -name = "caldav/d82063f6ef084c8887a8694e661689fc/events/" -# Calendar display name -display_name = "Work Calendar" -# Calendar color in hex format -color = "#4285F4" -# Default timezone for the calendar -timezone = "UTC" -# Whether this calendar is enabled for synchronization -enabled = true +# Zoho CalDAV Configuration (Source) +zoho: + enabled: true + + # Server settings + server: + url: "https://caldav.zoho.com/caldav" + timeout: 30 # seconds + + # Authentication + auth: + username: "your-zoho-email@domain.com" + password: "your-zoho-app-password" # Use app-specific password, not main password + + # Calendar selection - which calendars to import from + calendars: + - name: "Work Calendar" + enabled: true + color: "#4285F4" + sync_direction: "pull" # Only pull from Zoho + + - name: "Personal Calendar" + enabled: true + color: "#34A853" + sync_direction: "pull" + + - name: "Team Meetings" + enabled: false # Disabled by default + color: "#EA4335" + sync_direction: "pull" + + # Sync options + sync: + sync_past_events: false # Don't sync past events + sync_future_events: true + sync_future_days: 7 # Only sync next week + include_attendees: false # Keep it simple + include_attachments: false -# Synchronization Configuration -[sync] -# Synchronization interval in seconds (300 = 5 minutes) -interval = 300 -# Whether to perform synchronization on startup -sync_on_startup = true -# Maximum number of retry attempts for failed operations -max_retries = 3 -# Delay between retry attempts in seconds -retry_delay = 5 -# Whether to delete local events that are missing on server -delete_missing = false -# Date range configuration -[sync.date_range] -# Number of days ahead to sync -days_ahead = 30 -# Number of days in the past to sync -days_back = 30 -# Whether to sync all events regardless of date -sync_all_events = false +# Nextcloud CalDAV Configuration (Target) +nextcloud: + enabled: true + + # Server settings + server: + url: "https://your-nextcloud-domain.com" + timeout: 30 # seconds + + # Authentication + auth: + username: "your-nextcloud-username" + password: "your-nextcloud-app-password" # Use app-specific password + + # Calendar discovery + discovery: + principal_url: "/remote.php/dav/principals/users/{username}/" + calendar_home_set: "/remote.php/dav/calendars/{username}/" + + # Target calendar - all Zoho events go here + calendars: + - name: "Imported-Zoho-Events" + enabled: true + color: "#FF6B6B" + sync_direction: "push" # Only push to Nextcloud + create_if_missing: true # Auto-create if it doesn't exist + + # Sync options + sync: + sync_past_events: false + sync_future_events: true + sync_future_days: 7 -# Optional filtering configuration -[filters] -# Keywords to filter events by (events containing any of these will be included) -keywords = ["work", "meeting", "project"] -# Keywords to exclude (events containing any of these will be excluded) -exclude_keywords = ["personal", "holiday", "cancelled"] -# Minimum event duration in minutes -min_duration_minutes = 5 -# Maximum event duration in hours -max_duration_hours = 24 +# Event filtering +filters: + events: + exclude_patterns: + - "Cancelled:" + - "BLOCKED" + + # Time-based filters + min_duration_minutes: 5 + max_duration_hours: 24 + + # Status filters + include_status: ["confirmed", "tentative"] + exclude_status: ["cancelled"] -# Import Configuration (for unidirectional sync to target server) -[import] -# Target server configuration (e.g., Nextcloud) -[import.target_server] -# Nextcloud CalDAV URL -url = "https://your-nextcloud-domain.com/remote.php/dav/calendars/username/" -# Username for Nextcloud authentication -username = "your-nextcloud-username" -# Password for Nextcloud authentication (use app-specific password) -password = "your-nextcloud-app-password" -# Whether to use HTTPS (recommended) -use_https = true -# Request timeout in seconds -timeout = 30 +# Logging +logging: + level: "info" + format: "text" + file: "caldav-sync.log" + max_size: "10MB" + max_files: 3 -# Target calendar configuration -[import.target_calendar] -# Target calendar name -name = "Imported-Zoho-Events" -# Target calendar display name (optional - will be discovered from server if not specified) -display_name = "" -# Target calendar color (optional - will be discovered from server if not specified) -color = "" -# Target calendar timezone (optional - will be discovered from server if not specified) -timezone = "" -# Whether this calendar is enabled for import -enabled = true +# Performance settings +performance: + max_concurrent_syncs: 3 + batch_size: 25 + retry_attempts: 3 + retry_delay: 5 # seconds -# Import behavior settings -overwrite_existing = true # Source always wins - overwrite target events -delete_missing = false # Don't delete events missing from source -dry_run = false # Set to true for preview mode -batch_size = 50 # Number of events to process in each batch -create_target_calendar = true # Create target calendar if it doesn't exist +# Security settings +security: + ssl_verify: true + encryption: "tls12" diff --git a/src/caldav_client.rs b/src/caldav_client.rs index a349643..479e38d 100644 --- a/src/caldav_client.rs +++ b/src/caldav_client.rs @@ -6,10 +6,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use base64::Engine; use url::Url; -use tracing::{debug, info}; /// CalDAV client for communicating with CalDAV servers -#[derive(Clone)] pub struct CalDavClient { client: Client, config: ServerConfig, @@ -237,136 +235,16 @@ impl CalDavClient { } /// Parse events from XML response - fn parse_events(&self, xml: &str) -> CalDavResult> { + fn parse_events(&self, _xml: &str) -> CalDavResult> { // This is a simplified XML parser - in a real implementation, // you'd use a proper XML parsing library - let mut events = Vec::new(); + let events = Vec::new(); - debug!("Parsing events from XML response ({} bytes)", xml.len()); + // Placeholder implementation + // TODO: Implement proper XML parsing for event data - // Simple regex-based parsing for demonstration - // In production, use a proper XML parser like quick-xml - use regex::Regex; - - // Look for iCalendar data in the response - let ical_regex = Regex::new(r"]*>(.*?)").unwrap(); - let href_regex = Regex::new(r"]*>(.*?)").unwrap(); - let etag_regex = Regex::new(r"]*>(.*?)").unwrap(); - - // Find all iCalendar data blocks and extract corresponding href and etag - let ical_matches: Vec<_> = ical_regex.find_iter(xml).collect(); - let href_matches: Vec<_> = href_regex.find_iter(xml).collect(); - let etag_matches: Vec<_> = etag_regex.find_iter(xml).collect(); - - // Process events by matching the three iterators - for ((ical_match, href_match), etag_match) in ical_matches.into_iter() - .zip(href_matches.into_iter()) - .zip(etag_matches.into_iter()) { - - let _ical_data = ical_match.as_str(); - let href = href_match.as_str(); - let _etag = etag_match.as_str(); - - debug!("Found iCalendar data in href: {}", href); - - // Extract content between tags - let ical_content = ical_regex.captures(ical_match.as_str()) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .unwrap_or(""); - - // Extract event ID from href - let event_id = href_regex.captures(href) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .unwrap_or("") - .split('/') - .last() - .unwrap_or("") - .replace(".ics", ""); - - if !event_id.is_empty() { - // Parse the iCalendar data to extract basic event info - let event_info = self.parse_simple_ical_event(&event_id, ical_content)?; - events.push(event_info); - } - } - - info!("Parsed {} events from XML response", events.len()); Ok(events) } - - /// Parse basic event information from iCalendar data - fn parse_simple_ical_event(&self, event_id: &str, ical_data: &str) -> CalDavResult { - use regex::Regex; - - let summary_regex = Regex::new(r"SUMMARY:(.*)").unwrap(); - let description_regex = Regex::new(r"DESCRIPTION:(.*)").unwrap(); - let location_regex = Regex::new(r"LOCATION:(.*)").unwrap(); - let dtstart_regex = Regex::new(r"DTSTART[^:]*:(.*)").unwrap(); - let dtend_regex = Regex::new(r"DTEND[^:]*:(.*)").unwrap(); - let status_regex = Regex::new(r"STATUS:(.*)").unwrap(); - - let summary = summary_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .unwrap_or("Untitled Event") - .to_string(); - - let description = description_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .map(|s| s.to_string()); - - let location = location_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .map(|s| s.to_string()); - - let status = status_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .unwrap_or("CONFIRMED") - .to_string(); - - // Parse datetime (simplified) - let now = Utc::now(); - let start = dtstart_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .and_then(|m| self.parse_datetime(m.as_str()).ok()) - .unwrap_or(now); - - let end = dtend_regex.captures(ical_data) - .and_then(|caps| caps.get(1)) - .and_then(|m| self.parse_datetime(m.as_str()).ok()) - .unwrap_or(now + chrono::Duration::hours(1)); - - Ok(CalDavEventInfo { - id: event_id.to_string(), - summary, - description, - start, - end, - location, - status, - etag: None, - ical_data: ical_data.to_string(), - }) - } - - /// Parse datetime from iCalendar format - fn parse_datetime(&self, dt_str: &str) -> CalDavResult> { - // Basic parsing for iCalendar datetime format - if dt_str.len() == 15 { - // Format: 20241015T143000Z - chrono::DateTime::parse_from_str(&format!("{} +0000", &dt_str), "%Y%m%dT%H%M%S %z") - .map(|dt| dt.with_timezone(&Utc)) - .map_err(|_| CalDavError::InvalidFormat("Invalid datetime format".to_string())) - } else { - // Try other formats or return current time as fallback - Ok(Utc::now()) - } - } } /// Calendar information @@ -410,637 +288,7 @@ pub struct CalDavEventInfo { #[cfg(test)] mod tests { use super::*; - use crate::config::ServerConfig; - use chrono::{DateTime, Utc, Timelike, Datelike}; - /// Create a test server configuration - fn create_test_server_config() -> ServerConfig { - ServerConfig { - url: "https://caldav.test.com".to_string(), - username: "testuser".to_string(), - password: "testpass".to_string(), - use_https: true, - timeout: 30, - headers: None, - } - } - - /// Mock XML response for calendar listing - const MOCK_CALENDAR_XML: &str = r#" - - - /calendars/testuser/calendar1/ - - - - - - - Work Calendar - Work related events - - - - - #3174ad - - HTTP/1.1 200 OK - - - - /calendars/testuser/personal/ - - - - - - - Personal - Personal events - - - - #ff6b6b - - HTTP/1.1 200 OK - - -"#; - - /// Mock XML response for event listing - const MOCK_EVENTS_XML: &str = r#" - - - /calendars/testuser/work/1234567890.ics - - - "1234567890-1" - BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -UID:1234567890 -SUMMARY:Team Meeting -DESCRIPTION:Weekly team sync to discuss project progress -LOCATION:Conference Room A -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -STATUS:CONFIRMED -END:VEVENT -END:VCALENDAR - - - HTTP/1.1 200 OK - - - - /calendars/testuser/work/0987654321.ics - - - "0987654321-1" - BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -UID:0987654321 -SUMMARY:Project Deadline -DESCRIPTION:Final project deliverable due -LOCATION:Office -DTSTART:20241020T170000Z -DTEND:20241020T180000Z -STATUS:TENTATIVE -END:VEVENT -END:VCALENDAR - - - HTTP/1.1 200 OK - - -"#; - - mod calendar_discovery { - use super::*; - - #[test] - fn test_calendar_client_creation() { - let config = create_test_server_config(); - let client = CalDavClient::new(config); - assert!(client.is_ok()); - - let client = client.unwrap(); - assert_eq!(client.base_url.as_str(), "https://caldav.test.com/"); - } - - #[test] - fn test_calendar_parsing_empty_xml() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_calendar_list(""); - assert!(result.is_ok()); - - let calendars = result.unwrap(); - assert_eq!(calendars.len(), 0); - } - - #[test] - fn test_calendar_info_structure() { - let calendar_info = CalendarInfo { - path: "/calendars/test/work".to_string(), - display_name: "Work Calendar".to_string(), - description: Some("Work events".to_string()), - supported_components: vec!["VEVENT".to_string(), "VTODO".to_string()], - color: Some("#3174ad".to_string()), - }; - - assert_eq!(calendar_info.path, "/calendars/test/work"); - assert_eq!(calendar_info.display_name, "Work Calendar"); - assert_eq!(calendar_info.description, Some("Work events".to_string())); - assert_eq!(calendar_info.supported_components.len(), 2); - assert_eq!(calendar_info.color, Some("#3174ad".to_string())); - } - - #[test] - fn test_calendar_info_serialization() { - let calendar_info = CalendarInfo { - path: "/calendars/test/personal".to_string(), - display_name: "Personal".to_string(), - description: None, - supported_components: vec!["VEVENT".to_string()], - color: Some("#ff6b6b".to_string()), - }; - - // Test serialization for configuration storage - let serialized = serde_json::to_string(&calendar_info); - assert!(serialized.is_ok()); - - let deserialized: Result = serde_json::from_str(&serialized.unwrap()); - assert!(deserialized.is_ok()); - - let restored = deserialized.unwrap(); - assert_eq!(restored.path, calendar_info.path); - assert_eq!(restored.display_name, calendar_info.display_name); - assert_eq!(restored.color, calendar_info.color); - } - } - - mod event_retrieval { - use super::*; - - #[test] - fn test_event_parsing_empty_xml() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_events(""); - assert!(result.is_ok()); - - let events = result.unwrap(); - assert_eq!(events.len(), 0); - } - - #[test] - fn test_event_parsing_single_event() { - let single_event_xml = r#" - - - /calendars/test/work/event123.ics - - - "event123-1" - BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -UID:event123 -SUMMARY:Test Event -DESCRIPTION:This is a test event -LOCATION:Test Location -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -STATUS:CONFIRMED -END:VEVENT -END:VCALENDAR - - - HTTP/1.1 200 OK - - -"#; - - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_events(single_event_xml); - assert!(result.is_ok()); - - let events = result.unwrap(); - // Should parse 1 event from the XML - assert_eq!(events.len(), 1); - - let event = &events[0]; - assert_eq!(event.id, "event123"); - assert_eq!(event.summary, "Test Event"); - 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, "CONFIRMED"); - } - - #[test] - fn test_event_parsing_multiple_events() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_events(MOCK_EVENTS_XML); - assert!(result.is_ok()); - - let events = result.unwrap(); - // Should parse 2 events from the XML - assert_eq!(events.len(), 2); - - // Validate first event - let event1 = &events[0]; - assert_eq!(event1.id, "1234567890"); - assert_eq!(event1.summary, "Team Meeting"); - assert_eq!(event1.description, Some("Weekly team sync to discuss project progress".to_string())); - assert_eq!(event1.location, Some("Conference Room A".to_string())); - assert_eq!(event1.status, "CONFIRMED"); - - // Validate second event - let event2 = &events[1]; - assert_eq!(event2.id, "0987654321"); - assert_eq!(event2.summary, "Project Deadline"); - assert_eq!(event2.description, Some("Final project deliverable due".to_string())); - assert_eq!(event2.location, Some("Office".to_string())); - assert_eq!(event2.status, "TENTATIVE"); - } - - #[test] - fn test_datetime_parsing() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test valid UTC datetime format - let result = client.parse_datetime("20241015T140000Z"); - assert!(result.is_ok()); - - let dt = result.unwrap(); - assert_eq!(dt.year(), 2024); - assert_eq!(dt.month(), 10); - assert_eq!(dt.day(), 15); - assert_eq!(dt.hour(), 14); - assert_eq!(dt.minute(), 0); - assert_eq!(dt.second(), 0); - } - - #[test] - fn test_datetime_parsing_invalid_format() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test invalid format - should return current time as fallback - let result = client.parse_datetime("invalid-datetime"); - assert!(result.is_ok()); // Current time fallback - - let dt = result.unwrap(); - // Should be close to current time - let now = Utc::now(); - let diff = (dt - now).num_seconds().abs(); - assert!(diff < 60); // Within 1 minute - } - - #[test] - fn test_simple_ical_parsing() { - let ical_data = r#"BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -UID:test123 -SUMMARY:Test Meeting -DESCRIPTION:Test description -LOCATION:Test room -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -STATUS:CONFIRMED -END:VEVENT -END:VCALENDAR"#; - - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_simple_ical_event("test123", ical_data); - assert!(result.is_ok()); - - let event = result.unwrap(); - assert_eq!(event.id, "test123"); - assert_eq!(event.summary, "Test Meeting"); - assert_eq!(event.description, Some("Test description".to_string())); - assert_eq!(event.location, Some("Test room".to_string())); - assert_eq!(event.status, "CONFIRMED"); - } - - #[test] - fn test_ical_parsing_missing_fields() { - let minimal_ical = r#"BEGIN:VCALENDAR -VERSION:2.0 -BEGIN:VEVENT -UID:minimal123 -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -END:VEVENT -END:VCALENDAR"#; - - let client = CalDavClient::new(create_test_server_config()).unwrap(); - let result = client.parse_simple_ical_event("minimal123", minimal_ical); - assert!(result.is_ok()); - - let event = result.unwrap(); - assert_eq!(event.id, "minimal123"); - assert_eq!(event.summary, "Untitled Event"); // Default value - assert_eq!(event.description, None); // Not present - assert_eq!(event.location, None); // Not present - assert_eq!(event.status, "CONFIRMED"); // Default value - } - - #[test] - fn test_event_info_structure() { - let event_info = CalDavEventInfo { - id: "test123".to_string(), - summary: "Test Event".to_string(), - description: Some("Test description".to_string()), - start: DateTime::parse_from_rfc3339("2024-10-15T14:00:00Z").unwrap().with_timezone(&Utc), - end: DateTime::parse_from_rfc3339("2024-10-15T15:00:00Z").unwrap().with_timezone(&Utc), - location: Some("Test Location".to_string()), - status: "CONFIRMED".to_string(), - etag: Some("test-etag-123".to_string()), - ical_data: "BEGIN:VCALENDAR\r\n...".to_string(), - }; - - assert_eq!(event_info.id, "test123"); - assert_eq!(event_info.summary, "Test Event"); - assert_eq!(event_info.description, Some("Test description".to_string())); - assert_eq!(event_info.location, Some("Test Location".to_string())); - assert_eq!(event_info.status, "CONFIRMED"); - assert_eq!(event_info.etag, Some("test-etag-123".to_string())); - } - - #[test] - fn test_event_info_serialization() { - let event_info = CalDavEventInfo { - id: "serialize-test".to_string(), - summary: "Serialization Test".to_string(), - description: None, - start: Utc::now(), - end: Utc::now() + chrono::Duration::hours(1), - location: None, - status: "TENTATIVE".to_string(), - etag: None, - ical_data: "BEGIN:VCALENDAR...".to_string(), - }; - - // Test serialization for state storage - let serialized = serde_json::to_string(&event_info); - assert!(serialized.is_ok()); - - let deserialized: Result = serde_json::from_str(&serialized.unwrap()); - assert!(deserialized.is_ok()); - - let restored = deserialized.unwrap(); - assert_eq!(restored.id, event_info.id); - assert_eq!(restored.summary, event_info.summary); - assert_eq!(restored.status, event_info.status); - } - } - - mod integration { - use super::*; - - #[test] - fn test_client_with_real_config() { - let config = ServerConfig { - url: "https://apidata.googleusercontent.com/caldav/v2/testuser@gmail.com/events/".to_string(), - username: "testuser@gmail.com".to_string(), - password: "app-password".to_string(), - use_https: true, - timeout: 30, - headers: None, - }; - - let client = CalDavClient::new(config); - assert!(client.is_ok()); - - let client = client.unwrap(); - assert_eq!(client.config.url, "https://apidata.googleusercontent.com/caldav/v2/testuser@gmail.com/events/"); - assert!(client.base_url.as_str().contains("googleusercontent.com")); - } - - #[test] - fn test_url_handling() { - let mut config = create_test_server_config(); - - // Test URL without trailing slash - config.url = "https://caldav.test.com/calendars".to_string(); - let client = CalDavClient::new(config).unwrap(); - assert_eq!(client.base_url.as_str(), "https://caldav.test.com/calendars/"); - - // Test URL with trailing slash - let config = ServerConfig { - url: "https://caldav.test.com/calendars/".to_string(), - ..create_test_server_config() - }; - let client = CalDavClient::new(config).unwrap(); - assert_eq!(client.base_url.as_str(), "https://caldav.test.com/calendars/"); - } - - #[tokio::test] - async fn test_mock_calendar_workflow() { - // This test simulates the complete calendar discovery workflow - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Simulate parsing calendar list response - let calendars = client.parse_calendar_list(MOCK_CALENDAR_XML).unwrap(); - - // Since parse_calendar_list is a placeholder, we'll validate the structure - // and ensure no panics occur during parsing - assert!(calendars.len() >= 0); // Should not panic - - // Validate that the client can handle the XML structure - let result = client.parse_calendar_list("invalid xml"); - assert!(result.is_ok()); // Should handle gracefully - } - - #[tokio::test] - async fn test_mock_event_workflow() { - // This test simulates the complete event retrieval workflow - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Simulate parsing events response - let events = client.parse_events(MOCK_EVENTS_XML).unwrap(); - - // Validate that events were parsed correctly - assert_eq!(events.len(), 2); - - // Validate event data integrity - for event in &events { - assert!(!event.id.is_empty()); - assert!(!event.summary.is_empty()); - assert!(event.start <= event.end); - assert!(!event.status.is_empty()); - } - } - } - - mod error_handling { - use super::*; - - #[test] - fn test_malformed_xml_handling() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test with completely malformed XML - let malformed_xml = "This is not XML at all"; - let result = client.parse_events(malformed_xml); - // Should not panic and should return empty result - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 0); - } - - #[test] - fn test_partially_malformed_xml() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test XML with some valid and some invalid parts - let partial_xml = r#" - - - /event1.ics - - - BEGIN:VCALENDAR -BEGIN:VEVENT -UID:event1 -SUMMARY:Event 1 -DTSTART:20241015T140000Z -DTEND:20241015T150000Z -END:VEVENT -END:VCALENDAR - - - - - /event2.ics - - -"#; - - let result = client.parse_events(partial_xml); - // Should handle gracefully and parse what it can - assert!(result.is_ok()); - let events = result.unwrap(); - // Should parse at least the valid event - assert!(events.len() >= 0); - } - - #[test] - fn test_empty_icalendar_data() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - let empty_ical_xml = r#" - - - /empty-event.ics - - - - - - -"#; - - let result = client.parse_events(empty_ical_xml); - assert!(result.is_ok()); - let events = result.unwrap(); - // Should handle empty calendar data gracefully - assert_eq!(events.len(), 0); - } - - #[test] - fn test_invalid_datetime_formats() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test various invalid datetime formats - let invalid_formats = vec![ - "invalid", - "2024-10-15", // Wrong format - "20241015", // Missing time - "T140000Z", // Missing date - "20241015140000", // Missing Z - "20241015T25:00:00Z", // Invalid hour - ]; - - for invalid_format in invalid_formats { - let result = client.parse_datetime(invalid_format); - // Should handle gracefully with current time fallback - assert!(result.is_ok()); - } - } - } - - mod performance { - use super::*; - - #[test] - fn test_large_event_parsing() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Create a large XML response with many events - let mut large_xml = String::from(r#" -"#); - - for i in 0..100 { - large_xml.push_str(&format!(r#" - - /event{}.ics - - - BEGIN:VCALENDAR -BEGIN:VEVENT -UID:event{} -SUMMARY:Event {} -DTSTART:20241015T{:02}0000Z -DTEND:20241015T{:02}0000Z -END:VEVENT -END:VCALENDAR - - - "#, i, i, i, i % 24, (i + 1) % 24)); - } - - large_xml.push_str("\n"); - - let start = std::time::Instant::now(); - let result = client.parse_events(&large_xml); - let duration = start.elapsed(); - - assert!(result.is_ok()); - let events = result.unwrap(); - assert_eq!(events.len(), 100); - - // Performance assertion - should parse 100 events in reasonable time - assert!(duration.as_millis() < 1000, "Parsing 100 events took too long: {:?}", duration); - } - - #[test] - fn test_memory_usage() { - let client = CalDavClient::new(create_test_server_config()).unwrap(); - - // Test that parsing doesn't leak memory or use excessive memory - let xml_with_repeating_data = MOCK_EVENTS_XML.repeat(10); - - let result = client.parse_events(&xml_with_repeating_data); - assert!(result.is_ok()); - - let events = result.unwrap(); - assert_eq!(events.len(), 20); // 2 events * 10 repetitions - - // Verify all events have valid data - for event in &events { - assert!(!event.id.is_empty()); - assert!(!event.summary.is_empty()); - assert!(event.start <= event.end); - } - } - } - - // Keep the original test #[test] fn test_client_creation() { let config = ServerConfig { diff --git a/src/calendar_filter.rs b/src/calendar_filter.rs index 5caf930..e08fd53 100644 --- a/src/calendar_filter.rs +++ b/src/calendar_filter.rs @@ -23,11 +23,6 @@ impl Default for CalendarFilter { } impl CalendarFilter { - /// Check if the filter is enabled (has any rules) - pub fn is_enabled(&self) -> bool { - !self.rules.is_empty() - } - /// Create a new calendar filter pub fn new(match_any: bool) -> Self { Self { diff --git a/src/config.rs b/src/config.rs index 3456130..b23b4a2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,16 +7,14 @@ use anyhow::Result; /// Main configuration structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { - /// Source server configuration (primary CalDAV server) + /// Server configuration pub server: ServerConfig, - /// Source calendar configuration + /// Calendar configuration pub calendar: CalendarConfig, /// Filter configuration pub filters: Option, /// Sync configuration pub sync: SyncConfig, - /// Import configuration (for unidirectional import to target) - pub import: Option, } /// Server connection configuration @@ -41,12 +39,12 @@ pub struct ServerConfig { pub struct CalendarConfig { /// Calendar name/path pub name: String, - /// Calendar display name (optional - will be discovered from server if not specified) + /// Calendar display name pub display_name: Option, - /// Calendar color (optional - will be discovered from server if not specified) + /// Calendar color pub color: Option, - /// Calendar timezone (optional - will be discovered from server if not specified) - pub timezone: Option, + /// Calendar timezone + pub timezone: String, /// Whether to sync this calendar pub enabled: bool, } @@ -79,38 +77,6 @@ pub struct SyncConfig { pub retry_delay: u64, /// Whether to delete events not found on server pub delete_missing: bool, - /// Date range configuration - pub date_range: DateRangeConfig, -} - -/// Date range configuration for event synchronization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DateRangeConfig { - /// Number of days ahead to sync - pub days_ahead: i64, - /// Number of days in the past to sync - pub days_back: i64, - /// Whether to sync all events regardless of date - pub sync_all_events: bool, -} - -/// Import configuration for unidirectional sync to target server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportConfig { - /// Target server configuration - pub target_server: ServerConfig, - /// Target calendar configuration - pub target_calendar: CalendarConfig, - /// Whether to overwrite existing events in target - pub overwrite_existing: bool, - /// Whether to delete events in target that are missing from source - pub delete_missing: bool, - /// Whether to run in dry-run mode (preview changes only) - pub dry_run: bool, - /// Batch size for import operations - pub batch_size: usize, - /// Whether to create target calendar if it doesn't exist - pub create_target_calendar: bool, } impl Default for Config { @@ -120,7 +86,6 @@ impl Default for Config { calendar: CalendarConfig::default(), filters: None, sync: SyncConfig::default(), - import: None, } } } @@ -144,7 +109,7 @@ impl Default for CalendarConfig { name: "calendar".to_string(), display_name: None, color: None, - timezone: None, + timezone: "UTC".to_string(), enabled: true, } } @@ -158,17 +123,6 @@ impl Default for SyncConfig { max_retries: 3, retry_delay: 5, delete_missing: false, - date_range: DateRangeConfig::default(), - } - } -} - -impl Default for DateRangeConfig { - fn default() -> Self { - Self { - days_ahead: 7, // Next week - days_back: 0, // Today only - sync_all_events: false, } } } @@ -204,22 +158,6 @@ impl Config { if let Ok(calendar) = std::env::var("CALDAV_CALENDAR") { config.calendar.name = calendar; } - - // Override target server settings for import - if let Some(ref mut import_config) = config.import { - if let Ok(target_url) = std::env::var("CALDAV_TARGET_URL") { - import_config.target_server.url = target_url; - } - if let Ok(target_username) = std::env::var("CALDAV_TARGET_USERNAME") { - import_config.target_server.username = target_username; - } - if let Ok(target_password) = std::env::var("CALDAV_TARGET_PASSWORD") { - import_config.target_server.password = target_password; - } - if let Ok(target_calendar) = std::env::var("CALDAV_TARGET_CALENDAR") { - import_config.target_calendar.name = target_calendar; - } - } Ok(config) } @@ -238,23 +176,6 @@ impl Config { if self.calendar.name.is_empty() { anyhow::bail!("Calendar name cannot be empty"); } - - // Validate import configuration if present - if let Some(import_config) = &self.import { - if import_config.target_server.url.is_empty() { - anyhow::bail!("Target server URL cannot be empty when import is enabled"); - } - if import_config.target_server.username.is_empty() { - anyhow::bail!("Target server username cannot be empty when import is enabled"); - } - if import_config.target_server.password.is_empty() { - anyhow::bail!("Target server password cannot be empty when import is enabled"); - } - if import_config.target_calendar.name.is_empty() { - anyhow::bail!("Target calendar name cannot be empty when import is enabled"); - } - } - Ok(()) } } diff --git a/src/error.rs b/src/error.rs index 5620254..2ab655b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,9 +10,6 @@ pub type CalDavResult = Result; pub enum CalDavError { #[error("Configuration error: {0}")] Config(String), - - #[error("Configuration error: {0}")] - ConfigurationError(String), #[error("Authentication failed: {0}")] Authentication(String), @@ -43,9 +40,6 @@ pub enum CalDavError { #[error("Event not found: {0}")] EventNotFound(String), - - #[error("Not found: {0}")] - NotFound(String), #[error("Synchronization error: {0}")] Sync(String), @@ -77,14 +71,8 @@ pub enum CalDavError { #[error("Timeout error: operation timed out after {0} seconds")] Timeout(u64), - #[error("Invalid format: {0}")] - InvalidFormat(String), - #[error("Unknown error: {0}")] Unknown(String), - - #[error("Anyhow error: {0}")] - Anyhow(#[from] anyhow::Error), } impl CalDavError { @@ -136,6 +124,11 @@ mod tests { #[test] fn test_error_retryable() { + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(network_error.is_retryable()); + let auth_error = CalDavError::Authentication("Invalid credentials".to_string()); assert!(!auth_error.is_retryable()); @@ -147,6 +140,11 @@ mod tests { fn test_retry_delay() { let rate_limit_error = CalDavError::RateLimited(120); assert_eq!(rate_limit_error.retry_delay(), Some(120)); + + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert_eq!(network_error.retry_delay(), Some(5)); } #[test] @@ -156,5 +154,11 @@ mod tests { let config_error = CalDavError::Config("Invalid".to_string()); assert!(config_error.is_config_error()); + + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(!network_error.is_auth_error()); + assert!(!network_error.is_config_error()); } } diff --git a/src/lib.rs b/src/lib.rs index f53953e..650f25a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,20 +5,20 @@ pub mod config; pub mod error; -pub mod sync; +pub mod caldav_client; +pub mod event; pub mod timezone; pub mod calendar_filter; -pub mod event; -pub mod caldav_client; +pub mod sync; // Re-export main types for convenience -pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig, ImportConfig}; +pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig}; pub use error::{CalDavError, CalDavResult}; -pub use sync::{SyncEngine, SyncResult, SyncState, SyncStats, ImportState, ImportResult, ImportAction, ImportError}; +pub use caldav_client::CalDavClient; +pub use event::{Event, EventStatus, EventType}; pub use timezone::TimezoneHandler; pub use calendar_filter::{CalendarFilter, FilterRule}; -pub use event::Event; -pub use caldav_client::CalDavClient; +pub use sync::{SyncEngine, SyncResult}; /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/main.rs b/src/main.rs index dc181c1..ed36b74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,8 @@ use anyhow::Result; use clap::Parser; use tracing::{info, warn, error, Level}; use tracing_subscriber; -use caldav_sync::{Config, CalDavResult, SyncEngine}; +use caldav_sync::{Config, SyncEngine, CalDavResult}; use std::path::PathBuf; -use chrono::{Utc, Duration}; #[derive(Parser)] #[command(name = "caldav-sync")] @@ -12,7 +11,7 @@ use chrono::{Utc, Duration}; #[command(version)] struct Cli { /// Configuration file path - #[arg(short, long, default_value = "config/config.toml")] + #[arg(short, long, default_value = "config/default.toml")] config: PathBuf, /// CalDAV server URL (overrides config file) @@ -46,56 +45,6 @@ struct Cli { /// List events and exit #[arg(long)] list_events: bool, - - /// List available calendars and exit - #[arg(long)] - list_calendars: bool, - - /// Use specific CalDAV approach (report-simple, propfind-depth, simple-propfind, multiget, report-filter, ical-export, zoho-export, zoho-events-list, zoho-events-direct) - #[arg(long)] - approach: Option, - - /// Use specific calendar URL instead of discovering from config - #[arg(long)] - calendar_url: Option, - - // ==================== IMPORT COMMANDS ==================== - - /// Import events from source to target calendar - #[arg(long)] - import: bool, - - /// Preview import without making changes (dry run) - #[arg(long)] - dry_run: bool, - - /// Perform full import (ignore import state, import all events) - #[arg(long)] - full_import: bool, - - /// Show import status and statistics - #[arg(long)] - import_status: bool, - - /// Reset import state (for full re-import) - #[arg(long)] - reset_import_state: bool, - - /// Target server URL for import (overrides config file) - #[arg(long)] - target_server_url: Option, - - /// Target username for import authentication (overrides config file) - #[arg(long)] - target_username: Option, - - /// Target password for import authentication (overrides config file) - #[arg(long)] - target_password: Option, - - /// Target calendar name for import (overrides config file) - #[arg(long)] - target_calendar: Option, } #[tokio::main] @@ -139,22 +88,6 @@ async fn main() -> Result<()> { config.calendar.name = calendar.clone(); } - // Override import configuration with command line arguments - if let Some(ref mut import_config) = &mut config.import { - if let Some(ref target_server_url) = cli.target_server_url { - import_config.target_server.url = target_server_url.clone(); - } - if let Some(ref target_username) = cli.target_username { - import_config.target_server.username = target_username.clone(); - } - if let Some(ref target_password) = cli.target_password { - import_config.target_server.password = target_password.clone(); - } - if let Some(ref target_calendar) = cli.target_calendar { - import_config.target_calendar.name = target_calendar.clone(); - } - } - // Validate configuration if let Err(e) = config.validate() { error!("Configuration validation failed: {}", e); @@ -183,173 +116,10 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { // Create sync engine let mut sync_engine = SyncEngine::new(config.clone()).await?; - // ==================== IMPORT COMMANDS ==================== - - if cli.import_status { - // Show import status and statistics - info!("Showing import status and statistics"); - - if let Some(import_state) = sync_engine.get_import_status() { - println!("Import Status:"); - println!(" Last Import: {:?}", import_state.last_import); - println!(" Total Imported: {}", import_state.total_imported); - println!(" Imported Events: {}", import_state.imported_events.len()); - println!(" Failed Imports: {}", import_state.failed_imports.len()); - - if let Some(last_import) = import_state.last_import { - let duration = Utc::now() - last_import; - println!(" Time Since Last Import: {} minutes", duration.num_minutes()); - } - - println!("\nImport Statistics:"); - println!(" Total Processed: {}", import_state.stats.total_processed); - println!(" Successful Imports: {}", import_state.stats.successful_imports); - println!(" Updated Events: {}", import_state.stats.updated_events); - println!(" Skipped Events: {}", import_state.stats.skipped_events); - println!(" Failed Imports: {}", import_state.stats.failed_imports); - - let duration_ms = import_state.stats.last_import_duration_ms; - println!(" Last Import Duration: {}ms", duration_ms); - - // Show recent failed imports - if !import_state.failed_imports.is_empty() { - println!("\nRecent Failed Imports:"); - for (event_id, error) in import_state.failed_imports.iter().take(5) { - println!(" Event {}: {}", event_id, error); - } - if import_state.failed_imports.len() > 5 { - println!(" ... and {} more", import_state.failed_imports.len() - 5); - } - } - } else { - println!("Import not configured or no import state available."); - println!("Please configure import settings in your configuration file."); - } - - return Ok(()); - } - - if cli.reset_import_state { - // Reset import state - info!("Resetting import state"); - sync_engine.reset_import_state(); - println!("Import state has been reset. Next import will process all events."); - return Ok(()); - } - - if cli.import { - // Perform import from source to target - info!("Starting import from source to target calendar"); - let dry_run = cli.dry_run; - let full_import = cli.full_import; - - if dry_run { - println!("DRY RUN MODE: No changes will be made to target calendar"); - } - - match sync_engine.import_events(dry_run, full_import).await { - Ok(result) => { - println!("Import completed successfully!"); - println!(" Events Processed: {}", result.events_processed); - println!(" Events Imported: {}", result.events_imported); - println!(" Events Updated: {}", result.events_updated); - println!(" Events Skipped: {}", result.events_skipped); - println!(" Events Failed: {}", result.events_failed); - - if full_import { - println!(" Full Import: Processed all events from source"); - } - - if !result.errors.is_empty() { - println!("\nFailed Imports:"); - for error in &result.errors { - println!(" Error: {}", error); - } - } - - if dry_run { - println!("\nThis was a dry run. No actual changes were made."); - println!("Run without --dry-run to perform the actual import."); - } - } - Err(e) => { - error!("Import failed: {}", e); - return Err(e); - } - } - - return Ok(()); - } - - // ==================== EXISTING COMMANDS ==================== - - if cli.list_calendars { - // List calendars and exit - info!("Listing available calendars from server"); - - // Get calendars directly from the client - let calendars = sync_engine.client.list_calendars().await?; - println!("Found {} calendars:", calendars.len()); - - for (i, calendar) in calendars.iter().enumerate() { - println!(" {}. {}", i + 1, calendar.display_name); - println!(" Path: {}", calendar.path); - if let Some(ref description) = calendar.description { - println!(" Description: {}", description); - } - if let Some(ref color) = calendar.color { - println!(" Color: {}", color); - } - println!(" Supported Components: {}", calendar.supported_components.join(", ")); - println!(); - } - - return Ok(()); - } - if cli.list_events { // List events and exit info!("Listing events from calendar: {}", config.calendar.name); - // Use the specific approach if provided - if let Some(ref approach) = cli.approach { - info!("Using specific approach: {}", approach); - - // Use the provided calendar URL if available, otherwise list calendars - let calendar_path = if let Some(ref url) = cli.calendar_url { - url.clone() - } else { - let calendars = sync_engine.client.list_calendars().await?; - if let Some(calendar) = calendars.iter().find(|c| c.path == config.calendar.name || c.display_name == config.calendar.name) { - calendar.path.clone() - } else { - warn!("Calendar '{}' not found", config.calendar.name); - return Ok(()); - } - }; - - let now = Utc::now(); - let start_date = now - Duration::days(30); - let end_date = now + Duration::days(30); - - match sync_engine.client.get_events(&calendar_path, start_date, end_date).await { - Ok(events) => { - println!("Found {} events using approach {}:", events.len(), approach); - for event in events { - println!(" - {} ({} to {})", - event.summary, - event.start.format("%Y-%m-%d %H:%M"), - event.end.format("%Y-%m-%d %H:%M") - ); - } - } - Err(e) => { - error!("Failed to get events with approach {}: {}", approach, e); - } - } - return Ok(()); - } - // Perform a sync to get events let sync_result = sync_engine.sync_full().await?; info!("Sync completed: {} events processed", sync_result.events_processed); diff --git a/src/minicaldav_client.rs b/src/minicaldav_client.rs deleted file mode 100644 index 6f3e2d7..0000000 --- a/src/minicaldav_client.rs +++ /dev/null @@ -1,872 +0,0 @@ -//! Direct HTTP-based CalDAV client implementation - -use anyhow::Result; -use reqwest::{Client, header}; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc, TimeZone}; -use tracing::{debug, info, warn}; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use std::time::Duration; -use std::collections::HashMap; - -pub struct Config { - pub server: ServerConfig, -} - -pub struct ServerConfig { - pub url: String, - pub username: String, - pub password: String, -} - -/// CalDAV client using direct HTTP requests -pub struct RealCalDavClient { - client: Client, - base_url: String, - username: String, -} - -impl RealCalDavClient { - /// Create a new CalDAV client with authentication - pub async fn new(base_url: &str, username: &str, password: &str) -> Result { - info!("Creating CalDAV client for: {}", base_url); - - // Create credentials - let credentials = BASE64.encode(format!("{}:{}", username, password)); - - // Build client with proper authentication - let mut headers = header::HeaderMap::new(); - headers.insert( - header::USER_AGENT, - header::HeaderValue::from_static("caldav-sync/0.1.0"), - ); - headers.insert( - header::ACCEPT, - header::HeaderValue::from_static("text/calendar, text/xml, application/xml"), - ); - headers.insert( - header::AUTHORIZATION, - header::HeaderValue::from_str(&format!("Basic {}", credentials)) - .map_err(|e| anyhow::anyhow!("Invalid authorization header: {}", e))?, - ); - - let client = Client::builder() - .default_headers(headers) - .timeout(Duration::from_secs(30)) - .build() - .map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))?; - - debug!("CalDAV client created successfully"); - - Ok(Self { - client, - base_url: base_url.to_string(), - username: username.to_string(), - }) - } - - /// Create a new client from configuration - pub async fn from_config(config: &Config) -> Result { - let base_url = &config.server.url; - let username = &config.server.username; - let password = &config.server.password; - - Self::new(base_url, username, password).await - } - - /// Discover calendars on the server using PROPFIND - pub async fn discover_calendars(&self) -> Result> { - info!("Discovering calendars for user: {}", self.username); - - // Create PROPFIND request to discover calendars - let propfind_xml = r#" - - - - - - - - "#; - - let response = self.client - .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self.base_url) - .header("Depth", "1") - .header("Content-Type", "application/xml") - .body(propfind_xml) - .send() - .await?; - - if response.status().as_u16() != 207 { - return Err(anyhow::anyhow!("PROPFIND failed with status: {}", response.status())); - } - - let response_text = response.text().await?; - debug!("PROPFIND response: {}", response_text); - - // Parse XML response to extract calendar information - let calendars = self.parse_calendar_response(&response_text)?; - - info!("Found {} calendars", calendars.len()); - Ok(calendars) - } - - /// Get events from a specific calendar using REPORT - pub async fn get_events(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime) -> Result> { - self.get_events_with_approach(calendar_href, start_date, end_date, None).await - } - - /// Get events using a specific approach - pub async fn get_events_with_approach(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime, approach: Option) -> Result> { - info!("Getting events from calendar: {} between {} and {} (approach: {:?})", - calendar_href, - start_date.format("%Y-%m-%d %H:%M:%S UTC"), - end_date.format("%Y-%m-%d %H:%M:%S UTC"), - approach); - - // Try multiple CalDAV query approaches - let all_approaches = vec![ - // Standard calendar-query with time-range - (r#" - - - - - - - - - - - - -"#, "calendar-query"), - ]; - - // Filter approaches if a specific one is requested - let approaches = if let Some(ref req_approach) = approach { - all_approaches.into_iter() - .filter(|(_, name)| name == req_approach) - .collect() - } else { - all_approaches - }; - - for (i, (xml_template, method_name)) in approaches.iter().enumerate() { - info!("Trying approach {}: {}", i + 1, method_name); - - let report_xml = if xml_template.contains("{start}") && xml_template.contains("{end}") { - // Replace named placeholders for start and end dates - let start_formatted = start_date.format("%Y%m%dT000000Z").to_string(); - let end_formatted = end_date.format("%Y%m%dT000000Z").to_string(); - xml_template - .replace("{start}", &start_formatted) - .replace("{end}", &end_formatted) - } else { - xml_template.to_string() - }; - - info!("Request XML: {}", report_xml); - - let method = if method_name.contains("propfind") { - reqwest::Method::from_bytes(b"PROPFIND").unwrap() - } else if method_name.contains("zoho-export") || method_name.contains("zoho-events-direct") { - reqwest::Method::GET - } else { - reqwest::Method::from_bytes(b"REPORT").unwrap() - }; - - // For approach 5 (direct-calendar), try different URL variations - let target_url = if method_name.contains("direct-calendar") { - // Try alternative URL patterns for Zoho - if calendar_href.ends_with('/') { - format!("{}?export", calendar_href.trim_end_matches('/')) - } else { - format!("{}/?export", calendar_href) - } - } else if method_name.contains("zoho-export") { - // Zoho-specific export endpoint - if calendar_href.ends_with('/') { - format!("{}export?format=ics", calendar_href.trim_end_matches('/')) - } else { - format!("{}/export?format=ics", calendar_href) - } - } else if method_name.contains("zoho-events-list") { - // Try to list events in a different way - if calendar_href.ends_with('/') { - format!("{}events/", calendar_href) - } else { - format!("{}/events/", calendar_href) - } - } else if method_name.contains("zoho-events-direct") { - // Try different Zoho event access patterns - let base_url = self.base_url.trim_end_matches('/'); - if calendar_href.contains("/caldav/user/") { - let username_part = calendar_href.split("/caldav/user/").nth(1).unwrap_or(""); - format!("{}/caldav/events/{}", base_url, username_part.trim_end_matches('/')) - } else { - calendar_href.to_string() - } - } else { - calendar_href.to_string() - }; - - let response = self.client - .request(method, &target_url) - .header("Depth", "1") - .header("Content-Type", "application/xml") - .header("User-Agent", "caldav-sync/0.1.0") - .body(report_xml) - .send() - .await?; - - let status = response.status(); - let status_code = status.as_u16(); - info!("Approach {} response status: {} ({})", i + 1, status, status_code); - - if status_code == 200 || status_code == 207 { - let response_text = response.text().await?; - info!("Approach {} response length: {} characters", i + 1, response_text.len()); - - if !response_text.trim().is_empty() { - info!("Approach {} got non-empty response", i + 1); - debug!("Approach {} response body:\n{}", i + 1, response_text); - - // Try to parse the response - let events = self.parse_events_response(&response_text, calendar_href).await?; - if !events.is_empty() || !method_name.contains("filter") { - info!("Successfully parsed {} events using approach {}", events.len(), i + 1); - return Ok(events); - } - } else { - info!("Approach {} got empty response", i + 1); - } - } else { - info!("Approach {} failed with status: {}", i + 1, status); - } - } - - warn!("All approaches failed, returning empty result"); - Ok(vec![]) - } - - /// Parse PROPFIND response to extract calendar information - fn parse_calendar_response(&self, xml: &str) -> Result> { - // Simple XML parsing - in a real implementation, use a proper XML parser - let mut calendars = Vec::new(); - - // Extract href from the XML response - let href = if xml.contains("") { - // Extract href from XML - if let Some(start) = xml.find("") { - if let Some(end) = xml.find("") { - let href_content = &xml[start + 9..end]; - href_content.to_string() - } else { - self.base_url.clone() - } - } else { - self.base_url.clone() - } - } else { - self.base_url.clone() - }; - - // For now, use the href as both name and derive display name from it - // In a real implementation, we would parse displayname property from XML - let display_name = self.extract_display_name_from_href(&href); - - let calendar = CalendarInfo { - url: self.base_url.clone(), - name: href.clone(), // Use href as the calendar identifier - display_name: Some(display_name), - color: None, - description: None, - timezone: Some("UTC".to_string()), - supported_components: vec!["VEVENT".to_string()], - }; - - calendars.push(calendar); - - Ok(calendars) - } - - /// Parse REPORT response to extract calendar events - async fn parse_events_response(&self, xml: &str, calendar_href: &str) -> Result> { - // Check if response is empty - if xml.trim().is_empty() { - info!("Empty response from server - no events found in date range"); - return Ok(Vec::new()); - } - - debug!("Parsing CalDAV response XML:\n{}", xml); - - // Check if response is plain iCalendar data (not wrapped in XML) - if xml.starts_with("BEGIN:VCALENDAR") { - info!("Response contains plain iCalendar data"); - return self.parse_icalendar_data(xml, calendar_href); - } - - // Check if this is a multistatus REPORT response - if xml.contains("") { - return self.parse_multistatus_response(xml, calendar_href).await; - } - - // Simple XML parsing to extract calendar data - let mut events = Vec::new(); - - // Look for calendar-data content in the XML response - if let Some(start) = xml.find("") { - if let Some(end) = xml.find("") { - let ical_data = &xml[start + 17..end]; - debug!("Found iCalendar data: {}", ical_data); - - // Parse the iCalendar data - if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { - events.extend(parsed_events); - } else { - warn!("Failed to parse iCalendar data, falling back to mock"); - return self.create_mock_event(calendar_href); - } - } else { - debug!("No calendar-data closing tag found"); - return self.create_mock_event(calendar_href); - } - } else { - debug!("No calendar-data found in XML response"); - - // Check if this is a PROPFIND response with hrefs to individual event files - if xml.contains("") && xml.contains(".ics") { - return self.parse_propfind_response(xml, calendar_href).await; - } - - // If no calendar-data but we got hrefs, try to fetch individual .ics files - if xml.contains("") { - return self.parse_propfind_response(xml, calendar_href).await; - } - - return self.create_mock_event(calendar_href); - } - - info!("Parsed {} real events from CalDAV response", events.len()); - Ok(events) - } - - /// Parse multistatus response from REPORT request - async fn parse_multistatus_response(&self, xml: &str, calendar_href: &str) -> Result> { - let mut events = Vec::new(); - - // Parse multi-status response - let mut start_pos = 0; - while let Some(response_start) = xml[start_pos..].find("") { - let absolute_start = start_pos + response_start; - if let Some(response_end) = xml[absolute_start..].find("") { - let absolute_end = absolute_start + response_end; - let response_content = &xml[absolute_start..absolute_end + 14]; - - // Extract href - if let Some(href_start) = response_content.find("") { - if let Some(href_end) = response_content.find("") { - let href_content = &response_content[href_start + 9..href_end]; - - // Check if this is a .ics file event (not the calendar collection itself) - if href_content.contains(".ics") { - info!("Found event href: {}", href_content); - - // Try to fetch the individual event - match self.fetch_single_event(href_content, calendar_href).await { - Ok(Some(event)) => events.push(event), - Ok(None) => warn!("Failed to get event data for {}", href_content), - Err(e) => warn!("Failed to fetch event {}: {}", href_content, e), - } - } - } - } - - start_pos = absolute_end + 14; - } else { - break; - } - } - - info!("Parsed {} real events from multistatus response", events.len()); - Ok(events) - } - - /// Parse iCalendar data into CalendarEvent structs - fn parse_icalendar_data(&self, ical_data: &str, calendar_href: &str) -> Result> { - let mut events = Vec::new(); - - // Handle iCalendar line folding (unfold continuation lines) - let unfolded_data = self.unfold_icalendar(ical_data); - - // Simple iCalendar parsing - split by BEGIN:VEVENT and END:VEVENT - let lines: Vec<&str> = unfolded_data.lines().collect(); - let mut current_event = std::collections::HashMap::new(); - let mut in_event = false; - - for line in lines { - let line = line.trim(); - - if line == "BEGIN:VEVENT" { - in_event = true; - current_event.clear(); - continue; - } - - if line == "END:VEVENT" { - if in_event && !current_event.is_empty() { - if let Ok(event) = self.build_calendar_event(¤t_event, calendar_href) { - events.push(event); - } - } - in_event = false; - continue; - } - - if in_event && line.contains(':') { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 { - current_event.insert(parts[0].to_string(), parts[1].to_string()); - } - } - - // Handle timezone parameters (e.g., DTSTART;TZID=America/New_York:20240315T100000) - if in_event && line.contains(';') && line.contains(':') { - // Parse properties with parameters like DTSTART;TZID=... - if let Some(semi_pos) = line.find(';') { - if let Some(colon_pos) = line.find(':') { - if semi_pos < colon_pos { - let property_name = &line[..semi_pos]; - let params_part = &line[semi_pos + 1..colon_pos]; - let value = &line[colon_pos + 1..]; - - // Extract TZID parameter if present - let tzid = if params_part.contains("TZID=") { - if let Some(tzid_start) = params_part.find("TZID=") { - let tzid_value = ¶ms_part[tzid_start + 5..]; - Some(tzid_value.to_string()) - } else { - None - } - } else { - None - }; - - // Store the main property - current_event.insert(property_name.to_string(), value.to_string()); - - // Store timezone information separately - if let Some(tz) = tzid { - current_event.insert(format!("{}_TZID", property_name), tz); - } - } - } - } - } - } - - Ok(events) - } - - /// Unfold iCalendar line folding (continuation lines starting with space) - fn unfold_icalendar(&self, ical_data: &str) -> String { - let mut unfolded = String::new(); - let mut lines = ical_data.lines().peekable(); - - while let Some(line) = lines.next() { - let line = line.trim_end(); - unfolded.push_str(line); - - // Continue unfolding while the next line starts with a space - while let Some(next_line) = lines.peek() { - let next_line = next_line.trim_start(); - if next_line.starts_with(' ') || next_line.starts_with('\t') { - // Remove the leading space and append - let folded_line = lines.next().unwrap().trim_start(); - unfolded.push_str(&folded_line[1..]); - } else { - break; - } - } - - unfolded.push('\n'); - } - - unfolded - } - - /// Build a CalendarEvent from parsed iCalendar properties - fn build_calendar_event(&self, properties: &HashMap, calendar_href: &str) -> Result { - let now = Utc::now(); - - // Extract basic properties - let uid = properties.get("UID").cloned().unwrap_or_else(|| format!("event-{}", now.timestamp())); - let summary = properties.get("SUMMARY").cloned().unwrap_or_else(|| "Untitled Event".to_string()); - let description = properties.get("DESCRIPTION").cloned(); - let location = properties.get("LOCATION").cloned(); - let status = properties.get("STATUS").cloned(); - - // Parse dates - let (start, end) = self.parse_event_dates(properties)?; - - // Extract timezone information - let start_tzid = properties.get("DTSTART_TZID").cloned(); - let end_tzid = properties.get("DTEND_TZID").cloned(); - - // Store original datetime strings for reference - let original_start = properties.get("DTSTART").cloned(); - let original_end = properties.get("DTEND").cloned(); - - let event = CalendarEvent { - id: uid.clone(), - href: format!("{}/{}.ics", calendar_href, uid), - summary, - description, - start, - end, - location, - status, - created: self.parse_datetime(properties.get("CREATED").map(|s| s.as_str())), - last_modified: self.parse_datetime(properties.get("LAST-MODIFIED").map(|s| s.as_str())), - sequence: properties.get("SEQUENCE") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0), - transparency: properties.get("TRANSP").cloned(), - uid: Some(uid), - recurrence_id: self.parse_datetime(properties.get("RECURRENCE-ID").map(|s| s.as_str())), - etag: None, - // Enhanced timezone information - start_tzid, - end_tzid, - original_start, - original_end, - }; - - Ok(event) - } - - /// Parse start and end dates from event properties - fn parse_event_dates(&self, properties: &HashMap) -> Result<(DateTime, DateTime)> { - let start = self.parse_datetime(properties.get("DTSTART").map(|s| s.as_str())) - .unwrap_or_else(Utc::now); - - let end = if let Some(dtend) = properties.get("DTEND") { - self.parse_datetime(Some(dtend)).unwrap_or(start + chrono::Duration::hours(1)) - } else if let Some(duration) = properties.get("DURATION") { - self.parse_duration(&duration).map(|d| start + d).unwrap_or(start + chrono::Duration::hours(1)) - } else { - start + chrono::Duration::hours(1) - }; - - Ok((start, end)) - } - - /// Parse datetime from iCalendar format - fn parse_datetime(&self, dt_str: Option<&str>) -> Option> { - let dt_str = dt_str?; - - // Handle both basic format (20251010T143000Z) and format with timezone - if dt_str.ends_with('Z') { - // UTC time - let cleaned = dt_str.replace('Z', ""); - if cleaned.len() == 15 { // YYYYMMDDTHHMMSS - let year = cleaned[0..4].parse::().ok()?; - let month = cleaned[4..6].parse::().ok()?; - let day = cleaned[6..8].parse::().ok()?; - let hour = cleaned[9..11].parse::().ok()?; - let minute = cleaned[11..13].parse::().ok()?; - let second = cleaned[13..15].parse::().ok()?; - - return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single(); - } - } else if dt_str.len() == 15 && dt_str.contains('T') { // YYYYMMDDTHHMMSS (no Z) - let year = dt_str[0..4].parse::().ok()?; - let month = dt_str[4..6].parse::().ok()?; - let day = dt_str[6..8].parse::().ok()?; - let hour = dt_str[9..11].parse::().ok()?; - let minute = dt_str[11..13].parse::().ok()?; - let second = dt_str[13..15].parse::().ok()?; - - return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single(); - } else if dt_str.len() == 8 { // YYYYMMDD (date only) - let year = dt_str[0..4].parse::().ok()?; - let month = dt_str[4..6].parse::().ok()?; - let day = dt_str[6..8].parse::().ok()?; - - return Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).single(); - } - - debug!("Failed to parse datetime: {}", dt_str); - None - } - - /// Parse duration from iCalendar format - fn parse_duration(&self, duration_str: &str) -> Option { - // Simple duration parsing - handle basic PT1H format - if duration_str.starts_with('P') { - // This is a simplified implementation - if let Some(hours_pos) = duration_str.find('H') { - let before_hours = &duration_str[..hours_pos]; - if let Some(last_char) = before_hours.chars().last() { - if let Some(hours_str) = last_char.to_string().parse::().ok() { - return Some(chrono::Duration::hours(hours_str)); - } - } - } - } - None - } - - /// Fetch a single event .ics file and parse it - async fn fetch_single_event(&self, event_url: &str, calendar_href: &str) -> Result> { - info!("Fetching single event from: {}", event_url); - - // Try multiple approaches to fetch the event - // Approach 1: Zoho-compatible approach (exact curl headers match) - try this first - let approaches = vec![ - // Approach 1: Zoho-compatible headers - this works best with Zoho - (self.client.get(event_url) - .header("Accept", "text/calendar") - .header("User-Agent", "curl/8.16.0"), - "zoho-compatible"), - // Approach 2: Basic request with minimal headers - (self.client.get(event_url), "basic"), - // Approach 3: With specific Accept header like curl uses - (self.client.get(event_url).header("Accept", "*/*"), "accept-all"), - // Approach 4: With text/calendar Accept header - (self.client.get(event_url).header("Accept", "text/calendar"), "accept-calendar"), - // Approach 5: With user agent matching curl - (self.client.get(event_url).header("User-Agent", "curl/8.16.0"), "curl-ua"), - ]; - - for (req, approach_name) in approaches { - info!("Trying approach: {}", approach_name); - match req.send().await { - Ok(response) => { - let status = response.status(); - info!("Approach '{}' response status: {}", approach_name, status); - - if status.is_success() { - let ical_data = response.text().await?; - debug!("Retrieved iCalendar data ({} chars): {}", ical_data.len(), - if ical_data.len() > 200 { - format!("{}...", &ical_data[..200]) - } else { - ical_data.clone() - }); - - // Parse the iCalendar data - if let Ok(mut events) = self.parse_icalendar_data(&ical_data, calendar_href) { - if !events.is_empty() { - // Update the href to the correct URL - events[0].href = event_url.to_string(); - info!("Successfully parsed event with approach '{}': {}", approach_name, events[0].summary); - return Ok(Some(events.remove(0))); - } else { - warn!("Approach '{}' got {} bytes but parsed 0 events", approach_name, ical_data.len()); - } - } else { - warn!("Approach '{}' failed to parse iCalendar data", approach_name); - } - } else { - let error_text = response.text().await.unwrap_or_else(|_| "Unable to read error response".to_string()); - warn!("Approach '{}' failed: {} - {}", approach_name, status, error_text); - } - } - Err(e) => { - warn!("Approach '{}' request failed: {}", approach_name, e); - } - } - } - - warn!("All approaches failed for event: {}", event_url); - Ok(None) - } - - /// Parse PROPFIND response to extract event hrefs and fetch individual events - async fn parse_propfind_response(&self, xml: &str, calendar_href: &str) -> Result> { - let mut events = Vec::new(); - let mut start_pos = 0; - - info!("Starting to parse PROPFIND response for href-list approach"); - - while let Some(href_start) = xml[start_pos..].find("") { - let absolute_start = start_pos + href_start; - if let Some(href_end) = xml[absolute_start..].find("") { - let absolute_end = absolute_start + href_end; - let href_content = &xml[absolute_start + 9..absolute_end]; - - // Skip the calendar collection itself and focus on .ics files - if !href_content.ends_with('/') && href_content != calendar_href { - debug!("Found resource href: {}", href_content); - - // Construct full URL if needed - let full_url = if href_content.starts_with("http") { - href_content.to_string() - } else if href_content.starts_with('/') { - // Absolute path from server root - construct from base domain - let base_parts: Vec<&str> = self.base_url.split('/').take(3).collect(); - let base_domain = base_parts.join("/"); - format!("{}{}", base_domain, href_content) - } else { - // Relative path - check if it's already a full path or needs base URL - let base_url = self.base_url.trim_end_matches('/'); - - // If href already starts with caldav/ and base_url already contains the calendar path - // just use the href as-is with the domain - if href_content.starts_with("caldav/") && base_url.contains("/caldav/") { - let base_parts: Vec<&str> = base_url.split('/').take(3).collect(); - let base_domain = base_parts.join("/"); - format!("{}/{}", base_domain, href_content) - } else if href_content.starts_with("caldav/") { - format!("{}/{}", base_url, href_content) - } else { - format!("{}{}", base_url, href_content) - } - }; - - info!("Trying to fetch this resource: {} -> {}", href_content, full_url); - - // Try to fetch this resource as an .ics file - match self.fetch_single_event(&full_url, calendar_href).await { - Ok(Some(event)) => { - events.push(event); - } - Ok(None) => { - debug!("Resource {} is not an event", href_content); - } - Err(e) => { - warn!("Failed to fetch resource {}: {}", href_content, e); - } - } - } - - start_pos = absolute_end; - } else { - break; - } - } - - info!("Fetched {} individual events", events.len()); - - // Debug: show first few URLs being constructed - if !events.is_empty() { - info!("First few URLs tried:"); - for (idx, event) in events.iter().take(3).enumerate() { - info!(" [{}] URL: {}", idx + 1, event.href); - } - } else { - info!("No events fetched successfully"); - } - - Ok(events) - } - - /// Create mock event for debugging - fn create_mock_event(&self, calendar_href: &str) -> Result> { - let now = Utc::now(); - let mock_event = CalendarEvent { - id: "mock-event-1".to_string(), - href: format!("{}/mock-event-1.ics", calendar_href), - summary: "Mock Event".to_string(), - description: Some("This is a mock event for testing".to_string()), - start: now, - end: now + chrono::Duration::hours(1), - location: Some("Mock Location".to_string()), - status: Some("CONFIRMED".to_string()), - created: Some(now), - last_modified: Some(now), - sequence: 0, - transparency: None, - uid: Some("mock-event-1@example.com".to_string()), - recurrence_id: None, - etag: None, - // Enhanced timezone information - start_tzid: Some("UTC".to_string()), - end_tzid: Some("UTC".to_string()), - original_start: Some(now.format("%Y%m%dT%H%M%SZ").to_string()), - original_end: Some((now + chrono::Duration::hours(1)).format("%Y%m%dT%H%M%SZ").to_string()), - }; - - Ok(vec![mock_event]) - } - - /// Extract calendar name from URL - fn extract_calendar_name(&self, url: &str) -> String { - // Extract calendar name from URL path - if let Some(last_slash) = url.rfind('/') { - let name_part = &url[last_slash + 1..]; - if !name_part.is_empty() { - return name_part.to_string(); - } - } - - "Default Calendar".to_string() - } - - /// Extract display name from href/URL - fn extract_display_name_from_href(&self, href: &str) -> String { - // If href ends with a slash, extract the parent directory name - // Otherwise, extract the last path component - if href.ends_with('/') { - // Remove trailing slash - let href_without_slash = href.trim_end_matches('/'); - if let Some(last_slash) = href_without_slash.rfind('/') { - let name_part = &href_without_slash[last_slash + 1..]; - if !name_part.is_empty() { - return name_part.replace('_', " ").split('-').map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::() + &chars.as_str().to_lowercase(), - } - }).collect::>().join(" "); - } - } - } else { - // Use the existing extract_calendar_name logic - return self.extract_calendar_name(href); - } - - "Default Calendar".to_string() - } -} - -/// Calendar information from CalDAV server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CalendarInfo { - pub url: String, - pub name: String, - pub display_name: Option, - pub color: Option, - pub description: Option, - pub timezone: Option, - pub supported_components: Vec, -} - -/// Calendar event from CalDAV server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CalendarEvent { - pub id: String, - pub href: String, - pub summary: String, - pub description: Option, - pub start: DateTime, - pub end: DateTime, - pub location: Option, - pub status: Option, - pub created: Option>, - pub last_modified: Option>, - pub sequence: i32, - pub transparency: Option, - pub uid: Option, - pub recurrence_id: Option>, - pub etag: Option, - // Enhanced timezone information - pub start_tzid: Option, - pub end_tzid: Option, - pub original_start: Option, - pub original_end: Option, -} \ No newline at end of file diff --git a/src/real_caldav_client.rs b/src/real_caldav_client.rs deleted file mode 100644 index be756cc..0000000 --- a/src/real_caldav_client.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Real CalDAV client implementation using libdav library - -use anyhow::Result; -use libdav::{auth::Auth, dav::WebDavClient, CalDavClient}; -use http::Uri; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use crate::error::CalDavError; -use tracing::{debug, info, warn, error}; - -/// Real CalDAV client using libdav library -pub struct RealCalDavClient { - client: CalDavClient, - base_url: String, - username: String, -} - -impl RealCalDavClient { - /// Create a new CalDAV client with authentication - pub async fn new(base_url: &str, username: &str, password: &str) -> Result { - info!("Creating CalDAV client for: {}", base_url); - - // Parse the base URL - let uri: Uri = base_url.parse() - .map_err(|e| CalDavError::Config(format!("Invalid URL: {}", e)))?; - - // Create authentication - let auth = Auth::Basic(username.to_string(), password.to_string()); - - // Create WebDav client first - let webdav = WebDavClient::builder() - .set_uri(uri) - .set_auth(auth) - .build() - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create WebDAV client: {}", e)))?; - - // Convert to CalDav client - let client = CalDavClient::new(webdav); - - debug!("CalDAV client created successfully"); - - Ok(Self { - client, - base_url: base_url.to_string(), - username: username.to_string(), - }) - } - - /// Discover calendars on the server - pub async fn discover_calendars(&self) -> Result> { - info!("Discovering calendars for user: {}", self.username); - - // Get the calendar home set - let calendar_home_set = self.client.calendar_home_set().await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get calendar home set: {}", e)))?; - - debug!("Calendar home set: {:?}", calendar_home_set); - - // List calendars - let calendars = self.client.list_calendars().await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to list calendars: {}", e)))?; - - info!("Found {} calendars", calendars.len()); - - let mut calendar_infos = Vec::new(); - for (href, calendar) in calendars { - info!("Calendar: {} - {}", href, calendar.display_name().unwrap_or("Unnamed")); - - let calendar_info = CalendarInfo { - url: href.to_string(), - name: calendar.display_name().unwrap_or_else(|| { - // Extract name from URL if no display name - href.split('/').last().unwrap_or("unknown").to_string() - }), - display_name: calendar.display_name().map(|s| s.to_string()), - color: calendar.color().map(|s| s.to_string()), - description: calendar.description().map(|s| s.to_string()), - timezone: calendar.calendar_timezone().map(|s| s.to_string()), - supported_components: calendar.supported_components().to_vec(), - }; - - calendar_infos.push(calendar_info); - } - - Ok(calendar_infos) - } - - /// Get events from a specific calendar - pub async fn get_events(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime) -> Result> { - info!("Getting events from calendar: {} between {} and {}", - calendar_href, start_date, end_date); - - // Get events for the time range - let events = self.client - .get_event_instances(calendar_href, start_date, end_date) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get events: {}", e)))?; - - info!("Found {} events", events.len()); - - let mut calendar_events = Vec::new(); - for (href, event) in events { - debug!("Event: {} - {}", href, event.summary().unwrap_or("Untitled")); - - // Convert libdav event to our format - let calendar_event = CalendarEvent { - id: self.extract_event_id(&href), - href: href.to_string(), - summary: event.summary().unwrap_or("Untitled").to_string(), - description: event.description().map(|s| s.to_string()), - start: event.start().unwrap_or(&chrono::Utc::now()).clone(), - end: event.end().unwrap_or(&chrono::Utc::now()).clone(), - location: event.location().map(|s| s.to_string()), - status: event.status().map(|s| s.to_string()), - created: event.created().copied(), - last_modified: event.last_modified().copied(), - sequence: event.sequence(), - transparency: event.transparency().map(|s| s.to_string()), - uid: event.uid().map(|s| s.to_string()), - recurrence_id: event.recurrence_id().cloned(), - }; - - calendar_events.push(calendar_event); - } - - Ok(calendar_events) - } - - /// Create an event in the calendar - pub async fn create_event(&self, calendar_href: &str, event: &CalendarEvent) -> Result<()> { - info!("Creating event: {} in calendar: {}", event.summary, calendar_href); - - // Convert our event format to libdav's format - let mut ical_event = icalendar::Event::new(); - ical_event.summary(&event.summary); - ical_event.start(&event.start); - ical_event.end(&event.end); - - if let Some(description) = &event.description { - ical_event.description(description); - } - - if let Some(location) = &event.location { - ical_event.location(location); - } - - if let Some(uid) = &event.uid { - ical_event.uid(uid); - } else { - ical_event.uid(&event.id); - } - - if let Some(status) = &event.status { - ical_event.status(status); - } - - // Create iCalendar component - let mut calendar = icalendar::Calendar::new(); - calendar.push(ical_event); - - // Generate iCalendar string - let ical_str = calendar.to_string(); - - // Create event on server - let event_href = format!("{}/{}.ics", calendar_href.trim_end_matches('/'), event.id); - - self.client - .create_resource(&event_href, ical_str.as_bytes()) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create event: {}", e)))?; - - info!("Event created successfully: {}", event_href); - Ok(()) - } - - /// Update an existing event - pub async fn update_event(&self, event_href: &str, event: &CalendarEvent) -> Result<()> { - info!("Updating event: {} at {}", event.summary, event_href); - - // Convert to iCalendar format (similar to create_event) - let mut ical_event = icalendar::Event::new(); - ical_event.summary(&event.summary); - ical_event.start(&event.start); - ical_event.end(&event.end); - - if let Some(description) = &event.description { - ical_event.description(description); - } - - if let Some(location) = &event.location { - ical_event.location(location); - } - - if let Some(uid) = &event.uid { - ical_event.uid(uid); - } - - if let Some(status) = &event.status { - ical_event.status(status); - } - - // Update sequence number - ical_event.add_property("SEQUENCE", &event.sequence.to_string()); - - let mut calendar = icalendar::Calendar::new(); - calendar.push(ical_event); - - let ical_str = calendar.to_string(); - - // Update event on server - self.client - .update_resource(event_href, ical_str.as_bytes()) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to update event: {}", e)))?; - - info!("Event updated successfully: {}", event_href); - Ok(()) - } - - /// Delete an event - pub async fn delete_event(&self, event_href: &str) -> Result<()> { - info!("Deleting event: {}", event_href); - - self.client - .delete_resource(event_href) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to delete event: {}", e)))?; - - info!("Event deleted successfully: {}", event_href); - Ok(()) - } - - /// Extract event ID from href - fn extract_event_id(&self, href: &str) -> String { - href.split('/') - .last() - .and_then(|s| s.strip_suffix(".ics")) - .unwrap_or("unknown") - .to_string() - } -} - -/// Calendar information from CalDAV server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CalendarInfo { - pub url: String, - pub name: String, - pub display_name: Option, - pub color: Option, - pub description: Option, - pub timezone: Option, - pub supported_components: Vec, -} - -/// Calendar event from CalDAV server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CalendarEvent { - pub id: String, - pub href: String, - pub summary: String, - pub description: Option, - pub start: DateTime, - pub end: DateTime, - pub location: Option, - pub status: Option, - pub created: Option>, - pub last_modified: Option>, - pub sequence: i32, - pub transparency: Option, - pub uid: Option, - pub recurrence_id: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - - #[test] - fn test_extract_event_id() { - let client = RealCalDavClient { - client: unsafe { std::mem::zeroed() }, // Not used in test - base_url: "https://example.com".to_string(), - username: "test".to_string(), - }; - - assert_eq!(client.extract_event_id("/calendar/event123.ics"), "event123"); - assert_eq!(client.extract_event_id("/calendar/path/event456.ics"), "event456"); - assert_eq!(client.extract_event_id("event789.ics"), "event789"); - assert_eq!(client.extract_event_id("no_extension"), "no_extension"); - } -} diff --git a/src/real_sync.rs b/src/real_sync.rs deleted file mode 100644 index fd325d6..0000000 --- a/src/real_sync.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Synchronization engine for CalDAV calendars using real CalDAV implementation - -use crate::{config::Config, minicaldav_client::RealCalDavClient, error::CalDavResult}; -use chrono::{DateTime, Utc, Duration}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use tokio::time::sleep; -use tracing::{info, warn, error, debug}; - -/// Synchronization engine for managing calendar synchronization -pub struct SyncEngine { - /// CalDAV client - pub client: RealCalDavClient, - /// Configuration - config: Config, - /// Local cache of events - local_events: HashMap, - /// Sync state - sync_state: SyncState, -} - -/// Synchronization state -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncState { - /// Last successful sync timestamp - pub last_sync: Option>, - /// Sync token for incremental syncs - pub sync_token: Option, - /// Known event HREFs - pub known_events: HashMap, - /// Sync statistics - pub stats: SyncStats, -} - -/// Synchronization statistics -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncStats { - /// Total events synchronized - pub total_events: u64, - /// Events created - pub events_created: u64, - /// Events updated - pub events_updated: u64, - /// Events deleted - pub events_deleted: u64, - /// Errors encountered - pub errors: u64, - /// Last sync duration in milliseconds - pub sync_duration_ms: u64, -} - -/// Event for synchronization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncEvent { - pub id: String, - pub href: String, - pub summary: String, - pub description: Option, - pub start: DateTime, - pub end: DateTime, - pub location: Option, - pub status: Option, - pub last_modified: Option>, - pub source_calendar: String, - pub start_tzid: Option, - pub end_tzid: Option, -} - -/// Synchronization result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncResult { - pub success: bool, - pub events_processed: u64, - pub duration_ms: u64, - pub error_message: Option, - pub stats: SyncStats, -} - -impl SyncEngine { - /// Create a new sync engine - pub async fn new(config: Config) -> CalDavResult { - info!("Creating sync engine for: {}", config.server.url); - - // Create CalDAV client - let client = RealCalDavClient::new( - &config.server.url, - &config.server.username, - &config.server.password, - ).await?; - - let sync_state = SyncState { - last_sync: None, - sync_token: None, - known_events: HashMap::new(), - stats: SyncStats::default(), - }; - - Ok(Self { - client, - config, - local_events: HashMap::new(), - sync_state, - }) - } - - /// Perform full synchronization - pub async fn sync_full(&mut self) -> CalDavResult { - let start_time = Utc::now(); - info!("Starting full calendar synchronization"); - - let mut result = SyncResult { - success: true, - events_processed: 0, - duration_ms: 0, - error_message: None, - stats: SyncStats::default(), - }; - - // Discover calendars - match self.discover_and_sync_calendars().await { - Ok(events_count) => { - result.events_processed = events_count; - result.stats.events_created = events_count; - info!("Full sync completed: {} events processed", events_count); - } - Err(e) => { - error!("Full sync failed: {}", e); - result.success = false; - result.error_message = Some(e.to_string()); - result.stats.errors = 1; - } - } - - let duration = Utc::now() - start_time; - result.duration_ms = duration.num_milliseconds() as u64; - result.stats.sync_duration_ms = result.duration_ms; - - // Update sync state - self.sync_state.last_sync = Some(Utc::now()); - self.sync_state.stats = result.stats.clone(); - - Ok(result) - } - - /// Perform incremental synchronization - pub async fn sync_incremental(&mut self) -> CalDavResult { - let _start_time = Utc::now(); - info!("Starting incremental calendar synchronization"); - - // For now, incremental sync is the same as full sync - // In a real implementation, we would use sync tokens or last modified timestamps - self.sync_full().await - } - - /// Force a full resynchronization - pub async fn force_full_resync(&mut self) -> CalDavResult { - info!("Forcing full resynchronization"); - - // Clear sync state - self.sync_state.sync_token = None; - self.sync_state.known_events.clear(); - self.local_events.clear(); - - self.sync_full().await - } - - /// Start automatic synchronization loop - pub async fn start_auto_sync(&mut self) -> CalDavResult<()> { - info!("Starting automatic synchronization loop"); - - loop { - if let Err(e) = self.sync_incremental().await { - error!("Auto sync failed: {}", e); - // Wait before retrying - sleep(tokio::time::Duration::from_secs(60)).await; - } - - // Wait for next sync interval - let interval_secs = self.config.sync.interval; - debug!("Waiting {} seconds for next sync", interval_secs); - sleep(tokio::time::Duration::from_secs(interval_secs as u64)).await; - } - } - - /// Get local events - pub fn get_local_events(&self) -> Vec { - self.local_events.values().cloned().collect() - } - - /// Discover calendars and sync events - async fn discover_and_sync_calendars(&mut self) -> CalDavResult { - info!("Discovering calendars"); - - // Get calendar list - let calendars = self.client.discover_calendars().await?; - let mut total_events = 0u64; - let mut found_matching_calendar = false; - - for calendar in calendars { - info!("Processing calendar: {}", calendar.name); - - // Find calendar matching our configured calendar name - if calendar.name == self.config.calendar.name || - calendar.display_name.as_ref().map_or(false, |n| n == &self.config.calendar.name) { - - found_matching_calendar = true; - info!("Found matching calendar: {}", calendar.name); - - // Calculate date range based on configuration - let now = Utc::now(); - let (start_date, end_date) = if self.config.sync.date_range.sync_all_events { - // Sync all events regardless of date - // Use a very wide date range - let start_date = now - Duration::days(365 * 10); // 10 years ago - let end_date = now + Duration::days(365 * 10); // 10 years in future - info!("Syncing all events (wide date range: {} to {})", - start_date.format("%Y-%m-%d"), end_date.format("%Y-%m-%d")); - (start_date, end_date) - } else { - // Use configured date range - let days_back = self.config.sync.date_range.days_back; - let days_ahead = self.config.sync.date_range.days_ahead; - - let start_date = now - Duration::days(days_back); - let end_date = now + Duration::days(days_ahead); - - info!("Syncing events for date range: {} to {} ({} days back, {} days ahead)", - start_date.format("%Y-%m-%d"), - end_date.format("%Y-%m-%d"), - days_back, days_ahead); - (start_date, end_date) - }; - - // Get events for this calendar - match self.client.get_events(&calendar.url, start_date, end_date).await { - Ok(events) => { - info!("Found {} events in calendar: {}", events.len(), calendar.name); - - // Process events - for event in events { - let sync_event = SyncEvent { - id: event.id.clone(), - href: event.href.clone(), - summary: event.summary.clone(), - description: event.description, - start: event.start, - end: event.end, - location: event.location, - status: event.status, - last_modified: event.last_modified, - source_calendar: calendar.name.clone(), - start_tzid: event.start_tzid, - end_tzid: event.end_tzid, - }; - - // Add to local cache - self.local_events.insert(event.id.clone(), sync_event); - total_events += 1; - } - } - Err(e) => { - warn!("Failed to get events from calendar {}: {}", calendar.name, e); - } - } - - // For now, we only sync from one calendar as configured - break; - } - } - - if !found_matching_calendar { - warn!("No calendars found matching: {}", self.config.calendar.name); - } else if total_events == 0 { - info!("No events found in matching calendar for the specified date range"); - } - - Ok(total_events) - } -} - -impl Default for SyncState { - fn default() -> Self { - Self { - last_sync: None, - sync_token: None, - known_events: HashMap::new(), - stats: SyncStats::default(), - } - } -} diff --git a/src/sync.rs b/src/sync.rs index 4b19ee1..c456234 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -9,8 +9,8 @@ use tracing::{info, warn, error, debug}; /// Synchronization engine for managing calendar synchronization pub struct SyncEngine { - /// CalDAV client for source (primary) operations - pub client: CalDavClient, + /// CalDAV client + client: CalDavClient, /// Configuration config: Config, /// Local cache of events @@ -19,16 +19,10 @@ pub struct SyncEngine { sync_state: SyncState, /// Timezone handler timezone_handler: crate::timezone::TimezoneHandler, - - // NEW: Import functionality fields - /// Import client for target operations (optional) - import_client: Option, - /// Import state for tracking import operations - import_state: Option, } /// Synchronization state -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncState { /// Last successful sync timestamp pub last_sync: Option>, @@ -84,153 +78,11 @@ pub struct SyncResult { pub duration_ms: u64, } -/// Import state for tracking import operations -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportState { - /// Last successful import timestamp - pub last_import: Option>, - /// Imported events with their import timestamps - pub imported_events: HashMap>, - /// Failed imports with error messages - pub failed_imports: HashMap, - /// Total events imported - pub total_imported: u64, - /// Last modified timestamp for incremental imports - pub last_modified: Option>, - /// Import statistics - pub stats: ImportStats, -} - -/// Import statistics -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ImportStats { - /// Total events processed for import - pub total_processed: u64, - /// Events successfully imported - pub successful_imports: u64, - /// Events skipped (already exist) - pub skipped_events: u64, - /// Events failed to import - pub failed_imports: u64, - /// Events updated on target - pub updated_events: u64, - /// Last import duration in milliseconds - pub last_import_duration_ms: u64, -} - -/// Import result -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ImportResult { - /// Success flag - pub success: bool, - /// Number of events processed - pub events_processed: usize, - /// Events imported - pub events_imported: usize, - /// Events updated - pub events_updated: usize, - /// Events skipped - pub events_skipped: usize, - /// Events failed - pub events_failed: usize, - /// Error messages if any - pub errors: Vec, - /// Import duration in milliseconds - pub duration_ms: u64, - /// Whether this was a dry run - pub dry_run: bool, -} - -/// Import error types -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ImportError { - /// Source connection error - SourceConnectionError(String), - /// Target connection error - TargetConnectionError(String), - /// Event conversion error - EventConversionError { event_id: String, details: String }, - /// Target calendar missing - TargetCalendarMissing(String), - /// Permission denied on target - PermissionDenied(String), - /// Quota exceeded on target - QuotaExceeded(String), - /// Event conflict - EventConflict { event_id: String, reason: String }, - /// Other error - Other(String), -} - -impl std::fmt::Display for ImportError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ImportError::SourceConnectionError(msg) => write!(f, "Source connection error: {}", msg), - ImportError::TargetConnectionError(msg) => write!(f, "Target connection error: {}", msg), - ImportError::EventConversionError { event_id, details } => { - write!(f, "Event conversion error for {}: {}", event_id, details) - } - ImportError::TargetCalendarMissing(name) => write!(f, "Target calendar missing: {}", name), - ImportError::PermissionDenied(msg) => write!(f, "Permission denied: {}", msg), - ImportError::QuotaExceeded(msg) => write!(f, "Quota exceeded: {}", msg), - ImportError::EventConflict { event_id, reason } => { - write!(f, "Event conflict for {}: {}", event_id, reason) - } - ImportError::Other(msg) => write!(f, "Error: {}", msg), - } - } -} - -impl ImportState { - /// Reset import state for full re-import - pub fn reset(&mut self) { - self.last_import = None; - self.imported_events.clear(); - self.failed_imports.clear(); - self.total_imported = 0; - self.last_modified = None; - self.stats = ImportStats::default(); - } -} - -/// Import action result -#[derive(Debug, Clone, PartialEq)] -pub enum ImportAction { - /// Event was created on target - Created, - /// Event was updated on target - Updated, - /// Event was skipped (already exists and no overwrite) - Skipped, -} - impl SyncEngine { /// Create a new synchronization engine pub async fn new(config: Config) -> CalDavResult { let client = CalDavClient::new(config.server.clone())?; - let timezone_handler = crate::timezone::TimezoneHandler::new(config.calendar.timezone.as_deref())?; - - // Initialize import client if import configuration is present - let import_client = if let Some(import_config) = &config.import { - info!("Initializing import client for target server"); - Some(CalDavClient::new(import_config.target_server.clone())?) - } else { - None - }; - - // Initialize import state if import configuration is present - let import_state = if config.import.is_some() { - Some(ImportState { - last_import: None, - imported_events: HashMap::new(), - failed_imports: HashMap::new(), - total_imported: 0, - last_modified: None, - stats: ImportStats::default(), - }) - } else { - None - }; + let timezone_handler = crate::timezone::TimezoneHandler::new(&config.calendar.timezone)?; let engine = Self { client, @@ -243,32 +95,11 @@ impl SyncEngine { stats: SyncStats::default(), }, timezone_handler, - import_client, - import_state, }; - // Test source connection + // Test connection engine.client.test_connection().await?; - // Test import connection if present and skip if network issues - if let (Some(import_client), Some(import_config)) = (&engine.import_client, &engine.config.import) { - info!("Testing import client connection"); - if let Err(e) = import_client.test_connection().await { - warn!("Import client connection test failed: {}", e); - warn!("Import functionality will be available but may not work"); - // Don't fail initialization - allow the app to start - } else { - info!("Import client connection successful"); - - // Create target calendar if configured - if import_config.create_target_calendar { - info!("Creating target calendar: {}", import_config.target_calendar.name); - // TODO: Implement calendar creation - debug!("Calendar creation not yet implemented"); - } - } - } - Ok(engine) } @@ -308,7 +139,7 @@ impl SyncEngine { /// Perform incremental synchronization pub async fn sync_incremental(&mut self) -> CalDavResult { - let _start_time = Utc::now(); + let start_time = Utc::now(); info!("Starting incremental calendar synchronization"); let mut result = SyncResult { @@ -641,1049 +472,47 @@ impl SyncEngine { Ok(()) } - - // ==================== IMPORT FUNCTIONALITY ==================== - - /// Perform event import from source to target - pub async fn import_events(&mut self, dry_run: bool, full_import: bool) -> CalDavResult { - let _start_time = Utc::now(); - - // Check if import is configured - if self.import_client.is_none() { - return Err(crate::error::CalDavError::ConfigurationError( - "Import not configured. Please configure import settings.".to_string() - )); - } - - // Clone config and client before any borrows - let import_config_clone = self.config.import.clone(); - let import_client_clone = self.import_client.clone(); - - let import_config = import_config_clone.as_ref().unwrap(); - let import_client = import_client_clone.as_ref().unwrap(); - - info!("Starting {}event import from source to target", - if dry_run { "dry-run " } else { "" }); - - let mut result = ImportResult { - success: false, - events_processed: 0, - events_imported: 0, - events_updated: 0, - events_skipped: 0, - events_failed: 0, - errors: Vec::new(), - duration_ms: 0, - dry_run, - }; - - // Reset sync state for full import - if full_import { - if let Some(ref mut import_state) = self.import_state { - import_state.reset(); - } - } - - match self.do_import_events(import_client, import_config, &mut result, dry_run, full_import).await { - Ok(_) => { - result.success = true; - info!("{} completed successfully", - if dry_run { "Dry-run import" } else { "Import" }); - } - Err(e) => { - let error_msg = e.to_string(); - result.errors.push(error_msg.clone()); - error!("Import failed: {}", error_msg); - - // Add error to import state - if let Some(ref mut import_state) = self.import_state { - if let Some(ref event_id) = result.events_processed.to_string().parse::().ok() { - import_state.failed_imports.insert(event_id.to_string(), error_msg); - } - } - } - } - - result.duration_ms = (Utc::now() - _start_time).num_milliseconds() as u64; - if let Some(ref mut import_state) = self.import_state { - import_state.stats.last_import_duration_ms = result.duration_ms; - } - - Ok(result) - } - - /// Internal import implementation - async fn do_import_events( - &mut self, - import_client: &CalDavClient, - import_config: &crate::config::ImportConfig, - result: &mut ImportResult, - dry_run: bool, - full_import: bool, - ) -> CalDavResult<()> { - // Determine date range for import - let (start, end) = if full_import { - // Full import: get wide date range - (Utc::now() - Duration::days(365), Utc::now() + Duration::days(365)) - } else { - // Incremental import: get events since last import - let start = if let Some(ref import_state) = self.import_state { - import_state.last_import - .unwrap_or_else(|| Utc::now() - Duration::days(30)) - } else { - Utc::now() - Duration::days(30) - }; - (start, Utc::now() + Duration::days(90)) - }; - - info!("Fetching source events from {} to {}", start, end); - - // Fetch events from source (using existing client) - let source_events = self.client.get_events(&self.config.calendar.name, start, end).await?; - debug!("Fetched {} events from source", source_events.len()); - - // Convert to Event objects - let source_events: Vec = source_events.into_iter().map(|caldav_event| { - Event::new(caldav_event.summary, caldav_event.start, caldav_event.end) - }).collect(); - - // Apply filters to source events - let filtered_events = if let Some(filter_config) = &self.config.filters { - let filter = self.create_filter_from_config(filter_config); - filter.filter_events_owned(source_events) - } else { - source_events - }; - - result.events_processed = filtered_events.len(); - info!("Processing {} events for import", filtered_events.len()); - - // Process each event for import - let events_len = filtered_events.len(); - for event in filtered_events { - // Create a helper function to avoid borrow issues - let result_action = self.process_single_event_import(&event, import_client, import_config, dry_run).await; - - // Get import state for this iteration - if let Some(import_state) = self.import_state.as_mut() { - match result_action { - Ok(import_action) => { - match import_action { - ImportAction::Created => { - result.events_imported += 1; - debug!("Imported new event: {}", event.uid); - } - ImportAction::Updated => { - result.events_updated += 1; - debug!("Updated existing event: {}", event.uid); - } - ImportAction::Skipped => { - result.events_skipped += 1; - debug!("Skipped event: {}", event.uid); - } - } - } - Err(e) => { - result.events_failed += 1; - let error_msg = format!("Failed to import event {}: {}", event.uid, e); - result.errors.push(error_msg.clone()); - import_state.failed_imports.insert(event.uid.clone(), error_msg.clone()); - warn!("{}", error_msg); - } - } - } - } - - // Update import state - if let Some(ref mut import_state) = self.import_state { - import_state.last_import = Some(Utc::now()); - import_state.total_imported = result.events_imported as u64 + result.events_updated as u64; - - // Update statistics - import_state.stats.total_processed = events_len as u64; - import_state.stats.successful_imports = result.events_imported as u64; - import_state.stats.updated_events = result.events_updated as u64; - import_state.stats.skipped_events = result.events_skipped as u64; - import_state.stats.failed_imports = result.events_failed as u64; - } - - Ok(()) - } - - /// Helper method to process a single event import without borrow conflicts - async fn process_single_event_import( - &self, - event: &Event, - import_client: &CalDavClient, - import_config: &crate::config::ImportConfig, - dry_run: bool, - ) -> CalDavResult { - // Check if event was already imported - if let Some(ref import_state) = self.import_state { - if import_state.imported_events.contains_key(&event.uid) && !import_config.overwrite_existing { - return Ok(ImportAction::Skipped); - } - } - - // Check if event exists on target - let target_event_exists = self.check_target_event_exists(import_client, &event.uid).await?; - - match target_event_exists { - None => { - // Event doesn't exist on target, create it - if !dry_run { - self.create_event_on_target(import_client, event).await?; - } - Ok(ImportAction::Created) - } - Some(_) => { - // Event exists on target - if import_config.overwrite_existing { - // Update existing event (source-wins strategy) - if !dry_run { - self.update_event_on_target(import_client, event).await?; - } - Ok(ImportAction::Updated) - } else { - // Skip existing event - Ok(ImportAction::Skipped) - } - } - } - } - - /// Check if an event exists on target calendar - async fn check_target_event_exists( - &self, - import_client: &CalDavClient, - event_id: &str, - ) -> CalDavResult> { - // Try to get the event from target - let import_config = self.config.import.as_ref().unwrap(); - - match import_client.get_event(&import_config.target_calendar.name, event_id).await { - Ok(ical_data_option) => { - if let Some(ical_data) = ical_data_option { - // Parse the iCalendar data to Event - match Event::from_ical(&ical_data) { - Ok(event) => Ok(Some(event)), - Err(e) => Err(e) - } - } else { - Ok(None) - } - }, - Err(crate::error::CalDavError::NotFound(_)) => Ok(None), - Err(e) => Err(e), - } - } - - /// Create an event on target calendar - async fn create_event_on_target( - &self, - import_client: &CalDavClient, - event: &Event, - ) -> CalDavResult<()> { - let import_config = self.config.import.as_ref().unwrap(); - - // Convert event to iCalendar format - let ical_data = event.to_ical()?; - - // Upload to target calendar - import_client.put_event(&import_config.target_calendar.name, &event.uid, &ical_data).await?; - - debug!("Created event {} on target calendar", event.uid); - Ok(()) - } - - /// Update an event on target calendar - async fn update_event_on_target( - &self, - import_client: &CalDavClient, - event: &Event, - ) -> CalDavResult<()> { - let import_config = self.config.import.as_ref().unwrap(); - - // Update modification timestamp - let mut event_clone = event.clone(); - event_clone.touch(); - - // Convert to iCalendar format - let ical_data = event_clone.to_ical()?; - - // Update on target calendar - import_client.put_event(&import_config.target_calendar.name, &event.uid, &ical_data).await?; - - debug!("Updated event {} on target calendar", event.uid); - Ok(()) - } - - /// Get import status and statistics - pub fn get_import_status(&self) -> Option<&ImportState> { - self.import_state.as_ref() - } - - /// Reset import state (for full re-import) - pub fn reset_import_state(&mut self) { - if let Some(ref mut import_state) = self.import_state { - import_state.last_import = None; - import_state.imported_events.clear(); - import_state.failed_imports.clear(); - import_state.total_imported = 0; - import_state.last_modified = None; - import_state.stats = ImportStats::default(); - info!("Import state reset for full re-import"); - } - } - - /// Clean up old import history - pub fn cleanup_import_history(&mut self, cutoff_days: u32) { - if let Some(ref mut import_state) = self.import_state { - let cutoff = Utc::now() - Duration::days(cutoff_days as i64); - - // Remove old imported events - import_state.imported_events.retain(|_, &mut timestamp| timestamp > cutoff); - - // Remove old failed imports - import_state.failed_imports.retain(|_, _| { - // Keep failed imports for longer as they might need attention - cutoff - Duration::days(30) > Utc::now() - }); - - info!("Cleaned up import history older than {} days", cutoff_days); - } - } } #[cfg(test)] mod tests { use super::*; - use crate::config::{Config, ServerConfig, CalendarConfig, SyncConfig, ImportConfig}; - use chrono::{DateTime, Utc, Duration}; + use crate::config::{Config, ServerConfig, CalendarConfig, SyncConfig}; - /// Create a test configuration - fn create_test_config() -> Config { - Config { - server: ServerConfig { - url: "https://caldav.test.com".to_string(), - username: "testuser".to_string(), - password: "testpass".to_string(), - use_https: true, - timeout: 30, - headers: None, - }, - calendar: CalendarConfig { - name: "test-calendar".to_string(), - display_name: Some("Test Calendar".to_string()), - color: Some("#3174ad".to_string()), - timezone: Some("UTC".to_string()), - enabled: true, - }, - filters: None, - sync: SyncConfig::default(), - import: None, - } + #[test] + fn test_sync_state_creation() { + let state = SyncState { + last_sync: None, + sync_token: None, + event_etags: HashMap::new(), + stats: SyncStats::default(), + }; + + assert!(state.last_sync.is_none()); + assert!(state.sync_token.is_none()); + assert_eq!(state.stats.total_events, 0); } - /// Create a test configuration with import settings - fn create_test_import_config() -> Config { - let mut config = create_test_config(); - config.import = Some(ImportConfig { - target_server: ServerConfig { - url: "https://nextcloud.test.com/remote.php/dav/".to_string(), - username: "targetuser".to_string(), - password: "targetpass".to_string(), - use_https: true, - timeout: 30, - headers: None, - }, - target_calendar: CalendarConfig { - name: "imported-calendar".to_string(), - display_name: Some("Imported Calendar".to_string()), - color: Some("#ff6b6b".to_string()), - timezone: Some("UTC".to_string()), - enabled: true, - }, - overwrite_existing: true, - delete_missing: false, - dry_run: false, - batch_size: 50, - create_target_calendar: true, - }); - config - } - - mod sync_state_management { - use super::*; - - #[test] - fn test_sync_state_creation() { - let state = SyncState { - last_sync: None, - sync_token: None, - event_etags: HashMap::new(), - stats: SyncStats::default(), - }; - - assert!(state.last_sync.is_none()); - assert!(state.sync_token.is_none()); - assert_eq!(state.stats.total_events, 0); - } - - #[test] - fn test_sync_state_with_data() { - let now = Utc::now(); - let mut event_etags = HashMap::new(); - event_etags.insert("event1".to_string(), "\"etag1\"".to_string()); - event_etags.insert("event2".to_string(), "\"etag2\"".to_string()); - - let state = SyncState { - last_sync: Some(now), - sync_token: Some("sync-token-123".to_string()), - event_etags, - stats: SyncStats { - total_events: 2, - local_created: 1, - local_updated: 1, - local_deleted: 0, - server_created: 0, - server_updated: 0, - server_deleted: 0, - conflicts: 0, - last_sync_duration_ms: 1500, - }, - }; - - assert!(state.last_sync.is_some()); - assert_eq!(state.last_sync.unwrap(), now); - assert_eq!(state.sync_token.as_ref().unwrap(), "sync-token-123"); - assert_eq!(state.event_etags.len(), 2); - assert_eq!(state.stats.total_events, 2); - assert_eq!(state.stats.last_sync_duration_ms, 1500); - } - - #[test] - fn test_sync_stats_defaults() { - let stats = SyncStats::default(); - assert_eq!(stats.total_events, 0); - assert_eq!(stats.local_created, 0); - assert_eq!(stats.local_updated, 0); - assert_eq!(stats.local_deleted, 0); - assert_eq!(stats.server_created, 0); - assert_eq!(stats.server_updated, 0); - assert_eq!(stats.server_deleted, 0); - assert_eq!(stats.conflicts, 0); - assert_eq!(stats.last_sync_duration_ms, 0); - } - - #[test] - fn test_sync_result_creation() { - let result = SyncResult { - success: true, - events_processed: 10, - events_created: 2, - events_updated: 3, - events_deleted: 1, - conflicts: 0, - error: None, - duration_ms: 1000, - }; - - assert!(result.success); - assert_eq!(result.events_processed, 10); - assert_eq!(result.events_created, 2); - assert_eq!(result.events_updated, 3); - assert_eq!(result.events_deleted, 1); - assert_eq!(result.conflicts, 0); - assert!(result.error.is_none()); - assert_eq!(result.duration_ms, 1000); - } - - #[test] - fn test_sync_result_with_error() { - let result = SyncResult { - success: false, - events_processed: 0, - events_created: 0, - events_updated: 0, - events_deleted: 0, - conflicts: 0, - error: Some("Connection failed".to_string()), - duration_ms: 500, - }; - - assert!(!result.success); - assert_eq!(result.events_processed, 0); - assert!(result.error.is_some()); - assert_eq!(result.error.as_ref().unwrap(), "Connection failed"); - assert_eq!(result.duration_ms, 500); - } - } - - mod import_state_management { - use super::*; - - #[test] - fn test_import_state_creation() { - let state = ImportState { - last_import: None, - imported_events: HashMap::new(), - failed_imports: HashMap::new(), - total_imported: 0, - last_modified: None, - stats: ImportStats::default(), - }; - - assert!(state.last_import.is_none()); - assert_eq!(state.imported_events.len(), 0); - assert_eq!(state.failed_imports.len(), 0); - assert_eq!(state.total_imported, 0); - assert_eq!(state.stats.total_processed, 0); - } - - #[test] - fn test_import_state_reset() { - let now = Utc::now(); - let mut imported_events = HashMap::new(); - imported_events.insert("event1".to_string(), now); - - let mut failed_imports = HashMap::new(); - failed_imports.insert("event2".to_string(), "Failed to parse".to_string()); - - let mut state = ImportState { - last_import: Some(now), - imported_events, - failed_imports, - total_imported: 5, - last_modified: Some(now), - stats: ImportStats { - total_processed: 10, - successful_imports: 5, - updated_events: 2, - skipped_events: 3, - failed_imports: 0, - last_import_duration_ms: 2000, - }, - }; - - // Reset the state - state.reset(); - - assert!(state.last_import.is_none()); - assert_eq!(state.imported_events.len(), 0); - assert_eq!(state.failed_imports.len(), 0); - assert_eq!(state.total_imported, 0); - assert!(state.last_modified.is_none()); - assert_eq!(state.stats.total_processed, 0); - } - - #[test] - fn test_import_stats_defaults() { - let stats = ImportStats::default(); - assert_eq!(stats.total_processed, 0); - assert_eq!(stats.successful_imports, 0); - assert_eq!(stats.updated_events, 0); - assert_eq!(stats.skipped_events, 0); - assert_eq!(stats.failed_imports, 0); - assert_eq!(stats.last_import_duration_ms, 0); - } - - #[test] - fn test_import_result_creation() { - let result = ImportResult { - success: true, - events_processed: 15, - events_imported: 8, - events_updated: 3, - events_skipped: 4, - events_failed: 0, - errors: Vec::new(), - duration_ms: 3000, - dry_run: false, - }; - - assert!(result.success); - assert_eq!(result.events_processed, 15); - assert_eq!(result.events_imported, 8); - assert_eq!(result.events_updated, 3); - assert_eq!(result.events_skipped, 4); - assert_eq!(result.events_failed, 0); - assert!(result.errors.is_empty()); - assert_eq!(result.duration_ms, 3000); - assert!(!result.dry_run); - } - - #[test] - fn test_import_result_dry_run() { - let result = ImportResult { - success: true, - events_processed: 10, - events_imported: 0, // No imports in dry run - events_updated: 0, - events_skipped: 10, // All skipped in dry run - events_failed: 0, - errors: Vec::new(), - duration_ms: 500, - dry_run: true, - }; - - assert!(result.success); - assert_eq!(result.events_processed, 10); - assert_eq!(result.events_imported, 0); - assert_eq!(result.events_skipped, 10); - assert!(result.dry_run); - } - - #[test] - fn test_import_error_display() { - let errors = [ - ImportError::SourceConnectionError("Network timeout".to_string()), - ImportError::TargetConnectionError("Authentication failed".to_string()), - ImportError::EventConversionError { - event_id: "event123".to_string(), - details: "Invalid date format".to_string() - }, - ImportError::TargetCalendarMissing("calendar-name".to_string()), - ImportError::PermissionDenied("Read-only access".to_string()), - ImportError::QuotaExceeded("Storage limit reached".to_string()), - ImportError::EventConflict { - event_id: "event456".to_string(), - reason: "Different start times".to_string() - }, - ImportError::Other("Unknown error occurred".to_string()), - ]; - - for error in &errors { - let display = format!("{}", error); - assert!(!display.is_empty()); - // Should contain meaningful error information - assert!(display.len() > 10); - } - } - - #[test] - fn test_import_action_equality() { - assert_eq!(ImportAction::Created, ImportAction::Created); - assert_eq!(ImportAction::Updated, ImportAction::Updated); - assert_eq!(ImportAction::Skipped, ImportAction::Skipped); - - assert_ne!(ImportAction::Created, ImportAction::Updated); - assert_ne!(ImportAction::Updated, ImportAction::Skipped); - assert_ne!(ImportAction::Skipped, ImportAction::Created); - } - } - - mod configuration_validation { - use super::*; - - #[test] - fn test_config_creation() { - let config = create_test_config(); - assert_eq!(config.server.url, "https://caldav.test.com"); - assert_eq!(config.server.username, "testuser"); - assert_eq!(config.calendar.name, "test-calendar"); - assert!(config.calendar.enabled); - assert!(config.import.is_none()); - } - - #[test] - fn test_import_config_creation() { - let config = create_test_import_config(); - assert!(config.import.is_some()); - - let import_config = config.import.unwrap(); - assert_eq!(import_config.target_server.url, "https://nextcloud.test.com/remote.php/dav/"); - assert_eq!(import_config.target_calendar.name, "imported-calendar"); - assert!(import_config.overwrite_existing); - assert!(import_config.create_target_calendar); - assert_eq!(import_config.batch_size, 50); - assert!(!import_config.dry_run); - } - - #[test] - fn test_config_serialization() { - let config = create_test_import_config(); - - // Test serialization - let serialized = serde_json::to_string(&config); - assert!(serialized.is_ok()); - - // Test deserialization - let deserialized: Result = serde_json::from_str(&serialized.unwrap()); - assert!(deserialized.is_ok()); - - let restored = deserialized.unwrap(); - assert_eq!(restored.server.url, config.server.url); - assert_eq!(restored.calendar.name, config.calendar.name); - assert!(restored.import.is_some()); - } - } - - mod event_processing { - use super::*; - use crate::event::Event; - - #[test] - fn test_event_creation() { - let start = Utc::now(); - let end = start + Duration::hours(1); - let event = Event::new("Test Event".to_string(), start, end); - - assert_eq!(event.summary, "Test Event"); - assert_eq!(event.start, start); - assert_eq!(event.end, end); - assert!(!event.uid.is_empty()); - assert!(!event.all_day); - assert_eq!(event.status, crate::event::EventStatus::Confirmed); - } - - #[test] - fn test_event_modification() { - let mut event = Event::new( - "Original Title".to_string(), - Utc::now(), - Utc::now() + Duration::hours(1), - ); - - let original_sequence = event.sequence; - let original_modified = event.last_modified; - - // Touch the event - event.touch(); - - assert_eq!(event.sequence, original_sequence + 1); - assert!(event.last_modified > original_modified); - } - - #[test] - fn test_event_occurs_on_date() { - let date = chrono::NaiveDate::from_ymd_opt(2024, 10, 15).unwrap(); - let start = DateTime::from_naive_utc_and_offset( - date.and_hms_opt(14, 0, 0).unwrap(), - Utc - ); - let end = start + Duration::hours(1); - - let event = Event::new("Test Event".to_string(), start, end); - - // Should occur on the same date - assert!(event.occurs_on(date)); - - // Should not occur on different date - let other_date = chrono::NaiveDate::from_ymd_opt(2024, 10, 16).unwrap(); - assert!(!event.occurs_on(other_date)); - } - - #[test] - fn test_event_duration() { - let start = Utc::now(); - let end = start + Duration::hours(2) + Duration::minutes(30); - let event = Event::new("Test Event".to_string(), start, end); - - let duration = event.duration(); - assert_eq!(duration.num_hours(), 2); - assert_eq!(duration.num_minutes() % 60, 30); - } - - #[test] - fn test_event_in_progress() { - let now = Utc::now(); - let event_start = now - Duration::minutes(30); - let event_end = now + Duration::minutes(30); - - let event = Event::new("Current Event".to_string(), event_start, event_end); - assert!(event.is_in_progress()); - - // Event that hasn't started yet - let future_event = Event::new( - "Future Event".to_string(), - now + Duration::hours(1), - now + Duration::hours(2), - ); - assert!(!future_event.is_in_progress()); - - // Event that has already ended - let past_event = Event::new( - "Past Event".to_string(), - now - Duration::hours(2), - now - Duration::hours(1), - ); - assert!(!past_event.is_in_progress()); - } - } - - mod filter_integration { - use super::*; - - #[test] - fn test_sync_engine_filter_creation() { - let config = create_test_config(); - let client = crate::caldav_client::CalDavClient::new(config.server.clone()).unwrap(); - let timezone_handler = crate::timezone::TimezoneHandler::new(config.calendar.timezone.as_deref()).unwrap(); - - let engine = SyncEngine { - client, - config, - local_events: HashMap::new(), - sync_state: SyncState::default(), - timezone_handler, - import_client: None, - import_state: None, - }; - - // Test creating filter from config (even if no filters are configured) - let filter_config = crate::config::FilterConfig { - start_date: Some("2024-10-01T00:00:00Z".to_string()), - end_date: Some("2024-10-31T23:59:59Z".to_string()), - keywords: Some(vec!["meeting".to_string(), "important".to_string()]), - exclude_keywords: Some(vec!["cancelled".to_string()]), - event_types: None, - }; - - let filter = engine.create_filter_from_config(&filter_config); - assert!(filter.is_enabled()); - } - - #[test] - fn test_event_filtering() { - let config = create_test_config(); - let client = crate::caldav_client::CalDavClient::new(config.server.clone()).unwrap(); - let timezone_handler = crate::timezone::TimezoneHandler::new(config.calendar.timezone.as_deref()).unwrap(); - - let engine = SyncEngine { - client, - config, - local_events: HashMap::new(), - sync_state: SyncState::default(), - timezone_handler, - import_client: None, - import_state: None, - }; - - // Create test events - let now = Utc::now(); - let events = vec![ - Event::new("Team Meeting".to_string(), now, now + Duration::hours(1)), - Event::new("Important Deadline".to_string(), now + Duration::days(1), now + Duration::days(1) + Duration::hours(2)), - Event::new("Cancelled Event".to_string(), now + Duration::days(2), now + Duration::days(2) + Duration::hours(1)), - ]; - - // Apply keyword filter - let filter_config = crate::config::FilterConfig { - keywords: Some(vec!["meeting".to_string(), "important".to_string()]), - exclude_keywords: Some(vec!["cancelled".to_string()]), - start_date: None, - end_date: None, - event_types: None, - }; - - let filter = engine.create_filter_from_config(&filter_config); - let filtered_events = filter.filter_events_owned(events); - - // Should only include events with "meeting" or "important" but not "cancelled" - assert_eq!(filtered_events.len(), 2); - assert!(filtered_events.iter().any(|e| e.summary.contains("Team Meeting"))); - assert!(filtered_events.iter().any(|e| e.summary.contains("Important"))); - assert!(!filtered_events.iter().any(|e| e.summary.contains("Cancelled"))); - } - } - - mod import_integration { - use super::*; - - #[test] - fn test_import_config_validation() { - let config = create_test_import_config(); - let import_config = config.import.as_ref().unwrap(); - - assert!(!import_config.target_server.url.is_empty()); - assert!(!import_config.target_server.username.is_empty()); - assert!(!import_config.target_server.password.is_empty()); - assert!(!import_config.target_calendar.name.is_empty()); - assert!(import_config.batch_size > 0); - } - - #[test] - fn test_import_state_serialization() { - let now = Utc::now(); - let mut imported_events = HashMap::new(); - imported_events.insert("event1".to_string(), now); - imported_events.insert("event2".to_string(), now - Duration::hours(1)); - - let mut failed_imports = HashMap::new(); - failed_imports.insert("event3".to_string(), "Parse error".to_string()); - - let state = ImportState { - last_import: Some(now), - imported_events, - failed_imports, - total_imported: 2, - last_modified: Some(now), - stats: ImportStats { - total_processed: 3, - successful_imports: 2, - updated_events: 1, - skipped_events: 0, - failed_imports: 1, - last_import_duration_ms: 5000, - }, - }; - - // Test serialization - let serialized = serde_json::to_string(&state); - assert!(serialized.is_ok()); - - // Test deserialization - let deserialized: Result = serde_json::from_str(&serialized.unwrap()); - assert!(deserialized.is_ok()); - - let restored = deserialized.unwrap(); - assert_eq!(restored.last_import, state.last_import); - assert_eq!(restored.imported_events.len(), 2); - assert_eq!(restored.failed_imports.len(), 1); - assert_eq!(restored.total_imported, 2); - assert_eq!(restored.stats.total_processed, 3); - } - - #[test] - fn test_import_result_validation() { - let mut result = ImportResult::default(); - - // Update result with sample data - result.success = true; - result.events_processed = 10; - result.events_imported = 6; - result.events_updated = 2; - result.events_skipped = 2; - result.events_failed = 0; - result.duration_ms = 2500; - result.dry_run = false; - - assert!(result.success); - assert_eq!(result.events_processed, 10); - assert_eq!(result.events_imported + result.events_updated + result.events_skipped + result.events_failed, 10); - assert_eq!(result.duration_ms, 2500); - assert!(!result.dry_run); - - // Test dry run scenario - result.dry_run = true; - result.events_imported = 0; - result.events_skipped = 10; - - assert!(result.dry_run); - assert_eq!(result.events_imported, 0); - assert_eq!(result.events_skipped, 10); - } - } - - mod error_handling { - use super::*; - - #[test] - fn test_import_error_creation() { - let error = ImportError::EventConversionError { - event_id: "test-event-123".to_string(), - details: "Invalid datetime format: 2024-13-45T25:99:99Z".to_string(), - }; - - let display = format!("{}", error); - assert!(display.contains("test-event-123")); - assert!(display.contains("Invalid datetime format")); - } - - #[test] - fn test_multiple_import_errors() { - let errors = vec![ - ImportError::SourceConnectionError("Network timeout after 30 seconds".to_string()), - ImportError::TargetConnectionError("401 Unauthorized".to_string()), - ImportError::QuotaExceeded("Calendar storage limit: 1000 events".to_string()), - ]; - - for error in errors { - let display = format!("{}", error); - assert!(!display.is_empty()); - assert!(display.len() > 20); // Should have meaningful content - } - } - } - - mod performance_tests { - use super::*; - - #[test] - fn test_large_sync_state_handling() { - let mut event_etags = HashMap::new(); - - // Create a large number of events - for i in 0..10000 { - event_etags.insert(format!("event-{}", i), format!("\"etag-{}\"", i)); - } - - let state = SyncState { - last_sync: Some(Utc::now()), - sync_token: Some("large-sync-token".to_string()), - event_etags, - stats: SyncStats { - total_events: 10000, - local_created: 5000, - local_updated: 3000, - local_deleted: 2000, - server_created: 1000, - server_updated: 1500, - server_deleted: 500, - conflicts: 10, - last_sync_duration_ms: 15000, - }, - }; - - assert_eq!(state.event_etags.len(), 10000); - assert_eq!(state.stats.total_events, 10000); - - // Test serialization performance - let start = std::time::Instant::now(); - let serialized = serde_json::to_string(&state); - let duration = start.elapsed(); - - assert!(serialized.is_ok()); - assert!(duration.as_millis() < 1000, "Serialization took too long: {:?}", duration); - } - - #[test] - fn test_large_import_state_handling() { - let mut imported_events = HashMap::new(); - let now = Utc::now(); - - // Create a large number of imported events - for i in 0..5000 { - imported_events.insert(format!("imported-event-{}", i), now - Duration::seconds(i as i64)); - } - - let state = ImportState { - last_import: Some(now), - imported_events, - failed_imports: HashMap::new(), - total_imported: 5000, - last_modified: Some(now), - stats: ImportStats { - total_processed: 6000, - successful_imports: 5000, - updated_events: 800, - skipped_events: 100, - failed_imports: 100, - last_import_duration_ms: 25000, - }, - }; - - assert_eq!(state.imported_events.len(), 5000); - assert_eq!(state.total_imported, 5000); - - // Test serialization performance - let start = std::time::Instant::now(); - let serialized = serde_json::to_string(&state); - let duration = start.elapsed(); - - assert!(serialized.is_ok()); - assert!(duration.as_millis() < 1000, "Serialization took too long: {:?}", duration); - } + #[test] + fn test_sync_result_creation() { + let result = SyncResult { + success: true, + events_processed: 10, + events_created: 2, + events_updated: 3, + events_deleted: 1, + conflicts: 0, + error: None, + duration_ms: 1000, + }; + + assert!(result.success); + assert_eq!(result.events_processed, 10); + assert_eq!(result.events_created, 2); + assert_eq!(result.events_updated, 3); + assert_eq!(result.events_deleted, 1); + assert_eq!(result.conflicts, 0); + assert!(result.error.is_none()); + assert_eq!(result.duration_ms, 1000); } } diff --git a/src/timezone.rs b/src/timezone.rs index 11f3ddf..e4514ad 100644 --- a/src/timezone.rs +++ b/src/timezone.rs @@ -17,18 +17,12 @@ pub struct TimezoneHandler { impl TimezoneHandler { /// Create a new timezone handler with the given default timezone - pub fn new(default_timezone: Option<&str>) -> CalDavResult { - let default_tz: Tz = default_timezone - .unwrap_or("UTC") - .parse() - .map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone.unwrap_or("UTC"))))?; + pub fn new(default_timezone: &str) -> CalDavResult { + let default_tz: Tz = default_timezone.parse() + .map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone)))?; let mut cache = HashMap::new(); - if let Some(tz) = default_timezone { - cache.insert(tz.to_string(), default_tz); - } else { - cache.insert("UTC".to_string(), default_tz); - } + cache.insert(default_timezone.to_string(), default_tz); Ok(Self { default_tz, @@ -39,7 +33,7 @@ impl TimezoneHandler { /// Create a timezone handler with system local timezone pub fn with_local_timezone() -> CalDavResult { let local_tz = Self::get_system_timezone()?; - Self::new(Some(local_tz.as_str())) + Self::new(&local_tz) } /// Parse a datetime with timezone information @@ -174,7 +168,7 @@ impl TimezoneHandler { impl Default for TimezoneHandler { fn default() -> Self { - Self::new(None).unwrap() + Self::new("UTC").unwrap() } } @@ -272,13 +266,13 @@ mod tests { #[test] fn test_timezone_handler_creation() { - let handler = TimezoneHandler::new(Some("UTC")).unwrap(); + let handler = TimezoneHandler::new("UTC").unwrap(); assert_eq!(handler.default_timezone(), "UTC"); } #[test] fn test_utc_datetime_parsing() { - let mut handler = TimezoneHandler::default(); + let handler = TimezoneHandler::default(); let dt = handler.parse_datetime("20231225T100000Z", None).unwrap(); assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z"); } @@ -293,7 +287,7 @@ mod tests { #[test] fn test_ical_formatting() { - let mut handler = TimezoneHandler::default(); + let handler = TimezoneHandler::default(); let dt = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), Utc @@ -308,7 +302,7 @@ mod tests { #[test] fn test_timezone_conversion() { - let mut handler = TimezoneHandler::new(Some("UTC")).unwrap(); + let mut handler = TimezoneHandler::new("UTC").unwrap(); let dt = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), Utc diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8c00fa0..57ec2e2 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,5 +1,4 @@ use caldav_sync::{Config, CalDavResult}; -use chrono::Utc; #[cfg(test)] mod config_tests { @@ -33,20 +32,20 @@ mod config_tests { #[cfg(test)] mod error_tests { - use caldav_sync::CalDavError; + use caldav_sync::{CalDavError, CalDavResult}; #[test] fn test_error_retryable() { - // Create a simple network error test - skip the reqwest::Error creation + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(network_error.is_retryable()); + let auth_error = CalDavError::Authentication("Invalid credentials".to_string()); assert!(!auth_error.is_retryable()); let config_error = CalDavError::Config("Missing URL".to_string()); assert!(!config_error.is_retryable()); - - // Just test that is_retryable works for different error types - assert!(!CalDavError::Authentication("test".to_string()).is_retryable()); - assert!(!CalDavError::Config("test".to_string()).is_retryable()); } #[test] @@ -115,12 +114,10 @@ mod event_tests { #[cfg(test)] mod timezone_tests { use caldav_sync::timezone::TimezoneHandler; - use caldav_sync::CalDavResult; - use chrono::{DateTime, Utc}; #[test] fn test_timezone_handler_creation() -> CalDavResult<()> { - let handler = TimezoneHandler::new(Some("UTC"))?; + let handler = TimezoneHandler::new("UTC")?; assert_eq!(handler.default_timezone(), "UTC"); Ok(()) } @@ -135,7 +132,7 @@ mod timezone_tests { #[test] fn test_ical_formatting() -> CalDavResult<()> { - let mut handler = TimezoneHandler::default(); + let handler = TimezoneHandler::default(); let dt = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), Utc @@ -154,9 +151,9 @@ mod timezone_tests { mod filter_tests { use caldav_sync::calendar_filter::{ CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter, - EventStatusFilter, FilterBuilder + EventTypeFilter, EventStatusFilter, FilterBuilder }; - use caldav_sync::event::{Event, EventStatus}; + use caldav_sync::event::{Event, EventStatus, EventType}; use chrono::{DateTime, Utc}; #[test] @@ -184,7 +181,7 @@ mod filter_tests { start - chrono::Duration::days(1), start - chrono::Duration::hours(23), ); - assert!(!filter.matches_event(&event_outside)); + assert!(!filter_outside.matches_event(&event_outside)); } #[test] @@ -220,10 +217,11 @@ mod filter_tests { let filter = FilterBuilder::new() .match_any(false) // AND logic .keywords(vec!["meeting".to_string()]) + .event_types(vec![EventType::Public]) .build(); let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); - assert!(filter.matches_event(&event)); // Matches condition + assert!(filter.matches_event(&event)); // Matches both conditions } }