diff --git a/CHANGELOG.md b/CHANGELOG.md index e59e88a..4b0a91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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.5.0] - 2026-03-23 + +### Added +- **Calendar view iterators**: `Calendar::days()`, `days_back()`, `weeks()`, and `weeks_back()` provide lazy day/week traversal for UI-oriented calendar rendering. +- **Owned day/week view models**: Added `DayView`, `WeekView`, and `OwnedEventOccurrence` so iterator output can be collected and moved independently of the source `Calendar`. +- **Calendar views example**: Added `examples/calendar_views.rs` to demonstrate day/week traversal and UI-friendly mapping patterns. +- Integration tests: shared `tests/common` helpers (`parse`); coverage-focused cases distributed across `calendar_view_tests`, `timezone_ics_tests`, `gap_validation_tests`, `property_tests`, and `booking_status_tests`. +- Unit tests: ICS path round-trip and parse edge cases; timezone `local_day_window` (spring-forward) and `convert_timezone`; calendar JSON (malformed input, calendar-level timezone round-trip). + +### Changed +- **[BREAKING] Fallible view iteration**: `DayIterator` and `WeekIterator` now yield `Result` / `Result` items instead of silently stopping on expansion errors. +- **Week back-navigation**: `WeekIterator::backward()` now yields proper contiguous Monday-Sunday blocks when walking into the past. +- **[BREAKING] Explicit day boundaries**: `DayView::end()` now returns the exclusive next-midnight boundary, with `end_inclusive()` available for display-only scenarios. +- **[BREAKING]** `Calendar::events_on_date()` and `Event::occurs_on()` now use an exclusive next-day boundary, improving correctness for occurrences that start exactly at midnight boundaries and for DST-length days. +- `Calendar::to_json`: serialization failures are returned as `EventixError::Other` instead of panicking. +- `local_day_window`: validation errors now say β€œFailed to resolve start/end time” (clearer than β€œAmbiguous”). +- `rustfmt.toml`: stricter line breaking (`use_small_heuristics = "Off"`, `chain_width`). + +### Fixed +- `expand_weekdays_in_month`: advance with `succ_opt()` without panicking on unexpected `None`. +- README: calendar view iterator example imports `Duration`. + +### Documentation +- `Calendar::from_json`: documented that an invalid top-level `"timezone"` string is ignored (lenient import); event-level timezones must still parse. + ## [0.4.0] - 2026-03-07 ### Added diff --git a/Cargo.toml b/Cargo.toml index 822168c..c1167c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eventix" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Raj Sarkar "] description = "High-level calendar & recurrence crate with timezone-aware scheduling, exceptions, and ICS import/export" @@ -18,6 +18,7 @@ exclude = [ "TODO.md", "llms.txt", "rustfmt.toml", + "schedule.ics", ] [dependencies] diff --git a/README.md b/README.md index 99cb6e6..b97461f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # Eventix πŸ“… -A high-level calendar and recurrence library for Rust with timezone-aware scheduling, exceptions, and ICS import/export. +A high-level calendar and recurrence library for Rust with timezone-aware scheduling, exceptions, ICS import/export, and lazy calendar views. [![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/EventixCI/badge.svg)](https://github.com/AriajSarkar/eventix/actions) [![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](LICENSE) +![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/AriajSarkar/eventix?utm_source=oss&utm_medium=github&utm_campaign=AriajSarkar%2Feventix&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) ## Features - 🌍 **Timezone-aware events** - Full support for timezones and DST handling using `chrono-tz` - πŸ”„ **Recurrence patterns** - All seven RFC 5545 frequencies (secondly, minutely, hourly, daily, weekly, monthly, yearly) with advanced rules +- πŸ—“οΈ **Calendar view iterators** - Lazy day/week traversal for UI rendering and infinite-scroll agendas - 🚫 **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 @@ -28,6 +30,7 @@ A high-level calendar and recurrence library for Rust with timezone-aware schedu | **Booking State** | βœ… Confirmed/Cancelled | ❌ No Concept | ❌ No Concept | | **Timezone/DST** | βœ… Built-in (`chrono-tz`) | ⚠️ Partial | βœ… Built-in | | **Recurrence** | βœ… RRule + Exdates | βœ… RRule | ❌ None | +| **View Iterators** | βœ… Day/Week lazy APIs | ❌ Manual Grouping | ❌ Manual Logic | ## Quick Start @@ -35,7 +38,7 @@ Add eventix to your `Cargo.toml`: ```toml [dependencies] -eventix = "0.4.0" +eventix = "0.5.0" ``` ### Basic Usage @@ -180,6 +183,49 @@ let mondays: Vec<_> = daily.occurrences(start) .collect(); ``` +### Calendar View Iterators + +For UI rendering, `Calendar::days()` and `Calendar::weeks()` lazily bucket +active occurrences into day/week views. This avoids choosing a large query +window up front and maps cleanly into frontend components. The iterators yield +`Result` / `Result` so expansion errors stay explicit. + +```rust +use eventix::{Calendar, Duration, Event, Recurrence, timezone}; + +let mut cal = Calendar::new("Personal"); +cal.add_event( + Event::builder() + .title("Standup") + .start("2025-11-04 10:00:00", "America/New_York") + .duration(Duration::minutes(15)) + .recurrence(Recurrence::daily().count(10)) + .build()? +); + +let tz = timezone::parse_timezone("America/New_York")?; +let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz)?; + +let busy_days: Vec<_> = cal.days(start) + .take(14) + .collect::>>()? + .into_iter() + .filter(|day| !day.is_empty()) + .collect(); + +for week in cal.weeks(start).take(2) { + let week = week?; + println!("{} -> {}", week.start_date(), week.event_count()); +} +``` + +`DayView::start()` and `DayView::end()` expose the actual half-open day window +`[start, end)`, so `end()` is the next midnight. Use `end_inclusive()` only for +display formatting. Day and week views are built by interval intersection, so +overnight events appear on every day they overlap. If you're passing large +`DayView` values through Yew props, wrapping them in `Rc` can avoid +expensive prop clones. + ### Booking Status ```rust @@ -329,6 +375,9 @@ Run the examples: # Basic calendar usage cargo run --example basic +# Day/week calendar views +cargo run --example calendar_views + # Recurrence patterns cargo run --example recurrence @@ -352,6 +401,7 @@ The crate is organized into several modules: - **`calendar`** - Calendar container for managing events - **`event`** - Event types and builder API - **`recurrence`** - Recurrence rules and patterns +- **`views`** - Lazy day/week calendar view iterators - **`ics`** - ICS format import/export - **`timezone`** - Timezone handling and DST support - **`gap_validation`** - Schedule analysis, gap detection, conflict resolution diff --git a/TODO.md b/TODO.md index 0f56a5b..c94a1a5 100644 --- a/TODO.md +++ b/TODO.md @@ -47,3 +47,52 @@ This document outlines the strategic technical direction for the `eventix` crate - [ ] **Property-Based Testing** `(Priority: High)` - **Action**: Implement `proptest` suites for `gap_validation`. - **Goal**: mathematically guarantee no overlapping slots are missed under edge-case conditions (e.g., DST transitions). + +## 5. Calendar View API +*Objective: Expose lazy, UI-friendly calendar traversal primitives for day/week rendering.* + +- [x] **Day and Week View Iterators** `(Priority: Medium)` + - **Feature**: Add lazy `Calendar::days()` / `days_back()` and `Calendar::weeks()` / `weeks_back()` iterators that yield pre-bucketed `DayView` and `WeekView` values. + - **Use Case**: Lets consumers render personal calendar UIs in frameworks like Yew, Leptos, and Dioxus without eagerly expanding wide date ranges or manually grouping occurrences by day. + +## 6. v0.5.1 API Polish +*Objective: Small additive improvements requested by early users and follow-up review feedback.* + +- [ ] **Calendar `FromStr` parsing** `(Priority: Medium)` + - **Feature**: Parse ICS data directly from `&str` so callers can load calendar payloads without a temporary file. + - **Value**: Useful for tests, embedded fixtures, and API handlers that already have the raw string body. + +- [ ] **Streaming ICS import** `(Priority: Medium)` + - **Feature**: Add `Calendar::from_ics_reader(impl Read)` for incremental loading from files, sockets, or database-backed readers. + - **Value**: Avoids forcing all ICS payloads into memory up front. + +- [ ] **DayView date conversions** `(Priority: Low)` + - **Feature**: Implement `From for NaiveDate` and `From<&DayView> for NaiveDate`. + - **Value**: Makes calendar views easier to feed into UI and serialization layers. + +- [ ] **Yew ergonomics docs** `(Priority: Low)` + - **Feature**: Document the `Rc` wrapping pattern for component props. + - **Value**: Clarifies the intended usage for UI users without changing runtime behavior. + +- [ ] **Optional serde for views** `(Priority: Low)` + - **Feature**: Gate `DayView` and `WeekView` serialization behind an opt-in `serde` feature. + - **Value**: Helps SSR and persistence consumers without imposing serde on every downstream build. + +## 7. v0.6.0 Performance / Bigger Features +*Objective: Scale out the calendar view layer and import path for heavier workloads.* + +- [ ] **K-way merge optimization** `(Priority: Medium)` + - **Feature**: Merge per-event occurrence streams instead of materializing every event's range independently. + - **Value**: Reduces work when rendering large day/week windows across many recurring events. + +- [ ] **Chunked/lazy ICS loading** `(Priority: Low)` + - **Feature**: Stream ICS parsing in chunks so enterprise-scale calendars do not need to fit fully in memory. + - **Value**: Better for very large imports, long-running services, and database-backed sync pipelines. + +- [ ] **Configurable week start** `(Priority: Low)` + - **Feature**: Allow Sunday-first calendars for regions that expect that convention. + - **Value**: Improves UX for US-style calendar rendering without changing the default ISO behavior. + +- [ ] **MonthView / YearView iterators** `(Priority: Low)` + - **Feature**: Extend the lazy view model beyond day/week traversal. + - **Value**: Useful for full calendar grids and high-level overview screens. diff --git a/examples/calendar_views.rs b/examples/calendar_views.rs new file mode 100644 index 0000000..301f193 --- /dev/null +++ b/examples/calendar_views.rs @@ -0,0 +1,90 @@ +//! Demonstrates lazy calendar day/week view iteration. + +use eventix::{timezone, Calendar, Duration, Event, EventStatus, Recurrence}; + +fn main() -> Result<(), Box> { + let mut calendar = Calendar::new("Personal Planner"); + + calendar.add_event( + Event::builder() + .title("Design Review") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_hours(1) + .location("Room A") + .build()?, + ); + + calendar.add_event( + Event::builder() + .title("Daily Standup") + .start("2025-11-04 10:00:00", "America/New_York") + .duration(Duration::minutes(15)) + .recurrence(Recurrence::daily().count(10)) + .build()?, + ); + + calendar.add_event( + Event::builder() + .title("Draft Blog Post") + .start("2025-11-05 13:00:00", "America/New_York") + .duration_minutes(45) + .status(EventStatus::Tentative) + .build()?, + ); + + let tz = timezone::parse_timezone("America/New_York")?; + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz)?; + + println!("Next 7 days:"); + for day in calendar.days(start).take(7) { + let day = day?; + println!("{} -> {} events", day.date(), day.event_count()); + for occurrence in day.events() { + println!( + " - {} at {} ({:?})", + occurrence.title(), + occurrence.occurrence_time.format("%H:%M %Z"), + occurrence.status + ); + } + } + + println!("\nNext 2 weeks:"); + for week in calendar.weeks(start).take(2) { + let week = week?; + println!( + "Week {} to {} -> {} events", + week.start_date(), + week.end_date(), + week.event_count() + ); + for day in week.days() { + println!(" {} -> {} events", day.date(), day.event_count()); + } + } + + // A UI layer can map DayView values directly into components. + let busy_day_titles: Vec<_> = calendar + .days(start) + .take(14) + .collect::>>()? + .into_iter() + .filter(|day| !day.is_empty()) + .map(|day| { + let titles = day + .events() + .iter() + .map(|event| event.title().to_string()) + .collect::>() + .join(", "); + format!("{} => {}", day.date(), titles) + }) + .collect(); + + println!("\nBusy days:"); + for line in busy_day_titles { + println!(" {line}"); + } + + Ok(()) +} diff --git a/rustfmt.toml b/rustfmt.toml index 48c809a..2f313c8 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,48 +1,26 @@ -# CrabGraph Rustfmt Configuration +# eventix β€” rustfmt (stable `cargo fmt` compatible) +# Run: cargo fmt --all -# Edition edition = "2021" -# Formatting +# Primary line length. max_width = 100 -hard_tabs = false -tab_spaces = 4 -newline_style = "Auto" - -# Imports -imports_granularity = "Crate" -group_imports = "StdExternalCrate" - -# Function calls -use_small_heuristics = "Default" -fn_call_width = 80 -fn_single_line = false - -# Chains +# Break method / builder chains sooner (sub-limit of max_width). chain_width = 80 -# Comments -comment_width = 100 -wrap_comments = true -normalize_comments = true -normalize_doc_attributes = true +# "Off" = stricter wrapping at max_width (fewer very long lines than "Default"). +use_small_heuristics = "Off" -# Attributes -reorder_impl_items = false - -# Match -match_block_trailing_comma = false -match_arm_blocks = true -match_arm_leading_pipes = "Never" - -# Spacing -spaces_around_ranges = false -type_punctuation_density = "Wide" +hard_tabs = false +tab_spaces = 4 +newline_style = "Auto" -# Misc reorder_imports = true reorder_modules = true -remove_nested_parens = true -format_code_in_doc_comments = true -format_macro_matchers = true -format_macro_bodies = true + +# --- Nightly rustfmt only (ignored on stable; use `cargo +nightly fmt --all`) --- +# imports_granularity = "Module" +# group_imports = "StdExternalCrate" +# wrap_comments = true +# comment_width = 80 +# format_code_in_doc_comments = true diff --git a/src/calendar.rs b/src/calendar.rs index 51fef7e..6396648 100644 --- a/src/calendar.rs +++ b/src/calendar.rs @@ -3,7 +3,9 @@ use crate::error::{EventixError, Result}; use crate::event::Event; use crate::recurrence::Recurrence; -use chrono::{DateTime, TimeZone}; +use crate::timezone::local_day_window; +use crate::views::{DayIterator, WeekIterator}; +use chrono::DateTime; use chrono_tz::Tz; use rrule::Frequency; @@ -180,6 +182,12 @@ impl Calendar { end: DateTime, max_per_event: usize, ) -> Result>> { + if start > end { + return Err(crate::error::EventixError::ValidationError( + "Start time must be before or equal to end time".to_string(), + )); + } + let mut occurrences = Vec::new(); for (index, event) in self.events.iter().enumerate() { @@ -202,28 +210,44 @@ impl Calendar { /// Get all events occurring on a specific date pub fn events_on_date(&self, date: DateTime) -> Result>> { - let start = date - .date_naive() - .and_hms_opt(0, 0, 0) - .ok_or_else(|| EventixError::ValidationError("Invalid start time".to_string()))?; - let end = date - .date_naive() - .and_hms_opt(23, 59, 59) - .ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?; - - let tz = date.timezone(); - let start_dt = tz - .from_local_datetime(&start) - .earliest() - .ok_or_else(|| EventixError::ValidationError("Ambiguous start time".to_string()))?; - let end_dt = tz - .from_local_datetime(&end) - .latest() - .ok_or_else(|| EventixError::ValidationError("Ambiguous end time".to_string()))?; - + let (start_dt, end_dt) = local_day_window(date.date_naive(), date.timezone())?; self.events_between(start_dt, end_dt) } + /// Create a lazy iterator over calendar days starting from the given date. + /// + /// Each yielded item is a [`crate::Result`] containing a [`crate::DayView`] + /// for the requested local day. Views bucket active occurrences whose time + /// span intersects the day, so overnight events appear on every day they + /// overlap. The iterator advances one day at a time until the supported + /// date range is exhausted. + pub fn days(&self, start: DateTime) -> DayIterator<'_> { + DayIterator::new(self, start) + } + + /// Create a lazy iterator over calendar days moving backward in time. + /// + /// Each yielded item is a [`crate::Result`] containing a [`crate::DayView`]. + pub fn days_back(&self, start: DateTime) -> DayIterator<'_> { + DayIterator::backward(self, start) + } + + /// Create a lazy iterator over ISO weeks (Monday through Sunday). + /// + /// The first yielded item is a [`crate::Result`] containing the Monday-Sunday + /// week that contains `start`. + pub fn weeks(&self, start: DateTime) -> WeekIterator<'_> { + WeekIterator::new(self, start) + } + + /// Create a lazy iterator over ISO weeks moving backward in time. + /// + /// Each yielded item is a [`crate::Result`] containing a contiguous + /// Monday-Sunday block. + pub fn weeks_back(&self, start: DateTime) -> WeekIterator<'_> { + WeekIterator::backward(self, start) + } + /// Get the number of events in the calendar pub fn event_count(&self) -> usize { self.events.len() @@ -267,12 +291,15 @@ impl Calendar { "timezone": self.timezone.map(|tz| tz.name()), }); - serde_json::to_string_pretty(&json_val).map_err(|e| { - crate::error::EventixError::Other(format!("JSON serialization error: {}", e)) - }) + serde_json::to_string_pretty(&json_val) + .map_err(|e| EventixError::Other(format!("JSON serialization error: {}", e))) } - /// Import calendar from JSON + /// Import calendar from JSON. + /// + /// If the top-level `"timezone"` field is present but not a valid IANA name, + /// it is ignored (treated as unset) so a typo does not fail the whole import. + /// Event-level `"timezone"` strings must still be valid or import fails. pub fn from_json(json: &str) -> Result { use crate::timezone::parse_timezone; @@ -629,7 +656,7 @@ mod tests { 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("\"frequency\"")); assert!(json.contains("\"exdates\""), "JSON should contain exdates"); let restored = Calendar::from_json(&json).unwrap(); @@ -657,7 +684,7 @@ mod tests { }] }"#; let result = Calendar::from_json(json); - assert!(result.is_err(), "Should reject unknown recurrence frequency"); + assert!(result.is_err()); } #[test] @@ -674,7 +701,7 @@ mod tests { }] }"#; let result = Calendar::from_json(json); - assert!(result.is_err(), "Should reject unparseable exdate"); + assert!(result.is_err()); } #[test] @@ -690,7 +717,7 @@ mod tests { }] }"#; let result = Calendar::from_json(json); - assert!(result.is_err(), "Should reject interval exceeding u16::MAX"); + assert!(result.is_err()); } #[test] @@ -707,6 +734,171 @@ mod tests { }] }"#; let result = Calendar::from_json(json); - assert!(result.is_err(), "Should reject both count and until"); + assert!(result.is_err()); + } + + #[test] + fn test_from_json_allows_missing_events_array() { + let calendar = Calendar::from_json(r#"{"name":"Empty Calendar"}"#).unwrap(); + assert!(calendar.events.is_empty()); + } + + #[test] + fn test_from_json_rejects_malformed_json() { + let err = Calendar::from_json("not valid json {{{").unwrap_err(); + assert!( + matches!(err, crate::error::EventixError::Other(message) if message.contains("JSON parse error")) + ); + } + + #[test] + fn test_to_json_roundtrip_preserves_calendar_level_timezone() { + let tz = crate::timezone::parse_timezone("Europe/London").unwrap(); + let mut cal = Calendar::new("TZ Calendar").timezone(tz); + cal.add_event( + Event::builder() + .title("Meeting") + .start("2025-06-01 14:00:00", "Europe/London") + .duration_hours(1) + .build() + .unwrap(), + ); + + let json = cal.to_json().unwrap(); + assert!(json.contains("\"timezone\"")); + assert!(json.contains("Europe/London")); + + let restored = Calendar::from_json(&json).unwrap(); + assert_eq!(restored.timezone, Some(tz)); + assert_eq!(restored.event_count(), 1); + } + + #[test] + fn test_recurrence_to_json_covers_all_frequencies_and_optional_fields() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let until = crate::timezone::parse_datetime_with_tz("2025-02-01 00:00:00", tz).unwrap(); + + for (recurrence, expected) in [ + (Recurrence::secondly(), "secondly"), + (Recurrence::minutely(), "minutely"), + (Recurrence::hourly(), "hourly"), + (Recurrence::daily(), "daily"), + (Recurrence::weekly(), "weekly"), + (Recurrence::monthly(), "monthly"), + (Recurrence::yearly(), "yearly"), + ] { + assert_eq!(recurrence_to_json(&recurrence)["frequency"], expected); + } + + let recurrence = Recurrence::yearly().interval(3).until(until).weekdays(vec![ + chrono::Weekday::Mon, + chrono::Weekday::Tue, + chrono::Weekday::Wed, + chrono::Weekday::Thu, + chrono::Weekday::Fri, + chrono::Weekday::Sat, + chrono::Weekday::Sun, + ]); + let json = recurrence_to_json(&recurrence); + + assert_eq!(json["frequency"], "yearly"); + assert_eq!(json["interval"], 3); + assert_eq!(json["until"], until.to_rfc3339()); + assert_eq!(json["weekdays"], serde_json::json!(["MO", "TU", "WE", "TH", "FR", "SA", "SU"])); + } + + #[test] + fn test_json_to_recurrence_parses_count_until_and_weekdays() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let recurrence = json_to_recurrence( + &serde_json::json!({ + "frequency": "weekly", + "interval": 2, + "count": 7, + "weekdays": ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + }), + tz, + ) + .unwrap(); + + assert_eq!(recurrence.frequency(), Frequency::Weekly); + assert_eq!(recurrence.get_interval(), 2); + assert_eq!(recurrence.get_count(), Some(7)); + assert_eq!( + recurrence.get_weekdays().unwrap(), + [ + chrono::Weekday::Mon, + chrono::Weekday::Tue, + chrono::Weekday::Wed, + chrono::Weekday::Thu, + chrono::Weekday::Fri, + chrono::Weekday::Sat, + chrono::Weekday::Sun, + ] + ); + + let until = json_to_recurrence( + &serde_json::json!({ + "frequency": "monthly", + "interval": 1, + "until": "2025-02-01T00:00:00+00:00" + }), + tz, + ) + .unwrap(); + assert_eq!( + until.get_until(), + Some(crate::timezone::parse_datetime_with_tz("2025-02-01 00:00:00", tz).unwrap()) + ); + } + + #[test] + fn test_json_to_recurrence_rejects_unknown_weekday() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let err = json_to_recurrence( + &serde_json::json!({ + "frequency": "weekly", + "interval": 1, + "weekdays": ["XX"] + }), + tz, + ) + .unwrap_err(); + + assert!( + matches!(err, EventixError::Other(message) if message.contains("Unknown weekday: XX")) + ); + } + #[test] + fn test_events_between_invalid_range() { + use crate::timezone::parse_datetime_with_tz; + use crate::timezone::parse_timezone; + let cal = Calendar::new("Test"); + let tz = parse_timezone("UTC").unwrap(); + let start = parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap(); + let end = parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + + let result = cal.events_between(start, end); + assert!(result.is_err()); + } + + #[test] + fn test_events_on_date_valid() { + use crate::timezone::parse_datetime_with_tz; + use crate::timezone::parse_timezone; + let mut cal = Calendar::new("Test"); + let event = crate::event::Event::builder() + .title("Test Event") + .start("2025-11-01 15:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(); + cal.add_event(event); + + let tz = parse_timezone("America/New_York").unwrap(); + let query_date = parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + + let occurrences = cal.events_on_date(query_date).unwrap(); + assert_eq!(occurrences.len(), 1); } } diff --git a/src/event.rs b/src/event.rs index 6ca9b4a..819eec8 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2,14 +2,16 @@ use crate::error::{EventixError, Result}; use crate::recurrence::{Recurrence, RecurrenceFilter}; -use crate::timezone::{parse_datetime_with_tz, parse_timezone}; -use chrono::{DateTime, Duration, TimeZone}; +use crate::timezone::{local_day_window, parse_datetime_with_tz, parse_timezone}; +use chrono::{DateTime, Duration}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; /// Status of an event in the booking lifecycle -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] pub enum EventStatus { /// The event is confirmed and occupies time (default) #[default] @@ -98,6 +100,11 @@ impl Event { end: DateTime, max_occurrences: usize, ) -> Result>> { + if start > end { + return Err(crate::error::EventixError::ValidationError( + "Start time must be before or equal to end time".to_string(), + )); + } if max_occurrences == 0 { return Ok(vec![]); } @@ -151,20 +158,7 @@ impl Event { /// 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(|| { - EventixError::ValidationError("Invalid start time for date check".to_string()) - })?; - let end = date.date_naive().and_hms_opt(23, 59, 59).ok_or_else(|| { - EventixError::ValidationError("Invalid end time for date check".to_string()) - })?; - - let start_dt = self.timezone.from_local_datetime(&start).earliest().ok_or_else(|| { - EventixError::ValidationError("Ambiguous start time for date check".to_string()) - })?; - let end_dt = self.timezone.from_local_datetime(&end).latest().ok_or_else(|| { - EventixError::ValidationError("Ambiguous end time for date check".to_string()) - })?; - + let (start_dt, end_dt) = local_day_window(date.date_naive(), self.timezone)?; let occurrences = self.occurrences_between(start_dt, end_dt, 1)?; Ok(!occurrences.is_empty()) } @@ -445,9 +439,7 @@ impl EventBuilder { EventixError::ValidationError("Event end time is required".to_string()) })?; - let timezone = self.timezone.ok_or_else(|| { - EventixError::ValidationError("Event timezone is required".to_string()) - })?; + let timezone = self.timezone.unwrap_or_else(|| start_time.timezone()); if end_time <= start_time { return Err(EventixError::ValidationError( @@ -573,11 +565,7 @@ mod tests { // 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 - ); + assert!(wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun); } } @@ -661,11 +649,7 @@ mod tests { 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 - ); + assert!(wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun); } } @@ -696,7 +680,7 @@ mod tests { 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); + assert!(!err.contains("required")); } #[test] @@ -708,7 +692,7 @@ mod tests { .build(); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(!err.contains("required"), "Expected datetime parse error, got: {}", err); + assert!(!err.contains("required")); } #[test] @@ -735,15 +719,57 @@ mod tests { 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::>() - ); + assert_eq!(occs.len(), 4); // Verify 12:00 is not in the list for occ in &occs { assert_ne!(occ.hour(), 12, "12:00 should be excluded"); } } + + #[test] + fn test_occurs_on_true_for_matching_day() { + let event = Event::builder() + .title("Meeting") + .start("2025-11-03 10:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(); + + let tz = crate::timezone::parse_timezone("America/New_York").unwrap(); + let date = crate::timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + + assert!(event.occurs_on(date).unwrap()); + } + + #[test] + fn test_occurs_on_false_for_non_matching_day() { + let event = Event::builder() + .title("Meeting") + .start("2025-11-03 10:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(); + + let tz = crate::timezone::parse_timezone("America/New_York").unwrap(); + let date = crate::timezone::parse_datetime_with_tz("2025-11-04 00:00:00", tz).unwrap(); + + assert!(!event.occurs_on(date).unwrap()); + } + + #[test] + fn test_occurrences_between_invalid_range() { + let tz = crate::timezone::parse_timezone("America/New_York").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-11-03 10:00:00", tz).unwrap(); + let end = crate::timezone::parse_datetime_with_tz("2025-11-02 10:00:00", tz).unwrap(); + + let event = Event::builder() + .title("Meeting") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(); + + let result = event.occurrences_between(start, end, 5); + assert!(result.is_err()); + } } diff --git a/src/gap_validation.rs b/src/gap_validation.rs index 7ecb5b8..999c566 100644 --- a/src/gap_validation.rs +++ b/src/gap_validation.rs @@ -171,6 +171,17 @@ pub fn find_gaps( end: DateTime, min_gap_duration: Duration, ) -> Result> { + if start >= end { + return Err(crate::error::EventixError::ValidationError( + "Start time must be before end time".to_string(), + )); + } + if min_gap_duration < Duration::zero() { + return Err(crate::error::EventixError::ValidationError( + "min_gap_duration cannot be negative".to_string(), + )); + } + let mut occurrences = calendar.events_between(start, end)?; // Filter out inactive events (e.g. Cancelled) @@ -181,14 +192,19 @@ pub fn find_gaps( let mut gaps = Vec::new(); let mut current_time = start; + let mut last_event_title: Option = None; for occurrence in occurrences.iter() { let event_start = occurrence.occurrence_time; // Check if there's a gap before this event if event_start > current_time { - let gap = - TimeGap::new(current_time, event_start, None, Some(occurrence.title().to_string())); + let gap = TimeGap::new( + current_time, + event_start, + last_event_title.clone(), + Some(occurrence.title().to_string()), + ); if gap.duration >= min_gap_duration { gaps.push(gap); @@ -199,12 +215,13 @@ pub fn find_gaps( let event_end = occurrence.end_time(); if event_end > current_time { current_time = event_end; + last_event_title = Some(occurrence.title().to_string()); } } // Check for gap at the end if end > current_time { - let gap = TimeGap::new(current_time, end, None, None); + let gap = TimeGap::new(current_time, end, last_event_title, None); if gap.duration >= min_gap_duration { gaps.push(gap); } @@ -252,6 +269,12 @@ pub fn find_overlaps( start: DateTime, end: DateTime, ) -> Result> { + if start >= end { + return Err(crate::error::EventixError::ValidationError( + "Start time must be before end time".to_string(), + )); + } + use std::collections::BTreeSet; let mut occurrences = calendar.events_between(start, end)?; @@ -357,28 +380,54 @@ pub fn calculate_density( start: DateTime, end: DateTime, ) -> Result { + if start >= end { + return Err(crate::error::EventixError::ValidationError( + "Start time must be before end time".to_string(), + )); + } + let total_duration = end.signed_duration_since(start); let mut occurrences = calendar.events_between(start, end)?; // Filter out inactive events occurrences.retain(|e| e.event.is_active()); - // Calculate busy time + // Calculate busy time by merging overlapping intervals to avoid + // double-counting shared time (which would make free_duration negative). + occurrences.sort_by_key(|o| o.occurrence_time); let mut busy_duration = Duration::zero(); + let mut current_end: Option> = None; + for occurrence in occurrences.iter() { let event_start = occurrence.occurrence_time.max(start); let event_end = occurrence.end_time().min(end); - if event_end > event_start { - busy_duration += event_end.signed_duration_since(event_start); + if event_end <= event_start { + continue; + } + + match current_end { + None => { + current_end = Some(event_end); + busy_duration += event_end.signed_duration_since(event_start); + } + Some(prev_end) => { + if event_start >= prev_end { + // No overlap β€” add full duration + busy_duration += event_end.signed_duration_since(event_start); + current_end = Some(event_end); + } else if event_end > prev_end { + // Partial overlap β€” add only the extension past prev_end + busy_duration += event_end.signed_duration_since(prev_end); + current_end = Some(event_end); + } + // else: fully contained in previous interval, no additional busy time + } } } let free_duration = total_duration - busy_duration; - let occupancy_percentage = if total_duration.num_seconds() > 0 { - (busy_duration.num_seconds() as f64 / total_duration.num_seconds() as f64) * 100.0 - } else { - 0.0 - }; + let occupancy_percentage = + (busy_duration.num_seconds() as f64 / total_duration.num_seconds() as f64) * 100.0; let gaps = find_gaps(calendar, start, end, Duration::minutes(0))?; let overlaps = find_overlaps(calendar, start, end)?; @@ -424,21 +473,32 @@ pub fn is_slot_available( slot_start: DateTime, slot_end: DateTime, ) -> Result { - // To catch events that might end during our slot, we need to query from - // a wider range - start from beginning of day or before slot_start - let query_start = slot_start - Duration::days(1); - let occurrences = calendar.events_between(query_start, slot_end)?; + if slot_start >= slot_end { + return Err(crate::error::EventixError::ValidationError( + "Slot start time must be before end time".to_string(), + )); + } - for occurrence in occurrences.iter() { - if !occurrence.event.is_active() { + for event in calendar.get_events() { + if !event.is_active() { continue; } - let event_start = occurrence.occurrence_time; - let event_end = occurrence.end_time(); - // Check for any overlap between event and slot - if event_start < slot_end && slot_start < event_end { - return Ok(false); + let duration = event.duration(); + if duration <= Duration::zero() { + continue; + } + + let query_start = slot_start - duration; + let occurrences = event.occurrences_between(query_start, slot_end, 100_000)?; + + for occurrence in occurrences { + let event_end = occurrence + duration; + + // Check for any overlap between event and slot + if occurrence < slot_end && slot_start < event_end { + return Ok(false); + } } } @@ -487,6 +547,17 @@ pub fn suggest_alternatives( duration: Duration, search_window: Duration, ) -> Result>> { + if duration <= Duration::zero() { + return Err(crate::error::EventixError::ValidationError( + "Duration must be greater than zero".to_string(), + )); + } + if search_window <= Duration::zero() { + return Err(crate::error::EventixError::ValidationError( + "Search window must be greater than zero".to_string(), + )); + } + let search_start = requested_start - search_window; let search_end = requested_start + search_window; @@ -704,4 +775,48 @@ mod tests { assert!(density.is_busy()); assert!(density.occupancy_percentage > 60.0); } + + #[test] + fn test_calculate_density_with_overlapping_events() { + let mut cal = Calendar::new("Overlapping"); + + // Event A: 09:00 - 11:00 (2 hours) + cal.add_event( + Event::builder() + .title("Event A") + .start("2025-11-01 09:00:00", "UTC") + .duration_hours(2) + .build() + .unwrap(), + ); + + // Event B: 10:00 - 12:00 (2 hours, overlaps A by 1 hour) + cal.add_event( + Event::builder() + .title("Event B") + .start("2025-11-01 10:00:00", "UTC") + .duration_hours(2) + .build() + .unwrap(), + ); + + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = parse_datetime_with_tz("2025-11-01 09:00:00", tz).unwrap(); + let end = parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap(); + + let density = calculate_density(&cal, start, end).unwrap(); + + // Actual wall-clock busy time: 09:00 - 12:00 = 3 hours (the merged interval) + // NOT 4 hours (2+2 with double-counting) + assert_eq!(density.busy_duration.num_hours(), 3); + + // free = total - busy = 3h - 3h = 0 + assert_eq!(density.free_duration.num_seconds(), 0); + + // 100% occupied (fully busy window) + assert!((density.occupancy_percentage - 100.0).abs() < 0.1); + + // Overlaps are still detected independently + assert_eq!(density.overlap_count, 1); + } } diff --git a/src/ics.rs b/src/ics.rs index 774f58a..872c283 100644 --- a/src/ics.rs +++ b/src/ics.rs @@ -162,7 +162,7 @@ fn event_to_ical(event: &Event) -> Result { // Add attendees for attendee in &event.attendees { - ical_event.add_property("ATTENDEE", format!("mailto:{}", attendee)); + ical_event.add_multi_property("ATTENDEE", &format!("mailto:{}", attendee)); } // Add recurrence rule if present @@ -460,6 +460,7 @@ fn parse_ical_datetime_value(dt_str: &str, tz: Tz) -> Result> { mod tests { #![allow(clippy::unwrap_used)] use super::*; + use chrono::{Datelike, Timelike}; #[test] fn test_ics_export() { @@ -567,11 +568,7 @@ mod tests { 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 - ); + assert!(err_msg.contains("BYMONTH")); // BYSETPOS is unsupported let result = parse_rrule_value("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", start); @@ -581,7 +578,7 @@ mod tests { 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); + assert!(err_msg.contains("1MO")); let result = parse_rrule_value("FREQ=MONTHLY;BYDAY=-1FR", start); assert!(result.is_err()); @@ -590,10 +587,127 @@ mod tests { 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")); + } + + #[test] + fn test_parse_rrule_value_covers_yearly_and_numeric_errors() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 10:00:00", tz).unwrap(); + + let yearly = parse_rrule_value("FREQ=YEARLY;COUNT=1", start).unwrap(); + assert_eq!(yearly.frequency(), rrule::Frequency::Yearly); + + let err = parse_rrule_value("FREQ=FORTNIGHTLY;COUNT=1", start).unwrap_err(); + assert!(err.to_string().contains("FORTNIGHTLY")); + + let err = parse_rrule_value("FREQ=DAILY;INTERVAL=abc", start).unwrap_err(); + assert!(err.to_string().contains("INTERVAL")); + + let err = parse_rrule_value("FREQ=DAILY;COUNT=abc", start).unwrap_err(); + assert!(err.to_string().contains("COUNT")); + } + + #[test] + fn test_parse_ical_datetime_value_rejects_dst_gap() { + let tz = crate::timezone::parse_timezone("America/New_York").unwrap(); + let err = parse_ical_datetime_value("20250309T023000", tz).unwrap_err(); + assert!( + matches!(err, EventixError::DateTimeParse(message) if message.contains("Cannot create datetime")) + ); + } + + #[test] + fn test_parse_ical_datetime_value_date_only() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let dt = parse_ical_datetime_value("20251101", tz).unwrap(); + assert_eq!(dt.hour(), 0); + assert_eq!(dt.minute(), 0); + assert_eq!(dt.day(), 1); + assert_eq!(dt.month(), 11); + } + + #[test] + fn test_parse_ical_datetime_value_invalid_short_string() { + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let err = parse_ical_datetime_value("2025110", tz).unwrap_err(); + assert!(matches!(err, EventixError::DateTimeParse(_))); + } + + #[test] + fn test_from_ics_string_rejects_unparseable_icalendar() { + let err = Calendar::from_ics_string("<<>>").unwrap_err(); assert!( - err_msg.contains("COUNT") && err_msg.contains("UNTIL"), - "Error should mention both COUNT and UNTIL: {}", - err_msg + matches!(err, EventixError::IcsError(message) if message.contains("Failed to parse ICS")) + ); + } + + #[test] + fn test_export_to_ics_and_import_from_ics_roundtrip_path() { + let mut cal = Calendar::new("Path Roundtrip"); + cal.add_event( + Event::builder() + .title("Disk Event") + .start("2025-11-01 12:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(), ); + + let path = std::env::temp_dir().join(format!("eventix_path_{}.ics", uuid::Uuid::new_v4())); + cal.export_to_ics(&path).unwrap(); + + let imported = Calendar::import_from_ics(&path).unwrap(); + assert_eq!(imported.name, "Path Roundtrip"); + assert_eq!(imported.event_count(), 1); + assert_eq!(imported.events[0].title, "Disk Event"); + + std::fs::remove_file(&path).ok(); + } + + #[test] + fn test_import_from_ics_missing_file_errors() { + let path = + std::env::temp_dir().join(format!("eventix_missing_{}.ics", uuid::Uuid::new_v4())); + let err = Calendar::import_from_ics(&path).unwrap_err(); + assert!( + matches!(err, EventixError::IcsError(message) if message.contains("Failed to read ICS file")) + ); + } + + #[test] + fn test_from_ics_string_skips_bad_event_continues_others() { + let ics = "\ +BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Good +DTSTART:20251101T100000Z +DTEND:20251101T110000Z +END:VEVENT +BEGIN:VEVENT +SUMMARY:Bad +END:VEVENT +END:VCALENDAR"; + + let cal = Calendar::from_ics_string(ics).unwrap(); + assert_eq!(cal.event_count(), 1); + assert_eq!(cal.events[0].title, "Good"); + } + + #[test] + fn test_parse_rrule_secondly_from_ics_import() { + let ics = "\ +BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Secondly +DTSTART:20251101T100000Z +DTEND:20251101T100001Z +RRULE:FREQ=SECONDLY;COUNT=3 +END:VEVENT +END:VCALENDAR"; + + let cal = Calendar::from_ics_string(ics).unwrap(); + let ev = &cal.events[0]; + assert_eq!(ev.recurrence.as_ref().unwrap().frequency(), rrule::Frequency::Secondly); } } diff --git a/src/lib.rs b/src/lib.rs index 0031e94..738e711 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ //! - **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 +//! - **Calendar view iterators**: Lazy day/week traversal for UI-friendly calendar rendering //! - **Gap validation**: Find gaps between events, detect conflicts, and analyze schedule density //! - **Schedule analysis**: Unique features for occupancy metrics, availability finding, and conflict resolution //! @@ -105,11 +106,13 @@ //! - [`ics`] - ICS (iCalendar) import/export with TZID support //! - [`recurrence`] - Recurrence patterns (secondly, minutely, hourly, daily, weekly, monthly, yearly) //! - [`timezone`] - Timezone utilities with DST awareness +//! - [`views`] - Lazy day/week calendar view iterators //! //! ## Examples //! //! See the `examples/` directory for more comprehensive examples: //! - `basic.rs` - Simple calendar creation and event management +//! - `calendar_views.rs` - Lazy day and week view iteration for UI rendering //! - `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 @@ -121,6 +124,7 @@ pub mod gap_validation; pub mod ics; pub mod recurrence; pub mod timezone; +pub mod views; mod error; @@ -128,7 +132,8 @@ pub use calendar::Calendar; pub use error::{EventixError, Result}; pub use event::{Event, EventBuilder, EventStatus}; pub use recurrence::{OccurrenceIterator, Recurrence}; +pub use views::{DayIterator, DayView, OwnedEventOccurrence, WeekIterator, WeekView}; // Re-export commonly used types from chrono -pub use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +pub use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc}; pub use chrono_tz::Tz; diff --git a/src/recurrence.rs b/src/recurrence.rs index f9449d8..67d05b5 100644 --- a/src/recurrence.rs +++ b/src/recurrence.rs @@ -1,7 +1,8 @@ //! Recurrence rules and patterns for repeating events use crate::error::Result; -use chrono::{DateTime, Datelike, Offset, TimeZone}; +use crate::timezone::resolve_local; +use chrono::{DateTime, Datelike}; use chrono_tz::Tz; use rrule::Frequency; @@ -150,7 +151,11 @@ impl Recurrence { /// let biweekly = Recurrence::weekly().interval(2).count(10); /// ``` pub fn interval(mut self, interval: u16) -> Self { - self.interval = if interval == 0 { 1 } else { interval }; + self.interval = if interval == 0 { + 1 + } else { + interval + }; self } @@ -358,30 +363,6 @@ impl Recurrence { /// 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)) -} - /// Advance a `DateTime` by one recurrence step. /// /// `intended_time` is the original start's wall-clock time. For @@ -594,9 +575,7 @@ fn skip_subdaily_to_matching_day( // 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); - } + debug_assert!(gap_secs > 0, "next matching midnight must be after current"); let interval_secs = match frequency { Frequency::Hourly => interval as i64 * 3600, @@ -644,10 +623,10 @@ fn expand_weekdays_in_month( if date >= last { break; } - date = match date.succ_opt() { - Some(d) => d, + match date.succ_opt() { + Some(d) => date = d, None => break, - }; + } } results } @@ -831,18 +810,14 @@ impl OccurrenceIterator { 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; - } - _ => {} + if 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; + } else { + self.byday_next_year += self.recurrence.interval as i32; } // Safety: prevent runaway expansion beyond chrono's NaiveDate range @@ -2062,4 +2037,84 @@ mod tests { // Third: Sunday 02:00 assert_eq!(occurrences[2].hour(), 2); } + + #[test] + fn test_uses_byday_expansion_false() { + // Uncovered line: frequency matching Monthly without by_weekday + let recurrence = Recurrence::monthly().count(1); + let tz = crate::timezone::parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + let occs: Vec<_> = recurrence.occurrences(start).collect(); + assert_eq!(occs.len(), 1); + } + + #[test] + fn test_private_recurrence_helper_guards_and_edges() { + use chrono::{Datelike, Weekday}; + + let tz = parse_timezone("UTC").unwrap(); + let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap(); + let intended = start.time(); + + let recurrence = Recurrence { + frequency: Frequency::Weekly, + interval: 1, + count: Some(2), + until: None, + by_weekday: Some(vec![]), + }; + let rrule = recurrence.to_rrule_string(start).unwrap(); + assert!(!rrule.contains("BYDAY")); + + assert!(advance_by_frequency(start, Frequency::Daily, 0, intended).is_none()); + + let monthly = advance_by_frequency(start, Frequency::Monthly, 14, intended).unwrap(); + assert_eq!(monthly.year(), 2026); + assert_eq!(monthly.month(), 3); + + assert!(clamp_day_to_month(2025, 13, 31).is_none()); + assert!(advance_weekly_weekday(start, 0, &[Weekday::Mon], intended).is_none()); + assert!(advance_weekly_weekday(start, 1, &[], intended).is_none()); + assert!(advance_daily_weekday(start, 0, &[Weekday::Mon], intended).is_none()); + assert!(advance_daily_weekday(start, 7, &[Weekday::Tue], intended).is_none()); + assert!(skip_subdaily_to_matching_day(start, Frequency::Hourly, 1, &[]).is_none()); + assert!( + skip_subdaily_to_matching_day(start, Frequency::Daily, 1, &[Weekday::Thu]).is_none() + ); + assert!(expand_weekdays_in_month(2025, 13, &[Weekday::Mon], tz, intended).is_empty()); + } + + #[test] + fn test_private_occurrence_iterator_exhaustion_paths() { + 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 mut exhausted_iter = + Recurrence::monthly().weekdays(vec![Weekday::Mon]).count(1).occurrences(start); + exhausted_iter.exhausted = true; + assert!(exhausted_iter.is_exhausted()); + assert!(exhausted_iter.next_byday_expanded().is_none()); + + let mut no_byday = Recurrence::daily().count(1).occurrences(start); + no_byday.expand_next_byday_period(); + assert!(no_byday.exhausted); + + let mut wrong_freq = Recurrence::daily().count(1).occurrences(start); + wrong_freq.recurrence.by_weekday = Some(vec![Weekday::Mon]); + wrong_freq.expand_next_byday_period(); + assert!(wrong_freq.exhausted); + + let mut high_year = + Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(1).occurrences(start); + high_year.byday_next_year = 10_000; + high_year.expand_next_byday_period(); + assert!(high_year.exhausted); + + let mut no_next = Recurrence::daily().count(2).occurrences(start); + no_next.recurrence.interval = 0; + assert_eq!(no_next.next(), Some(start)); + assert!(no_next.exhausted); + } } diff --git a/src/timezone.rs b/src/timezone.rs index f5d0e86..18b4395 100644 --- a/src/timezone.rs +++ b/src/timezone.rs @@ -1,7 +1,8 @@ //! Timezone handling utilities with DST awareness use crate::error::{EventixError, Result}; -use chrono::{DateTime, NaiveDateTime, Offset, TimeZone}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, Offset, TimeZone}; +use chrono_tz::OffsetComponents; use chrono_tz::Tz; /// Parse a timezone string into a `Tz` object @@ -58,6 +59,40 @@ pub fn parse_datetime_with_tz(datetime_str: &str, tz: Tz) -> Result }) } +/// Resolve a local datetime in a timezone, preserving wall-clock semantics +/// across DST gaps by applying the pre-gap UTC offset. +pub(crate) fn resolve_local(tz: Tz, naive: NaiveDateTime) -> Option> { + if let Some(dt) = tz.from_local_datetime(&naive).earliest() { + return Some(dt); + } + + let day_before = naive.checked_sub_signed(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.checked_sub_offset(pre_offset)?; + Some(chrono::Utc.from_utc_datetime(&utc_naive).with_timezone(&tz)) +} + +/// Compute the inclusive start and exclusive end of a local calendar day. +pub(crate) fn local_day_window(date: NaiveDate, tz: Tz) -> Result<(DateTime, DateTime)> { + let start_naive = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| EventixError::ValidationError("Invalid start time".to_string()))?; + let next_date = date + .succ_opt() + .ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?; + let end_naive = next_date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?; + + let start_dt = resolve_local(tz, start_naive) + .ok_or_else(|| EventixError::ValidationError("Failed to resolve start time".to_string()))?; + let end_dt = resolve_local(tz, end_naive) + .ok_or_else(|| EventixError::ValidationError("Failed to resolve end time".to_string()))?; + + Ok((start_dt, end_dt)) +} + /// Convert a datetime from one timezone to another /// /// # Examples @@ -91,16 +126,14 @@ pub fn convert_timezone(dt: &DateTime, target_tz: Tz) -> DateTime { /// // Winter time is typically standard time /// ``` pub fn is_dst(dt: &DateTime) -> bool { - let offset = dt.offset().fix(); - let std_offset = dt.timezone().offset_from_utc_date(&dt.naive_utc().date()).fix(); - offset != std_offset + dt.offset().dst_offset() != chrono::Duration::zero() } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; - use chrono::Timelike; + use chrono::{Duration, Timelike}; #[test] fn test_parse_timezone() { @@ -129,4 +162,55 @@ mod tests { // UTC 15:00 should be around 10:00 or 11:00 in NY depending on DST assert!(dt_ny.hour() == 10 || dt_ny.hour() == 11); } + + #[test] + fn test_convert_timezone_across_pacific() { + let tz_utc = parse_timezone("UTC").unwrap(); + let tz_la = parse_timezone("America/Los_Angeles").unwrap(); + + let dt_utc = parse_datetime_with_tz("2025-07-15 20:00:00", tz_utc).unwrap(); + let dt_la = convert_timezone(&dt_utc, tz_la); + + assert_eq!(dt_la.timezone(), tz_la); + assert_eq!(dt_la.hour(), 13); + } + + #[test] + fn test_local_day_window_dst_fall_back() { + let tz = parse_timezone("America/New_York").unwrap(); + let date = chrono::NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(); + + let (start, end) = local_day_window(date, tz).unwrap(); + + assert_eq!(start.date_naive(), date); + assert_eq!(end.date_naive(), date.succ_opt().unwrap()); + assert_eq!(end - start, Duration::hours(25)); + } + + /// Spring-forward: local day is 23 hours (2:00 β†’ 3:00). + #[test] + fn test_local_day_window_dst_spring_forward() { + let tz = parse_timezone("America/New_York").unwrap(); + let date = chrono::NaiveDate::from_ymd_opt(2025, 3, 9).unwrap(); + + let (start, end) = local_day_window(date, tz).unwrap(); + + assert_eq!(start.date_naive(), date); + assert_eq!(end.date_naive(), date.succ_opt().unwrap()); + assert_eq!(end - start, Duration::hours(23)); + } + + #[test] + fn test_resolve_local_dst_gap_uses_pre_gap_offset() { + let tz = parse_timezone("America/New_York").unwrap(); + let naive = chrono::NaiveDate::from_ymd_opt(2025, 3, 9) + .unwrap() + .and_hms_opt(2, 30, 0) + .unwrap(); + + let resolved = resolve_local(tz, naive).unwrap(); + + assert_eq!(resolved.hour(), 3); + assert_eq!(resolved.minute(), 30); + } } diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..3104e99 --- /dev/null +++ b/src/views.rs @@ -0,0 +1,889 @@ +//! Calendar day/week view iterators for UI-friendly rendering. +//! +//! This module lifts the crate's existing occurrence expansion into lazy +//! calendar-level traversal primitives. Each iterator item owns its event +//! metadata, making it easy to collect and move into UI component props. +//! Iteration is fallible: each item is yielded as a [`crate::Result`] so +//! callers can handle calendar expansion errors explicitly. + +use crate::calendar::{Calendar, EventOccurrence}; +use crate::error::{EventixError, Result}; +use crate::event::EventStatus; +use crate::timezone::local_day_window; +use crate::{DateTime, Duration, Tz}; +use chrono::{Datelike, Days, NaiveDate}; +use std::cmp::Ordering; +use std::iter::FusedIterator; + +/// An owned occurrence snapshot detached from the source calendar borrow. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OwnedEventOccurrence { + /// Index of the event in the calendar's event list. + pub event_index: usize, + /// Snapshot of the event title. + pub title: String, + /// Snapshot of the optional event description. + pub description: Option, + /// Snapshot of the optional event location. + pub location: Option, + /// Event booking status at iteration time. + pub status: EventStatus, + /// When this occurrence starts. + pub occurrence_time: DateTime, + /// Duration of the occurrence. + pub duration: Duration, +} + +impl OwnedEventOccurrence { + fn from_occurrence(occurrence: EventOccurrence<'_>) -> Self { + Self { + event_index: occurrence.event_index, + title: occurrence.event.title.clone(), + description: occurrence.event.description.clone(), + location: occurrence.event.location.clone(), + status: occurrence.event.status, + occurrence_time: occurrence.occurrence_time, + duration: occurrence.event.duration(), + } + } + + /// End time of this occurrence. + pub fn end_time(&self) -> DateTime { + self.occurrence_time + self.duration + } + + /// Title of this occurrence. + pub fn title(&self) -> &str { + &self.title + } + + /// Description of this occurrence. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +impl PartialOrd for OwnedEventOccurrence { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for OwnedEventOccurrence { + fn cmp(&self, other: &Self) -> Ordering { + self.occurrence_time + .cmp(&other.occurrence_time) + .then_with(|| self.event_index.cmp(&other.event_index)) + .then_with(|| self.title.cmp(&other.title)) + .then_with(|| self.description.cmp(&other.description)) + .then_with(|| self.location.cmp(&other.location)) + .then_with(|| self.status.cmp(&other.status)) + .then_with(|| self.duration.cmp(&other.duration)) + } +} + +/// A single calendar day with all active events whose time span intersects the +/// local day pre-bucketed. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DayView { + date: NaiveDate, + timezone: Tz, + start: DateTime, + end_exclusive: DateTime, + events: Vec, +} + +impl DayView { + fn new( + date: NaiveDate, + timezone: Tz, + start: DateTime, + end_exclusive: DateTime, + events: Vec, + ) -> Self { + Self { + date, + timezone, + start, + end_exclusive, + events, + } + } + + /// The calendar date for this day view. + pub fn date(&self) -> NaiveDate { + self.date + } + + /// The timezone used to compute this day. + pub fn timezone(&self) -> Tz { + self.timezone + } + + /// All active events intersecting this day, sorted by occurrence start time. + pub fn events(&self) -> &[OwnedEventOccurrence] { + &self.events + } + + /// Number of events intersecting this day. + pub fn event_count(&self) -> usize { + self.events.len() + } + + /// Whether this day has no active events. + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + /// Start of the day in the configured timezone. + pub fn start(&self) -> DateTime { + self.start + } + + /// Exclusive end of the day in the configured timezone. + /// + /// This is the next day's midnight and matches the half-open interval used + /// by [`Calendar::events_between`](crate::Calendar::events_between). + pub fn end(&self) -> DateTime { + self.end_exclusive + } + + /// Exclusive end of the day in the configured timezone. + pub fn end_exclusive(&self) -> DateTime { + self.end_exclusive + } + + /// Inclusive end of the day for display-only scenarios. + /// + /// This subtracts one nanosecond from the exclusive boundary. Use + /// [`DayView::end()`] or [`DayView::end_exclusive()`] for computations. + pub fn end_inclusive(&self) -> DateTime { + if self.end_exclusive > self.start { + self.end_exclusive - Duration::nanoseconds(1) + } else { + self.start + } + } +} + +/// A calendar week containing seven day views. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WeekView { + days: [DayView; 7], +} + +impl WeekView { + fn new(days: [DayView; 7]) -> Self { + Self { + days, + } + } + + /// The seven day views in this week. + pub fn days(&self) -> &[DayView; 7] { + &self.days + } + + /// Date of the first day in the week. + pub fn start_date(&self) -> NaiveDate { + self.days[0].date() + } + + /// Date of the last day in the week. + pub fn end_date(&self) -> NaiveDate { + self.days[6].date() + } + + /// Total number of events across all seven days. + pub fn event_count(&self) -> usize { + self.days.iter().map(DayView::event_count).sum() + } + + /// All events across the week, flattened and sorted by occurrence time. + pub fn all_events(&self) -> Vec<&OwnedEventOccurrence> { + let mut events: Vec<_> = self.days.iter().flat_map(|day| day.events()).collect(); + events.sort(); + events + } + + /// Whether every day in the week is empty. + pub fn is_empty(&self) -> bool { + self.days.iter().all(DayView::is_empty) + } +} + +#[derive(Debug, Clone, Copy)] +enum Direction { + Forward, + Backward, +} + +/// A lazy iterator over calendar days. +#[derive(Debug, Clone)] +pub struct DayIterator<'a> { + calendar: &'a Calendar, + current_date: Option, + timezone: Tz, + direction: Direction, +} + +impl<'a> DayIterator<'a> { + pub(crate) fn new(calendar: &'a Calendar, start: DateTime) -> Self { + Self { + calendar, + current_date: Some(start.date_naive()), + timezone: start.timezone(), + direction: Direction::Forward, + } + } + + pub(crate) fn backward(calendar: &'a Calendar, start: DateTime) -> Self { + Self { + calendar, + current_date: Some(start.date_naive()), + timezone: start.timezone(), + direction: Direction::Backward, + } + } + + /// Move the iterator to a new calendar date in the same timezone. + /// + /// This resets the cursor unconditionally. In a forward iterator, + /// jumping to an earlier date will re-visit dates that may already + /// have been yielded, and vice versa for backward iteration. + pub fn skip_to(&mut self, date: NaiveDate) { + self.current_date = Some(date); + } +} + +impl Iterator for DayIterator<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + let date = self.current_date?; + self.current_date = advance_date(date, self.direction); + Some(build_day_view(self.calendar, date, self.timezone)) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = + self.current_date.map(|date| remaining_days(date, self.direction)).unwrap_or(0); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for DayIterator<'_> { + fn len(&self) -> usize { + self.current_date.map(|date| remaining_days(date, self.direction)).unwrap_or(0) + } +} + +impl FusedIterator for DayIterator<'_> {} + +/// A lazy iterator over calendar weeks. +#[derive(Debug, Clone)] +pub struct WeekIterator<'a> { + calendar: &'a Calendar, + current_week_start: Option, + timezone: Tz, + direction: Direction, +} + +impl<'a> WeekIterator<'a> { + pub(crate) fn new(calendar: &'a Calendar, start: DateTime) -> Self { + Self::from_date(calendar, start.date_naive(), start.timezone(), Direction::Forward) + } + + pub(crate) fn backward(calendar: &'a Calendar, start: DateTime) -> Self { + Self::from_date(calendar, start.date_naive(), start.timezone(), Direction::Backward) + } + + fn from_date( + calendar: &'a Calendar, + date: NaiveDate, + timezone: Tz, + direction: Direction, + ) -> Self { + Self { + calendar, + current_week_start: aligned_full_week_start(date), + timezone, + direction, + } + } + + /// Move the iterator to the week containing `date`. + /// + /// This resets the cursor unconditionally. In a forward iterator, + /// jumping to an earlier date will re-visit week windows that may + /// already have been yielded, and vice versa for backward iteration. + /// If `date` falls so close to the supported upper bound that a full + /// Monday-Sunday window cannot be formed, the iterator becomes empty. + pub fn skip_to(&mut self, date: NaiveDate) { + self.current_week_start = aligned_full_week_start(date); + } +} + +impl Iterator for WeekIterator<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + let week_start = self.current_week_start?; + self.current_week_start = advance_week_start(week_start, self.direction); + Some(build_week_view(self.calendar, week_start, self.timezone)) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self + .current_week_start + .map(|week_start| remaining_full_weeks(week_start, self.direction)) + .unwrap_or(0); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for WeekIterator<'_> { + fn len(&self) -> usize { + self.current_week_start + .map(|week_start| remaining_full_weeks(week_start, self.direction)) + .unwrap_or(0) + } +} + +impl FusedIterator for WeekIterator<'_> {} + +fn advance_date(date: NaiveDate, direction: Direction) -> Option { + match direction { + Direction::Forward => date.succ_opt(), + Direction::Backward => date.pred_opt(), + } +} + +fn build_day_view(calendar: &Calendar, date: NaiveDate, timezone: Tz) -> Result { + let (start, end_exclusive) = local_day_window(date, timezone)?; + let events = calendar + .events_between(start, end_exclusive)? + .into_iter() + .filter(|occurrence| occurrence.event.is_active()) + .map(OwnedEventOccurrence::from_occurrence) + .collect(); + + Ok(DayView::new(date, timezone, start, end_exclusive, events)) +} + +fn build_week_view(calendar: &Calendar, week_start: NaiveDate, timezone: Tz) -> Result { + let nth_date = |offset| -> Result { + week_start.checked_add_days(Days::new(offset)).ok_or_else(|| { + EventixError::ValidationError( + "Could not construct a full Monday-Sunday week window".to_string(), + ) + }) + }; + + let dates = [ + nth_date(0)?, + nth_date(1)?, + nth_date(2)?, + nth_date(3)?, + nth_date(4)?, + nth_date(5)?, + nth_date(6)?, + ]; + + let days = [ + build_day_view(calendar, dates[0], timezone)?, + build_day_view(calendar, dates[1], timezone)?, + build_day_view(calendar, dates[2], timezone)?, + build_day_view(calendar, dates[3], timezone)?, + build_day_view(calendar, dates[4], timezone)?, + build_day_view(calendar, dates[5], timezone)?, + build_day_view(calendar, dates[6], timezone)?, + ]; + + Ok(WeekView::new(days)) +} + +fn align_to_monday(date: NaiveDate) -> Option { + let days_since_monday = date.weekday().num_days_from_monday() as u64; + date.checked_sub_days(Days::new(days_since_monday)) +} + +fn aligned_full_week_start(date: NaiveDate) -> Option { + let monday = align_to_monday(date)?; + if monday.checked_add_days(Days::new(6)).is_some() { + Some(monday) + } else { + None + } +} + +fn advance_week_start(week_start: NaiveDate, direction: Direction) -> Option { + let next = match direction { + Direction::Forward => week_start.checked_add_days(Days::new(7)), + Direction::Backward => week_start.checked_sub_days(Days::new(7)), + }?; + + if next.checked_add_days(Days::new(6)).is_some() { + Some(next) + } else { + None + } +} + +fn remaining_days(date: NaiveDate, direction: Direction) -> usize { + let remaining = match direction { + Direction::Forward => NaiveDate::MAX.signed_duration_since(date).num_days(), + Direction::Backward => date.signed_duration_since(NaiveDate::MIN).num_days(), + }; + + remaining.try_into().unwrap_or(usize::MAX).saturating_add(1) +} + +fn remaining_full_weeks(week_start: NaiveDate, direction: Direction) -> usize { + let remaining = match direction { + Direction::Forward => last_full_week_start().signed_duration_since(week_start).num_days(), + Direction::Backward => week_start.signed_duration_since(first_full_week_start()).num_days(), + }; + + if remaining < 0 { + return 0; + } + + remaining.try_into().unwrap_or(usize::MAX).saturating_div(7).saturating_add(1) +} + +fn first_full_week_start() -> NaiveDate { + let days_until_monday = (7 - NaiveDate::MIN.weekday().num_days_from_monday()) % 7; + NaiveDate::MIN + .checked_add_days(Days::new(days_until_monday as u64)) + .unwrap_or(NaiveDate::MIN) +} + +fn last_full_week_start() -> NaiveDate { + let latest_start_candidate = + NaiveDate::MAX.checked_sub_days(Days::new(6)).unwrap_or(NaiveDate::MAX); + align_to_monday(latest_start_candidate).unwrap_or(latest_start_candidate) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::{timezone, Calendar, Duration, Event, EventStatus, Recurrence}; + use chrono::Timelike; + + fn collect_ok(iter: impl Iterator>) -> Vec { + iter.collect::>>().unwrap() + } + + fn next_ok(mut iter: impl Iterator>) -> T { + iter.next().unwrap().unwrap() + } + + fn sample_calendar() -> Calendar { + let mut calendar = Calendar::new("Views"); + + calendar.add_event( + Event::builder() + .title("Planning") + .description("Weekly planning") + .location("Room A") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(), + ); + + calendar.add_event( + Event::builder() + .title("Standup") + .start("2025-11-04 10:00:00", "America/New_York") + .duration_minutes(15) + .recurrence(Recurrence::daily().count(5)) + .build() + .unwrap(), + ); + + calendar.add_event( + Event::builder() + .title("Cancelled") + .start("2025-11-05 11:00:00", "America/New_York") + .duration_minutes(30) + .status(EventStatus::Cancelled) + .build() + .unwrap(), + ); + + calendar + } + + #[test] + fn test_day_iterator_basic() { + let calendar = Calendar::new("Basic"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap(); + + let days = collect_ok(calendar.days(start).take(3)); + + assert_eq!( + days.iter().map(DayView::date).collect::>(), + vec![ + chrono::NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 3).unwrap(), + ] + ); + } + + #[test] + fn test_day_iterator_with_events() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + + let days = collect_ok(calendar.days(start).take(3)); + + assert_eq!(days[0].event_count(), 1); + assert_eq!(days[0].events()[0].title(), "Planning"); + assert_eq!(days[1].event_count(), 1); + assert_eq!(days[1].events()[0].title(), "Standup"); + } + + #[test] + fn test_day_iterator_backward() { + let calendar = Calendar::new("Backward"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-03 12:00:00", tz).unwrap(); + + let days = collect_ok(calendar.days_back(start).take(3)); + + assert_eq!( + days.iter().map(DayView::date).collect::>(), + vec![ + chrono::NaiveDate::from_ymd_opt(2025, 11, 3).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 1).unwrap(), + ] + ); + } + + #[test] + fn test_day_iterator_recurring_events() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-04 00:00:00", tz).unwrap(); + + let days = collect_ok(calendar.days(start).take(5)); + + assert!(days + .iter() + .all(|day| day.events().iter().all(|event| event.title() == "Standup"))); + assert_eq!(days.iter().map(DayView::event_count).sum::(), 5); + } + + #[test] + fn test_day_iterator_empty_days() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + + let days = collect_ok(calendar.days(start).take(2)); + + assert!(days.iter().all(DayView::is_empty)); + } + + #[test] + fn test_week_iterator_basic() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-05 12:00:00", tz).unwrap(); + + let week = next_ok(calendar.weeks(start)); + + assert_eq!(week.start_date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 3).unwrap()); + assert_eq!(week.end_date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 9).unwrap()); + assert_eq!(week.days().len(), 7); + } + + #[test] + fn test_week_iterator_event_count() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-05 12:00:00", tz).unwrap(); + + let week = next_ok(calendar.weeks(start)); + + assert_eq!(week.event_count(), 6); + assert!(!week.is_empty()); + } + + #[test] + fn test_owned_occurrence_fields() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + + let day = next_ok(calendar.days(start)); + let occurrence = &day.events()[0]; + + assert_eq!(occurrence.title(), "Planning"); + assert_eq!(occurrence.description(), Some("Weekly planning")); + assert_eq!(occurrence.location.as_deref(), Some("Room A")); + assert_eq!(occurrence.status, EventStatus::Confirmed); + assert_eq!(occurrence.end_time(), occurrence.occurrence_time + Duration::hours(1)); + } + + #[test] + fn test_day_view_helpers() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-04 00:00:00", tz).unwrap(); + + let day = next_ok(calendar.days(start)); + + assert_eq!(day.date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 4).unwrap()); + assert_eq!(day.timezone(), tz); + assert_eq!(day.event_count(), 1); + assert!(!day.is_empty()); + assert_eq!(day.start().hour(), 0); + assert_eq!(day.end().hour(), 0); + assert_eq!(day.end().date_naive(), day.date().succ_opt().unwrap()); + assert_eq!(day.end_exclusive(), day.end()); + assert_eq!(day.end_inclusive().date_naive(), day.date()); + } + + #[test] + fn test_cancelled_events_are_excluded_from_day_views() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-05 00:00:00", tz).unwrap(); + + let day = next_ok(calendar.days(start)); + + assert_eq!(day.event_count(), 1); + assert!(day.events().iter().all(|event| event.status != EventStatus::Cancelled)); + } + + #[test] + fn test_week_iterator_backward() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-12 09:00:00", tz).unwrap(); + + let weeks = collect_ok(calendar.weeks_back(start).take(2)); + + assert_eq!(weeks[0].start_date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 10).unwrap()); + assert_eq!(weeks[1].start_date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 3).unwrap()); + assert_eq!( + weeks[0].days().iter().map(DayView::date).collect::>(), + vec![ + chrono::NaiveDate::from_ymd_opt(2025, 11, 10).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 11).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 12).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 13).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 14).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 15).unwrap(), + chrono::NaiveDate::from_ymd_opt(2025, 11, 16).unwrap(), + ] + ); + } + + #[test] + fn test_owned_occurrence_ordering_uses_start_time() { + let mut calendar = Calendar::new("Ordering"); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + calendar.add_event( + Event::builder() + .title("Later") + .start("2025-11-03 15:00:00", "America/New_York") + .duration_minutes(30) + .build() + .unwrap(), + ); + calendar.add_event( + Event::builder() + .title("Earlier") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_minutes(30) + .build() + .unwrap(), + ); + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + + let mut events = next_ok(calendar.days(start)).events().to_vec(); + events.reverse(); + events.sort(); + + assert_eq!(events[0].title(), "Earlier"); + assert_eq!(events[1].title(), "Later"); + } + + #[test] + fn test_day_iterator_skip_to() { + let calendar = Calendar::new("Skip"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let mut iter = calendar.days(start); + + iter.skip_to(chrono::NaiveDate::from_ymd_opt(2025, 11, 5).unwrap()); + let day = next_ok(iter); + + assert_eq!(day.date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 5).unwrap()); + } + + #[test] + fn test_week_iterator_size_hint_and_skip_to() { + let calendar = sample_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-05 12:00:00", tz).unwrap(); + let mut iter = calendar.weeks(start); + + let (lower, upper) = iter.size_hint(); + assert!(lower > 0); + assert_eq!(upper, Some(lower)); + assert_eq!(iter.len(), lower); + + iter.skip_to(chrono::NaiveDate::from_ymd_opt(2025, 11, 17).unwrap()); + let week = next_ok(iter); + assert_eq!(week.start_date(), chrono::NaiveDate::from_ymd_opt(2025, 11, 17).unwrap()); + } + + #[test] + fn test_day_iterator_size_hint_is_exact() { + let calendar = Calendar::new("Hints"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let iter = calendar.days(start); + + let (lower, upper) = iter.size_hint(); + assert!(lower > 0); + assert_eq!(upper, Some(lower)); + assert_eq!(iter.len(), lower); + } + + #[test] + fn test_week_view_all_events_is_sorted_across_days() { + let mut calendar = Calendar::new("All Events"); + let tz = timezone::parse_timezone("UTC").unwrap(); + + calendar.add_event( + Event::builder() + .title("Wednesday") + .start("2025-11-05 09:00:00", "UTC") + .duration_minutes(30) + .build() + .unwrap(), + ); + calendar.add_event( + Event::builder() + .title("Monday") + .start("2025-11-03 09:00:00", "UTC") + .duration_minutes(30) + .build() + .unwrap(), + ); + + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + let week = next_ok(calendar.weeks(start)); + let titles: Vec<_> = week.all_events().into_iter().map(|event| event.title()).collect(); + + assert_eq!(titles, vec!["Monday", "Wednesday"]); + } + + #[test] + fn test_end_inclusive_zero_length_window_falls_back_to_start() { + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let day = DayView::new(start.date_naive(), tz, start, start, vec![]); + + assert_eq!(day.end_inclusive(), start); + } + + #[test] + fn test_week_boundary_helpers_are_monday_aligned() { + assert_eq!(first_full_week_start().weekday(), chrono::Weekday::Mon); + assert_eq!(last_full_week_start().weekday(), chrono::Weekday::Mon); + assert!(last_full_week_start().checked_add_days(Days::new(6)).is_some()); + } + + #[test] + fn test_remaining_full_weeks_invalid_start_returns_zero() { + let invalid_start = last_full_week_start().succ_opt().unwrap(); + assert_eq!(remaining_full_weeks(invalid_start, Direction::Forward), 0); + } + + #[test] + fn test_day_iterator_is_fused_after_exhaustion() { + let calendar = Calendar::new("Fuse Day"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let mut iter = calendar.days(start); + + iter.skip_to(chrono::NaiveDate::MAX); + assert!(iter.next().unwrap().is_err()); + assert!(iter.next().is_none()); + assert!(iter.next().is_none()); + } + + #[test] + fn test_week_iterator_is_fused_after_exhaustion() { + let calendar = Calendar::new("Fuse Week"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 00:00:00", tz).unwrap(); + let mut iter = calendar.weeks(start); + + iter.skip_to(chrono::NaiveDate::MAX); + assert!(iter.next().is_none()); + assert!(iter.next().is_none()); + } + + #[test] + fn test_build_week_view_rejects_incomplete_week_window() { + let calendar = Calendar::new("Broken Week"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let err = build_week_view(&calendar, NaiveDate::MAX, tz).unwrap_err(); + assert!( + matches!(err, EventixError::ValidationError(message) if message.contains("Monday-Sunday")) + ); + } + + #[test] + fn test_advance_week_start_none_paths() { + assert!(advance_week_start(NaiveDate::MAX, Direction::Forward).is_none()); + assert!(advance_week_start(last_full_week_start(), Direction::Forward).is_none()); + } + + #[test] + fn test_week_view_all_events_tie_break_same_timestamp() { + let mut calendar = Calendar::new("Tie Break"); + let tz = timezone::parse_timezone("UTC").unwrap(); + + calendar.add_event( + Event::builder() + .title("Bravo") + .start("2025-11-03 09:00:00", "UTC") + .duration_minutes(30) + .build() + .unwrap(), + ); + calendar.add_event( + Event::builder() + .title("Alpha") + .description("Later index") + .start("2025-11-03 09:00:00", "UTC") + .duration_minutes(30) + .build() + .unwrap(), + ); + + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + let week = next_ok(calendar.weeks(start)); + let all = week.all_events(); + + assert_eq!(all[0].title(), "Bravo"); + assert_eq!(all[0].description(), None); + assert_eq!(all[1].title(), "Alpha"); + assert_eq!(all[1].description(), Some("Later index")); + } +} diff --git a/tests/booking_status_tests.rs b/tests/booking_status_tests.rs index c6d7a5d..0f43cdd 100644 --- a/tests/booking_status_tests.rs +++ b/tests/booking_status_tests.rs @@ -1,7 +1,9 @@ //! Integration tests for Booking State Machine #![allow(clippy::unwrap_used)] -use eventix::{gap_validation, timezone, Calendar, Duration, Event, EventStatus}; +use eventix::{ + gap_validation, timezone, Calendar, Duration, Event, EventBuilder, EventStatus, EventixError, +}; #[test] fn test_event_status_lifecycle() { @@ -156,3 +158,47 @@ fn test_event_status_serialization() { assert_eq!(restored.get_events()[0].status, EventStatus::Cancelled); } + +#[test] +fn test_event_builder_end_validation_and_default_builder() { + let err = Event::builder() + .title("Broken end") + .start("2025-11-01 10:00:00", "UTC") + .end("not-a-date") + .build() + .unwrap_err(); + assert!( + matches!(err, EventixError::DateTimeParse(message) if message.contains("Could not parse")) + ); + + let err = Event::builder() + .title("Missing timezone") + .end("2025-11-01 11:00:00") + .build() + .unwrap_err(); + assert!( + matches!(err, EventixError::ValidationError(message) if message.contains("Cannot set end time")) + ); + + let err = Event::builder().title("Missing start").build().unwrap_err(); + assert!( + matches!(err, EventixError::ValidationError(message) if message.contains("start time is required")) + ); + + let err = Event::builder() + .title("Missing end") + .start("2025-11-01 10:00:00", "UTC") + .build() + .unwrap_err(); + assert!( + matches!(err, EventixError::ValidationError(message) if message.contains("end time is required")) + ); + + let event = EventBuilder::default() + .title("Default Builder") + .start("2025-11-01 09:00:00", "UTC") + .duration_hours(1) + .build() + .unwrap(); + assert_eq!(event.title, "Default Builder"); +} diff --git a/tests/calendar_view_tests.rs b/tests/calendar_view_tests.rs new file mode 100644 index 0000000..b165233 --- /dev/null +++ b/tests/calendar_view_tests.rs @@ -0,0 +1,267 @@ +#![allow(clippy::unwrap_used)] + +mod common; + +use common::parse; +use eventix::{timezone, Calendar, Duration, Event, EventStatus, EventixError, Recurrence}; +use serde_json::json; + +fn collect_ok(iter: impl Iterator>) -> Vec { + iter.collect::>>().unwrap() +} + +fn next_ok(mut iter: impl Iterator>) -> T { + iter.next().unwrap().unwrap() +} + +fn build_calendar() -> Calendar { + let mut calendar = Calendar::new("Integration Views"); + + calendar.add_event( + Event::builder() + .title("Planning") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(), + ); + + calendar.add_event( + Event::builder() + .title("Standup") + .start("2025-11-04 10:00:00", "America/New_York") + .duration_minutes(15) + .recurrence(Recurrence::daily().count(5)) + .build() + .unwrap(), + ); + + calendar.add_event( + Event::builder() + .title("Overnight Deploy") + .start("2025-11-05 23:30:00", "America/New_York") + .duration(Duration::hours(2)) + .build() + .unwrap(), + ); + + calendar.add_event( + Event::builder() + .title("Cancelled") + .start("2025-11-06 11:00:00", "America/New_York") + .duration_minutes(30) + .status(EventStatus::Cancelled) + .build() + .unwrap(), + ); + + calendar +} + +#[test] +fn test_views_match_events_on_date_for_active_events() { + let calendar = build_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz).unwrap(); + + for day in collect_ok(calendar.days(start).take(7)) { + let query = + timezone::parse_datetime_with_tz(&format!("{} 00:00:00", day.date()), tz).unwrap(); + let expected: Vec<_> = calendar + .events_on_date(query) + .unwrap() + .into_iter() + .filter(|occurrence| occurrence.event.is_active()) + .map(|occurrence| { + ( + occurrence.title().to_string(), + occurrence.occurrence_time, + occurrence.end_time(), + occurrence.event.status, + ) + }) + .collect(); + let actual: Vec<_> = day + .events() + .iter() + .map(|occurrence| { + ( + occurrence.title().to_string(), + occurrence.occurrence_time, + occurrence.end_time(), + occurrence.status, + ) + }) + .collect(); + + assert_eq!(actual, expected, "day {}", day.date()); + } +} + +#[test] +fn test_views_dst_boundary() { + let mut calendar = Calendar::new("DST"); + calendar.add_event( + Event::builder() + .title("Night Shift") + .start("2025-11-02 00:30:00", "America/New_York") + .duration_minutes(30) + .recurrence(Recurrence::hourly().count(4)) + .build() + .unwrap(), + ); + + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-02 00:00:00", tz).unwrap(); + let day = next_ok(calendar.days(start)); + + assert_eq!(day.event_count(), 4); + assert_eq!(day.end() - day.start(), Duration::hours(25)); + assert_eq!(day.end_inclusive().date_naive(), day.date()); +} + +#[test] +fn test_views_cancelled_events_excluded() { + let calendar = build_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-06 00:00:00", tz).unwrap(); + let day = next_ok(calendar.days(start)); + + assert!(day + .events() + .iter() + .all(|occurrence| occurrence.status != EventStatus::Cancelled)); + assert_eq!(day.event_count(), 2); +} + +#[test] +fn test_weeks_align_to_monday() { + let calendar = build_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-06 18:00:00", tz).unwrap(); + let week = next_ok(calendar.weeks(start)); + + assert_eq!(week.start_date().to_string(), "2025-11-03"); + assert_eq!(week.end_date().to_string(), "2025-11-09"); + assert_eq!(week.event_count(), 8); +} + +#[test] +fn test_backward_weeks_are_contiguous_monday_to_sunday_windows() { + let calendar = build_calendar(); + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-12 18:00:00", tz).unwrap(); + let weeks = collect_ok(calendar.weeks_back(start).take(2)); + + assert_eq!( + weeks[0].days().iter().map(|day| day.date().to_string()).collect::>(), + vec![ + "2025-11-10".to_string(), + "2025-11-11".to_string(), + "2025-11-12".to_string(), + "2025-11-13".to_string(), + "2025-11-14".to_string(), + "2025-11-15".to_string(), + "2025-11-16".to_string(), + ] + ); + assert_eq!(weeks[1].start_date().to_string(), "2025-11-03"); +} + +fn sample_event(title: &str, datetime: &str, tz_name: &str) -> Event { + Event::builder() + .title(title) + .start(datetime, tz_name) + .duration_hours(1) + .build() + .unwrap() +} + +#[test] +fn test_calendar_mutators_and_occurrence_helpers() { + let tz = timezone::parse_timezone("America/New_York").unwrap(); + let mut calendar = + Calendar::new("Coverage Calendar").description("Calendar metadata").timezone(tz); + + let planning = Event::builder() + .title("Planning") + .description("Detailed planning session") + .start("2025-11-01 09:00:00", "America/New_York") + .duration_hours(1) + .build() + .unwrap(); + let review = sample_event("Review", "2025-11-01 11:00:00", "America/New_York"); + + calendar.add_events(vec![planning, review]); + + assert_eq!(calendar.timezone, Some(tz)); + assert_eq!(calendar.event_count(), 2); + + let removed = calendar.remove_event(1).unwrap(); + assert_eq!(removed.title, "Review"); + assert!(calendar.remove_event(99).is_none()); + + let day = parse("2025-11-01 00:00:00", "America/New_York"); + let occurrences = calendar.events_on_date(day).unwrap(); + assert_eq!(occurrences.len(), 1); + assert_eq!(occurrences[0].title(), "Planning"); + assert_eq!(occurrences[0].description(), Some("Detailed planning session")); + assert_eq!(occurrences[0].end_time(), parse("2025-11-01 10:00:00", "America/New_York")); + + calendar.clear_events(); + assert_eq!(calendar.event_count(), 0); +} + +#[test] +fn test_calendar_from_json_reports_missing_event_fields() { + let base = json!({ + "name": "Broken Calendar", + "events": [{ + "title": "Meeting", + "start_time": "2025-11-01T10:00:00+00:00", + "end_time": "2025-11-01T11:00:00+00:00", + "timezone": "UTC" + }] + }); + + for (field, needle) in [ + ("title", "Event missing 'title'"), + ("start_time", "Event missing 'start_time'"), + ("end_time", "Event missing 'end_time'"), + ("timezone", "Event missing 'timezone'"), + ] { + let mut payload = base.clone(); + payload["events"].as_array_mut().unwrap()[0] + .as_object_mut() + .unwrap() + .remove(field); + + let err = Calendar::from_json(&payload.to_string()).unwrap_err(); + assert!(matches!(err, EventixError::Other(message) if message.contains(needle))); + } +} + +#[test] +fn test_calendar_from_json_rejects_invalid_exdates_and_status() { + let mut exdate_payload = json!({ + "name": "Broken Calendar", + "events": [{ + "title": "Meeting", + "start_time": "2025-11-01T10:00:00+00:00", + "end_time": "2025-11-01T11:00:00+00:00", + "timezone": "UTC", + "exdates": [123] + }] + }); + let err = Calendar::from_json(&exdate_payload.to_string()).unwrap_err(); + assert!( + matches!(err, EventixError::Other(message) if message.contains("exdates[0]: expected string")) + ); + + exdate_payload["events"][0]["exdates"] = json!(["2025-11-02T10:00:00+00:00"]); + exdate_payload["events"][0]["status"] = json!("not-a-real-status"); + let err = Calendar::from_json(&exdate_payload.to_string()).unwrap_err(); + assert!( + matches!(err, EventixError::Other(message) if message.contains("Invalid event status")) + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..3fd23e7 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,10 @@ +//! Shared helpers for integration tests (`parse` only β€” each `tests/*.rs` crate is separate). + +use chrono::DateTime; +use chrono_tz::Tz; +use eventix::timezone; + +pub fn parse(datetime: &str, tz_name: &str) -> DateTime { + let tz = timezone::parse_timezone(tz_name).unwrap(); + timezone::parse_datetime_with_tz(datetime, tz).unwrap() +} diff --git a/tests/gap_validation_tests.rs b/tests/gap_validation_tests.rs index 2417e59..0cbc4d0 100644 --- a/tests/gap_validation_tests.rs +++ b/tests/gap_validation_tests.rs @@ -3,7 +3,13 @@ //! These tests validate the unique gap detection and schedule analysis //! features that set eventix apart from other calendar crates. #![allow(clippy::unwrap_used, clippy::len_zero)] -use eventix::{gap_validation, timezone, Calendar, Duration, Event, Recurrence}; + +mod common; + +use common::parse; +use eventix::gap_validation::{EventOverlap, ScheduleDensity, TimeGap}; +use eventix::{gap_validation, timezone, Calendar, Duration, Event, EventStatus, Recurrence}; +use serde_json::json; #[test] fn test_comprehensive_gap_detection() { @@ -566,3 +572,312 @@ fn test_zero_duration_events_no_false_overlaps() { // Zero-duration imported events should be ignored, leaving no false overlaps. assert_eq!(overlaps.len(), 0, "Zero-duration events should not produce false overlaps"); } + +#[test] +fn test_density_with_overlapping_events() { + // CRITICAL: This test catches the double-counting bug where overlapping + // events inflate busy_duration beyond total_duration, making free_duration negative. + let mut cal = Calendar::new("Overlapping Density"); + + // Event A: 09:00 - 11:00 (2h) + cal.add_event( + Event::builder() + .title("Event A") + .start("2025-11-01 09:00:00", "UTC") + .duration_hours(2) + .build() + .unwrap(), + ); + + // Event B: 10:00 - 12:00 (2h, overlaps A by 1 hour) + cal.add_event( + Event::builder() + .title("Event B") + .start("2025-11-01 10:00:00", "UTC") + .duration_hours(2) + .build() + .unwrap(), + ); + + // Event C: 14:00 - 16:00 (2h, separate) + cal.add_event( + Event::builder() + .title("Event C") + .start("2025-11-01 14:00:00", "UTC") + .duration_hours(2) + .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 18:00:00", tz).unwrap(); + + let density = gap_validation::calculate_density(&cal, start, end).unwrap(); + + // Merged busy: 09:00-12:00 (3h) + 14:00-16:00 (2h) = 5h wall-clock busy + // Total: 10h, so 50% occupancy + let busy_secs = density.busy_duration.num_seconds(); + let free_secs = density.free_duration.num_seconds(); + let total_secs = density.total_duration.num_seconds(); + + assert_eq!( + busy_secs + free_secs, + total_secs, + "busy ({}) + free ({}) must equal total ({})", + busy_secs, + free_secs, + total_secs + ); + assert!(free_secs >= 0, "free_duration must never be negative, got {}s", free_secs); + assert!( + density.occupancy_percentage <= 100.0, + "occupancy must not exceed 100%, got {:.2}%", + density.occupancy_percentage + ); + assert!( + (density.occupancy_percentage - 50.0).abs() < 1.0, + "expected ~50%, got {:.2}%", + density.occupancy_percentage + ); + assert_eq!(density.overlap_count, 1, "should detect the overlap"); +} + +#[test] +fn test_density_fully_contained_event_not_double_counted() { + // Event B is fully inside Event A β€” should not add any extra busy time + let mut cal = Calendar::new("Contained"); + + // Event A: 09:00 - 17:00 (8h) + cal.add_event( + Event::builder() + .title("All Day Block") + .start("2025-11-01 09:00:00", "UTC") + .duration_hours(8) + .build() + .unwrap(), + ); + + // Event B: 10:00 - 11:00 (1h, fully inside A) + cal.add_event( + Event::builder() + .title("Nested Meeting") + .start("2025-11-01 10: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 18:00:00", tz).unwrap(); + + let density = gap_validation::calculate_density(&cal, start, end).unwrap(); + + // Busy = 8h (just Event A, B is fully contained), Total = 10h -> 80% + assert_eq!( + density.busy_duration.num_hours(), + 8, + "Fully contained event should not add extra busy time" + ); + assert!(density.free_duration.num_seconds() >= 0, "free_duration must never be negative"); +} + +#[test] +fn test_gap_validation_invalid_time_range() { + let cal = Calendar::new("Invalid Range"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + + // start > end is an invalid range that must be explicitly rejected with an error + let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(0)); + assert!(gaps.is_err(), "Should return error for invalid range"); + + let overlaps = gap_validation::find_overlaps(&cal, start, end); + assert!(overlaps.is_err(), "Should return error for invalid range"); + + let density = gap_validation::calculate_density(&cal, start, end); + assert!(density.is_err(), "Should return error for invalid range"); +} + +#[test] +fn test_calculate_density_zero_range() { + let cal = Calendar::new("Zero Range"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + let end = start; + + // start == end is a zero-duration range that must be explicitly rejected + let density = gap_validation::calculate_density(&cal, start, end); + assert!(density.is_err(), "Zero range should be rejected by validation"); +} + +#[test] +fn test_suggest_alternatives_impossible_duration() { + let cal = Calendar::new("Impossible Alt"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let req = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + + // Zero duration must fail + let zero_dur = + gap_validation::suggest_alternatives(&cal, req, Duration::minutes(0), Duration::hours(1)); + assert!(zero_dur.is_err()); + + // Requesting a 10-hour duration in a 1-hour search window + let alternatives = + gap_validation::suggest_alternatives(&cal, req, Duration::hours(10), Duration::hours(1)) + .unwrap(); + assert_eq!(alternatives.len(), 0, "Should not return alternatives if duration exceeds window"); +} + +#[test] +fn test_suggest_alternatives_empty_calendar() { + let cal = Calendar::new("Empty Cal"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let req = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + + let alternatives = + gap_validation::suggest_alternatives(&cal, req, Duration::hours(1), Duration::hours(2)) + .unwrap(); + assert!(alternatives.len() >= 4, "Should suggest multiple slots in empty calendar"); +} + +#[test] +fn test_is_slot_available_invalid_time_range() { + let cal = Calendar::new("Invalid Slot Range"); + let tz = timezone::parse_timezone("UTC").unwrap(); + // slot_start > slot_end is an invalid slot and must be explicitly rejected + let slot_start = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + let slot_end = timezone::parse_datetime_with_tz("2025-11-01 09:00:00", tz).unwrap(); + + let available = gap_validation::is_slot_available(&cal, slot_start, slot_end); + assert!(available.is_err(), "Invalid slot is gracefully rejected"); +} + +#[test] +fn test_find_gaps_negative_min_duration() { + let cal = Calendar::new("Test"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let start = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + let end = timezone::parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap(); + let result = gap_validation::find_gaps(&cal, start, end, Duration::minutes(-5)); + assert!(result.is_err(), "Negative gap duration should return an error"); +} + +#[test] +fn test_suggest_alternatives_invalid_search_window() { + let cal = Calendar::new("Test"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let req = timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); + let result = + gap_validation::suggest_alternatives(&cal, req, Duration::hours(1), Duration::minutes(-1)); + assert!(result.is_err(), "Negative search window should return an error"); +} + +#[test] +fn test_gap_validation_slot_availability_uses_event_duration() { + let mut calendar = Calendar::new("Long event"); + calendar.add_event( + Event::builder() + .title("Overnight deploy") + .start("2025-11-01 09:00:00", "UTC") + .end("2025-11-03 09:00:00") + .build() + .unwrap(), + ); + + let slot_start = parse("2025-11-03 08:30:00", "UTC"); + let slot_end = parse("2025-11-03 09:30:00", "UTC"); + + assert!(!gap_validation::is_slot_available(&calendar, slot_start, slot_end).unwrap()); +} + +#[test] +fn test_gap_validation_helpers_and_zero_duration_events() { + let gap = TimeGap::new( + parse("2025-11-01 09:00:00", "UTC"), + parse("2025-11-01 12:00:00", "UTC"), + Some("Before".to_string()), + Some("After".to_string()), + ); + assert_eq!(gap.duration_hours(), 3); + assert!(gap.is_at_least(Duration::hours(2))); + + let overlap = EventOverlap::new( + parse("2025-11-01 10:00:00", "UTC"), + parse("2025-11-01 11:00:00", "UTC"), + vec!["A".to_string(), "B".to_string()], + ); + assert_eq!(overlap.event_count(), 2); + + let density = ScheduleDensity { + total_duration: Duration::hours(4), + busy_duration: Duration::hours(1), + free_duration: Duration::hours(3), + occupancy_percentage: 25.0, + event_count: 1, + gap_count: 2, + overlap_count: 1, + }; + assert!(density.is_light()); + assert!(density.has_conflicts()); + + let zero_duration = json!({ + "name": "Zero Duration", + "events": [{ + "title": "Marker", + "start_time": "2025-11-01T10:00:00+00:00", + "end_time": "2025-11-01T10:00:00+00:00", + "timezone": "UTC" + }] + }); + let calendar = Calendar::from_json(&zero_duration.to_string()).unwrap(); + let density = gap_validation::calculate_density( + &calendar, + parse("2025-11-01 09:00:00", "UTC"), + parse("2025-11-01 12:00:00", "UTC"), + ) + .unwrap(); + assert_eq!(density.busy_duration, Duration::zero()); + assert_eq!(density.occupancy_percentage, 0.0); +} + +#[test] +fn test_gap_validation_cancelled_slots_and_alternative_suggestions() { + let mut blocked = Calendar::new("Cancelled"); + blocked.add_event( + Event::builder() + .title("Cancelled block") + .start("2025-11-01 10:00:00", "UTC") + .duration_hours(2) + .status(EventStatus::Cancelled) + .build() + .unwrap(), + ); + assert!(gap_validation::is_slot_available( + &blocked, + parse("2025-11-01 10:30:00", "UTC"), + parse("2025-11-01 11:30:00", "UTC"), + ) + .unwrap()); + + let suggestions = gap_validation::suggest_alternatives( + &Calendar::new("Open"), + parse("2025-11-01 12:00:00", "UTC"), + Duration::hours(1), + Duration::hours(3), + ) + .unwrap(); + assert_eq!( + suggestions, + vec![ + parse("2025-11-01 09:00:00", "UTC"), + parse("2025-11-01 10:00:00", "UTC"), + parse("2025-11-01 11:00:00", "UTC"), + parse("2025-11-01 12:00:00", "UTC"), + parse("2025-11-01 13:00:00", "UTC"), + parse("2025-11-01 14:00:00", "UTC"), + ] + ); +} diff --git a/tests/property_tests.rs b/tests/property_tests.rs index 33583fa..d90ee3c 100644 --- a/tests/property_tests.rs +++ b/tests/property_tests.rs @@ -1,8 +1,12 @@ #![allow(clippy::unwrap_used)] -use chrono::{Duration, TimeZone}; +mod common; + +use chrono::{Duration, TimeZone, Weekday}; +use common::parse; +use eventix::recurrence::RecurrenceFilter; use eventix::timezone; -use eventix::{gap_validation, Calendar, Event, Recurrence}; +use eventix::{gap_validation, Calendar, Event, EventBuilder, EventStatus, Recurrence}; use proptest::prelude::*; proptest! { @@ -161,8 +165,8 @@ proptest! { 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) + // INVARIANT: occupancy_percentage is always 0.0 <= x <= 100.0 + // (overlapping intervals are merged before summing busy time) 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(); @@ -278,5 +282,126 @@ proptest! { prop_assert_eq!(gaps[0].end, end); prop_assert_eq!(gaps[0].duration_minutes(), window_hours * 60); } + #[test] + fn test_density_invariant_with_overlapping_events( + num_events in 2usize..8, + window_hours in 8i64..24 + ) { + // INVARIANT: busy + free = total, even with overlapping events. + // This catches the double-counting bug where overlapping intervals + // inflated busy_duration beyond total_duration. + let mut cal = Calendar::new("Overlap Density"); + let tz = timezone::parse_timezone("UTC").unwrap(); + let base = tz.with_ymd_and_hms(2025, 8, 1, 8, 0, 0).unwrap(); + + // Create events that WILL overlap: 2-hour events spaced 1 hour apart + for i in 0..num_events { + let event = Event::builder() + .title(format!("Overlap {}", i)) + .start_datetime(base + Duration::hours(i as i64)) + .duration_hours(2) + .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(); + + let busy_secs = density.busy_duration.num_seconds(); + let free_secs = density.free_duration.num_seconds(); + let total_secs = density.total_duration.num_seconds(); + + // Core invariant: busy + free = total + prop_assert_eq!( + busy_secs + free_secs, + total_secs, + "busy ({}) + free ({}) should equal total ({})", + busy_secs, free_secs, total_secs + ); + + // free_duration must never be negative + prop_assert!( + free_secs >= 0, + "free_duration must not be negative, got {}", + free_secs + ); + + // occupancy must not exceed 100% + prop_assert!( + density.occupancy_percentage <= 100.0, + "occupancy must not exceed 100%, got {:.2}%", + density.occupancy_percentage + ); + } // END: Gap Validation Property Tests } + +#[test] +fn test_event_builder_bulk_field_setters_and_filters() { + let monday = parse("2025-11-03 09:00:00", "UTC"); + let tuesday = parse("2025-11-04 09:00:00", "UTC"); + let thursday = parse("2025-11-06 09:00:00", "UTC"); + + let event = EventBuilder::default() + .title("Covered recurrence") + .start_datetime(monday) + .duration_hours(1) + .attendee("initial@example.com") + .attendees(vec!["alice@example.com".to_string(), "bob@example.com".to_string()]) + .recurrence(Recurrence::daily().count(7)) + .skip_weekends(true) + .exception_dates(vec![tuesday, thursday]) + .status(EventStatus::Blocked) + .build() + .unwrap(); + + assert_eq!( + event.attendees, + vec!["alice@example.com".to_string(), "bob@example.com".to_string()] + ); + assert_eq!(event.status, EventStatus::Blocked); + + let occurrences = event + .occurrences_between( + parse("2025-11-03 00:00:00", "UTC"), + parse("2025-11-10 00:00:00", "UTC"), + 16, + ) + .unwrap(); + + assert_eq!( + occurrences, + vec![monday, parse("2025-11-05 09:00:00", "UTC"), parse("2025-11-07 09:00:00", "UTC"),] + ); +} + +#[test] +fn test_recurrence_rrule_and_filter_helpers() { + let start = parse("2025-11-03 09:00:00", "UTC"); + let recurrence = Recurrence::weekly() + .interval(2) + .count(5) + .weekdays(vec![Weekday::Mon, Weekday::Wed]); + let until = parse("2025-12-31 09:00:00", "UTC"); + let yearly = Recurrence::yearly().until(until); + + assert_eq!(recurrence.get_interval(), 2); + assert_eq!(recurrence.get_count(), Some(5)); + assert_eq!(yearly.get_until(), Some(until)); + assert_eq!(recurrence.get_weekdays().unwrap(), [Weekday::Mon, Weekday::Wed]); + + let rrule = recurrence.to_rrule_string(start).unwrap(); + assert!(rrule.contains("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=MON,WED")); + + let filter = RecurrenceFilter::new() + .skip_weekends(true) + .skip_dates(vec![parse("2025-11-04 09:00:00", "UTC")]); + let filtered = filter.filter_occurrences(vec![ + parse("2025-11-03 09:00:00", "UTC"), + parse("2025-11-04 09:00:00", "UTC"), + parse("2025-11-08 09:00:00", "UTC"), + ]); + assert_eq!(filtered, vec![parse("2025-11-03 09:00:00", "UTC")]); +} diff --git a/tests/timezone_ics_tests.rs b/tests/timezone_ics_tests.rs index b996b38..89db4d9 100644 --- a/tests/timezone_ics_tests.rs +++ b/tests/timezone_ics_tests.rs @@ -2,7 +2,29 @@ #![allow(clippy::unwrap_used)] -use eventix::{timezone, Calendar, Event, Recurrence}; +mod common; + +use common::parse; +use eventix::{timezone, Calendar, Event, EventixError, Recurrence}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_ics_path(label: &str) -> PathBuf { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let dir = PathBuf::from("target").join("coverage-tests"); + fs::create_dir_all(&dir).unwrap(); + dir.join(format!("{label}-{stamp}.ics")) +} + +fn sample_event(title: &str, datetime: &str, tz_name: &str) -> Event { + Event::builder() + .title(title) + .start(datetime, tz_name) + .duration_hours(1) + .build() + .unwrap() +} #[test] fn test_timezone_aware_ics_export() { @@ -232,3 +254,149 @@ fn test_all_day_event_utc() { assert!(ics.contains("DTSTART:20251027T000000Z")); assert!(ics.contains("DTEND:20251028T000000Z")); } + +#[test] +fn test_ics_import_missing_property() { + let ics = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Test Missing\nEND:VEVENT\nEND:VCALENDAR"; + let cal = eventix::Calendar::from_ics_string(ics).unwrap(); + assert_eq!(cal.event_count(), 0, "Should skip event when properties like DTSTART are missing"); +} + +#[test] +fn test_ics_import_invalid_datetime_format() { + let ics = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Test Format\nDTSTART:INVALID_DATE\nDTEND:INVALID_DATE\nEND:VEVENT\nEND:VCALENDAR"; + let cal = eventix::Calendar::from_ics_string(ics).unwrap(); + assert_eq!(cal.event_count(), 0, "Should skip event for unparseable dates"); +} + +#[test] +fn test_timezone_dst_gap_and_is_dst_detection() { + let ny = timezone::parse_timezone("America/New_York").unwrap(); + let err = timezone::parse_datetime_with_tz("2025-03-09 02:30:00", ny).unwrap_err(); + assert!( + matches!(err, EventixError::DateTimeParse(message) if message.contains("Invalid datetime")) + ); + + let summer = parse("2025-07-01 10:00:00", "America/New_York"); + let winter = parse("2025-12-01 10:00:00", "America/New_York"); + assert!(timezone::is_dst(&summer)); + assert!(!timezone::is_dst(&winter)); +} + +#[test] +fn test_ics_file_round_trip_preserves_metadata_and_exported_fields() { + let path = temp_ics_path("round-trip"); + let mut calendar = Calendar::new("Coverage Export").description("Calendar level description"); + let event = Event::builder() + .title("Field Coverage") + .description("Event description") + .start("2025-11-03 09:00:00", "America/New_York") + .duration_hours(1) + .location("Main Room") + .uid("coverage-uid") + .attendees(vec!["alice@example.com".to_string(), "bob@example.com".to_string()]) + .build() + .unwrap(); + calendar.add_event(event); + + calendar.export_to_ics(&path).unwrap(); + let ics = fs::read_to_string(&path).unwrap(); + + assert!(ics.contains("NAME:Coverage Export")); + assert!(ics.contains("Calendar level description")); + assert!(ics.contains("UID:coverage-uid")); + assert!(ics.contains("ATTENDEE:mailto:alice@example.com")); + assert!(ics.contains("ATTENDEE:mailto:bob@example.com")); + + let imported = Calendar::import_from_ics(&path).unwrap(); + assert_eq!(imported.name, "Coverage Export"); + assert_eq!(imported.description.as_deref(), Some("Calendar level description")); + assert_eq!(imported.event_count(), 1); + + let imported_event = &imported.get_events()[0]; + assert_eq!(imported_event.title, "Field Coverage"); + assert_eq!(imported_event.description.as_deref(), Some("Event description")); + assert_eq!(imported_event.location.as_deref(), Some("Main Room")); + assert_eq!(imported_event.uid.as_deref(), Some("coverage-uid")); +} + +#[test] +fn test_ics_file_errors_are_wrapped() { + let mut calendar = Calendar::new("Broken export"); + calendar.add_event(sample_event("Meeting", "2025-11-01 10:00:00", "UTC")); + + let export_path = PathBuf::from("target") + .join("coverage-tests") + .join("missing-parent") + .join("calendar.ics"); + let err = calendar.export_to_ics(&export_path).unwrap_err(); + assert!( + matches!(err, EventixError::IcsError(message) if message.contains("Failed to write ICS file")) + ); + + let import_path = temp_ics_path("missing-file"); + let err = Calendar::import_from_ics(&import_path).unwrap_err(); + assert!( + matches!(err, EventixError::IcsError(message) if message.contains("Failed to read ICS file")) + ); +} + +#[test] +fn test_ics_import_skips_invalid_events_and_handles_exdate_fallbacks() { + let ics = "\ +BEGIN:VCALENDAR +NAME:Coverage Import +DESCRIPTION:Imported metadata +BEGIN:VEVENT +SUMMARY:Invalid TZID EXDATE +DESCRIPTION:Keeps parsing malformed RRULE segments +DTSTART;TZID=America/New_York:20251103T090000 +DTEND;TZID=America/New_York:20251103T100000 +RRULE:FREQ=DAILY;COUNT=3;BROKEN +EXDATE;TZID=Not/AZone:20251104T090000 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Floating EXDATE +DESCRIPTION:Floating exception date +LOCATION:Lab +UID:floating-uid +DTSTART;TZID=America/New_York:20251105T090000 +DTEND;TZID=America/New_York:20251105T100000 +RRULE:FREQ=DAILY;COUNT=3 +EXDATE:20251106T090000 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Broken EXDATE +DTSTART:20251107T090000Z +DTEND:20251107T100000Z +RRULE:FREQ=DAILY;COUNT=2 +EXDATE;TZID=UTC:not-a-date +END:VEVENT +BEGIN:VEVENT +SUMMARY:Missing End +DTSTART:20251108T090000Z +END:VEVENT +END:VCALENDAR"; + + let calendar = Calendar::from_ics_string(ics).unwrap(); + assert_eq!(calendar.name, "Coverage Import"); + assert_eq!(calendar.description.as_deref(), Some("Imported metadata")); + assert_eq!(calendar.event_count(), 2); + + let invalid_tzid = calendar + .get_events() + .iter() + .find(|event| event.title == "Invalid TZID EXDATE") + .unwrap(); + assert_eq!(invalid_tzid.exdates, vec![parse("2025-11-04 09:00:00", "America/New_York")]); + + let floating = calendar + .get_events() + .iter() + .find(|event| event.title == "Floating EXDATE") + .unwrap(); + assert_eq!(floating.uid.as_deref(), Some("floating-uid")); + assert_eq!(floating.location.as_deref(), Some("Lab")); + assert_eq!(floating.description.as_deref(), Some("Floating exception date")); + assert_eq!(floating.exdates, vec![parse("2025-11-06 09:00:00", "America/New_York")]); +}