diff --git a/.github/workflows/rust.yml b/.github/workflows/ci.yml similarity index 99% rename from .github/workflows/rust.yml rename to .github/workflows/ci.yml index 84a0744..641c5ca 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Rust CI +name: EventixCI on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 202e7db..d60541f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,9 @@ jobs: - name: Run all tests run: | - cargo test --all-features --verbose - cargo clippy --all-features -- -D warnings + set -euo pipefail + cargo test --all-features --all-targets --verbose + cargo clippy --all-features --all-targets -- -D warnings cargo fmt -- --check - name: Build documentation @@ -33,9 +34,48 @@ jobs: - name: Security audit run: | + set -euo pipefail cargo install cargo-audit cargo audit + - name: Resolve release version + id: release_meta + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + PACKAGE_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('Cargo.toml', 'rb'))['package']['version'])") + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + + echo "Cargo.toml version: $PACKAGE_VERSION" + echo "Git tag version: $TAG_VERSION" + + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "⚠️ Tag v$TAG_VERSION does not match Cargo.toml version $PACKAGE_VERSION" + echo "⚠️ Using Cargo.toml version as the authoritative README/publish version" + fi + + echo "version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Prepare README for publish + id: prepare_readme + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + VERSION="${{ steps.release_meta.outputs.version }}" + echo "Ensuring README dependency snippets match Cargo.toml version $VERSION" + + sed -i "s/^\([[:space:]]*\)eventix = \".*\"/\1eventix = \"$VERSION\"/" README.md + + if git diff --quiet README.md; then + echo "README already matches Cargo.toml version $VERSION" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "README updated in working tree before publish" + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff -- README.md + fi + + # Publish the tagged source, with README normalized from Cargo.toml just before packaging - name: Publish to crates.io if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') env: @@ -53,41 +93,83 @@ jobs: LICENSE-MIT LICENSE-APACHE - - name: Update README version + - name: Persist README version on main + id: sync_readme_main if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') run: | - # Checkout main branch for updates (release triggered on tag) - git checkout main - git pull origin main --rebase - - # Configure Git + set -euo pipefail + VERSION="${{ steps.release_meta.outputs.version }}" + git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' - - # Extract version from tag (e.g., v0.3.0 -> 0.3.0) - VERSION=${GITHUB_REF#refs/tags/v} - echo "Updating README to version $VERSION" - - # Replace 'eventix = "..."' with 'eventix = "$VERSION"', preserving indentation + + # Discard pre-publish README edit before switching branches + git checkout -- README.md + + git checkout main + git pull origin main --rebase + sed -i "s/^\([[:space:]]*\)eventix = \".*\"/\1eventix = \"$VERSION\"/" README.md - - # Check for changes + if git diff --quiet README.md; then - echo "No changes to README.md" + echo "README already up to date on main" + echo "commit_sha=" >> "$GITHUB_OUTPUT" else git commit -am "docs: update readme version to $VERSION [skip ci]" git push origin main - echo "Pushed README update to main" + + COMMIT_SHA=$(git rev-parse HEAD) + echo "commit_sha=$COMMIT_SHA" >> "$GITHUB_OUTPUT" + echo "Pushed README update to main ($COMMIT_SHA)" fi - - name: Merge main into dev - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + - name: Sync README update to dev + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && steps.sync_readme_main.outputs.commit_sha != '' run: | - VERSION=${GITHUB_REF#refs/tags/v} - echo "Merging main into dev..." + set -euo pipefail + COMMIT_SHA="${{ steps.sync_readme_main.outputs.commit_sha }}" + echo "Syncing README commit $COMMIT_SHA to dev..." git checkout dev git pull origin dev --rebase - git merge origin/main -m "merge: sync main to dev after release $VERSION" - git push origin dev - echo "Merged main into dev" + + # Check if commit is already on dev + if git merge-base --is-ancestor "$COMMIT_SHA" HEAD; then + echo "ℹ️ SKIPPED: Commit $COMMIT_SHA is already on dev branch" + exit 0 + fi + + # Attempt cherry-pick + echo "🍒 Cherry-picking commit $COMMIT_SHA..." + if git cherry-pick "$COMMIT_SHA" 2>&1; then + git push origin dev + echo "✅ SUCCESS: Cherry-picked and pushed README update to dev" + else + PICK_EXIT=$? + # Check if cherry-pick produced an empty commit (change already present) + if git diff --cached --quiet 2>/dev/null; then + echo "ℹ️ Cherry-pick is empty — change already present on dev" + git cherry-pick --abort 2>/dev/null || true + exit 0 + fi + + # Real conflict + echo "⚠️ Cherry-pick failed with exit code $PICK_EXIT" + + # Abort the failed cherry-pick + git cherry-pick --abort 2>/dev/null || true + + # Reset to clean state + git reset --hard HEAD + + echo "❌ CONFLICT: Could not cherry-pick README update to dev" + echo " The commit $COMMIT_SHA conflicts with current dev branch." + echo " Manual resolution required:" + echo " git checkout dev" + echo " git cherry-pick $COMMIT_SHA" + echo " # resolve conflicts" + echo " git push origin dev" + + # Exit with error to make the conflict visible + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf4677..e59e88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-03-07 + +### Added +- **Lazy recurrence iteration**: `Recurrence::occurrences()` returns an `OccurrenceIterator` for memory-efficient, on-demand occurrence generation. +- **Eager cap helper**: `Recurrence::generate_occurrences_capped()` provides capped eager collection, replacing the old `max_occurrences` parameter of `generate_occurrences()`. +- **Weekday filtering**: `Recurrence::weekdays()` builder method to restrict occurrences to specific days of the week. +- **Benchmark suite**: Criterion benchmarks for overlap/gap detection, density analysis, recurrence generation, and slot availability. +- **JSON/web examples**: Examples showing calendar import/export as JSON for API workflows. + +### Changed +- **[BREAKING] `generate_occurrences()` signature**: Changed from `generate_occurrences(start, max_occurrences)` to `generate_occurrences(start)` — now collects all occurrences defined by the recurrence's own `count`/`until` bounds. Use `generate_occurrences_capped(start, max)` for the old capped behavior. +- **[BREAKING] Count semantics**: `Recurrence::count(n)` now caps *emitted* occurrences, not scanned candidates. With weekday filters, `count(14)` yields exactly 14 matching days instead of scanning 14 slots. This affects both `generate_occurrences()` and `occurrences_between()`. +- **[BREAKING] Intersection-based filtering**: `Event::occurrences_between()` now uses time-range intersection (`dt < end && dt + duration > start`) instead of start-in-range filtering. Events starting before a query window but extending into it are now correctly included. +- **[BREAKING] Monthly/Yearly day clamping**: Monthly and yearly recurrence now clamps the day to the valid range (e.g. Jan 31 → Feb 28) instead of terminating the series. Code that relied on early termination will see additional occurrences. +- **Full sub-daily recurrence support**: `Hourly`, `Minutely`, and `Secondly` frequencies are now fully implemented. Sub-daily advancement uses UTC-duration arithmetic (`DateTime + Duration`) so DST transitions are handled transparently without any local-time look-up. New convenience constructors `Recurrence::hourly()`, `minutely()`, and `secondly()` are provided. `Recurrence::new()` remains infallible and accepts all seven RFC 5545 frequencies. +- **Faster overlap detection**: `find_overlaps()` uses an O(N log N) sweep-line algorithm instead of O(N²). +- **Correct boundary handling**: Back-to-back events that merely touch at a boundary are no longer reported as overlapping. +- **DST-safe recurrence**: Daily/Weekly recurrence uses local date arithmetic to preserve wall-clock time across DST transitions. +- **DST spring-forward resilience**: Recurrence generation uses pre-gap UTC offset conversion to land on the correct post-transition wall-clock time (e.g. 2:30 AM EST → 3:30 AM EDT), matching Google Calendar / RFC 5545 behaviour. Subsequent occurrences return to the originally intended time rather than drifting. +- **Deterministic overlap ordering**: `find_overlaps()` uses `BTreeSet` for consistent results. +- **Zero-duration events**: No longer interfere with overlap detection. +- **[BREAKING]** `generate_occurrences()` now returns an error for unbounded recurrences (no `count` or `until`). Use `occurrences()` for lazy iteration or `generate_occurrences_capped()` for a hard cap. + +### Fixed +- `occurs_on()` now correctly finds later occurrences of recurring events by using lazy iteration with post-filter capping. +- `occurrences_between()` now applies recurrence filters and exception dates lazily *before* the `max_occurrences` cap, so filtered-out dates don't consume result slots and capped queries stop after enough accepted results. +- `interval(0)` returns no occurrences instead of looping infinitely (previously caused an infinite loop). Consistent across all frequencies including weekly with BYDAY rules. +- `to_rrule_string()` now converts `UNTIL` values to UTC before appending the `Z` suffix, per RFC 5545 compliance. + ## [0.3.1] - 2025-12-18 ### Added diff --git a/Cargo.toml b/Cargo.toml index 8a9996c..822168c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eventix" -version = "0.3.1" +version = "0.4.0" edition = "2021" authors = ["Raj Sarkar "] description = "High-level calendar & recurrence crate with timezone-aware scheduling, exceptions, and ICS import/export" @@ -10,7 +10,15 @@ readme = "README.md" homepage = "https://github.com/AriajSarkar/eventix" keywords = ["calendar", "scheduling", "booking", "availability", "ics"] categories = ["date-and-time"] -exclude = ["usage-examples/"] +exclude = [ + "usage-examples/", + "examples_output/", + ".github/", + "target/", + "TODO.md", + "llms.txt", + "rustfmt.toml", +] [dependencies] chrono = { version = "0.4", features = ["serde"] } @@ -18,13 +26,18 @@ chrono-tz = { version = "0.10", features = ["serde"] } rrule = "0.14" icalendar = "0.17" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "2.0" -uuid = { version = "1.0", features = ["v4"] } +serde_json = "1" +thiserror = "2" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] -anyhow = "1.0" -proptest = "1.0" +anyhow = "1" +proptest = "1.10" +criterion = { version = "0.8", features = ["html_reports"] } + +[[bench]] +name = "performance" +harness = false [lints.clippy] unwrap_used = "warn" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 2456f20..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,180 +0,0 @@ -# Eventix Development Summary - -## ✅ Project Completed Successfully! - -### 📋 What Was Built - -A complete, production-ready Rust library crate called **Eventix** for high-level calendar and event scheduling with the following features: - -### 🎯 Core Features Implemented - -1. **Calendar & Event Management** - - ✅ `Calendar` struct to hold and organize events - - ✅ `Event` struct with full timezone support - - ✅ Builder pattern API for ergonomic event creation - - ✅ Event searching and filtering capabilities - -2. **Timezone Awareness** - - ✅ Full timezone support using `chrono` and `chrono-tz` - - ✅ DST (Daylight Saving Time) handling - - ✅ Timezone conversion utilities - - ✅ Timezone parsing and validation - -3. **Recurrence Support** - - ✅ Daily, weekly, monthly, and yearly recurrence patterns - - ✅ Interval support (every N days/weeks/months) - - ✅ Count limits (`count`) - - ✅ End date limits (`until`) - - ✅ Custom weekday selection - -4. **Exception Handling** - - ✅ Skip specific dates (`exdates`) - - ✅ Skip weekends filter - - ✅ Custom date filters for holidays - -5. **ICS Integration** - - ✅ Export calendars to `.ics` files - - ✅ Import calendars from `.ics` files - - ✅ Convert to/from ICS strings - - ✅ Full iCalendar format support - -6. **Serialization** - - ✅ JSON export/import for calendars - - ✅ Custom serialization for timezone-aware types - - ✅ Human-readable JSON format - -### 📁 Project Structure - -``` -Eventix/ -├── src/ -│ ├── lib.rs # Main library entry point -│ ├── calendar.rs # Calendar container and management -│ ├── event.rs # Event type and builder API -│ ├── recurrence.rs # Recurrence patterns and filters -│ ├── ics.rs # ICS import/export functionality -│ ├── timezone.rs # Timezone utilities and DST handling -│ └── error.rs # Error types and Result aliases -├── examples/ -│ ├── basic.rs # Basic calendar usage -│ ├── recurrence.rs # Recurrence patterns demo -│ └── ics_export.rs # ICS import/export demo -├── Cargo.toml # Dependencies and metadata -├── README.md # Comprehensive documentation -├── LICENSE-MIT # MIT license -└── LICENSE-APACHE # Apache 2.0 license -``` - -### 📦 Dependencies - -- `chrono` (0.4) with serde features - Date/time handling -- `chrono-tz` (0.10) with serde features - Timezone database -- `rrule` (0.13) - Recurrence rule support -- `icalendar` (0.16) - ICS format handling -- `serde` (1.0) - Serialization framework -- `serde_json` (1.0) - JSON support -- `thiserror` (1.0) - Error handling -- `uuid` (1.0) - Unique identifier generation - -### 🧪 Testing - -All tests passing ✅: -- **13 unit tests** - Core functionality -- **20 doc tests** - Documentation examples -- **100% test coverage** of public API - -### 📚 Examples Provided - -1. **basic.rs** - Simple calendar creation, event addition, searching -2. **recurrence.rs** - Daily, weekly, monthly, yearly recurrence patterns -3. **ics_export.rs** - Import/export calendar data in ICS format - -### 🚀 Running the Project - -```powershell -# Build the library -cargo build - -# Run tests -cargo test - -# Run examples -cargo run --example basic -cargo run --example recurrence -cargo run --example ics_export - -# Build documentation -cargo doc --open -``` - -### 💡 Usage Example - -```rust -use Eventix::{Calendar, Event, Recurrence}; - -let mut cal = Calendar::new("Work Calendar"); - -let meeting = Event::builder() - .title("Daily Standup") - .start("2025-11-01 09:00:00", "America/New_York") - .duration_minutes(15) - .recurrence(Recurrence::daily().count(30)) - .skip_weekends(true) - .build()?; - -cal.add_event(meeting); -cal.export_to_ics("calendar.ics")?; -``` - -### ✨ Key Highlights - -1. **Ergonomic API** - Builder pattern makes event creation intuitive -2. **Type Safety** - Leverages Rust's type system for correctness -3. **Timezone Aware** - Proper handling of timezones and DST -4. **Well Documented** - Comprehensive docs with examples -5. **Production Ready** - Error handling, tests, and validations -6. **Standards Compliant** - Full iCalendar (RFC 5545) support - -### 📖 Documentation - -- Comprehensive README with examples -- Inline documentation for all public APIs -- Doc tests for all major features -- Three complete working examples - -### 🎓 What You Learned - -This project demonstrates: -- Rust library crate creation -- Builder pattern implementation -- Working with external crates (`chrono`, `icalendar`, etc.) -- Timezone and datetime handling -- Custom serialization/deserialization -- Error handling with `thiserror` -- Writing tests and documentation -- Creating examples for users - -### 🔄 Next Steps (Optional Enhancements) - -If you want to extend this project, consider: -- Adding support for all-day events -- Implementing recurring event exceptions (RRULE + EXDATE) -- Adding alarm/reminder support -- Supporting multiple calendars -- Adding event attendee status tracking -- Implementing calendar sync protocols (CalDAV) -- Creating a CLI tool or web API - -### 📝 Notes - -- The project compiles with some warnings about lifetime syntax (cosmetic, not errors) -- Recurrence generation uses a simplified algorithm (could integrate full rrule parsing) -- ICS import is basic (could be extended for more complex iCalendar features) - ---- - -**Status**: ✅ Fully Functional and Ready to Use! - -**Build Status**: ✅ All tests passing -**Documentation**: ✅ Complete with examples -**Examples**: ✅ All working correctly diff --git a/README.md b/README.md index a0e7f0f..99cb6e6 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ A high-level calendar and recurrence library for Rust with timezone-aware schedu [![Crates.io](https://img.shields.io/crates/v/eventix.svg)](https://crates.io/crates/eventix) [![Documentation](https://docs.rs/eventix/badge.svg)](https://docs.rs/eventix) -[![CI](https://github.com/AriajSarkar/eventix/workflows/Rust%20CI/badge.svg)](https://github.com/AriajSarkar/eventix/actions) +[![CI](https://github.com/AriajSarkar/eventix/workflows/EventixCI/badge.svg)](https://github.com/AriajSarkar/eventix/actions) [![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](LICENSE) ## Features - 🌍 **Timezone-aware events** - Full support for timezones and DST handling using `chrono-tz` -- 🔄 **Recurrence patterns** - Daily, weekly, monthly, and yearly recurrence with advanced rules +- 🔄 **Recurrence patterns** - All seven RFC 5545 frequencies (secondly, minutely, hourly, daily, weekly, monthly, yearly) with advanced rules - 🚫 **Exception handling** - Skip specific dates, weekends, or custom holiday lists - 🚦 **Booking workflow** - Manage event status (`Confirmed`, `Tentative`, `Cancelled`) with smart gap validation - 📅 **ICS support** - Import and export events using the iCalendar (`.ics`) format @@ -35,7 +35,7 @@ Add eventix to your `Cargo.toml`: ```toml [dependencies] -eventix = "0.3.1" +eventix = "0.4.0" ``` ### Basic Usage @@ -82,8 +82,7 @@ fn main() -> Result<(), Box> { ### Daily Recurrence with Exceptions ```rust -use eventix::{Event, Recurrence, timezone}; - +use eventix::{Duration, Event, Recurrence, timezone}; let tz = timezone::parse_timezone("America/New_York")?; let holiday = timezone::parse_datetime_with_tz("2025-11-27 09:00:00", tz)?; @@ -100,8 +99,7 @@ let event = Event::builder() ### Weekly Recurrence ```rust -use eventix::{Event, Recurrence}; - +use eventix::{Duration, Event, Recurrence}; let event = Event::builder() .title("Weekly Team Meeting") .start("2025-11-03 14:00:00", "UTC") @@ -123,11 +121,69 @@ let event = Event::builder() .build()?; ``` -### Booking Workflow +### Sub-daily Recurrence (Hourly, Minutely, Secondly) + +Sub-daily frequencies advance by a fixed UTC duration. This gives **"same elapsed +time"** semantics — not "same local wall-clock slot." During a DST transition the +local-time label may shift (e.g. 1:00 AM → 3:00 AM when clocks spring forward) +but the actual interval between occurrences is always exact. + +```rust +use eventix::{Duration, Event, Recurrence}; + +// Every 4 hours — e.g. 08:00, 12:00, 16:00, 20:00... +let reminder = Event::builder() + .title("Medication Reminder") + .start("2025-06-01 08:00:00", "America/New_York") + .duration(Duration::minutes(5)) + .recurrence(Recurrence::hourly().interval(4).count(6)) + .build()?; + +// Every 15 minutes — e.g. pomodoro timer +let pomo = Event::builder() + .title("Pomodoro") + .start("2025-06-01 09:00:00", "UTC") + .duration(Duration::minutes(1)) + .recurrence(Recurrence::minutely().interval(15).count(8)) + .build()?; + +// Every 30 seconds — e.g. health-check ping +let ping = Event::builder() + .title("Health Check") + .start("2025-06-01 12:00:00", "UTC") + .duration(Duration::seconds(1)) + .recurrence(Recurrence::secondly().interval(30).count(10)) + .build()?; +``` + +### Lazy Occurrence Iteration + +The `OccurrenceIterator` computes each occurrence on demand, making it ideal for +large or unbounded recurrence patterns. It supports standard iterator combinators +(`.take()`, `.filter()`, `.collect()`, etc.): ```rust -use eventix::{Event, EventStatus}; +use eventix::{Recurrence, timezone}; + +let tz = timezone::parse_timezone("UTC")?; +let start = timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz)?; + +let daily = Recurrence::daily().count(365); +// Take only the first 5 occurrences lazily +let first_five: Vec<_> = daily.occurrences(start).take(5).collect(); + +// Filter to Mondays only (chrono::Weekday) +let mondays: Vec<_> = daily.occurrences(start) + .filter(|dt| dt.weekday() == chrono::Weekday::Mon) + .take(10) + .collect(); +``` + +### Booking Status + +```rust +use eventix::{Duration, Event, EventStatus}; let mut event = Event::builder() .title("Tentative Meeting") .start("2025-11-01 10:00:00", "UTC") diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 6f9b531..0000000 --- a/TESTING.md +++ /dev/null @@ -1,183 +0,0 @@ -# Eventix Testing Summary - -## Test Coverage - -### Unit Tests (22 tests) -Located in `src/` modules - testing individual functions and components: - -#### Calendar Module (`src/calendar.rs`) -- ✅ `test_calendar_creation` - Calendar initialization -- ✅ `test_add_events` - Adding events to calendar -- ✅ `test_find_events` - Finding events by title -- ✅ `test_json_serialization` - JSON serialization/deserialization - -#### Event Module (`src/event.rs`) -- ✅ `test_event_builder` - Builder pattern API -- ✅ `test_event_validation` - Event validation logic - -#### Gap Validation Module (`src/gap_validation.rs`) -- ✅ `test_find_gaps` - Gap detection between events -- ✅ `test_find_overlaps_no_conflict` - Overlap detection with no conflicts -- ✅ `test_find_overlaps_with_conflict` - Overlap detection with conflicts -- ✅ `test_calculate_density` - Schedule density calculation -- ✅ `test_find_longest_gap` - Finding longest available time slot -- ✅ `test_find_available_slots` - Finding slots matching duration -- ✅ `test_is_slot_available` - Checking slot availability -- ✅ `test_suggest_alternatives` - Alternative time suggestions -- ✅ `test_schedule_density_busy` - Busy schedule metrics - -#### Recurrence Module (`src/recurrence.rs`) -- ✅ `test_daily_recurrence` - Daily recurrence patterns -- ✅ `test_weekly_recurrence` - Weekly recurrence patterns -- ✅ `test_recurrence_filter_weekends` - Weekend filtering - -#### ICS Module (`src/ics.rs`) -- ✅ `test_ics_export` - ICS export functionality - -#### Timezone Module (`src/timezone.rs`) -- ✅ `test_parse_timezone` - Timezone parsing -- ✅ `test_parse_datetime` - Datetime parsing with timezone -- ✅ `test_convert_timezone` - Timezone conversion - -### Integration Tests (11 tests) -Located in `tests/gap_validation_tests.rs` - testing complete workflows: - -- ✅ `test_comprehensive_gap_detection` - Complete gap detection workflow -- ✅ `test_overlap_detection_complex` - Complex overlap scenarios -- ✅ `test_schedule_density_analysis` - Density analysis workflow -- ✅ `test_longest_gap_finder` - Longest gap finding -- ✅ `test_find_available_slots_for_meeting` - Meeting slot finding -- ✅ `test_slot_availability_edge_cases` - Edge case handling -- ✅ `test_conflict_resolution_suggestions` - Conflict resolution -- ✅ `test_recurring_events_gap_detection` - Recurring event gaps -- ✅ `test_multi_timezone_gap_detection` - Multi-timezone support -- ✅ `test_gap_metadata` - Gap metadata verification -- ✅ `test_density_metrics_comprehensive` - Comprehensive density metrics - -### Documentation Tests (23 tests) -Located in doc comments throughout the codebase: - -- ✅ 23 doc examples compile and run successfully -- Coverage includes: Calendar API, Event builder, Recurrence patterns, ICS export/import, Timezone handling, Gap validation - -## Test Results - -``` -running 22 tests (unit) -test result: ok. 22 passed; 0 failed; 0 ignored - -running 11 tests (integration) -test result: ok. 11 passed; 0 failed; 0 ignored - -running 23 tests (doc) -test result: ok. 23 passed; 0 failed; 0 ignored -``` - -**Total: 56 tests, 100% passing ✅** - -## Examples - -All examples run successfully: - -### `basic.rs` -Demonstrates: -- Calendar creation -- Adding events -- Timezone handling -- Event metadata - -### `recurrence.rs` -Demonstrates: -- Daily, weekly, monthly, yearly recurrence -- Weekend filtering -- Exception dates -- Count and until limits - -### `ics_export.rs` -Demonstrates: -- ICS export -- Multiple events -- Timezone preservation - -### `gap_validation.rs` -Demonstrates: -- Gap detection -- Overlap detection -- Schedule density analysis -- Available slot finding -- Conflict resolution -- Recurring event analysis - -## Build Status - -- ✅ **Zero warnings** - Clean compilation -- ✅ **Zero errors** - All code type-checks -- ✅ **Clippy clean** - No linter warnings -- ✅ **Doc generation** - All documentation builds successfully - -## Coverage by Feature - -| Feature | Unit Tests | Integration Tests | Doc Tests | Examples | -|---------|-----------|------------------|-----------|----------| -| Calendar Management | ✅ | ✅ | ✅ | ✅ | -| Event Building | ✅ | ✅ | ✅ | ✅ | -| Recurrence Patterns | ✅ | ✅ | ✅ | ✅ | -| ICS Import/Export | ✅ | ✅ | ✅ | ✅ | -| Timezone Support | ✅ | ✅ | ✅ | ✅ | -| Gap Detection | ✅ | ✅ | ✅ | ✅ | -| Overlap Detection | ✅ | ✅ | ✅ | ✅ | -| Schedule Analysis | ✅ | ✅ | ✅ | ✅ | -| Conflict Resolution | ✅ | ✅ | ✅ | ✅ | - -## Unique Features (Not in Other Crates) - -The `gap_validation` module provides features not found in other Rust calendar crates: - -1. **Gap Detection** - Find free time between events -2. **Overlap Detection** - Identify scheduling conflicts -3. **Schedule Density** - Calculate occupancy metrics -4. **Available Slots** - Find times that fit specific durations -5. **Slot Availability** - Check if a specific time is free -6. **Conflict Resolution** - Suggest alternative times for conflicts -7. **Longest Gap Finder** - Find maximum continuous free time - -## Running Tests - -```bash -# Run all tests -cargo test - -# Run specific test suite -cargo test --lib # Unit tests only -cargo test --test gap_validation_tests # Integration tests -cargo test --doc # Doc tests only - -# Run with output -cargo test -- --nocapture - -# Run specific test -cargo test test_find_gaps - -# Run examples -cargo run --example basic -cargo run --example recurrence -cargo run --example ics_export -cargo run --example gap_validation -``` - -## Test-Driven Development - -The project was developed using TDD: -1. Tests written before implementation -2. Implementation driven by test requirements -3. Refactoring validated by tests -4. Edge cases identified through test exploration - -## Future Test Enhancements - -Potential areas for additional testing: -- [ ] ICS import integration tests -- [ ] Performance benchmarks for large calendars -- [ ] Fuzzing for recurrence edge cases -- [ ] Property-based testing with proptest -- [ ] Multi-threaded access patterns diff --git a/TODO.md b/TODO.md index 94bc246..0f56a5b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,9 +10,10 @@ This document outlines the strategic technical direction for the `eventix` crate - **Implementation**: strict state transitions (`confirm()`, `cancel()`, `reschedule()`) to ensure data integrity. - **Impact**: Enables `gap_validation` to automatically ignore cancelled events, significantly reducing implementation complexity for consumers. -- [ ] **Advanced Recurrence Optimization** `(Priority: Medium)` - - **Optimization**: Implement caching or lazy evaluation for infinite recurrence rules (`RRule`). - - **Feature**: "Smart" expansion that only computes instances within the relevant view window. +- [x] **Advanced Recurrence Optimization** `(Priority: Medium)` + - **Optimization**: Lazy `OccurrenceIterator` computes occurrences on demand, zero upfront allocation for infinite recurrence rules. + - **Feature**: `occurrences_between()` uses windowed expansion (`take_while` + `filter`) to only compute instances within the relevant view window. + - **DST safety**: `resolve_local()` handles spring-forward gaps via pre-gap UTC offset; `intended_time` parameter prevents wall-clock drift across DST transitions. ## 2. API & Integration Ecosystem *Objective: Enable seamless integration with modern web stacks and AI agents.* diff --git a/benches/performance.rs b/benches/performance.rs new file mode 100644 index 0000000..c420103 --- /dev/null +++ b/benches/performance.rs @@ -0,0 +1,612 @@ +#![allow(clippy::unwrap_used)] + +//! Performance benchmarks for eventix +//! +//! Run with: cargo bench +//! +//! Benchmark groups: +//! +//! 1. **Gap / overlap / density analysis** — sweep-line algorithms at scale +//! 2. **Recurrence generation** — lazy vs eager, all 7 frequencies +//! 3. **Sub-daily dense recurrence** — secondly/minutely/hourly over large windows +//! 4. **Lazy capped `occurrences_between`** — the key perf path: dense series, +//! small cap, must NOT scan the whole window +//! 5. **Large synthetic calendar workloads** — 1K–10K events +//! 6. **ICS export throughput** — serialization at volume +//! 7. **Multi-timezone recurring expansion** — DST-heavy workloads + +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use eventix::{gap_validation, timezone, Calendar, Duration, Event, Recurrence}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Create a calendar with `n` non-overlapping 1-hour events spaced 2 hours apart. +fn create_calendar_with_events(num_events: usize) -> Calendar { + let mut cal = Calendar::new("Benchmark Calendar"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + + for i in 0..num_events { + let event = Event::builder() + .title(format!("Event {}", i)) + .start_datetime(base + Duration::hours(i as i64 * 2)) + .duration_hours(1) + .build() + .unwrap(); + cal.add_event(event); + } + cal +} + +/// Create a calendar with overlapping events (for overlap detection benchmarks). +fn create_calendar_with_overlaps(num_events: usize) -> Calendar { + let mut cal = Calendar::new("Overlap Calendar"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + for i in 0..num_events { + let event = Event::builder() + .title(format!("Overlap Event {}", i)) + .start_datetime(base + Duration::minutes(i as i64 * 30)) + .duration_hours(2) // 2-hour events every 30 mins = guaranteed overlaps + .build() + .unwrap(); + cal.add_event(event); + } + cal +} + +/// Create a large calendar mixing one-off and recurring events. +fn create_large_mixed_calendar(one_off: usize, recurring: usize) -> Calendar { + let mut cal = Calendar::new("Large Mixed Calendar"); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let base = timezone::parse_datetime_with_tz("2025-01-01 08:00:00", tz).unwrap(); + + for i in 0..one_off { + let event = Event::builder() + .title(format!("OneOff {}", i)) + .start_datetime(base + Duration::hours(i as i64)) + .duration_minutes(45) + .build() + .unwrap(); + cal.add_event(event); + } + + for i in 0..recurring { + let event = Event::builder() + .title(format!("Recurring {}", i)) + .start_datetime(base + Duration::hours(i as i64 * 3)) + .duration_hours(1) + .recurrence(Recurrence::daily().count(30)) + .build() + .unwrap(); + cal.add_event(event); + } + cal +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Gap / Overlap / Density analysis +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_find_overlaps(c: &mut Criterion) { + let mut group = c.benchmark_group("find_overlaps"); + + for size in [10, 50, 100, 500, 1000].iter() { + let cal = create_calendar_with_overlaps(*size); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", tz).unwrap(); + + group.bench_with_input(BenchmarkId::new("sweep_line", size), size, |b, _| { + b.iter(|| { + gap_validation::find_overlaps(black_box(&cal), black_box(start), black_box(end)) + .unwrap() + }) + }); + } + group.finish(); +} + +fn bench_find_gaps(c: &mut Criterion) { + let mut group = c.benchmark_group("find_gaps"); + + for size in [10, 50, 100, 500, 1000].iter() { + let cal = create_calendar_with_events(*size); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", tz).unwrap(); + + group.bench_with_input(BenchmarkId::new("sorted_sweep", size), size, |b, _| { + b.iter(|| { + gap_validation::find_gaps( + black_box(&cal), + black_box(start), + black_box(end), + black_box(Duration::minutes(0)), + ) + .unwrap() + }) + }); + } + group.finish(); +} + +fn bench_calculate_density(c: &mut Criterion) { + let mut group = c.benchmark_group("calculate_density"); + + for size in [10, 50, 100, 500, 1000].iter() { + let cal = create_calendar_with_events(*size); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", tz).unwrap(); + + group.bench_with_input(BenchmarkId::new("full_analysis", size), size, |b, _| { + b.iter(|| { + gap_validation::calculate_density(black_box(&cal), black_box(start), black_box(end)) + .unwrap() + }) + }); + } + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Recurrence generation — all 7 frequencies, lazy vs eager +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_recurrence_generation(c: &mut Criterion) { + let mut group = c.benchmark_group("recurrence_generation"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + for count in [10u32, 100, 500, 1000].iter() { + let recurrence = Recurrence::daily().count(*count); + + group.bench_with_input(BenchmarkId::new("eager_daily", count), count, |b, &c| { + b.iter(|| { + recurrence + .generate_occurrences_capped(black_box(start), black_box(c as usize)) + .unwrap() + }) + }); + + group.bench_with_input(BenchmarkId::new("lazy_daily", count), count, |b, _| { + b.iter(|| { + let result: Vec<_> = recurrence.occurrences(black_box(start)).collect(); + result + }) + }); + + // Lazy partial consumption — only take 10% + let take_count = (*count as usize) / 10; + group.bench_with_input(BenchmarkId::new("lazy_take_10pct", count), count, |b, _| { + b.iter(|| { + let result: Vec<_> = + recurrence.occurrences(black_box(start)).take(take_count).collect(); + result + }) + }); + } + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Sub-daily dense recurrence — secondly / minutely / hourly at scale +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_subdaily_recurrence(c: &mut Criterion) { + let mut group = c.benchmark_group("subdaily_recurrence"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap(); + + // Secondly: generate N occurrences + for count in [100u32, 1000, 10_000].iter() { + let recurrence = Recurrence::secondly().interval(1).count(*count); + group.bench_with_input(BenchmarkId::new("secondly", count), count, |b, _| { + b.iter(|| { + let result: Vec<_> = recurrence.occurrences(black_box(start)).collect(); + result + }) + }); + } + + // Minutely: generate N occurrences + for count in [100u32, 1000, 10_000].iter() { + let recurrence = Recurrence::minutely().interval(1).count(*count); + group.bench_with_input(BenchmarkId::new("minutely", count), count, |b, _| { + b.iter(|| { + let result: Vec<_> = recurrence.occurrences(black_box(start)).collect(); + result + }) + }); + } + + // Hourly: generate N occurrences + for count in [100u32, 1000, 10_000].iter() { + let recurrence = Recurrence::hourly().interval(1).count(*count); + group.bench_with_input(BenchmarkId::new("hourly", count), count, |b, _| { + b.iter(|| { + let result: Vec<_> = recurrence.occurrences(black_box(start)).collect(); + result + }) + }); + } + + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. Lazy capped `occurrences_between` — the critical perf path +// +// Dense sub-daily recurrence (e.g. 100K secondly occurrences) over a huge +// window, but only requesting a small number of results. The lazy +// pipeline must short-circuit after `max_occurrences` accepted entries. +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_occurrences_between_capped(c: &mut Criterion) { + let mut group = c.benchmark_group("occurrences_between_capped"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let window_start = timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap(); + let window_end = timezone::parse_datetime_with_tz("2025-07-01 00:00:00", tz).unwrap(); + + // Secondly — 100K candidates in a 30-day window, cap at 10/100/1000 + for cap in [10usize, 100, 1000].iter() { + let event = Event::builder() + .title("Dense Secondly") + .start("2025-06-01 00:00:00", "UTC") + .duration(Duration::seconds(1)) + .recurrence(Recurrence::secondly().interval(1).count(100_000)) + .build() + .unwrap(); + + group.bench_with_input(BenchmarkId::new("secondly_100k_cap", cap), cap, |b, &cap| { + b.iter(|| { + event + .occurrences_between( + black_box(window_start), + black_box(window_end), + black_box(cap), + ) + .unwrap() + }) + }); + } + + // Minutely — 100K candidates, cap at 10/100 + for cap in [10usize, 100].iter() { + let event = Event::builder() + .title("Dense Minutely") + .start("2025-06-01 00:00:00", "UTC") + .duration(Duration::seconds(10)) + .recurrence(Recurrence::minutely().interval(1).count(100_000)) + .build() + .unwrap(); + + group.bench_with_input(BenchmarkId::new("minutely_100k_cap", cap), cap, |b, &cap| { + b.iter(|| { + event + .occurrences_between( + black_box(window_start), + black_box(window_end), + black_box(cap), + ) + .unwrap() + }) + }); + } + + // Hourly with weekend filter — 50K candidates, cap at 20 + { + let event = Event::builder() + .title("Hourly Filtered") + .start("2025-06-02 08:00:00", "UTC") // Monday + .duration_minutes(5) + .recurrence(Recurrence::hourly().interval(1).count(50_000)) + .skip_weekends(true) + .build() + .unwrap(); + + group.bench_function("hourly_50k_skip_weekends_cap20", |b| { + b.iter(|| { + event + .occurrences_between( + black_box(window_start), + black_box(window_end), + black_box(20), + ) + .unwrap() + }) + }); + } + + // Daily with weekend filter — realistic corporate scheduling + { + let event = Event::builder() + .title("Daily Standup") + .start("2025-01-06 09:00:00", "America/New_York") // Monday + .duration_minutes(15) + .recurrence(Recurrence::daily().count(365)) + .skip_weekends(true) + .build() + .unwrap(); + + let ny_tz = timezone::parse_timezone("America/New_York").unwrap(); + let year_start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", ny_tz).unwrap(); + let year_end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", ny_tz).unwrap(); + + group.bench_function("daily_365_skip_weekends_cap50", |b| { + b.iter(|| { + event + .occurrences_between(black_box(year_start), black_box(year_end), black_box(50)) + .unwrap() + }) + }); + } + + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. Large synthetic calendar workloads +// (1K–10K events, mix of one-off and recurring) +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_large_scale_calendar(c: &mut Criterion) { + let mut group = c.benchmark_group("large_scale_calendar"); + group.sample_size(20); // fewer samples for expensive benches + + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let year_start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let year_end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", tz).unwrap(); + + // 1K one-off events — gap analysis across a full year + { + let cal = create_calendar_with_events(1000); + group.bench_function("gap_analysis_1k_events", |b| { + b.iter(|| { + gap_validation::find_gaps( + black_box(&cal), + black_box(year_start), + black_box(year_end), + black_box(Duration::minutes(30)), + ) + .unwrap() + }) + }); + } + + // 5K one-off events — overlap detection + { + let cal = create_calendar_with_overlaps(5000); + group.bench_function("overlap_sweep_5k_events", |b| { + b.iter(|| { + gap_validation::find_overlaps( + black_box(&cal), + black_box(year_start), + black_box(year_end), + ) + .unwrap() + }) + }); + } + + // Mixed calendar: 500 one-off + 100 recurring (×30 days each = 3500 expanded) + { + let cal = create_large_mixed_calendar(500, 100); + group.bench_function("density_mixed_500_100rec", |b| { + b.iter(|| { + gap_validation::calculate_density( + black_box(&cal), + black_box(year_start), + black_box(year_end), + ) + .unwrap() + }) + }); + } + + // Suggest alternatives with a dense schedule — 200 events + { + let cal = create_calendar_with_events(200); + let check_time = timezone::parse_datetime_with_tz("2025-01-01 02:00:00", tz).unwrap(); + group.bench_function("suggest_alternatives_200_events", |b| { + b.iter(|| { + gap_validation::suggest_alternatives( + black_box(&cal), + black_box(check_time), + black_box(Duration::hours(1)), + black_box(Duration::hours(8)), + ) + .unwrap() + }) + }); + } + + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. ICS export throughput — serialize large calendars +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_ics_export(c: &mut Criterion) { + let mut group = c.benchmark_group("ics_export"); + group.sample_size(20); + + for size in [50, 200, 1000].iter() { + let cal = create_calendar_with_events(*size); + group.bench_with_input(BenchmarkId::new("to_ics_string", size), size, |b, _| { + b.iter(|| black_box(&cal).to_ics_string().unwrap()) + }); + } + + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Multi-timezone DST-heavy recurrence +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_dst_recurrence(c: &mut Criterion) { + let mut group = c.benchmark_group("dst_recurrence"); + + // Daily recurrence across both DST transitions in America/New_York + // (spring-forward Mar 9, fall-back Nov 2 in 2025) + { + let event = Event::builder() + .title("Daily across DST") + .start("2025-01-01 02:30:00", "America/New_York") // 2:30 AM — DST gap time + .duration_hours(1) + .recurrence(Recurrence::daily().count(365)) + .build() + .unwrap(); + + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-12-31 23:59:59", tz).unwrap(); + + group.bench_function("daily_365_across_dst_nyc", |b| { + b.iter(|| { + event + .occurrences_between(black_box(start), black_box(end), black_box(365)) + .unwrap() + }) + }); + } + + // Hourly across spring-forward — sub-daily DST stress + { + let event = Event::builder() + .title("Hourly across spring-forward") + .start("2025-03-08 22:00:00", "America/New_York") + .duration_minutes(10) + .recurrence(Recurrence::hourly().count(48)) // 2 days of hourly + .build() + .unwrap(); + + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-03-08 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-03-11 00:00:00", tz).unwrap(); + + group.bench_function("hourly_48_spring_forward", |b| { + b.iter(|| { + event + .occurrences_between(black_box(start), black_box(end), black_box(48)) + .unwrap() + }) + }); + } + + // Multiple timezones — simulate a global org + { + let timezones = [ + "America/New_York", + "America/Los_Angeles", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", + "Australia/Sydney", + ]; + + let mut cal = Calendar::new("Global Org"); + for tz_name in timezones.iter() { + let event = Event::builder() + .title(format!("Regional Standup {}", tz_name)) + .start("2025-01-06 09:00:00", tz_name) + .duration_minutes(30) + .recurrence(Recurrence::daily().count(260)) // ~1 year of weekdays + .skip_weekends(true) + .build() + .unwrap(); + cal.add_event(event); + + // Add some hourly monitoring per region + let monitor = Event::builder() + .title(format!("Monitor {}", tz_name)) + .start("2025-01-01 00:00:00", tz_name) + .duration_minutes(5) + .recurrence(Recurrence::hourly().interval(6).count(1460)) // every 6h for a year + .build() + .unwrap(); + cal.add_event(monitor); + } + + let tz = timezone::parse_timezone("UTC").unwrap(); + let q1_start = timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let q1_end = timezone::parse_datetime_with_tz("2025-03-31 23:59:59", tz).unwrap(); + + group.bench_function("global_org_q1_density_6tz", |b| { + b.iter(|| { + gap_validation::calculate_density( + black_box(&cal), + black_box(q1_start), + black_box(q1_end), + ) + .unwrap() + }) + }); + } + + group.finish(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Slot availability — point queries at scale +// ═══════════════════════════════════════════════════════════════════════════ + +fn bench_is_slot_available(c: &mut Criterion) { + let mut group = c.benchmark_group("is_slot_available"); + + for size in [10, 50, 100, 500, 1000].iter() { + let cal = create_calendar_with_events(*size); + let tz = timezone::parse_timezone("UTC").unwrap(); + let slot_start_gap = timezone::parse_datetime_with_tz("2025-01-01 01:00:00", tz).unwrap(); + let slot_end_gap = slot_start_gap + Duration::hours(1); + let slot_start_conflict = + timezone::parse_datetime_with_tz("2025-01-01 02:30:00", tz).unwrap(); + let slot_end_conflict = slot_start_conflict + Duration::hours(1); + + group.bench_with_input(BenchmarkId::new("check_available_gap", size), size, |b, _| { + b.iter(|| { + gap_validation::is_slot_available( + black_box(&cal), + black_box(slot_start_gap), + black_box(slot_end_gap), + ) + .unwrap() + }) + }); + + group.bench_with_input(BenchmarkId::new("check_conflict", size), size, |b, _| { + b.iter(|| { + gap_validation::is_slot_available( + black_box(&cal), + black_box(slot_start_conflict), + black_box(slot_end_conflict), + ) + .unwrap() + }) + }); + } + group.finish(); +} + +criterion_group!( + benches, + bench_find_overlaps, + bench_find_gaps, + bench_calculate_density, + bench_recurrence_generation, + bench_subdaily_recurrence, + bench_occurrences_between_capped, + bench_large_scale_calendar, + bench_ics_export, + bench_dst_recurrence, + bench_is_slot_available, +); +criterion_main!(benches); diff --git a/examples/json_web.rs b/examples/json_web.rs new file mode 100644 index 0000000..6519cf0 --- /dev/null +++ b/examples/json_web.rs @@ -0,0 +1,84 @@ +//! JSON Serialization Example +//! +//! Shows how to export and import calendars as JSON. +//! Great for REST APIs, web apps, and database storage. +//! +//! Run with: cargo run --example json_web + +use eventix::{Calendar, Event, Utc}; +use serde_json::json; + +fn main() -> anyhow::Result<()> { + println!("=== JSON Example ===\n"); + + // ───────────────────────────────────────────── + // 1. CREATE A CALENDAR + // ───────────────────────────────────────────── + let mut cal = Calendar::new("Team Schedule"); + + cal.add_event( + Event::builder() + .title("Morning Standup") + .start("2025-01-15 09:00:00", "UTC") + .duration_minutes(15) + .build()?, + ); + + cal.add_event( + Event::builder() + .title("Sprint Planning") + .start("2025-01-15 14:00:00", "UTC") + .duration_hours(2) + .build()?, + ); + + // ───────────────────────────────────────────── + // 2. EXPORT TO JSON + // ───────────────────────────────────────────── + println!("📤 Export to JSON:"); + let json_output = cal.to_json()?; + println!("{}\n", json_output); + + // ───────────────────────────────────────────── + // 3. IMPORT FROM JSON (like from a web form) + // ───────────────────────────────────────────── + println!("📥 Import from JSON:"); + + let from_web = r#" + { + "name": "Client Calendar", + "events": [{ + "title": "Client Demo", + "start_time": "2025-02-01T14:00:00+00:00", + "end_time": "2025-02-01T15:00:00+00:00", + "timezone": "UTC", + "status": "Confirmed", + "attendees": ["client@example.com"], + "description": null, + "location": "Zoom", + "uid": null + }], + "timezone": "UTC" + } + "#; + + let imported = Calendar::from_json(from_web)?; + println!("Imported: '{}' with {} event(s)", imported.name, imported.event_count()); + + // ───────────────────────────────────────────── + // 4. BUILD API RESPONSE (using serde_json::json!) + // ───────────────────────────────────────────── + println!("\n🌐 API Response:"); + + let response = json!({ + "ok": true, + "calendar_name": cal.name, + "event_count": cal.event_count(), + "timestamp": Utc::now().to_rfc3339() + }); + + println!("{}", serde_json::to_string_pretty(&response)?); + + println!("\n✅ Done!"); + Ok(()) +} diff --git a/src/calendar.rs b/src/calendar.rs index 5d7d169..51fef7e 100644 --- a/src/calendar.rs +++ b/src/calendar.rs @@ -2,8 +2,10 @@ use crate::error::{EventixError, Result}; use crate::event::Event; +use crate::recurrence::Recurrence; use chrono::{DateTime, TimeZone}; use chrono_tz::Tz; +use rrule::Frequency; /// A calendar containing multiple events #[derive(Debug, Clone)] @@ -150,15 +152,38 @@ impl Calendar { /// Get all events occurring within a date range /// /// This expands recurring events into individual occurrences. + /// Uses [events_between_capped](Self::events_between_capped) with a + /// per-event limit of 100,000 occurrences. + /// + /// **Note:** If any single event generates more than 100,000 occurrences + /// within the range (e.g. a secondly recurrence over a large window), + /// the result will be silently truncated. Use + /// [`events_between_capped`](Self::events_between_capped) with an + /// explicit cap when result completeness is critical. pub fn events_between( &self, start: DateTime, end: DateTime, + ) -> Result>> { + self.events_between_capped(start, end, 100_000) + } + + /// Get all events occurring within a date range, with an explicit + /// per-event occurrence cap. + /// + /// `max_per_event` limits how many occurrences each individual event may + /// contribute. This prevents dense sub-daily recurrences from causing + /// unbounded memory use when querying large time windows. + pub fn events_between_capped( + &self, + start: DateTime, + end: DateTime, + max_per_event: usize, ) -> Result>> { let mut occurrences = Vec::new(); for (index, event) in self.events.iter().enumerate() { - let event_occurrences = event.occurrences_between(start, end, 1000)?; + let event_occurrences = event.occurrences_between(start, end, max_per_event)?; for occurrence_time in event_occurrences { occurrences.push(EventOccurrence { @@ -210,22 +235,35 @@ impl Calendar { } /// Export calendar to JSON + /// + /// Includes recurrence rules and exception dates for full round-trip + /// fidelity with [`from_json()`](Self::from_json). pub fn to_json(&self) -> Result { - // Create a simplified version for JSON serialization let json_val = serde_json::json!({ "name": self.name, "description": self.description, - "events": self.events.iter().map(|e| serde_json::json!({ - "title": e.title, - "description": e.description, - "start_time": e.start_time.to_rfc3339(), - "end_time": e.end_time.to_rfc3339(), - "timezone": e.timezone.name(), - "attendees": e.attendees, - "location": e.location, - "uid": e.uid, - "status": e.status, - })).collect::>(), + "events": self.events.iter().map(|e| { + let mut ev = serde_json::json!({ + "title": e.title, + "description": e.description, + "start_time": e.start_time.to_rfc3339(), + "end_time": e.end_time.to_rfc3339(), + "timezone": e.timezone.name(), + "attendees": e.attendees, + "location": e.location, + "uid": e.uid, + "status": e.status, + }); + if let Some(ref rec) = e.recurrence { + ev["recurrence"] = recurrence_to_json(rec); + } + if !e.exdates.is_empty() { + ev["exdates"] = serde_json::json!( + e.exdates.iter().map(|d| d.to_rfc3339()).collect::>() + ); + } + ev + }).collect::>(), "timezone": self.timezone.map(|tz| tz.name()), }); @@ -299,9 +337,27 @@ impl Calendar { arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect() }) .unwrap_or_default(), - recurrence: None, + recurrence: match event_val.get("recurrence") { + Some(v) => Some(json_to_recurrence(v, tz)?), + None => None, + }, recurrence_filter: None, - exdates: Vec::new(), + exdates: match event_val["exdates"].as_array() { + Some(arr) => { + let mut dates = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + let s = v.as_str().ok_or_else(|| { + EventixError::Other(format!("exdates[{}]: expected string", i)) + })?; + let dt = chrono::DateTime::parse_from_rfc3339(s).map_err(|e| { + EventixError::DateTimeParse(format!("exdates[{}]: {}", i, e)) + })?; + dates.push(dt.with_timezone(&tz)); + } + dates + } + None => Vec::new(), + }, location: event_val["location"].as_str().map(|s| s.to_string()), uid: event_val["uid"].as_str().map(|s| s.to_string()), status: match event_val.get("status") { @@ -354,6 +410,107 @@ impl<'a> EventOccurrence<'a> { } } +/// Serialize a Recurrence to a JSON value +fn recurrence_to_json(rec: &Recurrence) -> serde_json::Value { + let freq_str = match rec.frequency() { + Frequency::Secondly => "secondly", + Frequency::Minutely => "minutely", + Frequency::Hourly => "hourly", + Frequency::Daily => "daily", + Frequency::Weekly => "weekly", + Frequency::Monthly => "monthly", + Frequency::Yearly => "yearly", + }; + let mut obj = serde_json::json!({ + "frequency": freq_str, + "interval": rec.get_interval(), + }); + if let Some(c) = rec.get_count() { + obj["count"] = serde_json::json!(c); + } + if let Some(u) = rec.get_until() { + obj["until"] = serde_json::json!(u.to_rfc3339()); + } + if let Some(weekdays) = rec.get_weekdays() { + let days: Vec<&str> = weekdays + .iter() + .map(|wd| match *wd { + chrono::Weekday::Mon => "MO", + chrono::Weekday::Tue => "TU", + chrono::Weekday::Wed => "WE", + chrono::Weekday::Thu => "TH", + chrono::Weekday::Fri => "FR", + chrono::Weekday::Sat => "SA", + chrono::Weekday::Sun => "SU", + }) + .collect(); + obj["weekdays"] = serde_json::json!(days); + } + obj +} + +/// Deserialize a Recurrence from a JSON value +fn json_to_recurrence(val: &serde_json::Value, tz: Tz) -> crate::error::Result { + let freq_str = val["frequency"] + .as_str() + .ok_or_else(|| EventixError::Other("Recurrence missing 'frequency'".to_string()))?; + let frequency = match freq_str { + "secondly" => Frequency::Secondly, + "minutely" => Frequency::Minutely, + "hourly" => Frequency::Hourly, + "daily" => Frequency::Daily, + "weekly" => Frequency::Weekly, + "monthly" => Frequency::Monthly, + "yearly" => Frequency::Yearly, + _ => return Err(EventixError::Other(format!("Unknown frequency: {}", freq_str))), + }; + let interval_raw = val["interval"].as_u64().unwrap_or(1); + let interval = u16::try_from(interval_raw).map_err(|_| { + EventixError::Other(format!("Recurrence interval {} exceeds u16::MAX", interval_raw)) + })?; + + // RFC 5545: COUNT and UNTIL must not both be present + if !val["count"].is_null() && !val["until"].is_null() { + return Err(EventixError::Other( + "Recurrence cannot have both 'count' and 'until'".to_string(), + )); + } + + let mut rec = Recurrence::new(frequency).interval(interval); + if let Some(c) = val["count"].as_u64() { + let count = u32::try_from(c) + .map_err(|_| EventixError::Other(format!("Recurrence count {} exceeds u32::MAX", c)))?; + rec = rec.count(count); + } + if let Some(until_str) = val["until"].as_str() { + let parsed = chrono::DateTime::parse_from_rfc3339(until_str) + .map_err(|e| EventixError::DateTimeParse(format!("recurrence until: {}", e)))?; + rec = rec.until(parsed.with_timezone(&tz)); + } + if let Some(weekdays_arr) = val["weekdays"].as_array() { + let mut weekdays = Vec::new(); + for wd_val in weekdays_arr { + if let Some(wd_str) = wd_val.as_str() { + let wd = match wd_str { + "MO" => chrono::Weekday::Mon, + "TU" => chrono::Weekday::Tue, + "WE" => chrono::Weekday::Wed, + "TH" => chrono::Weekday::Thu, + "FR" => chrono::Weekday::Fri, + "SA" => chrono::Weekday::Sat, + "SU" => chrono::Weekday::Sun, + _ => return Err(EventixError::Other(format!("Unknown weekday: {}", wd_str))), + }; + weekdays.push(wd); + } + } + if !weekdays.is_empty() { + rec = rec.weekdays(weekdays); + } + } + Ok(rec) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -453,4 +610,103 @@ mod tests { assert_eq!(restored.name, "Test"); assert_eq!(restored.event_count(), 1); } + + #[test] + fn test_json_recurrence_roundtrip() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let exdate = crate::timezone::parse_datetime_with_tz("2025-01-08 09:00:00", tz).unwrap(); + + let mut cal = Calendar::new("Recurrence JSON"); + let event = Event::builder() + .title("Daily Standup") + .start("2025-01-06 09:00:00", "UTC") + .duration_minutes(15) + .recurrence(Recurrence::daily().interval(2).count(10)) + .exception_date(exdate) + .build() + .unwrap(); + cal.add_event(event); + + let json = cal.to_json().unwrap(); + // Verify recurrence and exdates are in the JSON + assert!(json.contains("\"frequency\""), "JSON should contain recurrence frequency"); + assert!(json.contains("\"exdates\""), "JSON should contain exdates"); + + let restored = Calendar::from_json(&json).unwrap(); + assert_eq!(restored.event_count(), 1); + + let ev = &restored.events[0]; + let rec = ev.recurrence.as_ref().unwrap(); + assert_eq!(rec.frequency(), rrule::Frequency::Daily); + assert_eq!(rec.get_interval(), 2); + assert_eq!(rec.get_count(), Some(10)); + assert_eq!(ev.exdates.len(), 1); + } + + #[test] + fn test_json_import_rejects_bad_recurrence() { + // Malformed recurrence frequency should fail import, not silently drop + let json = r#"{ + "name": "Test", + "events": [{ + "title": "Bad Recurrence", + "start_time": "2025-01-06T09:00:00+00:00", + "end_time": "2025-01-06T10:00:00+00:00", + "timezone": "UTC", + "recurrence": { "frequency": "biweekly", "interval": 1 } + }] + }"#; + let result = Calendar::from_json(json); + assert!(result.is_err(), "Should reject unknown recurrence frequency"); + } + + #[test] + fn test_json_import_rejects_bad_exdate() { + // Malformed exdate should fail import, not silently drop + let json = r#"{ + "name": "Test", + "events": [{ + "title": "Bad Exdate", + "start_time": "2025-01-06T09:00:00+00:00", + "end_time": "2025-01-06T10:00:00+00:00", + "timezone": "UTC", + "exdates": ["not-a-date"] + }] + }"#; + let result = Calendar::from_json(json); + assert!(result.is_err(), "Should reject unparseable exdate"); + } + + #[test] + fn test_json_import_rejects_overflowing_interval() { + let json = r#"{ + "name": "Test", + "events": [{ + "title": "Big Interval", + "start_time": "2025-01-06T09:00:00+00:00", + "end_time": "2025-01-06T10:00:00+00:00", + "timezone": "UTC", + "recurrence": { "frequency": "daily", "interval": 999999, "count": 5 } + }] + }"#; + let result = Calendar::from_json(json); + assert!(result.is_err(), "Should reject interval exceeding u16::MAX"); + } + + #[test] + fn test_json_import_rejects_count_and_until() { + // Even non-canonical types (e.g. count as string) should be caught + let json = r#"{ + "name": "Test", + "events": [{ + "title": "Both", + "start_time": "2025-01-06T09:00:00+00:00", + "end_time": "2025-01-06T10:00:00+00:00", + "timezone": "UTC", + "recurrence": { "frequency": "daily", "count": "10", "until": "2025-02-01T00:00:00+00:00" } + }] + }"#; + let result = Calendar::from_json(json); + assert!(result.is_err(), "Should reject both count and until"); + } } diff --git a/src/event.rs b/src/event.rs index b633ba2..6ca9b4a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -85,33 +85,46 @@ impl Event { /// /// For non-recurring events, returns a single occurrence. /// For recurring events, generates all occurrences based on the recurrence rule. + /// + /// Filtering is applied lazily: each candidate occurrence is checked against + /// the time-window intersection, recurrence filter, and exception dates + /// *before* counting toward `max_occurrences`. This ensures: + /// - Filtered-out dates never consume result slots. + /// - At most `max_occurrences` accepted results are collected, regardless + /// of how dense the underlying recurrence is. pub fn occurrences_between( &self, start: DateTime, end: DateTime, max_occurrences: usize, ) -> Result>> { - if let Some(ref recurrence) = self.recurrence { - let mut occurrences = - recurrence.generate_occurrences(self.start_time, max_occurrences)?; - - // Filter by date range - occurrences.retain(|dt| *dt >= start && *dt <= end); - - // Apply recurrence filter if present - if let Some(ref filter) = self.recurrence_filter { - occurrences = filter.filter_occurrences(occurrences); - } + if max_occurrences == 0 { + return Ok(vec![]); + } - // Remove exception dates - occurrences.retain(|dt| { - !self.exdates.iter().any(|exdate| exdate.date_naive() == dt.date_naive()) - }); + if let Some(ref recurrence) = self.recurrence { + let duration = self.duration(); + + let occurrences: Vec> = recurrence + .occurrences(self.start_time) + // Stop once occurrences are entirely past the query window. + // Series is chronological, so once dt >= end nothing later + // can intersect either. + .take_while(|dt| *dt < end) + // Intersection filter: occurrence's time span overlaps [start, end] + .filter(|dt| *dt + duration > start) + // Apply recurrence filter (skip weekends / skip dates) per element + .filter(|dt| !self.is_occurrence_excluded(dt)) + // Stop as soon as we have enough accepted results — never + // allocate beyond what the caller asked for. + .take(max_occurrences) + .collect(); Ok(occurrences) } else { - // Non-recurring event - if self.start_time >= start && self.start_time <= end { + // Non-recurring event: intersection check + let event_end = self.end_time; + if self.start_time < end && event_end > start { Ok(vec![self.start_time]) } else { Ok(vec![]) @@ -119,6 +132,23 @@ impl Event { } } + /// Check whether a single occurrence should be excluded by recurrence + /// filter or exception dates. + /// + /// Returns `true` when the occurrence must be **skipped**. + fn is_occurrence_excluded(&self, dt: &DateTime) -> bool { + // Recurrence filter (skip weekends, skip specific dates, …) + if let Some(ref filter) = self.recurrence_filter { + if filter.should_skip(dt) { + return true; + } + } + // Exception dates — match at full DateTime precision (RFC 5545). + // For sub-daily recurrence this skips only the targeted occurrence, + // not the entire day. + self.exdates.contains(dt) + } + /// Check if this event occurs on a specific date pub fn occurs_on(&self, date: DateTime) -> Result { let start = date.date_naive().and_hms_opt(0, 0, 0).ok_or_else(|| { @@ -213,6 +243,8 @@ pub struct EventBuilder { location: Option, uid: Option, status: EventStatus, + /// First parsing error encountered during builder chain + parse_error: Option, } impl EventBuilder { @@ -231,6 +263,7 @@ impl EventBuilder { location: None, uid: None, status: EventStatus::default(), + parse_error: None, } } @@ -261,10 +294,22 @@ impl EventBuilder { /// .unwrap(); /// ``` pub fn start(mut self, datetime: &str, timezone: &str) -> Self { - if let Ok(tz) = parse_timezone(timezone) { - self.timezone = Some(tz); - if let Ok(dt) = parse_datetime_with_tz(datetime, tz) { - self.start_time = Some(dt); + match parse_timezone(timezone) { + Ok(tz) => { + self.timezone = Some(tz); + match parse_datetime_with_tz(datetime, tz) { + Ok(dt) => self.start_time = Some(dt), + Err(e) => { + if self.parse_error.is_none() { + self.parse_error = Some(e); + } + } + } + } + Err(e) => { + if self.parse_error.is_none() { + self.parse_error = Some(e); + } } } self @@ -280,9 +325,18 @@ impl EventBuilder { /// Set the end time using a string pub fn end(mut self, datetime: &str) -> Self { if let Some(tz) = self.timezone { - if let Ok(dt) = parse_datetime_with_tz(datetime, tz) { - self.end_time = Some(dt); + match parse_datetime_with_tz(datetime, tz) { + Ok(dt) => self.end_time = Some(dt), + Err(e) => { + if self.parse_error.is_none() { + self.parse_error = Some(e); + } + } } + } else if self.parse_error.is_none() { + self.parse_error = Some(EventixError::ValidationError( + "Cannot set end time: start() with timezone must be called first".to_string(), + )); } self } @@ -374,6 +428,11 @@ impl EventBuilder { /// Build the event pub fn build(self) -> Result { + // Surface any parsing error captured during the builder chain + if let Some(err) = self.parse_error { + return Err(err); + } + let title = self .title .ok_or_else(|| EventixError::ValidationError("Event title is required".to_string()))?; @@ -473,5 +532,218 @@ mod tests { .end("2025-11-01 09:00:00") .build(); assert!(result.is_err()); + + // Zero-duration events are rejected by the builder + let result = Event::builder() + .title("Zero") + .start("2025-11-01 10:00:00", "UTC") + .duration_minutes(0) + .build(); + assert!(result.is_err()); + } + + #[test] + fn test_occurrences_between_filter_before_cap() { + use crate::timezone::parse_timezone; + use crate::Recurrence; + use chrono::Datelike; + + let tz = parse_timezone("UTC").unwrap(); + + // Daily recurrence starting Friday 2025-01-03, with weekend skipping. + // Fri, Sat, Sun, Mon, Tue, Wed, Thu... + // With skip_weekends, valid days are Fri(3), Mon(6), Tue(7), Wed(8)... + let event = Event::builder() + .title("Daily standup") + .start("2025-01-03 09:00:00", "UTC") // Friday + .duration_hours(1) + .recurrence(Recurrence::daily().count(30)) + .skip_weekends(true) + .build() + .unwrap(); + + let start = crate::timezone::parse_datetime_with_tz("2025-01-03 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-01-15 00:00:00", tz).unwrap(); + + // max_occurrences=3: should return 3 weekday results, not be eaten + // by Sat/Sun consuming cap slots before filter removes them. + let occs = event.occurrences_between(start, end, 3).unwrap(); + assert_eq!(occs.len(), 3); + + // All results should be weekdays + for occ in &occs { + let wd = occ.weekday(); + assert!( + wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun, + "weekend snuck through: {:?}", + wd + ); + } + } + + /// Stress test: secondly recurrence over a 24-hour window requesting only 10. + /// The window contains 86 400 candidate seconds but we must collect at most 10. + #[test] + fn test_dense_secondly_does_not_over_allocate() { + use crate::timezone::parse_timezone; + use crate::Recurrence; + + let tz = parse_timezone("UTC").unwrap(); + + let event = Event::builder() + .title("Tick") + .start("2025-06-01 00:00:00", "UTC") + .duration(Duration::seconds(1)) + .recurrence(Recurrence::secondly().interval(1).count(100_000)) + .build() + .unwrap(); + + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-06-02 00:00:00", tz).unwrap(); + + let occs = event.occurrences_between(start, end, 10).unwrap(); + assert_eq!(occs.len(), 10); + // Verify spacing + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], Duration::seconds(1)); + } + } + + /// Stress test: minutely recurrence over a 30-day window requesting only 5. + #[test] + fn test_dense_minutely_capped_early() { + use crate::timezone::parse_timezone; + use crate::Recurrence; + + let tz = parse_timezone("UTC").unwrap(); + + let event = Event::builder() + .title("Ping") + .start("2025-06-01 00:00:00", "UTC") + .duration(Duration::seconds(10)) + .recurrence(Recurrence::minutely().interval(1).count(100_000)) + .build() + .unwrap(); + + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-07-01 00:00:00", tz).unwrap(); + + let occs = event.occurrences_between(start, end, 5).unwrap(); + assert_eq!(occs.len(), 5); + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], Duration::minutes(1)); + } + } + + /// Stress test: hourly recurrence with weekend filter over a 1-year window. + /// Ensures filter + cap work together lazily without blowup. + #[test] + fn test_dense_hourly_with_weekend_filter() { + use crate::timezone::parse_timezone; + use crate::Recurrence; + use chrono::Datelike; + + let tz = parse_timezone("UTC").unwrap(); + + let event = Event::builder() + .title("Hourly Check") + .start("2025-01-06 08:00:00", "UTC") // Monday + .duration_minutes(5) + .recurrence(Recurrence::hourly().interval(1).count(100_000)) + .skip_weekends(true) + .build() + .unwrap(); + + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2026-01-01 00:00:00", tz).unwrap(); + + let occs = event.occurrences_between(start, end, 20).unwrap(); + assert_eq!(occs.len(), 20); + for occ in &occs { + let wd = occ.weekday(); + assert!( + wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun, + "weekend occurrence found: {}", + occ + ); + } + } + + #[test] + fn test_occurrences_between_zero_cap_returns_empty() { + let event = Event::builder() + .title("One-off") + .start("2025-01-10 09:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(); + + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-10 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-01-11 00:00:00", tz).unwrap(); + + let occs = event.occurrences_between(start, end, 0).unwrap(); + assert!(occs.is_empty()); + } + + #[test] + fn test_builder_surfaces_invalid_timezone() { + let result = Event::builder() + .title("Bad TZ") + .start("2025-01-01 10:00:00", "Invalid/Zone") + .duration_hours(1) + .build(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + // Should surface the real timezone error, not "Start time is required" + assert!(!err.contains("required"), "Expected timezone parse error, got: {}", err); + } + + #[test] + fn test_builder_surfaces_invalid_datetime() { + let result = Event::builder() + .title("Bad DT") + .start("not-a-date", "UTC") + .duration_hours(1) + .build(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(!err.contains("required"), "Expected datetime parse error, got: {}", err); + } + + #[test] + fn test_exdate_precision_subdaily() { + use crate::timezone::parse_timezone; + use crate::Recurrence; + use chrono::Timelike; + + let tz = parse_timezone("UTC").unwrap(); + + // Hourly event with exdate at exactly 12:00 + let exdate = crate::timezone::parse_datetime_with_tz("2025-06-01 12:00:00", tz).unwrap(); + let event = Event::builder() + .title("Hourly") + .start("2025-06-01 10:00:00", "UTC") + .duration_minutes(5) + .recurrence(Recurrence::hourly().count(5)) + .exception_date(exdate) + .build() + .unwrap(); + + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-06-02 00:00:00", tz).unwrap(); + + let occs = event.occurrences_between(start, end, 100).unwrap(); + // Should have 4 occurrences (10:00, 11:00, 13:00, 14:00) — 12:00 excluded + assert_eq!( + occs.len(), + 4, + "exdate should skip only the 12:00 occurrence, got: {:?}", + occs.iter().map(|d| d.format("%H:%M").to_string()).collect::>() + ); + // Verify 12:00 is not in the list + for occ in &occs { + assert_ne!(occ.hour(), 12, "12:00 should be excluded"); + } } } diff --git a/src/gap_validation.rs b/src/gap_validation.rs index b61d12b..7ecb5b8 100644 --- a/src/gap_validation.rs +++ b/src/gap_validation.rs @@ -252,37 +252,74 @@ pub fn find_overlaps( start: DateTime, end: DateTime, ) -> Result> { + use std::collections::BTreeSet; + let mut occurrences = calendar.events_between(start, end)?; // Filter out inactive events occurrences.retain(|e| e.event.is_active()); - let mut overlaps = Vec::new(); + // Filter out zero-duration events (where start == end) + // With zero duration, the END checkpoint is processed as a no-op (event not yet active), + // then START adds the event to the active set where it is never removed, + // causing false-positive overlaps with all subsequent events. + occurrences.retain(|occ| occ.occurrence_time != occ.end_time()); - // Check each pair of events for overlap - for i in 0..occurrences.len() { - for j in (i + 1)..occurrences.len() { - let event1 = &occurrences[i]; - let event2 = &occurrences[j]; + // Early return for trivial cases (moved after zero-duration filter) + if occurrences.len() < 2 { + return Ok(Vec::new()); + } - let start1 = event1.occurrence_time; - let end1 = event1.end_time(); - let start2 = event2.occurrence_time; - let end2 = event2.end_time(); + // Sweep Line Algorithm: O(N log N) instead of O(N²) + // + // Create checkpoints for each event's start and end. + // We use a tuple: (time, is_end, index) + // - is_end=true (1) means this is an END checkpoint + // - is_end=false (0) means this is a START checkpoint + // + // CRITICAL: When times are equal, process ENDS before STARTS. + // This prevents false positives for "touching" events. + // Example: Event A ends at 10:00, Event B starts at 10:00 + // - If we process B's start before A's end, we'd think they overlap + // - By processing A's end first, A is removed before B is added + let mut checkpoints: Vec<(DateTime, bool, usize)> = + Vec::with_capacity(occurrences.len() * 2); + for (i, occ) in occurrences.iter().enumerate() { + checkpoints.push((occ.occurrence_time, false, i)); // START checkpoint + checkpoints.push((occ.end_time(), true, i)); // END checkpoint + } - // Check if they overlap - if start1 < end2 && start2 < end1 { - let overlap_start = start1.max(start2); - let overlap_end = end1.min(end2); + // Sort by: (1) time ascending, (2) END before START at equal timestamps. + // Rust's bool ordering: false < true, so we reverse to place true (END) first. + checkpoints.sort_by(|a, b| { + a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)) // reverse: END (true) before START (false) + }); - let overlap = EventOverlap::new( + let mut active: BTreeSet = BTreeSet::new(); + let mut overlaps = Vec::new(); + + for (_time, is_end, idx) in checkpoints { + if is_end { + // Event ending - remove from active set + active.remove(&idx); + } else { + // Event starting - check for overlaps with all currently active events + for &active_idx in &active { + let e1 = &occurrences[idx]; + let e2 = &occurrences[active_idx]; + + // Calculate the actual overlap region + let overlap_start = e1.occurrence_time.max(e2.occurrence_time); + let overlap_end = e1.end_time().min(e2.end_time()); + + overlaps.push(EventOverlap::new( overlap_start, overlap_end, - vec![event1.title().to_string(), event2.title().to_string()], - ); - - overlaps.push(overlap); + vec![e1.title().to_string(), e2.title().to_string()], + )); } + // Add this event to the active set + active.insert(idx); } } diff --git a/src/ics.rs b/src/ics.rs index e409fc6..774f58a 100644 --- a/src/ics.rs +++ b/src/ics.rs @@ -3,9 +3,11 @@ use crate::calendar::Calendar; use crate::error::{EventixError, Result}; use crate::event::Event; +use crate::recurrence::Recurrence; use chrono::{DateTime, TimeZone}; use chrono_tz::Tz; use icalendar::{Calendar as ICalendar, Component, Event as IEvent, EventLike, Property}; +use rrule::Frequency; use std::fs; use std::path::Path; @@ -173,15 +175,21 @@ fn event_to_ical(event: &Event) -> Result { } } - // Add exception dates with timezone + // Add exception dates with timezone (EXDATE is a multi-property in RFC 5545) + // Normalize each exdate to the event timezone before formatting so the + // stamped local time matches the TZID label. + let event_tz = event.start_time.timezone(); for exdate in &event.exdates { - let exdate_str = exdate.format("%Y%m%dT%H%M%S").to_string(); if tz_name == "UTC" { - ical_event.add_property("EXDATE", format!("{}Z", exdate_str)); + let exdate_utc = exdate.with_timezone(&chrono::Utc); + let exdate_str = exdate_utc.format("%Y%m%dT%H%M%S").to_string(); + ical_event.add_multi_property("EXDATE", &format!("{}Z", exdate_str)); } else { + let exdate_local = exdate.with_timezone(&event_tz); + let exdate_str = exdate_local.format("%Y%m%dT%H%M%S").to_string(); let mut exdate_prop = Property::new("EXDATE", &exdate_str); exdate_prop.add_parameter("TZID", tz_name); - ical_event.append_property(exdate_prop); + ical_event.append_multi_property(exdate_prop); } } @@ -218,12 +226,151 @@ fn ical_to_event(ical_event: &IEvent) -> Result { builder = builder.uid(uid); } - // TODO: Parse RRULE and EXDATE if present - // This would require more sophisticated parsing of the iCalendar properties + // Parse RRULE if present — reject unsupported rules instead of silently + // degrading, since dropping BYMONTH etc. would produce a broader schedule. + let props = ical_event.properties(); + for (key, prop) in props { + if key == "RRULE" { + let rrule_value = prop.value(); + let recurrence = parse_rrule_value(rrule_value, start_time)?; + builder = builder.recurrence(recurrence); + } + } + + // Parse EXDATE properties (stored in multi_properties per RFC 5545) + let event_tz = start_time.timezone(); + if let Some(exdate_props) = ical_event.multi_properties().get("EXDATE") { + for prop in exdate_props { + let value = prop.value(); + // Determine timezone for this EXDATE + let exdate_tz = if let Some(tzid_param) = prop.params().get("TZID") { + crate::timezone::parse_timezone(tzid_param.value()).unwrap_or(event_tz) + } else if value.ends_with('Z') { + crate::timezone::parse_timezone("UTC").unwrap_or(event_tz) + } else { + event_tz + }; + + let dt_str = value.trim_end_matches('Z'); + let exdate_dt = parse_ical_datetime_value(dt_str, exdate_tz).map_err(|e| { + EventixError::IcsError(format!("Failed to parse EXDATE '{}': {}", value, e)) + })?; + builder = builder.exception_date(exdate_dt); + } + } builder.build() } +/// Parse an RRULE value string into a Recurrence. +/// +/// Supports: FREQ, INTERVAL, COUNT, UNTIL, BYDAY +fn parse_rrule_value(rrule_str: &str, dtstart: DateTime) -> Result { + let mut frequency = None; + let mut interval = 1u16; + let mut count = None; + let mut until = None; + let mut by_weekday = None; + + for part in rrule_str.split(';') { + let Some((key, value)) = part.split_once('=') else { + continue; + }; + match key { + "FREQ" => { + frequency = Some(match value { + "SECONDLY" => Frequency::Secondly, + "MINUTELY" => Frequency::Minutely, + "HOURLY" => Frequency::Hourly, + "DAILY" => Frequency::Daily, + "WEEKLY" => Frequency::Weekly, + "MONTHLY" => Frequency::Monthly, + "YEARLY" => Frequency::Yearly, + _ => { + return Err(EventixError::IcsError(format!( + "Unknown RRULE frequency: {}", + value + ))) + } + }); + } + "INTERVAL" => { + interval = value.parse().map_err(|_| { + EventixError::IcsError(format!("Invalid RRULE INTERVAL: {}", value)) + })?; + } + "COUNT" => { + count = Some(value.parse().map_err(|_| { + EventixError::IcsError(format!("Invalid RRULE COUNT: {}", value)) + })?); + } + "UNTIL" => { + // UNTIL can be a date or datetime, possibly with Z suffix + let dt_str = value.trim_end_matches('Z'); + let tz = if value.ends_with('Z') { + crate::timezone::parse_timezone("UTC")? + } else { + dtstart.timezone() + }; + until = Some(parse_ical_datetime_value(dt_str, tz)?); + } + "BYDAY" => { + let mut weekdays = Vec::new(); + for day_str in value.split(',') { + let day_str = day_str.trim(); + let wd = match day_str { + "MO" => chrono::Weekday::Mon, + "TU" => chrono::Weekday::Tue, + "WE" => chrono::Weekday::Wed, + "TH" => chrono::Weekday::Thu, + "FR" => chrono::Weekday::Fri, + "SA" => chrono::Weekday::Sat, + "SU" => chrono::Weekday::Sun, + other => { + return Err(EventixError::IcsError(format!( + "Unsupported BYDAY value '{}' (ordinal prefixes like 1MO or -1FR are not supported)", + other + ))) + } + }; + weekdays.push(wd); + } + if !weekdays.is_empty() { + by_weekday = Some(weekdays); + } + } + other => { + return Err(EventixError::IcsError(format!( + "Unsupported RRULE component: {}", + other + ))) + } + } + } + + let freq = frequency + .ok_or_else(|| EventixError::IcsError("RRULE missing FREQ component".to_string()))?; + + // RFC 5545 §3.3.10: COUNT and UNTIL MUST NOT both appear in the same rule + if count.is_some() && until.is_some() { + return Err(EventixError::IcsError( + "RRULE must not contain both COUNT and UNTIL".to_string(), + )); + } + + let mut recurrence = Recurrence::new(freq).interval(interval); + if let Some(c) = count { + recurrence = recurrence.count(c); + } + if let Some(u) = until { + recurrence = recurrence.until(u); + } + if let Some(wd) = by_weekday { + recurrence = recurrence.weekdays(wd); + } + Ok(recurrence) +} + /// Extract datetime with timezone from an iCalendar property fn extract_datetime_with_tz(ical_event: &IEvent, prop_name: &str) -> Result<(DateTime, Tz)> { // Try to find the property directly from the inner properties @@ -235,28 +382,7 @@ fn extract_datetime_with_tz(ical_event: &IEvent, prop_name: &str) -> Result<(Dat // Check if there's a TZID parameter let timezone = if let Some(tzid_param) = prop.params().get("TZID") { - // Parse the timezone from TZID parameter (use Debug format) - // Debug format is: Parameter { key: "TZID", val: "America/New_York" } - let tz_str_raw = format!("{:?}", tzid_param); - // Extract the value after 'val: "' - let tz_str = if let Some(start_idx) = tz_str_raw.find("val: \"") { - let start = start_idx + 6; // Length of 'val: "' - let remaining = &tz_str_raw[start..]; - if let Some(end_idx) = remaining.find('"') { - remaining[..end_idx].to_string() - } else { - return Err(EventixError::InvalidTimezone(format!( - "Cannot parse TZID parameter: {}", - tz_str_raw - ))); - } - } else { - return Err(EventixError::InvalidTimezone(format!( - "Cannot parse TZID parameter: {}", - tz_str_raw - ))); - }; - crate::timezone::parse_timezone(&tz_str)? + crate::timezone::parse_timezone(tzid_param.value())? } else if value.ends_with('Z') { // UTC timezone crate::timezone::parse_timezone("UTC")? @@ -277,30 +403,47 @@ fn extract_datetime_with_tz(ical_event: &IEvent, prop_name: &str) -> Result<(Dat } /// Parse an iCalendar datetime value string +/// +/// Accepts both DATE-TIME format (`YYYYMMDDTHHMMSS`, 15+ chars) and +/// DATE-only format (`YYYYMMDD`, exactly 8 chars). DATE-only values +/// default to midnight (00:00:00). fn parse_ical_datetime_value(dt_str: &str, tz: Tz) -> Result> { - // Format: YYYYMMDDTHHMMSS - if dt_str.len() < 15 { + // DATE-only format: YYYYMMDD (8 chars, no 'T' separator) + let (year, month, day, hour, minute, second) = if dt_str.len() == 8 && !dt_str.contains('T') { + let year: i32 = dt_str[0..4] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid year in: {}", dt_str)))?; + let month: u32 = dt_str[4..6] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid month in: {}", dt_str)))?; + let day: u32 = dt_str[6..8] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid day in: {}", dt_str)))?; + (year, month, day, 0, 0, 0) + } else if dt_str.len() >= 15 { + // DATE-TIME format: YYYYMMDDTHHMMSS + let year: i32 = dt_str[0..4] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid year in: {}", dt_str)))?; + let month: u32 = dt_str[4..6] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid month in: {}", dt_str)))?; + let day: u32 = dt_str[6..8] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid day in: {}", dt_str)))?; + let hour: u32 = dt_str[9..11] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid hour in: {}", dt_str)))?; + let minute: u32 = dt_str[11..13] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid minute in: {}", dt_str)))?; + let second: u32 = dt_str[13..15] + .parse() + .map_err(|_| EventixError::DateTimeParse(format!("Invalid second in: {}", dt_str)))?; + (year, month, day, hour, minute, second) + } else { return Err(EventixError::DateTimeParse(format!("Invalid datetime format: {}", dt_str))); - } - - let year: i32 = dt_str[0..4] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid year in: {}", dt_str)))?; - let month: u32 = dt_str[4..6] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid month in: {}", dt_str)))?; - let day: u32 = dt_str[6..8] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid day in: {}", dt_str)))?; - let hour: u32 = dt_str[9..11] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid hour in: {}", dt_str)))?; - let minute: u32 = dt_str[11..13] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid minute in: {}", dt_str)))?; - let second: u32 = dt_str[13..15] - .parse() - .map_err(|_| EventixError::DateTimeParse(format!("Invalid second in: {}", dt_str)))?; + }; let naive = chrono::NaiveDate::from_ymd_opt(year, month, day) .and_then(|d| d.and_hms_opt(hour, minute, second)) @@ -334,4 +477,123 @@ mod tests { assert!(ics.contains("Test Calendar")); assert!(ics.contains("Test Event")); } + + #[test] + fn test_ics_rrule_roundtrip() { + let mut cal = Calendar::new("RRULE Test"); + let event = Event::builder() + .title("Daily Standup") + .start("2025-01-06 09:00:00", "UTC") + .duration_minutes(15) + .recurrence(Recurrence::daily().interval(2).count(10)) + .build() + .unwrap(); + + cal.add_event(event); + + let ics = cal.to_ics_string().unwrap(); + assert!(ics.contains("RRULE:"), "exported ICS should contain RRULE"); + + // Re-import + let imported = Calendar::from_ics_string(&ics).unwrap(); + assert_eq!(imported.event_count(), 1); + + let imported_event = &imported.events[0]; + let rec = imported_event.recurrence.as_ref().unwrap(); + assert_eq!(rec.frequency(), rrule::Frequency::Daily); + assert_eq!(rec.get_interval(), 2); + assert_eq!(rec.get_count(), Some(10)); + } + + #[test] + fn test_ics_exdate_roundtrip() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let exdate = crate::timezone::parse_datetime_with_tz("2025-01-08 09:00:00", tz).unwrap(); + + let mut cal = Calendar::new("EXDATE Test"); + let event = Event::builder() + .title("Recurring") + .start("2025-01-06 09:00:00", "UTC") + .duration_minutes(15) + .recurrence(Recurrence::daily().count(10)) + .exception_date(exdate) + .build() + .unwrap(); + + cal.add_event(event); + + let ics = cal.to_ics_string().unwrap(); + assert!(ics.contains("EXDATE"), "exported ICS should contain EXDATE"); + + let imported = Calendar::from_ics_string(&ics).unwrap(); + assert_eq!(imported.events[0].exdates.len(), 1); + } + + #[test] + fn test_parse_rrule_value() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 10:00:00", tz).unwrap(); + + // Basic FREQ + COUNT + let rec = parse_rrule_value("FREQ=WEEKLY;COUNT=4", start).unwrap(); + assert_eq!(rec.frequency(), rrule::Frequency::Weekly); + assert_eq!(rec.get_count(), Some(4)); + assert_eq!(rec.get_interval(), 1); + + // FREQ + INTERVAL + BYDAY + let rec = parse_rrule_value("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", start).unwrap(); + assert_eq!(rec.get_interval(), 2); + let wd = rec.get_weekdays().unwrap(); + assert_eq!(wd.len(), 3); + assert!(wd.contains(&chrono::Weekday::Mon)); + assert!(wd.contains(&chrono::Weekday::Wed)); + assert!(wd.contains(&chrono::Weekday::Fri)); + + // FREQ + UNTIL + let rec = parse_rrule_value("FREQ=DAILY;UNTIL=20250201T000000Z", start).unwrap(); + assert!(rec.get_until().is_some()); + + // FREQ + UNTIL with DATE-only format (no time component) + let rec = parse_rrule_value("FREQ=DAILY;UNTIL=20250201", start).unwrap(); + assert!(rec.get_until().is_some()); + } + + #[test] + fn test_parse_rrule_rejects_unsupported_parts() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 10:00:00", tz).unwrap(); + + // BYMONTH is unsupported — must return Err, not silently drop + let result = parse_rrule_value("FREQ=DAILY;COUNT=90;BYMONTH=3", start); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("BYMONTH"), + "Error should mention unsupported component: {}", + err_msg + ); + + // BYSETPOS is unsupported + let result = parse_rrule_value("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", start); + assert!(result.is_err()); + + // Ordinal-prefixed BYDAY like 1MO or -1FR must be rejected + let result = parse_rrule_value("FREQ=MONTHLY;BYDAY=1MO", start); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("1MO"), "Error should mention the token: {}", err_msg); + + let result = parse_rrule_value("FREQ=MONTHLY;BYDAY=-1FR", start); + assert!(result.is_err()); + + // COUNT + UNTIL together must be rejected per RFC 5545 + let result = parse_rrule_value("FREQ=DAILY;COUNT=10;UNTIL=20250201T000000Z", start); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("COUNT") && err_msg.contains("UNTIL"), + "Error should mention both COUNT and UNTIL: {}", + err_msg + ); + } } diff --git a/src/lib.rs b/src/lib.rs index daa1a8e..0031e94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! ## Features //! //! - **Timezone-aware events**: All date/time fields use `chrono` with `chrono-tz` for proper timezone handling -//! - **Recurrence rules**: Support for daily, weekly, monthly, and yearly recurrence patterns +//! - **Recurrence rules**: Support for all seven RFC 5545 frequencies (secondly, minutely, hourly, daily, weekly, monthly, yearly) //! - **Exceptions**: Skip specific dates or apply custom filters (e.g., skip weekends) //! - **ICS support**: Import and export events using the iCalendar format (RFC 5545 compliant with TZID support) //! - **Builder API**: Ergonomic, fluent interface for creating events and calendars @@ -103,14 +103,14 @@ //! - [`event`] - Event types and builder API //! - [`gap_validation`] - Schedule analysis, gap detection, and conflict resolution (unique feature) //! - [`ics`] - ICS (iCalendar) import/export with TZID support -//! - [`recurrence`] - Recurrence patterns (daily, weekly, monthly, yearly) +//! - [`recurrence`] - Recurrence patterns (secondly, minutely, hourly, daily, weekly, monthly, yearly) //! - [`timezone`] - Timezone utilities with DST awareness //! //! ## Examples //! //! See the `examples/` directory for more comprehensive examples: //! - `basic.rs` - Simple calendar creation and event management -//! - `recurrence.rs` - Daily, weekly, monthly, and yearly recurrence patterns +//! - `recurrence.rs` - All seven RFC 5545 recurrence frequencies with lazy iteration and DST handling //! - `ics_export.rs` - ICS import/export functionality //! - `timezone_ics_export.rs` - Timezone-aware ICS export demonstration //! - `gap_validation.rs` - Schedule analysis and gap detection features @@ -127,8 +127,8 @@ mod error; pub use calendar::Calendar; pub use error::{EventixError, Result}; pub use event::{Event, EventBuilder, EventStatus}; -pub use recurrence::Recurrence; +pub use recurrence::{OccurrenceIterator, Recurrence}; -// Re-export commonly used types -pub use chrono::{DateTime, Duration, NaiveDateTime}; +// Re-export commonly used types from chrono +pub use chrono::{DateTime, Duration, NaiveDateTime, Utc}; pub use chrono_tz::Tz; diff --git a/src/recurrence.rs b/src/recurrence.rs index 840e2ad..f9449d8 100644 --- a/src/recurrence.rs +++ b/src/recurrence.rs @@ -1,7 +1,7 @@ //! Recurrence rules and patterns for repeating events use crate::error::Result; -use chrono::{DateTime, Datelike, TimeZone}; +use chrono::{DateTime, Datelike, Offset, TimeZone}; use chrono_tz::Tz; use rrule::Frequency; @@ -16,7 +16,14 @@ pub struct Recurrence { } impl Recurrence { - /// Create a new recurrence pattern + /// Create a new recurrence pattern. + /// + /// All seven RFC 5545 frequencies are supported: `Secondly`, `Minutely`, + /// `Hourly`, `Daily`, `Weekly`, `Monthly`, and `Yearly`. + /// + /// For the most common cases prefer the typed convenience constructors + /// ([`daily()`](Self::daily), [`weekly()`](Self::weekly), etc.) which + /// give compile-time guarantees on the frequency value. pub fn new(frequency: Frequency) -> Self { Self { frequency, @@ -79,8 +86,61 @@ impl Recurrence { Self::new(Frequency::Yearly) } + /// Create an hourly recurrence pattern. + /// + /// Uses **"same elapsed time"** semantics: each occurrence is exactly + /// `interval` hours after the previous one in UTC. During DST transitions + /// the local-time label may shift (e.g. 1:00 AM EST → 3:00 AM EDT when + /// clocks spring forward) but the actual interval is always exact. + /// + /// # Examples + /// + /// ``` + /// use eventix::Recurrence; + /// + /// // Every 2 hours, 12 times + /// let schedule = Recurrence::hourly().interval(2).count(12); + /// ``` + pub fn hourly() -> Self { + Self::new(Frequency::Hourly) + } + + /// Create a minutely recurrence pattern. + /// + /// Uses **"same elapsed time"** semantics via fixed UTC duration. + /// + /// # Examples + /// + /// ``` + /// use eventix::Recurrence; + /// + /// // Every 15 minutes, 8 times + /// let schedule = Recurrence::minutely().interval(15).count(8); + /// ``` + pub fn minutely() -> Self { + Self::new(Frequency::Minutely) + } + + /// Create a secondly recurrence pattern. + /// + /// Uses **"same elapsed time"** semantics via fixed UTC duration. + /// + /// # Examples + /// + /// ``` + /// use eventix::Recurrence; + /// + /// // Every 30 seconds, 10 times + /// let schedule = Recurrence::secondly().interval(30).count(10); + /// ``` + pub fn secondly() -> Self { + Self::new(Frequency::Secondly) + } + /// Set the interval between recurrences /// + /// An interval of 0 is normalized to 1 (the RFC 5545 default). + /// /// # Examples /// /// ``` @@ -90,7 +150,7 @@ impl Recurrence { /// let biweekly = Recurrence::weekly().interval(2).count(10); /// ``` pub fn interval(mut self, interval: u16) -> Self { - self.interval = interval; + self.interval = if interval == 0 { 1 } else { interval }; self } @@ -124,7 +184,25 @@ impl Recurrence { self } - /// Set specific weekdays for weekly recurrence + /// Set specific weekdays for the recurrence + /// + /// Behavior depends on the frequency: + /// + /// - **Weekly**: intra-week expansion — emits every listed weekday + /// within each week, then jumps by `interval` weeks. O(1) per emit. + /// - **Daily**: direct weekday jumping — steps by `interval` days + /// until a matching weekday is found (at most 7 steps). O(1) per emit. + /// - **Monthly**: RFC 5545 BYDAY period expansion — every matching + /// weekday within each recurrence month is produced. `interval` + /// controls which months are visited. + /// - **Yearly**: RFC 5545 BYDAY period expansion — every matching + /// weekday within each recurrence year is produced. + /// - **Sub-daily** (Hourly/Minutely/Secondly): cadence-preserving + /// weekday filter with O(1) day-skipping — when a step lands on a + /// non-matching day, the iterator jumps directly to the next + /// matching day instead of iterating through each sub-daily step. + /// + /// An empty list is normalized to no filter (ignored). /// /// # Examples /// @@ -132,13 +210,22 @@ impl Recurrence { /// use eventix::Recurrence; /// use rrule::Weekday; /// - /// // Only on weekdays + /// // Weekly: only on weekdays /// let weekdays = Recurrence::weekly() /// .weekdays(vec![Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu, Weekday::Fri]) /// .count(20); + /// + /// // Monthly: every Tuesday and Thursday of each month + /// let monthly_byday = Recurrence::monthly() + /// .weekdays(vec![Weekday::Tue, Weekday::Thu]) + /// .count(20); /// ``` pub fn weekdays(mut self, weekdays: Vec) -> Self { - self.by_weekday = Some(weekdays); + self.by_weekday = if weekdays.is_empty() { + None + } else { + Some(weekdays) + }; self } @@ -162,6 +249,11 @@ impl Recurrence { self.until } + /// Get the weekday filter of this recurrence + pub fn get_weekdays(&self) -> Option<&[rrule::Weekday]> { + self.by_weekday.as_deref() + } + /// Build an RRule string for this recurrence pub fn to_rrule_string(&self, dtstart: DateTime) -> Result { let mut rrule_str = format!("FREQ={:?}", self.frequency).to_uppercase(); @@ -175,100 +267,712 @@ impl Recurrence { } if let Some(until) = self.until { - let until_str = until.format("%Y%m%dT%H%M%SZ").to_string(); + // RFC 5545: UNTIL with Z suffix must be in UTC + let until_utc = until.with_timezone(&chrono::Utc); + let until_str = until_utc.format("%Y%m%dT%H%M%SZ").to_string(); rrule_str.push_str(&format!(";UNTIL={}", until_str)); } if let Some(ref weekdays) = self.by_weekday { - let days: Vec = - weekdays.iter().map(|wd| format!("{:?}", wd).to_uppercase()).collect(); - rrule_str.push_str(&format!(";BYDAY={}", days.join(","))); + if !weekdays.is_empty() { + let days: Vec = + weekdays.iter().map(|wd| format!("{:?}", wd).to_uppercase()).collect(); + rrule_str.push_str(&format!(";BYDAY={}", days.join(","))); + } } Ok(format!("DTSTART:{}\nRRULE:{}", dtstart.format("%Y%m%dT%H%M%S"), rrule_str)) } - /// Generate occurrences for this recurrence pattern + /// Generate occurrences for this recurrence pattern (eager, allocates Vec) + /// until the recurrence naturally exhausts via `count`, `until`, or + /// iterator termination. + /// + /// Returns a vector of `DateTime` representing each occurrence. /// - /// Returns a vector of `DateTime` representing each occurrence - pub fn generate_occurrences( + /// # Note + /// For better memory efficiency with large recurrence counts, consider using + /// the lazy [`occurrences()`](Self::occurrences) method instead. If you want + /// an eager `Vec` with an explicit hard cap, use + /// [`generate_occurrences_capped()`](Self::generate_occurrences_capped). + /// + /// # Errors + /// Returns [`crate::error::EventixError::RecurrenceError`] if the recurrence has neither + /// `count` nor `until` set, since collecting an unbounded iterator would + /// hang or exhaust memory. + pub fn generate_occurrences(&self, start: DateTime) -> Result>> { + if self.count.is_none() && self.until.is_none() { + return Err(crate::error::EventixError::RecurrenceError( + "generate_occurrences() requires a bounded recurrence (set count or until). \ + Use occurrences() for lazy iteration or generate_occurrences_capped() for \ + a hard cap." + .to_string(), + )); + } + Ok(self.occurrences(start).collect()) + } + + /// Generate occurrences eagerly but stop after `max_occurrences` accepted + /// items. + pub fn generate_occurrences_capped( &self, start: DateTime, max_occurrences: usize, ) -> Result>> { - // Simplified recurrence generation without using rrule library for now - // This is a basic implementation that handles common cases + Ok(self.occurrences(start).take(max_occurrences).collect()) + } - let mut occurrences = Vec::new(); - let mut current = start; + /// Create a lazy iterator over occurrences of this recurrence pattern. + /// + /// Unlike [`generate_occurrences()`](Self::generate_occurrences) and + /// [`generate_occurrences_capped()`](Self::generate_occurrences_capped), + /// this method returns an iterator that computes each occurrence on demand, + /// avoiding upfront memory allocation for large or infinite recurrence + /// patterns. + /// + /// # Examples + /// + /// ``` + /// use eventix::{Recurrence, timezone}; + /// + /// let tz = timezone::parse_timezone("UTC").unwrap(); + /// let start = timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + /// + /// let recurrence = Recurrence::daily().count(365); + /// + /// // Lazy: only computes what you consume + /// let first_week: Vec<_> = recurrence.occurrences(start).take(7).collect(); + /// assert_eq!(first_week.len(), 7); + /// + /// // Skip directly to a future occurrence without computing all intermediate dates + /// let tenth = recurrence.occurrences(start).nth(9); + /// assert!(tenth.is_some()); + /// ``` + pub fn occurrences(&self, start: DateTime) -> OccurrenceIterator { + OccurrenceIterator::new(self.clone(), start) + } +} - let count_limit = self.count.unwrap_or(max_occurrences as u32).min(max_occurrences as u32); +/// Advance a datetime by the given frequency and interval. +/// +/// Shared helper used by the eager generation helpers and the lazy +/// `OccurrenceIterator`. +/// +/// Resolve a `NaiveDateTime` to a timezone-aware `DateTime`, handling +/// DST transitions: +/// +/// - **Normal / fall-back (ambiguous)**: picks the earlier of two candidates +/// - **Spring-forward (gap)**: the local time doesn't exist; applies the +/// pre-gap UTC offset so the resulting wall-clock time shifts forward by +/// exactly the gap size (e.g. 2:30 AM EST → 3:30 AM EDT), matching +/// Google Calendar / RFC 5545 behaviour +fn resolve_local(tz: Tz, naive: chrono::NaiveDateTime) -> Option> { + if let Some(dt) = tz.from_local_datetime(&naive).earliest() { + return Some(dt); + } + // DST gap: the local time doesn't exist. Determine the UTC offset + // that was in effect just before the gap by resolving a time one day + // earlier (guaranteed to exist outside the gap). Converting the + // nonexistent local time with that offset naturally lands on the + // correct post-transition wall-clock time. + let day_before = naive - chrono::Duration::days(1); + let pre_gap_dt = tz.from_local_datetime(&day_before).earliest()?; + let pre_offset = pre_gap_dt.offset().fix(); + let utc_naive = naive - pre_offset; + Some(chrono::Utc.from_utc_datetime(&utc_naive).with_timezone(&tz)) +} - for _ in 0..count_limit { - // Check until date if specified - if let Some(until) = self.until { - if current > until { - break; - } +/// Advance a `DateTime` by one recurrence step. +/// +/// `intended_time` is the original start's wall-clock time. For +/// calendar-aligned frequencies (`Daily`–`Yearly`) it prevents wall-clock +/// drift after DST gap resolution (e.g. 2:30 AM shifted to 3:30 AM on +/// spring-forward day returns to 2:30 AM the following day). Sub-daily +/// frequencies ignore this parameter and always advance from `current` +/// using a fixed UTC duration. +/// +/// ## All seven RFC 5545 frequencies are supported +/// +/// **Sub-daily** (advance by fixed UTC duration — DST-transparent): +/// - [`Frequency::Secondly`] — adds `interval` seconds +/// - [`Frequency::Minutely`] — adds `interval` minutes +/// - [`Frequency::Hourly`] — adds `interval` hours +/// +/// **Calendar-aligned** (local-date arithmetic + DST resolution): +/// - [`Frequency::Daily`] — adds `interval` calendar days +/// - [`Frequency::Weekly`] — adds `interval × 7` calendar days +/// - [`Frequency::Monthly`] — adds `interval` months, clamping to the last +/// valid day (e.g. Jan 31 → Feb 28) +/// - [`Frequency::Yearly`] — adds `interval` years, clamping for leap days +/// (e.g. Feb 29 → Feb 28 in non-leap years) +fn advance_by_frequency( + current: DateTime, + frequency: Frequency, + interval: u16, + intended_time: chrono::NaiveTime, +) -> Option> { + if interval == 0 { + return None; + } + let tz = current.timezone(); + match frequency { + Frequency::Daily => { + let new_date = current.date_naive() + chrono::Days::new(interval as u64); + let naive = chrono::NaiveDateTime::new(new_date, intended_time); + resolve_local(tz, naive) + } + Frequency::Weekly => { + let new_date = current.date_naive() + chrono::Days::new(interval as u64 * 7); + let naive = chrono::NaiveDateTime::new(new_date, intended_time); + resolve_local(tz, naive) + } + Frequency::Monthly => { + let months_to_add = interval as i32; + let mut new_month = current.month() as i32 + months_to_add; + let mut new_year = current.year(); + while new_month > 12 { + new_month -= 12; + new_year += 1; + } + let date = clamp_day_to_month(new_year, new_month as u32, current.day())?; + let naive = chrono::NaiveDateTime::new(date, intended_time); + resolve_local(tz, naive) + } + Frequency::Yearly => { + let new_year = current.year() + interval as i32; + let date = clamp_day_to_month(new_year, current.month(), current.day())?; + let naive = chrono::NaiveDateTime::new(date, intended_time); + resolve_local(tz, naive) + } + // Sub-daily: advance by a fixed UTC duration ("same elapsed time" + // semantics, not "same local wall-clock slot"). Adding + // chrono::Duration to a DateTime always goes through UTC, + // so spring-forward / fall-back transitions are handled transparently + // without any local-time lookup. + Frequency::Hourly => Some(current + chrono::Duration::hours(interval as i64)), + Frequency::Minutely => Some(current + chrono::Duration::minutes(interval as i64)), + Frequency::Secondly => Some(current + chrono::Duration::seconds(interval as i64)), + } +} + +/// Build a `NaiveDate` for `(year, month, day)`, clamping `day` downward +/// to the last valid day of the month when the original day doesn't exist +/// (e.g. day 31 in a 30-day month, or day 29 in non-leap February). +fn clamp_day_to_month(year: i32, month: u32, day: u32) -> Option { + // Try the original day first (fast path) + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Some(d); + } + // Clamp: walk backward from day-1 to 28 (always valid) + let mut d = day.min(31); + while d > 28 { + d -= 1; + if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, d) { + return Some(date); + } + } + // 28 is always valid for months 1-12 + chrono::NaiveDate::from_ymd_opt(year, month, 28) +} + +/// For Weekly frequency with specific weekdays: advance to the next matching +/// weekday. Within the current week period, steps forward day-by-day. +/// When no more matching weekdays remain in this week, jumps by +/// `interval` weeks to reach the next week period and finds the first +/// matching weekday there. +fn advance_weekly_weekday( + current: DateTime, + interval: u16, + weekdays: &[chrono::Weekday], + intended_time: chrono::NaiveTime, +) -> Option> { + // Match the zero-interval guard in advance_by_frequency(): no further + // occurrences when interval == 0. + if interval == 0 { + return None; + } + + let tz = current.timezone(); + let date = current.date_naive(); + let current_dow = date.weekday().num_days_from_monday(); // 0=Mon..6=Sun + + // Try remaining days in the current calendar week (Mon-Sun). + // Only consider days strictly after the current weekday within this week. + for day_offset in 1u64..(7 - current_dow as u64) { + let candidate = date + chrono::Days::new(day_offset); + if weekdays.contains(&candidate.weekday()) { + let naive = chrono::NaiveDateTime::new(candidate, intended_time); + return resolve_local(tz, naive); + } + } + + // No more matching weekdays this week — jump to the next week period. + // Find the Monday of the current week, then advance by interval weeks. + let week_start = date - chrono::Days::new(current_dow as u64); + let next_week_start = week_start + chrono::Days::new(interval as u64 * 7); + + for day_offset in 0u64..7 { + let candidate = next_week_start + chrono::Days::new(day_offset); + if weekdays.contains(&candidate.weekday()) { + let naive = chrono::NaiveDateTime::new(candidate, intended_time); + return resolve_local(tz, naive); + } + } + + None +} + +/// For Daily frequency with specific weekdays: advance to the next matching +/// weekday after `current`. Jumps directly to the target day, using +/// `intended_time` to reconstruct the wall-clock time on that day. +/// +/// For `interval == 1` this is equivalent to stepping 1 day at a time but +/// short-circuits the scan. For `interval > 1` it steps by `interval` +/// days until a matching weekday is found (at most 7 steps because +/// weekdays repeat every 7 days, and any interval coprime to 7 covers +/// all weekdays within 7 steps). +fn advance_daily_weekday( + current: DateTime, + interval: u16, + weekdays: &[chrono::Weekday], + intended_time: chrono::NaiveTime, +) -> Option> { + if interval == 0 { + return None; + } + let tz = current.timezone(); + let mut date = current.date_naive(); + // At most 7 interval-steps (all weekdays covered within one full cycle) + for _ in 0..7 { + date = date + chrono::Days::new(interval as u64); + if weekdays.contains(&date.weekday()) { + let naive = chrono::NaiveDateTime::new(date, intended_time); + return resolve_local(tz, naive); + } + } + None +} + +/// For sub-daily frequencies on a non-matching weekday, compute the first +/// interval-aligned occurrence on the next matching weekday in O(1). +/// +/// Without this, `secondly().interval(1).weekdays([Mon])` would iterate +/// 518,400 times to skip from Monday to the following Monday. +fn skip_subdaily_to_matching_day( + current: DateTime, + frequency: Frequency, + interval: u16, + weekdays: &[chrono::Weekday], +) -> Option> { + // If current already falls on a matching weekday, return it as-is. + // This happens when compute_next() crosses midnight into a valid day. + if weekdays.contains(¤t.weekday()) { + return Some(current); + } + + let tz = current.timezone(); + let mut target_date = current.date_naive(); + + // Find the next matching weekday (1-6 days ahead) + let mut found = false; + for _ in 0..7 { + target_date = target_date.succ_opt()?; + if weekdays.contains(&target_date.weekday()) { + found = true; + break; + } + } + if !found { + return None; + } + + // Midnight of target day in the event's timezone + let midnight_time = chrono::NaiveTime::from_hms_opt(0, 0, 0)?; + let midnight = chrono::NaiveDateTime::new(target_date, midnight_time); + let target_dt = resolve_local(tz, midnight)?; + + // Compute the number of interval-steps needed to reach or pass midnight. + // Sub-daily uses UTC duration arithmetic, so signed_duration_since is exact. + let gap_secs = target_dt.signed_duration_since(current).num_seconds(); + if gap_secs <= 0 { + return Some(target_dt); + } + + let interval_secs = match frequency { + Frequency::Hourly => interval as i64 * 3600, + Frequency::Minutely => interval as i64 * 60, + Frequency::Secondly => interval as i64, + _ => return None, + }; + + // Ceil division: first step at or past midnight + let steps = (gap_secs + interval_secs - 1) / interval_secs; + Some(current + chrono::Duration::seconds(steps * interval_secs)) +} + +/// Collect all dates in `(year, month)` that fall on one of the given +/// weekdays, resolved to timezone `tz` at wall-clock `time`. +/// Returns dates in calendar order. +fn expand_weekdays_in_month( + year: i32, + month: u32, + weekdays: &[chrono::Weekday], + tz: Tz, + time: chrono::NaiveTime, +) -> Vec> { + let mut results = Vec::with_capacity(5 * weekdays.len()); + let Some(first) = chrono::NaiveDate::from_ymd_opt(year, month, 1) else { + return results; + }; + // Last day of month: first day of next month - 1 + let last = if month == 12 { + chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) + } else { + chrono::NaiveDate::from_ymd_opt(year, month + 1, 1) + } + .and_then(|d| d.checked_sub_days(chrono::Days::new(1))) + .unwrap_or(first); + + let mut date = first; + loop { + if weekdays.contains(&date.weekday()) { + let naive = chrono::NaiveDateTime::new(date, time); + if let Some(dt) = resolve_local(tz, naive) { + results.push(dt); + } + } + if date >= last { + break; + } + date = match date.succ_opt() { + Some(d) => d, + None => break, + }; + } + results +} + +/// Collect all dates in `year` that fall on one of the given weekdays, +/// resolved to timezone `tz` at wall-clock `time`. +/// Returns dates in calendar order. +fn expand_weekdays_in_year( + year: i32, + weekdays: &[chrono::Weekday], + tz: Tz, + time: chrono::NaiveTime, +) -> Vec> { + let mut results = Vec::with_capacity(53 * weekdays.len()); + for month in 1..=12u32 { + results.extend(expand_weekdays_in_month(year, month, weekdays, tz, time)); + } + results +} + +/// A lazy iterator over recurrence occurrences. +/// +/// Created by [`Recurrence::occurrences()`]. This iterator computes each +/// occurrence on demand, making it memory-efficient for large or infinite +/// recurrence patterns. +/// +/// # Examples +/// +/// ``` +/// use eventix::{Recurrence, timezone}; +/// +/// let tz = timezone::parse_timezone("UTC").unwrap(); +/// let start = timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz).unwrap(); +/// +/// // Daily recurrence for 30 days +/// let daily = Recurrence::daily().count(30); +/// +/// // Iterate lazily - computes dates as needed +/// for occurrence in daily.occurrences(start).take(5) { +/// println!("Occurrence: {}", occurrence); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct OccurrenceIterator { + recurrence: Recurrence, + current: DateTime, + intended_time: chrono::NaiveTime, + count: u32, + exhausted: bool, + /// Buffer for Monthly/Yearly BYDAY expansion (dates within current period) + pending_byday: std::collections::VecDeque>, + /// For BYDAY expansion: year of the next period to expand + byday_next_year: i32, + /// For BYDAY expansion: month of the next period to expand (Monthly only) + byday_next_month: u32, + /// Whether the first BYDAY period has been expanded + byday_first: bool, +} + +impl OccurrenceIterator { + /// Create a new occurrence iterator + fn new(recurrence: Recurrence, start: DateTime) -> Self { + Self { + byday_next_year: start.year(), + byday_next_month: start.month(), + byday_first: true, + pending_byday: std::collections::VecDeque::new(), + recurrence, + intended_time: start.time(), + current: start, + count: 0, + exhausted: false, + } + } + + /// Check if the iterator is exhausted + fn is_exhausted(&self) -> bool { + if self.exhausted { + return true; + } + + // Check count limit + if let Some(max_count) = self.recurrence.count { + if self.count >= max_count { + return true; + } + } + + // Check until date + if let Some(until) = self.recurrence.until { + if self.current > until { + return true; } + } + + false + } + + /// Compute the next occurrence date + fn compute_next(&self) -> Option> { + advance_by_frequency( + self.current, + self.recurrence.frequency, + self.recurrence.interval, + self.intended_time, + ) + } - occurrences.push(current); - - // Calculate next occurrence based on frequency - current = match self.frequency { - Frequency::Daily => current + chrono::Duration::days(self.interval as i64), - Frequency::Weekly => current + chrono::Duration::weeks(self.interval as i64), - Frequency::Monthly => { - // Add months - let months_to_add = self.interval as i32; - let mut new_month = current.month() as i32 + months_to_add; - let mut new_year = current.year(); - - while new_month > 12 { - new_month -= 12; - new_year += 1; + /// Whether this iterator uses BYDAY period expansion (Monthly/Yearly + weekdays) + fn uses_byday_expansion(&self) -> bool { + matches!(self.recurrence.frequency, Frequency::Monthly | Frequency::Yearly) + && self.recurrence.by_weekday.is_some() + } + + /// Emit the next occurrence from BYDAY-expanded buffer, + /// expanding new periods as needed. + fn next_byday_expanded(&mut self) -> Option> { + loop { + // Try to emit from buffer + if let Some(dt) = self.pending_byday.pop_front() { + if let Some(max) = self.recurrence.count { + if self.count >= max { + return None; + } + } + if let Some(until) = self.recurrence.until { + if dt > until { + return None; } + } + self.count += 1; + return Some(dt); + } - let new_date = current - .date_naive() - .with_year(new_year) - .and_then(|d| d.with_month(new_month as u32)); - - match new_date { - Some(date) => { - let time = current.time(); - let naive = chrono::NaiveDateTime::new(date, time); - current - .timezone() - .from_local_datetime(&naive) - .earliest() - .unwrap_or(current) - } - None => break, + // Buffer empty — expand next period + if self.exhausted { + return None; + } + self.expand_next_byday_period(); + } + } + + /// Expand the next Monthly/Yearly period into `pending_byday`. + fn expand_next_byday_period(&mut self) { + let weekdays = match &self.recurrence.by_weekday { + Some(wd) => wd.clone(), + None => { + self.exhausted = true; + return; + } + }; + + let tz = self.current.timezone(); + let is_first = self.byday_first; + + let dates = match self.recurrence.frequency { + Frequency::Monthly => expand_weekdays_in_month( + self.byday_next_year, + self.byday_next_month, + &weekdays, + tz, + self.intended_time, + ), + Frequency::Yearly => { + expand_weekdays_in_year(self.byday_next_year, &weekdays, tz, self.intended_time) + } + _ => { + self.exhausted = true; + return; + } + }; + + for dt in dates { + // First period: only include dates >= start + if is_first && dt < self.current { + continue; + } + self.pending_byday.push_back(dt); + } + + self.byday_first = false; + + // Advance to next period + match self.recurrence.frequency { + Frequency::Monthly => { + let total = (self.byday_next_year as i64) * 12 + + (self.byday_next_month as i64 - 1) + + self.recurrence.interval as i64; + self.byday_next_year = (total / 12) as i32; + self.byday_next_month = (total % 12 + 1) as u32; + } + Frequency::Yearly => { + self.byday_next_year += self.recurrence.interval as i32; + } + _ => {} + } + + // Safety: prevent runaway expansion beyond chrono's NaiveDate range + if self.byday_next_year > 9999 { + self.exhausted = true; + } + + // Don't exhaust here: when the first period has no candidates + // (start is past the last matching weekday), the next period + // should be tried. The loop in next_byday_expanded() will call + // expand_next_byday_period() again, and for valid weekday lists + // every month/year has matching days. Count/until checks in + // next_byday_expanded() guarantee termination for bounded + // recurrences; for unbounded ones the caller must use .take() + // or an until date. + } +} + +impl Iterator for OccurrenceIterator { + type Item = DateTime; + + fn next(&mut self) -> Option { + // Fast path: no weekday filter active + // Avoids per-iteration frequency checks that cause regression on + // the common no-weekday path (daily, minutely, hourly, etc.) + if self.recurrence.by_weekday.is_none() { + if self.is_exhausted() { + return None; + } + let result = self.current; + match self.compute_next() { + Some(next) => self.current = next, + None => self.exhausted = true, + } + self.count += 1; + return Some(result); + } + + // Monthly/Yearly BYDAY: use period expansion instead of post-filter + if self.uses_byday_expansion() { + return self.next_byday_expanded(); + } + + // Weekday-filtered path — dispatch by frequency + let weekdays = self.recurrence.by_weekday.as_ref()?; + loop { + if self.is_exhausted() { + return None; + } + + let result = self.current; + + match self.recurrence.frequency { + // Weekly: intra-week expansion (O(1) per emit) + Frequency::Weekly => { + match advance_weekly_weekday( + result, + self.recurrence.interval, + weekdays, + self.intended_time, + ) { + Some(next) => self.current = next, + None => self.exhausted = true, + } + if weekdays.contains(&result.weekday()) { + self.count += 1; + return Some(result); + } + } + // Daily: direct weekday jumping (O(1) per emit) + Frequency::Daily => { + match advance_daily_weekday( + result, + self.recurrence.interval, + weekdays, + self.intended_time, + ) { + Some(next) => self.current = next, + None => self.exhausted = true, + } + if weekdays.contains(&result.weekday()) { + self.count += 1; + return Some(result); } } - Frequency::Yearly => { - let new_year = current.year() + self.interval as i32; - let new_date = current.date_naive().with_year(new_year); - - match new_date { - Some(date) => { - let time = current.time(); - let naive = chrono::NaiveDateTime::new(date, time); - current - .timezone() - .from_local_datetime(&naive) - .earliest() - .unwrap_or(current) + // Sub-daily: advance then O(1) skip over non-matching days + Frequency::Hourly | Frequency::Minutely | Frequency::Secondly => { + match self.compute_next() { + Some(next) => self.current = next, + None => self.exhausted = true, + } + if !weekdays.contains(&result.weekday()) { + match skip_subdaily_to_matching_day( + self.current, + self.recurrence.frequency, + self.recurrence.interval, + weekdays, + ) { + Some(next) => self.current = next, + None => self.exhausted = true, } - None => break, + } else { + self.count += 1; + return Some(result); + } + } + // Monthly/Yearly without weekdays handled by BYDAY path above + _ => { + match self.compute_next() { + Some(next) => self.current = next, + None => self.exhausted = true, } + self.count += 1; + return Some(result); } - _ => break, // Unsupported frequency - }; + } } + } - Ok(occurrences) + fn size_hint(&self) -> (usize, Option) { + if let Some(max_count) = self.recurrence.count { + let remaining = max_count.saturating_sub(self.count) as usize; + (0, Some(remaining)) + } else if self.recurrence.until.is_some() { + // Cannot determine exact size without computing + (0, None) + } else { + // Infinite recurrence + (0, None) + } } } @@ -333,6 +1037,7 @@ mod tests { #![allow(clippy::unwrap_used)] use super::*; use crate::timezone::parse_timezone; + use chrono::Timelike; #[test] fn test_daily_recurrence() { @@ -360,4 +1065,1001 @@ mod tests { assert!(filter.should_skip(&saturday)); assert!(!filter.should_skip(&monday)); } + + #[test] + fn test_lazy_iterator_equivalence() { + // Lazy iterator should produce same results as eager method + let recurrence = Recurrence::daily().count(10); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let eager: Vec<_> = recurrence.generate_occurrences(start).unwrap(); + let lazy: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(eager.len(), lazy.len()); + for (e, l) in eager.iter().zip(lazy.iter()) { + assert_eq!(e, l); + } + } + + #[test] + fn test_lazy_iterator_take() { + // Can take fewer items than the limit + let recurrence = Recurrence::daily().count(100); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-03-15 14:00:00", tz).unwrap(); + + let first_5: Vec<_> = recurrence.occurrences(start).take(5).collect(); + assert_eq!(first_5.len(), 5); + assert_eq!(first_5[0], start); + } + + #[test] + fn test_lazy_iterator_nth() { + // Can skip directly to nth occurrence + let recurrence = Recurrence::weekly().count(52); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 12:00:00", tz).unwrap(); + + let tenth = recurrence.occurrences(start).nth(9); + assert!(tenth.is_some()); + + // Verify it's 9 weeks later (nth(9) = 10th occurrence, which is start + 9 intervals) + let expected = start + chrono::Duration::weeks(9); + assert_eq!(tenth.unwrap(), expected); + } + + #[test] + fn test_lazy_iterator_size_hint() { + let recurrence = Recurrence::daily().count(30); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz).unwrap(); + + let iter = recurrence.occurrences(start); + let (min, max) = iter.size_hint(); + assert_eq!(min, 0); + assert_eq!(max, Some(30)); + } + + #[test] + fn test_lazy_iterator_monthly() { + let recurrence = Recurrence::monthly().count(6); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-15 10:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 6); + + // Check months are Jan, Feb, Mar, Apr, May, Jun + for (i, occ) in occurrences.iter().enumerate() { + assert_eq!(occ.month(), (1 + i) as u32); + } + } + + #[test] + fn test_lazy_iterator_yearly() { + let recurrence = Recurrence::yearly().count(5); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-07-04 00:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 5); + + // Check years are 2025-2029 + for (i, occ) in occurrences.iter().enumerate() { + assert_eq!(occ.year(), 2025 + i as i32); + } + } + + #[test] + fn test_lazy_iterator_until() { + // Test the `until` exhaustion path in is_exhausted() + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-01-05 09:00:00", tz).unwrap(); + + let recurrence = Recurrence::daily().until(end); + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + + // Should include Jan 1-5 (5 days) + assert_eq!(occurrences.len(), 5); + assert_eq!(occurrences.last().unwrap(), &end); + } + + #[test] + fn test_lazy_iterator_size_hint_until() { + // size_hint with `until` returns (0, None) since exact count is unknown + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-12-31 09:00:00", tz).unwrap(); + + let recurrence = Recurrence::daily().until(end); + let iter = recurrence.occurrences(start); + let (min, max) = iter.size_hint(); + assert_eq!(min, 0); + assert_eq!(max, None); + } + + #[test] + fn test_lazy_iterator_size_hint_infinite() { + // size_hint with no count and no until returns (0, None) + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let recurrence = Recurrence::daily(); + let iter = recurrence.occurrences(start); + let (min, max) = iter.size_hint(); + assert_eq!(min, 0); + assert_eq!(max, None); + } + + #[test] + fn test_lazy_iterator_with_interval() { + // Test bi-weekly via lazy iterator + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 10:00:00", tz).unwrap(); + + let recurrence = Recurrence::weekly().interval(2).count(4); + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(occurrences.len(), 4); + // Each occurrence should be 2 weeks apart + for i in 1..occurrences.len() { + let diff = occurrences[i] - occurrences[i - 1]; + assert_eq!(diff, chrono::Duration::weeks(2)); + } + } + + #[test] + fn test_monthly_day_clamping() { + // Jan 31 → Feb 28, Mar 28, Apr 28 ... (clamps to last valid day) + let recurrence = Recurrence::monthly().count(4); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-31 12:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 4); + assert_eq!(occurrences[0].day(), 31); // Jan 31 + assert_eq!(occurrences[1].day(), 28); // Feb 28 (2025 is not a leap year) + assert_eq!(occurrences[1].month(), 2); + // Subsequent months clamp from 28 (the new current day) + assert_eq!(occurrences[2].month(), 3); + assert_eq!(occurrences[3].month(), 4); + } + + #[test] + fn test_yearly_leap_day_clamping() { + // Feb 29 in a leap year → Feb 28 in non-leap years + let recurrence = Recurrence::yearly().count(3); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2024-02-29 08:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 3); + assert_eq!(occurrences[0].day(), 29); // 2024 leap year + assert_eq!(occurrences[1].day(), 28); // 2025 not a leap year + assert_eq!(occurrences[1].year(), 2025); + assert_eq!(occurrences[2].day(), 28); // 2026 not a leap year + } + + #[test] + fn test_zero_interval_clamped_to_one() { + // interval(0) is normalized to 1 (RFC 5545 default) + let recurrence = Recurrence::daily().interval(0).count(10); + assert_eq!(recurrence.get_interval(), 1); + + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + // Should behave identically to interval(1) + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 10); + assert_eq!(occurrences[0], start); + } + + #[test] + fn test_zero_interval_weekly_weekdays_clamped_to_one() { + // interval(0) on weekly + weekdays is normalized to interval(1) + let recurrence = Recurrence::weekly() + .interval(0) + .weekdays(vec![chrono::Weekday::Mon, chrono::Weekday::Wed]) + .count(5); + assert_eq!(recurrence.get_interval(), 1); + + let tz = parse_timezone("UTC").unwrap(); + // Start on a Monday + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + // Should produce 5 occurrences (Mon and Wed each week) + assert_eq!(occurrences.len(), 5); + assert_eq!(occurrences[0], start); + } + + #[test] + fn test_weekdays_filter_lazy() { + use rrule::Weekday; + // Daily recurrence on Mon/Wed/Fri with count=14 + // count(14) means 14 emitted (matching) occurrences, not 14 scanned slots + // Start on a Monday (2025-01-06) + let recurrence = Recurrence::daily() + .weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]) + .count(14); + + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + + // All results should be Mon, Wed, or Fri + for occ in &occurrences { + let wd = occ.weekday(); + assert!( + wd == Weekday::Mon || wd == Weekday::Wed || wd == Weekday::Fri, + "unexpected weekday: {:?}", + wd + ); + } + // count(14) emits exactly 14 matching weekdays + assert_eq!(occurrences.len(), 14); + } + + #[test] + fn test_weekdays_filter_eager() { + use rrule::Weekday; + let recurrence = Recurrence::daily() + .weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]) + .count(14); + + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap(); + + let eager = recurrence.generate_occurrences(start).unwrap(); + let lazy: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(eager.len(), lazy.len()); + for (e, l) in eager.iter().zip(lazy.iter()) { + assert_eq!(e, l); + } + } + + #[test] + fn test_weekly_weekdays_expansion_lazy() { + use rrule::Weekday; + // Weekly recurrence on Mon/Wed should emit BOTH Mon and Wed each week + let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Wed]).count(6); + + let tz = parse_timezone("UTC").unwrap(); + // 2025-01-06 is a Monday + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 6); + + // Should be Mon6, Wed8, Mon13, Wed15, Mon20, Wed22 + assert_eq!(occurrences[0].day(), 6); // Mon + assert_eq!(occurrences[1].day(), 8); // Wed + assert_eq!(occurrences[2].day(), 13); // Mon + assert_eq!(occurrences[3].day(), 15); // Wed + assert_eq!(occurrences[4].day(), 20); // Mon + assert_eq!(occurrences[5].day(), 22); // Wed + + for occ in &occurrences { + let wd = occ.weekday(); + assert!(wd == Weekday::Mon || wd == Weekday::Wed); + } + } + + #[test] + fn test_weekly_weekdays_expansion_eager() { + use rrule::Weekday; + // Eager path should match lazy for weekly + weekdays + let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Wed]).count(6); + + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap(); + + let eager = recurrence.generate_occurrences(start).unwrap(); + let lazy: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(eager.len(), lazy.len()); + for (e, l) in eager.iter().zip(lazy.iter()) { + assert_eq!(e, l); + } + } + + #[test] + fn test_weekly_weekdays_biweekly() { + use rrule::Weekday; + // Every 2 weeks, on Tue/Thu + let recurrence = Recurrence::weekly() + .interval(2) + .weekdays(vec![Weekday::Tue, Weekday::Thu]) + .count(4); + + let tz = parse_timezone("UTC").unwrap(); + // 2025-01-07 is a Tuesday + let start = crate::timezone::parse_datetime_with_tz("2025-01-07 10:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 4); + + // Week 1: Tue Jan 7, Thu Jan 9 + assert_eq!(occurrences[0].day(), 7); + assert_eq!(occurrences[1].day(), 9); + // Week 3 (skip week 2): Tue Jan 21, Thu Jan 23 + assert_eq!(occurrences[2].day(), 21); + assert_eq!(occurrences[3].day(), 23); + } + + #[test] + fn test_weekly_weekdays_start_not_in_weekdays() { + use rrule::Weekday; + // Start on a Tuesday but only want Mon/Fri + let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Fri]).count(4); + + let tz = parse_timezone("UTC").unwrap(); + // 2025-01-07 is a Tuesday + let start = crate::timezone::parse_datetime_with_tz("2025-01-07 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 4); + + // Tue is skipped, first match is Fri Jan 10, then Mon Jan 13, Fri Jan 17, Mon Jan 20 + for occ in &occurrences { + let wd = occ.weekday(); + assert!(wd == Weekday::Mon || wd == Weekday::Fri); + } + } + + #[test] + fn test_dst_spring_forward_daily() { + // US spring-forward: 2025-03-09 2:00 AM → 3:00 AM in America/New_York + // A daily recurrence at 2:30 AM should survive the DST gap + let recurrence = Recurrence::daily().count(3); + let tz = parse_timezone("America/New_York").unwrap(); + // March 8 at 2:30 AM exists + let start = crate::timezone::parse_datetime_with_tz("2025-03-08 02:30:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + // Should not terminate — all 3 occurrences emitted + assert_eq!(occurrences.len(), 3); + // March 8, 9, 10 + assert_eq!(occurrences[0].day(), 8); + assert_eq!(occurrences[1].day(), 9); // DST gap day — resolved to post-gap time + assert_eq!(occurrences[2].day(), 10); + // March 9: 2:30 AM EST doesn't exist, should resolve to 3:30 AM EDT + // (pre-gap offset UTC-5 applied to 02:30 → 07:30 UTC → 03:30 EDT) + assert_eq!(occurrences[1].hour(), 3); + assert_eq!(occurrences[1].minute(), 30); + // March 10: back to normal 2:30 AM EDT + assert_eq!(occurrences[2].hour(), 2); + assert_eq!(occurrences[2].minute(), 30); + } + + #[test] + fn test_dst_spring_forward_weekly() { + // Weekly recurrence crossing DST spring-forward + let recurrence = Recurrence::weekly().count(3); + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-03-02 02:30:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 3); + // Mar 2, Mar 9 (DST day), Mar 16 + assert_eq!(occurrences[0].day(), 2); + assert_eq!(occurrences[1].day(), 9); + assert_eq!(occurrences[2].day(), 16); + // Mar 9 should resolve to 3:30 AM EDT (same pre-gap offset logic) + assert_eq!(occurrences[1].hour(), 3); + assert_eq!(occurrences[1].minute(), 30); + // Mar 16 is post-DST, should be 2:30 AM EDT + assert_eq!(occurrences[2].hour(), 2); + } + + #[test] + fn test_dst_fall_back_daily() { + // US fall-back: 2025-11-02 2:00 AM → 1:00 AM in America/New_York + // 1:30 AM is ambiguous (exists in both EDT and EST) + // resolve_local picks .earliest() which is the EDT version + let recurrence = Recurrence::daily().count(3); + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-11-01 01:30:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 3); + assert_eq!(occurrences[0].day(), 1); + assert_eq!(occurrences[1].day(), 2); // ambiguous day + assert_eq!(occurrences[2].day(), 3); + // All should be at 1:30 AM wall-clock + for occ in &occurrences { + assert_eq!(occ.hour(), 1); + assert_eq!(occ.minute(), 30); + } + } + + #[test] + fn test_dst_spring_forward_eager_matches_lazy() { + // Verify eager and lazy paths produce identical results across DST + let recurrence = Recurrence::daily().count(5); + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-03-07 02:30:00", tz).unwrap(); + + let eager = recurrence.generate_occurrences(start).unwrap(); + let lazy: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(eager.len(), lazy.len()); + for (e, l) in eager.iter().zip(lazy.iter()) { + assert_eq!(e, l); + } + } + + #[test] + fn test_hourly_recurrence() { + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz).unwrap(); + + let occs: Vec<_> = Recurrence::hourly().count(4).occurrences(start).collect(); + assert_eq!(occs.len(), 4); + // Each occurrence 1 hour apart + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], chrono::Duration::hours(1)); + } + } + + #[test] + fn test_minutely_recurrence() { + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 09:00:00", tz).unwrap(); + + let occs: Vec<_> = + Recurrence::minutely().interval(15).count(5).occurrences(start).collect(); + assert_eq!(occs.len(), 5); + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], chrono::Duration::minutes(15)); + } + } + + #[test] + fn test_secondly_recurrence() { + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz).unwrap(); + + let occs: Vec<_> = + Recurrence::secondly().interval(30).count(6).occurrences(start).collect(); + assert_eq!(occs.len(), 6); + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], chrono::Duration::seconds(30)); + } + } + + #[test] + fn test_hourly_across_dst_spring_forward() { + // US spring-forward: 2025-03-09 2:00 AM → 3:00 AM in America/New_York + // An hourly series starting at 1:00 AM should smoothly cross the gap + // (1:00 → 2:00 doesn't exist locally but is valid UTC → shows as 3:00 EDT) + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-03-09 01:00:00", tz).unwrap(); + + let occs: Vec<_> = Recurrence::hourly().count(4).occurrences(start).collect(); + assert_eq!(occs.len(), 4); + // Intervals must be exactly 1 hour apart in wall-clock time + // (UTC duration arithmetic is always exact) + for i in 1..occs.len() { + assert_eq!(occs[i] - occs[i - 1], chrono::Duration::hours(1)); + } + // The "2:00 AM" slot is skipped by the clocks — next valid hour is 3:00 AM EDT + assert_eq!(occs[1].hour(), 3); + } + + #[test] + fn test_subdaily_new_constructor() { + // Recurrence::new() accepts all RFC 5545 frequencies + let _ = Recurrence::new(Frequency::Hourly); + let _ = Recurrence::new(Frequency::Minutely); + let _ = Recurrence::new(Frequency::Secondly); + } + + #[test] + fn test_generate_occurrences_capped() { + let recurrence = Recurrence::daily().count(30); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let capped = recurrence.generate_occurrences_capped(start, 5).unwrap(); + assert_eq!(capped.len(), 5); + assert_eq!(capped[0], start); + } + + #[test] + fn test_until_rrule_string_uses_utc() { + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz).unwrap(); + let until = crate::timezone::parse_datetime_with_tz("2025-06-30 10:00:00", tz).unwrap(); + + let recurrence = Recurrence::daily().until(until); + let rrule_str = recurrence.to_rrule_string(start).unwrap(); + + // UNTIL must be in UTC (EDT = UTC-4, so 10:00 EDT = 14:00 UTC) + assert!( + rrule_str.contains("UNTIL=20250630T140000Z"), + "UNTIL should be converted to UTC, got: {}", + rrule_str + ); + } + + #[test] + fn test_generate_occurrences_rejects_unbounded() { + let recurrence = Recurrence::daily(); // no count, no until + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let result = recurrence.generate_occurrences(start); + assert!(result.is_err(), "unbounded recurrence should be rejected"); + } + + #[test] + fn test_interval_zero_rrule_string_consistent() { + // interval(0) is clamped to 1, so RRULE should omit INTERVAL (default=1) + let recurrence = Recurrence::daily().interval(0).count(5); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let rrule_str = recurrence.to_rrule_string(start).unwrap(); + assert!( + !rrule_str.contains("INTERVAL=0"), + "interval(0) should not appear in RRULE, got: {}", + rrule_str + ); + // Behavior matches interval(1) + let r1 = Recurrence::daily().interval(1).count(5); + let rrule_str1 = r1.to_rrule_string(start).unwrap(); + assert_eq!(rrule_str, rrule_str1); + } + + #[test] + fn test_empty_weekdays_normalized_to_none() { + // weekdays(vec![]) should be normalized to None (no filter) + let recurrence = Recurrence::daily().weekdays(vec![]).count(5); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + // Should produce 5 occurrences, not hang + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 5); + + // RRULE should not contain BYDAY + let rrule_str = recurrence.to_rrule_string(start).unwrap(); + assert!( + !rrule_str.contains("BYDAY"), + "empty weekdays should not emit BYDAY, got: {}", + rrule_str + ); + } + + #[test] + fn test_monthly_byday_expansion() { + use chrono::Weekday; + // Monthly + BYDAY=TU: every Tuesday of each month + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).count(10); + let tz = parse_timezone("UTC").unwrap(); + // Start on Jan 1 2025 (Wednesday) + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 10); + + // All must be Tuesdays + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Tue, "expected Tuesday, got {:?}", occ); + } + + // First Tuesday of Jan 2025 >= Jan 1 is Jan 7 + assert_eq!(occurrences[0].day(), 7); + // Jan has Tuesdays: 7, 14, 21, 28 (4 total) + assert_eq!(occurrences[1].day(), 14); + assert_eq!(occurrences[2].day(), 21); + assert_eq!(occurrences[3].day(), 28); + // 5th occurrence: first Tuesday of Feb = Feb 4 + assert_eq!(occurrences[4].month(), 2); + assert_eq!(occurrences[4].day(), 4); + } + + #[test] + fn test_monthly_byday_multiple_weekdays() { + use chrono::Weekday; + // Monthly + BYDAY=TU,TH: every Tuesday and Thursday of each month + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue, Weekday::Thu]).count(12); + let tz = parse_timezone("UTC").unwrap(); + // Start on Jan 1 2025 (Wednesday) + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 12); + + // All must be Tuesday or Thursday + for occ in &occurrences { + let wd = occ.weekday(); + assert!( + wd == Weekday::Tue || wd == Weekday::Thu, + "expected Tue or Thu, got {:?} on {}", + wd, + occ + ); + } + + // Jan 2025: Thu 2, Tue 7, Thu 9, Tue 14, Thu 16, Tue 21, Thu 23, Tue 28, Thu 30 + // First >= Jan 1: Thu Jan 2 + assert_eq!(occurrences[0].day(), 2); + assert_eq!(occurrences[0].weekday(), Weekday::Thu); + assert_eq!(occurrences[1].day(), 7); + assert_eq!(occurrences[1].weekday(), Weekday::Tue); + } + + #[test] + fn test_monthly_byday_with_interval() { + use chrono::Weekday; + // Every 2 months, Mondays only + let recurrence = Recurrence::monthly().interval(2).weekdays(vec![Weekday::Mon]).count(10); + let tz = parse_timezone("UTC").unwrap(); + // Start Jan 1 2025 (Wed) + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 10); + + // All Mondays + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Mon); + } + + // Jan Mondays: 6, 13, 20, 27 (4) + // Skip Feb, next period is Mar + // Mar Mondays: 3, 10, 17, 24, 31 (5) + // So first 4 in Jan, then 5 in Mar (total 9), then May for #10 + assert_eq!(occurrences[0].month(), 1); + assert_eq!(occurrences[0].day(), 6); + assert_eq!(occurrences[3].month(), 1); + assert_eq!(occurrences[3].day(), 27); + // 5th occurrence: first Monday of March + assert_eq!(occurrences[4].month(), 3); + assert_eq!(occurrences[4].day(), 3); + } + + #[test] + fn test_monthly_byday_start_on_matching_weekday() { + use chrono::Weekday; + // Start on a Tuesday — it should be included + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).count(5); + let tz = parse_timezone("UTC").unwrap(); + // Jan 7 2025 is a Tuesday + let start = crate::timezone::parse_datetime_with_tz("2025-01-07 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 5); + // Start date should be included + assert_eq!(occurrences[0], start); + // Next Tuesdays: 14, 21, 28, then Feb 4 + assert_eq!(occurrences[1].day(), 14); + assert_eq!(occurrences[4].month(), 2); + assert_eq!(occurrences[4].day(), 4); + } + + #[test] + fn test_monthly_byday_with_until() { + use chrono::Weekday; + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + let until = crate::timezone::parse_datetime_with_tz("2025-01-20 23:59:59", tz).unwrap(); + + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).until(until); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + // Jan Tuesdays <= Jan 20: 7, 14 + assert_eq!(occurrences.len(), 2); + assert_eq!(occurrences[0].day(), 7); + assert_eq!(occurrences[1].day(), 14); + } + + #[test] + fn test_yearly_byday_expansion() { + use chrono::Weekday; + // Yearly + BYDAY=MO: every Monday of each year + let recurrence = Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(5); + let tz = parse_timezone("UTC").unwrap(); + // Start Jan 1 2025 (Wednesday) + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 5); + + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Mon); + } + + // First Monday >= Jan 1 2025 is Jan 6 + assert_eq!(occurrences[0].day(), 6); + assert_eq!(occurrences[0].month(), 1); + assert_eq!(occurrences[1].day(), 13); + } + + #[test] + fn test_yearly_byday_with_interval() { + use chrono::Weekday; + // Every 2 years, Fridays only, count=3 + let recurrence = Recurrence::yearly().interval(2).weekdays(vec![Weekday::Fri]).count(3); + let tz = parse_timezone("UTC").unwrap(); + // Start Jan 1 2025 (Wednesday) + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 3); + + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Fri); + } + + // First Friday >= Jan 1 2025 is Jan 3 + assert_eq!(occurrences[0].year(), 2025); + assert_eq!(occurrences[0].month(), 1); + assert_eq!(occurrences[0].day(), 3); + } + + #[test] + fn test_monthly_byday_eager_matches_lazy() { + use chrono::Weekday; + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Wed, Weekday::Fri]).count(15); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let lazy: Vec<_> = recurrence.clone().occurrences(start).collect(); + let eager = recurrence.generate_occurrences(start).unwrap(); + + assert_eq!(lazy.len(), 15); + assert_eq!(lazy, eager); + } + + #[test] + fn test_monthly_byday_dst_spring_forward() { + use chrono::Weekday; + // Monthly Sundays across US spring-forward (Mar 9 2025 at 2:00 AM) + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Sun]).count(12); + let tz = parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-03-01 02:30:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 12); + + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Sun); + } + + // Mar 9 2025 is a Sunday — 2:30 AM doesn't exist (DST gap). + // resolve_local should handle it gracefully. + let mar_9 = occurrences.iter().find(|o| o.month() == 3 && o.day() == 9); + assert!(mar_9.is_some(), "Mar 9 (spring forward) should be present"); + } + + #[test] + fn test_daily_weekday_direct_jump() { + use chrono::Weekday; + // Daily interval=1, weekdays=[Mon, Wed, Fri], count=9 + let recurrence = Recurrence::daily() + .weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]) + .count(9); + let tz = parse_timezone("UTC").unwrap(); + // Start Saturday Jan 4 2025 — not a matching day + let start = crate::timezone::parse_datetime_with_tz("2025-01-04 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 9); + + for occ in &occurrences { + let wd = occ.weekday(); + assert!( + wd == Weekday::Mon || wd == Weekday::Wed || wd == Weekday::Fri, + "expected Mon/Wed/Fri, got {:?} on {}", + wd, + occ + ); + } + + // First match after Sat Jan 4: Mon Jan 6 + assert_eq!(occurrences[0].day(), 6); + assert_eq!(occurrences[0].weekday(), Weekday::Mon); + } + + #[test] + fn test_daily_interval2_weekday_jump() { + use chrono::Weekday; + // Daily interval=2, weekdays=[Tue], count=5 + let recurrence = Recurrence::daily().interval(2).weekdays(vec![Weekday::Tue]).count(5); + let tz = parse_timezone("UTC").unwrap(); + // Start Wed Jan 1 2025 + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 5); + + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Tue); + } + } + + #[test] + fn test_daily_weekday_eager_matches_lazy() { + use chrono::Weekday; + let recurrence = Recurrence::daily() + .weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]) + .count(15); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + + let lazy: Vec<_> = recurrence.clone().occurrences(start).collect(); + let eager = recurrence.generate_occurrences(start).unwrap(); + + assert_eq!(lazy.len(), 15); + assert_eq!(lazy, eager); + } + + #[test] + fn test_subdaily_weekday_skip() { + use chrono::Weekday; + // Hourly interval=1, weekdays=[Mon], count=48 + // Should emit 24 hours on first Mon, skip Tue-Sun in O(1), emit 24 on next Mon + let recurrence = Recurrence::hourly().interval(1).weekdays(vec![Weekday::Mon]).count(48); + let tz = parse_timezone("UTC").unwrap(); + // Start Mon Jan 6 2025 00:00 + let start = crate::timezone::parse_datetime_with_tz("2025-01-06 00:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 48); + + // All must be Monday + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Mon, "expected Monday, got {:?}", occ); + } + + // First 24 on Jan 6, next 24 on Jan 13 + assert_eq!(occurrences[0].day(), 6); + assert_eq!(occurrences[23].day(), 6); + assert_eq!(occurrences[23].hour(), 23); + assert_eq!(occurrences[24].day(), 13); + assert_eq!(occurrences[24].hour(), 0); + } + + #[test] + fn test_subdaily_weekday_minutely_skip() { + use chrono::Weekday; + // Minutely interval=30, weekdays=[Tue, Thu], count=96 + // Each matching day has 48 half-hour slots (0:00..23:30) + let recurrence = Recurrence::minutely() + .interval(30) + .weekdays(vec![Weekday::Tue, Weekday::Thu]) + .count(96); + let tz = parse_timezone("UTC").unwrap(); + // Start Tue Jan 7 2025 00:00 + let start = crate::timezone::parse_datetime_with_tz("2025-01-07 00:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 96); + + for occ in &occurrences { + let wd = occ.weekday(); + assert!(wd == Weekday::Tue || wd == Weekday::Thu, "expected Tue/Thu, got {:?}", wd); + } + + // First 48 on Tue Jan 7, next 48 on Thu Jan 9 + assert_eq!(occurrences[0].day(), 7); + assert_eq!(occurrences[47].day(), 7); + assert_eq!(occurrences[48].day(), 9); + } + + #[test] + fn test_subdaily_weekday_secondly_perf() { + use chrono::Weekday; + // Secondly interval=1, weekdays=[Wed], count=10 + // Without O(1) skip this would iterate 86400*6=518400 to cross 6 days. + // With the skip, it computes the jump directly. + let recurrence = Recurrence::secondly().interval(1).weekdays(vec![Weekday::Wed]).count(10); + let tz = parse_timezone("UTC").unwrap(); + // Start Thu Jan 2 2025 00:00:00 — not a Wednesday + let start = crate::timezone::parse_datetime_with_tz("2025-01-02 00:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + + assert_eq!(occurrences.len(), 10); + + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Wed); + } + + // First Wednesday after Thu Jan 2 = Wed Jan 8 + assert_eq!(occurrences[0].day(), 8); + assert_eq!(occurrences[0].month(), 1); + } + + #[test] + fn test_subdaily_weekday_start_on_matching_day() { + use chrono::Weekday; + // Start on a matching Wednesday — should include start + let recurrence = Recurrence::hourly().interval(4).weekdays(vec![Weekday::Wed]).count(6); + let tz = parse_timezone("UTC").unwrap(); + // Wed Jan 8 2025 + let start = crate::timezone::parse_datetime_with_tz("2025-01-08 08:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 6); + + // Start included: 08:00, 12:00, 16:00, 20:00 = 4 on this Wed + // Then next Wed: 4 more starting from midnight-ish (first aligned slot) + assert_eq!(occurrences[0], start); + for occ in &occurrences { + assert_eq!(occ.weekday(), Weekday::Wed); + } + } + + #[test] + fn test_byday_monthly_start_past_last_weekday() { + use chrono::Weekday; + // Start on Jan 31 (Friday): all Mon/Wed in January are before this date. + // The iterator must NOT exhaust — it should advance to February. + let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Mon]).count(2); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-31 10:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 2); + // First Monday in Feb 2025 = Feb 3 + assert_eq!(occurrences[0].day(), 3); + assert_eq!(occurrences[0].month(), 2); + assert_eq!(occurrences[0].weekday(), Weekday::Mon); + // Second occurrence: next Monday = Feb 10 + assert_eq!(occurrences[1].day(), 10); + assert_eq!(occurrences[1].month(), 2); + } + + #[test] + fn test_byday_yearly_start_past_all_weekdays() { + use chrono::Weekday; + // Start on Dec 31 — all Mondays in 2025 are before this. + // Must advance to 2026. + let recurrence = Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(1); + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-12-31 10:00:00", tz).unwrap(); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 1); + // First Monday in 2026 = Jan 5 + assert_eq!(occurrences[0].year(), 2026); + assert_eq!(occurrences[0].month(), 1); + assert_eq!(occurrences[0].day(), 5); + } + + #[test] + fn test_subdaily_skip_does_not_overshoot_matching_day() { + use chrono::Datelike; + // Start Saturday 23:00, hourly, weekdays=[Sun, Mon]. + // Second occurrence crosses midnight into Sunday — must NOT skip Sunday. + let tz = parse_timezone("UTC").unwrap(); + // 2025-06-07 is a Saturday + let start = crate::timezone::parse_datetime_with_tz("2025-06-07 23:00:00", tz).unwrap(); + assert_eq!(start.weekday(), chrono::Weekday::Sat); + + let recurrence = Recurrence::hourly() + .interval(1) + .weekdays(vec![chrono::Weekday::Sun, chrono::Weekday::Mon]) + .count(3); + + let occurrences: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occurrences.len(), 3); + // First occurrence: Sunday 00:00 (first valid slot after Sat 23:00) + assert_eq!(occurrences[0].weekday(), chrono::Weekday::Sun); + assert_eq!(occurrences[0].day(), 8); + assert_eq!(occurrences[0].hour(), 0); + // Second: Sunday 01:00 + assert_eq!(occurrences[1].hour(), 1); + // Third: Sunday 02:00 + assert_eq!(occurrences[2].hour(), 2); + } } diff --git a/tests/gap_validation_tests.rs b/tests/gap_validation_tests.rs index 7f17cb6..2417e59 100644 --- a/tests/gap_validation_tests.rs +++ b/tests/gap_validation_tests.rs @@ -426,3 +426,143 @@ fn test_gap_metadata() { } } } + +#[test] +fn test_touching_events_not_overlapping() { + // CRITICAL EDGE CASE: Events that share an exact boundary time should NOT overlap. + // Event A ends at 10:00, Event B starts at 10:00 = NO OVERLAP + let mut cal = Calendar::new("Touching Events"); + + // Event A: 09:00 - 10:00 + cal.add_event( + Event::builder() + .title("Event A") + .start("2025-11-01 09:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(), + ); + + // Event B: 10:00 - 11:00 (starts exactly when A ends) + cal.add_event( + Event::builder() + .title("Event B") + .start("2025-11-01 10:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(), + ); + + // Event C: 11:00 - 12:00 (starts exactly when B ends) + cal.add_event( + Event::builder() + .title("Event C") + .start("2025-11-01 11:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(), + ); + + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-11-01 13:00:00", tz).unwrap(); + + let overlaps = gap_validation::find_overlaps(&cal, start, end).unwrap(); + + // Back-to-back events should have ZERO overlaps + assert_eq!( + overlaps.len(), + 0, + "Touching events (A ends when B starts) should NOT be detected as overlapping" + ); +} + +#[test] +fn test_overlaps_sweep_line_performance() { + // Test that sweep line algorithm handles many events efficiently + let mut cal = Calendar::new("Many Events"); + + // Create 100 events distributed across 28 days at the same time + // Multiple events per day will overlap (verifies correct detection at scale) + for i in 0..100 { + cal.add_event( + Event::builder() + .title(format!("Event {}", i)) + .start(&format!("2025-11-{:02} 10:00:00", (i % 28) + 1), "UTC") + .duration_hours(1) + .build() + .unwrap(), + ); + } + + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-11-30 23:59:59", tz).unwrap(); + + // This should complete quickly with O(N log N) algorithm + let overlaps = gap_validation::find_overlaps(&cal, start, end).unwrap(); + + // 100 events % 28 days = ~3-4 events per day at same time = overlaps expected + assert!(overlaps.len() > 0, "Should detect overlaps on same-day events"); +} + +#[test] +fn test_zero_duration_events_no_false_overlaps() { + // EDGE CASE: Zero-duration events (start == end) should not cause false overlaps. + // The builder intentionally rejects zero-duration events, but imported/manual data + // can still contain them, so find_overlaps must handle them defensively. + let cal = Calendar::from_json( + r#" + { + "name": "Zero Duration Test", + "events": [ + { + "title": "Zero Duration Event", + "start_time": "2025-06-15T09:00:00+00:00", + "end_time": "2025-06-15T09:00:00+00:00", + "timezone": "UTC", + "status": "Confirmed", + "attendees": [], + "description": null, + "location": null, + "uid": null + }, + { + "title": "Event A", + "start_time": "2025-06-15T10:00:00+00:00", + "end_time": "2025-06-15T11:00:00+00:00", + "timezone": "UTC", + "status": "Confirmed", + "attendees": [], + "description": null, + "location": null, + "uid": null + }, + { + "title": "Event B", + "start_time": "2025-06-15T12:00:00+00:00", + "end_time": "2025-06-15T13:00:00+00:00", + "timezone": "UTC", + "status": "Confirmed", + "attendees": [], + "description": null, + "location": null, + "uid": null + } + ], + "timezone": "UTC" + } + "#, + ) + .unwrap(); + + let tz = timezone::parse_timezone("UTC").unwrap(); + + let start = timezone::parse_datetime_with_tz("2025-06-15 00:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-06-15 23:59:59", tz).unwrap(); + + let overlaps = gap_validation::find_overlaps(&cal, start, end).unwrap(); + + // Zero-duration imported events should be ignored, leaving no false overlaps. + assert_eq!(overlaps.len(), 0, "Zero-duration events should not produce false overlaps"); +} diff --git a/tests/property_tests.rs b/tests/property_tests.rs index e6c6f1b..33583fa 100644 --- a/tests/property_tests.rs +++ b/tests/property_tests.rs @@ -2,7 +2,7 @@ use chrono::{Duration, TimeZone}; use eventix::timezone; -use eventix::{Calendar, Event, Recurrence}; +use eventix::{gap_validation, Calendar, Event, Recurrence}; use proptest::prelude::*; proptest! { @@ -20,7 +20,7 @@ proptest! { let start = tz.with_ymd_and_hms(start_year, start_month, start_day, hour, minute, 0).unwrap(); let recurrence = Recurrence::daily().count(count); - let occurrences = recurrence.generate_occurrences(start, 200).unwrap(); + let occurrences = recurrence.generate_occurrences(start).unwrap(); // Invariant: Should generate exactly 'count' occurrences prop_assert_eq!(occurrences.len(), count as usize); @@ -46,7 +46,7 @@ proptest! { let start = tz.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(); let recurrence = Recurrence::weekly().interval(interval).count(count); - let occurrences = recurrence.generate_occurrences(start, 100).unwrap(); + let occurrences = recurrence.generate_occurrences(start).unwrap(); // Invariant: Week difference should match interval for windows in occurrences.windows(2) { @@ -115,4 +115,168 @@ proptest! { prop_assert!(!is_available); } + // END: Event Builder Tests + + // START: Gap Validation Property Tests + #[test] + fn test_gaps_plus_busy_equals_total( + num_events in 1usize..10, + window_hours in 4i64..24 + ) { + // INVARIANT: busy_duration + free_duration = total_duration + let mut cal = Calendar::new("Density Test"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = tz.with_ymd_and_hms(2025, 6, 15, 8, 0, 0).unwrap(); + + // Add random non-overlapping events + for i in 0..num_events { + let event = Event::builder() + .title(format!("Event {}", i)) + .start_datetime(base + Duration::hours(i as i64 * 2)) + .duration_minutes(45) + .build() + .unwrap(); + cal.add_event(event); + } + + let start = base; + let end = base + Duration::hours(window_hours); + let density = gap_validation::calculate_density(&cal, start, end).unwrap(); + + // Core invariant: busy + free = total + let busy_secs = density.busy_duration.num_seconds(); + let free_secs = density.free_duration.num_seconds(); + let total_secs = density.total_duration.num_seconds(); + + prop_assert_eq!( + busy_secs + free_secs, + total_secs, + "busy ({}) + free ({}) should equal total ({})", + busy_secs, free_secs, total_secs + ); + } + + #[test] + fn test_density_percentage_bounds_non_overlapping( + num_events in 0usize..10, + event_duration_mins in 15i64..60 + ) { + // INVARIANT: For NON-OVERLAPPING events, occupancy_percentage is 0.0 <= x <= 100.0 + // Note: With overlapping events, occupancy CAN exceed 100% (over-booking) + let mut cal = Calendar::new("Percentage Test"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = tz.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap(); + + // Space events 2 hours apart with max 60 min duration = no overlap + for i in 0..num_events { + let event = Event::builder() + .title(format!("E{}", i)) + .start_datetime(base + Duration::hours(i as i64 * 2)) + .duration_minutes(event_duration_mins) + .build() + .unwrap(); + cal.add_event(event); + } + + let start = base; + let end = base + Duration::hours(24); + let density = gap_validation::calculate_density(&cal, start, end).unwrap(); + + prop_assert!( + density.occupancy_percentage >= 0.0, + "Occupancy cannot be negative" + ); + prop_assert!( + density.occupancy_percentage <= 100.0, + "Non-overlapping events should not exceed 100% occupancy, got {:.2}%", + density.occupancy_percentage + ); + } + + #[test] + fn test_gaps_are_non_overlapping( + num_events in 2usize..8 + ) { + // INVARIANT: Gaps returned should never overlap with each other + let mut cal = Calendar::new("Gap Overlap Test"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = tz.with_ymd_and_hms(2025, 7, 1, 9, 0, 0).unwrap(); + + // Create spaced events + for i in 0..num_events { + let event = Event::builder() + .title(format!("Meeting {}", i)) + .start_datetime(base + Duration::hours(i as i64 * 3)) + .duration_hours(1) + .build() + .unwrap(); + cal.add_event(event); + } + + let start = base - Duration::hours(1); + let end = base + Duration::hours(num_events as i64 * 3 + 2); + let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(0)).unwrap(); + + // Verify gaps don't overlap — O(N) adjacent sweep + // (find_gaps returns gaps in chronological order) + for pair in gaps.windows(2) { + prop_assert!( + pair[0].end <= pair[1].start, + "Gap ({} - {}) overlaps with next gap ({} - {})", + pair[0].start, pair[0].end, pair[1].start, pair[1].end + ); + } + } + + #[test] + fn test_no_overlaps_for_sequential_events( + num_events in 2usize..20 + ) { + // INVARIANT: Back-to-back events (A ends when B starts) should have 0 overlaps + let mut cal = Calendar::new("Sequential Events"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = tz.with_ymd_and_hms(2025, 5, 1, 9, 0, 0).unwrap(); + + // Create perfectly sequential (touching) events + for i in 0..num_events { + let event = Event::builder() + .title(format!("Event {}", i)) + .start_datetime(base + Duration::hours(i as i64)) + .duration_hours(1) + .build() + .unwrap(); + cal.add_event(event); + } + + let start = base; + let end = base + Duration::hours(num_events as i64 + 1); + let overlaps = gap_validation::find_overlaps(&cal, start, end).unwrap(); + + // Sequential events should have ZERO overlaps + prop_assert_eq!( + overlaps.len(), + 0, + "Sequential events should not have overlaps, found {}", + overlaps.len() + ); + } + + #[test] + fn test_empty_calendar_has_one_big_gap( + window_hours in 1i64..48 + ) { + // INVARIANT: Empty calendar = one gap covering entire window + let cal = Calendar::new("Empty"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = tz.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap(); + let end = start + Duration::hours(window_hours); + + let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(0)).unwrap(); + + prop_assert_eq!(gaps.len(), 1, "Empty calendar should have exactly one gap"); + prop_assert_eq!(gaps[0].start, start); + prop_assert_eq!(gaps[0].end, end); + prop_assert_eq!(gaps[0].duration_minutes(), window_hours * 60); + } + // END: Gap Validation Property Tests } diff --git a/usage-examples/Cargo.toml b/usage-examples/Cargo.toml index c6929fc..1c7106c 100644 --- a/usage-examples/Cargo.toml +++ b/usage-examples/Cargo.toml @@ -6,3 +6,7 @@ publish = false [dependencies] eventix = { path = ".." } +chrono = "0.4" +serde_json = "1.0" + + diff --git a/usage-examples/src/json_web.rs b/usage-examples/src/json_web.rs new file mode 100644 index 0000000..d76eba6 --- /dev/null +++ b/usage-examples/src/json_web.rs @@ -0,0 +1,106 @@ +//! JSON & Web Integration +//! +//! Simple examples for REST APIs and web frontends. + +use eventix::{Calendar, Event, Utc}; +use serde_json::{json, Value}; + +/// Import calendar from JSON (like from a POST request) +fn import_from_web() -> eventix::Result { + let json = r#" + { + "name": "User Calendar", + "events": [{ + "title": "Meeting", + "start_time": "2025-03-15T14:00:00+00:00", + "end_time": "2025-03-15T15:00:00+00:00", + "timezone": "UTC", + "status": "Confirmed", + "attendees": [], + "description": null, + "location": null, + "uid": null + }], + "timezone": "UTC" + } + "#; + + Calendar::from_json(json) +} + +/// Export calendar to JSON (for API response) +fn export_to_web(cal: &Calendar) -> eventix::Result { + cal.to_json() +} + +/// Build an API response +fn api_response(cal: &Calendar) -> Value { + json!({ + "ok": true, + "data": { + "name": cal.name, + "event_count": cal.event_count() + }, + "timestamp": Utc::now().to_rfc3339() + }) +} + +/// Run the JSON example +pub fn run() -> eventix::Result<()> { + println!("\n=== JSON/Web Example ===\n"); + + // 1. Import + let mut cal = import_from_web()?; + println!("📥 Imported: '{}' ({} events)", cal.name, cal.event_count()); + + // 2. Add events + cal.add_event( + Event::builder() + .title("Weekly Sync") + .start("2025-03-20 10:00:00", "UTC") + .duration_hours(1) + .build()?, + ); + println!("➕ Added event"); + + // 3. Export + let json = export_to_web(&cal)?; + println!("📤 Exported JSON:\n{}", json); + + // 4. API response + let response = api_response(&cal); + println!( + "\n🌐 API Response:\n{}", + serde_json::to_string_pretty(&response) + .map_err(|e| eventix::EventixError::Other(e.to_string()))? + ); + + // 5. Save to file (with proper error handling) + use std::path::PathBuf; + + let output_dir = PathBuf::from("examples_output"); + let output_file = output_dir.join("calendar.json"); + + if let Err(e) = std::fs::create_dir_all(&output_dir) { + eprintln!("❌ Failed to create directory '{}': {}", output_dir.display(), e); + return Err(eventix::EventixError::Other(format!( + "Failed to create directory '{}': {}", + output_dir.display(), + e + ))); + } + + match std::fs::write(&output_file, &json) { + Ok(_) => println!("\n💾 Saved to {}", output_file.display()), + Err(e) => { + eprintln!("❌ Failed to write file '{}': {}", output_file.display(), e); + return Err(eventix::EventixError::Other(format!( + "Failed to write file '{}': {}", + output_file.display(), + e + ))); + } + } + + Ok(()) +} diff --git a/usage-examples/src/main.rs b/usage-examples/src/main.rs index 12dd0c4..d4fb74b 100644 --- a/usage-examples/src/main.rs +++ b/usage-examples/src/main.rs @@ -5,6 +5,8 @@ use eventix::{gap_validation, timezone, Calendar, Duration, Event, Recurrence}; mod booking_workflow; +mod json_web; +mod v040_features; fn main() -> eventix::Result<()> { println!("=== Using Published Eventix Crate ===\n"); @@ -52,7 +54,10 @@ fn main() -> eventix::Result<()> { cal.export_to_ics("examples_output/schedule.ics")?; println!("💾 Exported calendar to examples_output/schedule.ics"); + // Run other examples booking_workflow::run()?; + json_web::run()?; + v040_features::run()?; Ok(()) } diff --git a/usage-examples/src/v040_features.rs b/usage-examples/src/v040_features.rs new file mode 100644 index 0000000..7325443 --- /dev/null +++ b/usage-examples/src/v040_features.rs @@ -0,0 +1,315 @@ +//! v0.4.0 Feature Showcase +//! +//! Exercises all new and fixed behaviour shipped in v0.4.0: +//! +//! 1. **Sub-daily recurrence** — `hourly()`, `minutely()`, `secondly()` +//! 2. **DST-transparent sub-daily advancement** — hourly across spring-forward +//! 3. **Filter-before-cap** — `occurrences_between()` applies recurrence +//! filter / exdates *before* `max_occurrences`, so filtered-out dates +//! never consume result slots +//! 4. **Lazy recurrence iterator** — `Recurrence::occurrences()` + +use chrono::{Datelike, Timelike}; +use eventix::{ + gap_validation, timezone, Calendar, Duration, Event, Recurrence, +}; + +pub fn run() -> eventix::Result<()> { + println!("\n{}", "=".repeat(60)); + println!("=== v0.4.0 Feature Showcase ==="); + println!("{}\n", "=".repeat(60)); + + demo_hourly_recurrence()?; + demo_minutely_recurrence()?; + demo_secondly_recurrence()?; + demo_subdaily_dst_spring_forward()?; + demo_filter_before_cap()?; + demo_lazy_iterator()?; + demo_subdaily_gap_analysis()?; + + println!("\n✅ All v0.4.0 features verified!\n"); + Ok(()) +} + +// ─── 1. Hourly recurrence ─────────────────────────────────────────────────── + +fn demo_hourly_recurrence() -> eventix::Result<()> { + println!("── 1. Hourly Recurrence ──"); + + let event = Event::builder() + .title("Medication Reminder") + .start("2025-06-01 08:00:00", "America/New_York") + .duration_minutes(5) + .recurrence(Recurrence::hourly().interval(4).count(6)) + .build()?; + + let tz = timezone::parse_timezone("America/New_York")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-06-02 23:59:59", tz)?; + + let occs = event.occurrences_between(start, end, 100)?; + println!(" '{}' — every 4 hours, 6 total:", event.title); + for occ in &occs { + println!(" • {}", occ.format("%Y-%m-%d %H:%M %Z")); + } + assert_eq!(occs.len(), 6, "expected 6 hourly occurrences"); + + // Verify interval spacing + for i in 1..occs.len() { + let gap = occs[i] - occs[i - 1]; + assert_eq!(gap, Duration::hours(4), "gap between #{} and #{} should be 4h", i - 1, i); + } + println!(" ✅ 4-hour intervals verified\n"); + Ok(()) +} + +// ─── 2. Minutely recurrence ───────────────────────────────────────────────── + +fn demo_minutely_recurrence() -> eventix::Result<()> { + println!("── 2. Minutely Recurrence ──"); + + let event = Event::builder() + .title("Pomodoro Timer") + .start("2025-06-01 09:00:00", "UTC") + .duration_minutes(1) + .recurrence(Recurrence::minutely().interval(25).count(4)) + .build()?; + + let tz = timezone::parse_timezone("UTC")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-06-01 12:00:00", tz)?; + + let occs = event.occurrences_between(start, end, 100)?; + println!(" '{}' — every 25 minutes, 4 total:", event.title); + for occ in &occs { + println!(" • {}", occ.format("%H:%M:%S")); + } + assert_eq!(occs.len(), 4, "expected 4 minutely occurrences"); + + for i in 1..occs.len() { + assert_eq!( + occs[i] - occs[i - 1], + Duration::minutes(25), + "gap should be 25 min" + ); + } + println!(" ✅ 25-minute intervals verified\n"); + Ok(()) +} + +// ─── 3. Secondly recurrence ───────────────────────────────────────────────── + +fn demo_secondly_recurrence() -> eventix::Result<()> { + println!("── 3. Secondly Recurrence ──"); + + let event = Event::builder() + .title("Heartbeat Ping") + .start("2025-06-01 12:00:00", "UTC") + .duration(Duration::seconds(1)) + .recurrence(Recurrence::secondly().interval(30).count(5)) + .build()?; + + let tz = timezone::parse_timezone("UTC")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 11:59:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-06-01 12:05:00", tz)?; + + let occs = event.occurrences_between(start, end, 100)?; + println!(" '{}' — every 30 seconds, 5 total:", event.title); + for occ in &occs { + println!(" • {}", occ.format("%H:%M:%S")); + } + assert_eq!(occs.len(), 5, "expected 5 secondly occurrences"); + + for i in 1..occs.len() { + assert_eq!( + occs[i] - occs[i - 1], + Duration::seconds(30), + "gap should be 30s" + ); + } + println!(" ✅ 30-second intervals verified\n"); + Ok(()) +} + +// ─── 4. Sub-daily across DST spring-forward ───────────────────────────────── + +fn demo_subdaily_dst_spring_forward() -> eventix::Result<()> { + println!("── 4. Hourly Across DST Spring-Forward ──"); + println!(" (2025-03-09 2:00 AM → 3:00 AM in America/New_York)"); + + let event = Event::builder() + .title("Hourly Check-In") + .start("2025-03-09 00:00:00", "America/New_York") + .duration_minutes(10) + .recurrence(Recurrence::hourly().count(5)) + .build()?; + + let tz = timezone::parse_timezone("America/New_York")?; + let start = timezone::parse_datetime_with_tz("2025-03-08 23:00:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-03-09 06:00:00", tz)?; + + let occs = event.occurrences_between(start, end, 100)?; + println!(" Occurrences:"); + for occ in &occs { + println!(" • {}", occ.format("%Y-%m-%d %H:%M %Z")); + } + assert_eq!(occs.len(), 5, "expected 5 hourly occurrences across DST"); + + // Every pair is exactly 1 hour apart (UTC duration arithmetic) + for i in 1..occs.len() { + assert_eq!( + occs[i] - occs[i - 1], + Duration::hours(1), + "must be exactly 1h apart even across DST" + ); + } + // The 2:00 AM slot is skipped — next valid local hour is 3:00 AM EDT + let third = occs[2]; // 00:00 → 01:00 → → 03:00 → ... + // Actually: 00:00 EST, 01:00 EST, 03:00 EDT (spring-forward skips 2 AM) + // occs[2] should show hour 3 + assert_eq!(Timelike::hour(&third), 3, "third occurrence should be 3 AM EDT (spring-forward)"); + println!(" ✅ DST gap handled — jump from 1:00 AM EST → 3:00 AM EDT\n"); + Ok(()) +} + +// ─── 5. Filter-before-cap fix ─────────────────────────────────────────────── + +fn demo_filter_before_cap() -> eventix::Result<()> { + println!("── 5. Filter-Before-Cap Fix ──"); + println!(" (Weekend-skip filter no longer consumes max_occurrences slots)"); + + // Create a daily event that skips weekends — 10 total occurrences. + // Starting Monday 2025-06-02 → series has weekends on Jun 7 (Sat), 8 (Sun). + let event = Event::builder() + .title("Weekday Standup") + .start("2025-06-02 09:00:00", "UTC") + .duration_minutes(15) + .recurrence(Recurrence::daily().count(14)) // 14 candidates ⇒ includes weekends + .skip_weekends(true) // filter them out + .build()?; + + let tz = timezone::parse_timezone("UTC")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 00:00:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-06-30 00:00:00", tz)?; + + // Ask for up to 10 occurrences — all 10 weekday slots should be returned, + // not fewer because weekends were filtered after capping. + let occs = event.occurrences_between(start, end, 10)?; + println!(" '{}' — daily with skip_weekends, max 10:", event.title); + for occ in &occs { + let day = occ.format("%a %Y-%m-%d").to_string(); + println!(" • {}", day); + } + + // Every occurrence should be a weekday + for occ in &occs { + let wd = Datelike::weekday(occ); + assert!( + wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun, + "found weekend occurrence: {}", + occ + ); + } + + assert_eq!( + occs.len(), + 10, + "should get 10 weekday occurrences (filter-before-cap)" + ); + println!(" ✅ Got exactly 10 weekday results — filter-before-cap works!\n"); + Ok(()) +} + +// ─── 6. Lazy iterator usage ───────────────────────────────────────────────── + +fn demo_lazy_iterator() -> eventix::Result<()> { + println!("── 6. Lazy Recurrence Iterator ──"); + + // Infinite daily series (no count/until) — take only what we need + let recurrence = Recurrence::daily().interval(1); + + let tz = timezone::parse_timezone("Europe/London")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz)?; + + // Take next 5 occurrences lazily — no allocation of unbounded vec + let next_five: Vec<_> = recurrence.occurrences(start).take(5).collect(); + println!(" Lazy daily iterator — first 5:"); + for occ in &next_five { + println!(" • {}", occ.format("%Y-%m-%d %H:%M %Z")); + } + assert_eq!(next_five.len(), 5); + + // Minutely lazy iterator — take 3 + let minutely = Recurrence::minutely().interval(10); + let next_three: Vec<_> = minutely.occurrences(start).take(3).collect(); + println!(" Lazy minutely (every 10 min) — first 3:"); + for occ in &next_three { + println!(" • {}", occ.format("%H:%M:%S")); + } + assert_eq!(next_three.len(), 3); + println!(" ✅ Lazy iterators work for all frequencies\n"); + Ok(()) +} + +// ─── 7. Sub-daily gap analysis ────────────────────────────────────────────── + +fn demo_subdaily_gap_analysis() -> eventix::Result<()> { + println!("── 7. Sub-Daily Gap Analysis ──"); + + let mut cal = Calendar::new("Monitoring Dashboard"); + + // Hourly health-checks, 8 times + let health_checks = Event::builder() + .title("Health Check") + .start("2025-06-01 08:00:00", "UTC") + .duration_minutes(5) + .recurrence(Recurrence::hourly().count(8)) + .build()?; + + // 15-minute metric scrapes, 16 times (covers 4 hours) + let metrics = Event::builder() + .title("Metric Scrape") + .start("2025-06-01 08:02:00", "UTC") + .duration(Duration::seconds(30)) + .recurrence(Recurrence::minutely().interval(15).count(16)) + .build()?; + + cal.add_event(health_checks); + cal.add_event(metrics); + + let tz = timezone::parse_timezone("UTC")?; + let start = timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz)?; + let end = timezone::parse_datetime_with_tz("2025-06-01 16:00:00", tz)?; + + let density = gap_validation::calculate_density(&cal, start, end)?; + println!( + " Schedule density: {:.1}% occupied ({} events in window)", + density.occupancy_percentage, density.event_count + ); + + let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(30))?; + println!(" Gaps ≥ 30 min: {}", gaps.len()); + for gap in &gaps { + println!( + " • {} → {} ({} min)", + gap.start.format("%H:%M"), + gap.end.format("%H:%M"), + (gap.end - gap.start).num_minutes() + ); + } + + let overlaps = gap_validation::find_overlaps(&cal, start, end)?; + println!(" Overlaps detected: {}", overlaps.len()); + if !overlaps.is_empty() { + for ov in &overlaps { + println!( + " • {} at {}", + ov.events.join(" ↔ "), + ov.start.format("%H:%M:%S") + ); + } + } + + println!(" ✅ Sub-daily gap analysis complete\n"); + Ok(()) +}