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..dab1c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,7 +213,6 @@ dependencies = [ "config", "icalendar", "quick-xml", - "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1dfcd37..3c1fed4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 064a0a9..7c2ea30 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -376,29 +376,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. +The architecture is ready for: +1. **Bidirectional Sync**: Two-way synchronization with conflict resolution +2. **Multiple Calendar Support**: Sync multiple calendars simultaneously +3. **Enhanced Filtering**: Advanced regex and attendee-based filtering +4. **Performance Optimizations**: Parallel processing and incremental sync +5. **Web Interface**: Interactive configuration and status dashboard ### 🎉 **Final Status** 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/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..5afb316 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, } @@ -94,25 +92,6 @@ pub struct DateRangeConfig { 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 { fn default() -> Self { Self { @@ -120,7 +99,6 @@ impl Default for Config { calendar: CalendarConfig::default(), filters: None, sync: SyncConfig::default(), - import: None, } } } @@ -144,7 +122,7 @@ impl Default for CalendarConfig { name: "calendar".to_string(), display_name: None, color: None, - timezone: None, + timezone: "UTC".to_string(), enabled: true, } } @@ -204,22 +182,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 +200,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..7446628 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,9 +71,6 @@ 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), @@ -136,6 +127,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 +143,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 +157,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..4cae1a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,20 +5,14 @@ pub mod config; pub mod error; -pub mod sync; -pub mod timezone; -pub mod calendar_filter; -pub mod event; -pub mod caldav_client; +pub mod minicaldav_client; +pub mod real_sync; // Re-export main types for convenience -pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig, ImportConfig}; +pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig}; pub use error::{CalDavError, CalDavResult}; -pub use sync::{SyncEngine, SyncResult, SyncState, SyncStats, ImportState, ImportResult, ImportAction, ImportError}; -pub use timezone::TimezoneHandler; -pub use calendar_filter::{CalendarFilter, FilterRule}; -pub use event::Event; -pub use caldav_client::CalDavClient; +pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent}; +pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats}; /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/main.rs b/src/main.rs index dc181c1..da773d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,44 +58,6 @@ struct Cli { /// 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 +101,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,123 +129,30 @@ 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?; + let calendars = sync_engine.client.discover_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); + println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + println!(" Name: {}", calendar.name); + println!(" URL: {}", calendar.url); + if let Some(ref display_name) = calendar.display_name { + println!(" Display Name: {}", display_name); } if let Some(ref color) = calendar.color { println!(" Color: {}", color); } + if let Some(ref description) = calendar.description { + println!(" Description: {}", description); + } + if let Some(ref timezone) = calendar.timezone { + println!(" Timezone: {}", timezone); + } println!(" Supported Components: {}", calendar.supported_components.join(", ")); println!(); } @@ -315,13 +168,13 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { 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 { + // Use the provided calendar URL if available, otherwise discover calendars + let calendar_url = 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() + let calendars = sync_engine.client.discover_calendars().await?; + if let Some(calendar) = calendars.iter().find(|c| c.name == config.calendar.name || c.display_name.as_ref().map_or(false, |n| n == &config.calendar.name)) { + calendar.url.clone() } else { warn!("Calendar '{}' not found", config.calendar.name); return Ok(()); @@ -332,14 +185,18 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { 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 { + match sync_engine.client.get_events_with_approach(&calendar_url, start_date, end_date, Some(approach.clone())).await { Ok(events) => { println!("Found {} events using approach {}:", events.len(), approach); for event in events { - println!(" - {} ({} to {})", + let start_tz = event.start_tzid.as_deref().unwrap_or("UTC"); + let end_tz = event.end_tzid.as_deref().unwrap_or("UTC"); + println!(" - {} ({} {} to {} {})", event.summary, event.start.format("%Y-%m-%d %H:%M"), - event.end.format("%Y-%m-%d %H:%M") + start_tz, + event.end.format("%Y-%m-%d %H:%M"), + end_tz ); } } @@ -359,10 +216,14 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { println!("Found {} events:", events.len()); for event in events { - println!(" - {} ({} to {})", + let start_tz = event.start_tzid.as_deref().unwrap_or("UTC"); + let end_tz = event.end_tzid.as_deref().unwrap_or("UTC"); + println!(" - {} ({} {} to {} {})", event.summary, event.start.format("%Y-%m-%d %H:%M"), - event.end.format("%Y-%m-%d %H:%M") + start_tz, + event.end.format("%Y-%m-%d %H:%M"), + end_tz ); } 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 } }