eventix 0.5.0

High-level calendar & recurrence crate with timezone-aware scheduling, exceptions, and ICS import/export
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
#![allow(clippy::unwrap_used)]

mod common;

use chrono::{Duration, TimeZone, Weekday};
use common::parse;
use eventix::recurrence::RecurrenceFilter;
use eventix::timezone;
use eventix::{gap_validation, Calendar, Event, EventBuilder, EventStatus, Recurrence};
use proptest::prelude::*;

proptest! {
    // START: Recurrence Tests
    #[test]
    fn test_recurrence_daily_count_invariant(
        count in 1u32..100,
        start_year in 2020i32..2030,
        start_month in 1u32..=12,
        start_day in 1u32..28, // Safe day range
        hour in 0u32..23,
        minute in 0u32..59
    ) {
        let tz = timezone::parse_timezone("UTC").unwrap();
        let start = tz.with_ymd_and_hms(start_year, start_month, start_day, hour, minute, 0).unwrap();

        let recurrence = Recurrence::daily().count(count);
        let occurrences = recurrence.generate_occurrences(start).unwrap();

        // Invariant: Should generate exactly 'count' occurrences
        prop_assert_eq!(occurrences.len(), count as usize);

        // Invariant: Occurrences should be strictly increasing
        for windows in occurrences.windows(2) {
            prop_assert!(windows[0] < windows[1]);
        }

        // Invariant: Daily recurrence should have 24 hours diff (ignoring DST for UTC)
        for windows in occurrences.windows(2) {
            let diff = windows[1] - windows[0];
            prop_assert_eq!(diff.num_hours(), 24);
        }
    }

    #[test]
    fn test_recurrence_weekly_interval_invariant(
        interval in 1u16..52,
        count in 1u32..50
    ) {
        let tz = timezone::parse_timezone("UTC").unwrap();
        let start = tz.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();

        let recurrence = Recurrence::weekly().interval(interval).count(count);
        let occurrences = recurrence.generate_occurrences(start).unwrap();

        // Invariant: Week difference should match interval
        for windows in occurrences.windows(2) {
            let diff = windows[1] - windows[0];
            prop_assert_eq!(diff.num_days(), 7 * interval as i64);
        }
    }
    // END: Recurrence Tests

    // START: Event Builder Tests
    #[test]
    fn test_event_builder_invariants(
        ref title in "[a-zA-Z0-9 ]+",
        duration_hours in 1i64..100,
        start_offset_hours in 0i64..1000
    ) {
        let tz = timezone::parse_timezone("UTC").unwrap();
        let base_time = tz.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
        let start_time = base_time + Duration::hours(start_offset_hours);

        let event_res = Event::builder()
            .title(title.clone())
            .start_datetime(start_time)
            .duration_hours(duration_hours)
            .build();

        // Invariant: Valid inputs should always produce a valid event
        prop_assert!(event_res.is_ok());
        let event = event_res.unwrap();

        // Invariant: Title match
        prop_assert_eq!(&event.title, title);

        // Invariant: Start < End
        prop_assert!(event.start_time < event.end_time);

        // Invariant: Duration match
        prop_assert_eq!(event.duration().num_hours(), duration_hours);
    }

    #[test]
    fn test_event_overlap_logic(
        start_offset in 0i64..100,
        duration in 1i64..10
    ) {
        let mut cal = Calendar::new("Prop Test");
        let tz = timezone::parse_timezone("UTC").unwrap();
        let base = tz.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();

        let start1 = base + Duration::hours(start_offset);
        let event1 = Event::builder()
            .title("E1")
            .start_datetime(start1)
            .duration_hours(duration)
            .build().unwrap();

        cal.add_event(event1);

        // Check same slot availability
        // If we check the exact same time, it MUST NOT be available
        let is_available = eventix::gap_validation::is_slot_available(
            &cal,
            start1,
            start1 + Duration::hours(duration)
        ).unwrap();

        prop_assert!(!is_available);
    }
    // END: Event Builder Tests

    // START: Gap Validation Property Tests
    #[test]
    fn test_gaps_plus_busy_equals_total(
        num_events in 1usize..10,
        window_hours in 4i64..24
    ) {
        // INVARIANT: busy_duration + free_duration = total_duration
        let mut cal = Calendar::new("Density Test");
        let tz = timezone::parse_timezone("UTC").unwrap();
        let base = tz.with_ymd_and_hms(2025, 6, 15, 8, 0, 0).unwrap();

        // Add random non-overlapping events
        for i in 0..num_events {
            let event = Event::builder()
                .title(format!("Event {}", i))
                .start_datetime(base + Duration::hours(i as i64 * 2))
                .duration_minutes(45)
                .build()
                .unwrap();
            cal.add_event(event);
        }

        let start = base;
        let end = base + Duration::hours(window_hours);
        let density = gap_validation::calculate_density(&cal, start, end).unwrap();

        // Core invariant: busy + free = total
        let busy_secs = density.busy_duration.num_seconds();
        let free_secs = density.free_duration.num_seconds();
        let total_secs = density.total_duration.num_seconds();

        prop_assert_eq!(
            busy_secs + free_secs,
            total_secs,
            "busy ({}) + free ({}) should equal total ({})",
            busy_secs, free_secs, total_secs
        );
    }

    #[test]
    fn test_density_percentage_bounds_non_overlapping(
        num_events in 0usize..10,
        event_duration_mins in 15i64..60
    ) {
        // INVARIANT: 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();

        // Space events 2 hours apart with max 60 min duration = no overlap
        for i in 0..num_events {
            let event = Event::builder()
                .title(format!("E{}", i))
                .start_datetime(base + Duration::hours(i as i64 * 2))
                .duration_minutes(event_duration_mins)
                .build()
                .unwrap();
            cal.add_event(event);
        }

        let start = base;
        let end = base + Duration::hours(24);
        let density = gap_validation::calculate_density(&cal, start, end).unwrap();

        prop_assert!(
            density.occupancy_percentage >= 0.0,
            "Occupancy cannot be negative"
        );
        prop_assert!(
            density.occupancy_percentage <= 100.0,
            "Non-overlapping events should not exceed 100% occupancy, got {:.2}%",
            density.occupancy_percentage
        );
    }

    #[test]
    fn test_gaps_are_non_overlapping(
        num_events in 2usize..8
    ) {
        // INVARIANT: Gaps returned should never overlap with each other
        let mut cal = Calendar::new("Gap Overlap Test");
        let tz = timezone::parse_timezone("UTC").unwrap();
        let base = tz.with_ymd_and_hms(2025, 7, 1, 9, 0, 0).unwrap();

        // Create spaced events
        for i in 0..num_events {
            let event = Event::builder()
                .title(format!("Meeting {}", i))
                .start_datetime(base + Duration::hours(i as i64 * 3))
                .duration_hours(1)
                .build()
                .unwrap();
            cal.add_event(event);
        }

        let start = base - Duration::hours(1);
        let end = base + Duration::hours(num_events as i64 * 3 + 2);
        let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(0)).unwrap();

        // Verify gaps don't overlap — O(N) adjacent sweep
        // (find_gaps returns gaps in chronological order)
        for pair in gaps.windows(2) {
            prop_assert!(
                pair[0].end <= pair[1].start,
                "Gap ({} - {}) overlaps with next gap ({} - {})",
                pair[0].start, pair[0].end, pair[1].start, pair[1].end
            );
        }
    }

    #[test]
    fn test_no_overlaps_for_sequential_events(
        num_events in 2usize..20
    ) {
        // INVARIANT: Back-to-back events (A ends when B starts) should have 0 overlaps
        let mut cal = Calendar::new("Sequential Events");
        let tz = timezone::parse_timezone("UTC").unwrap();
        let base = tz.with_ymd_and_hms(2025, 5, 1, 9, 0, 0).unwrap();

        // Create perfectly sequential (touching) events
        for i in 0..num_events {
            let event = Event::builder()
                .title(format!("Event {}", i))
                .start_datetime(base + Duration::hours(i as i64))
                .duration_hours(1)
                .build()
                .unwrap();
            cal.add_event(event);
        }

        let start = base;
        let end = base + Duration::hours(num_events as i64 + 1);
        let overlaps = gap_validation::find_overlaps(&cal, start, end).unwrap();

        // Sequential events should have ZERO overlaps
        prop_assert_eq!(
            overlaps.len(),
            0,
            "Sequential events should not have overlaps, found {}",
            overlaps.len()
        );
    }

    #[test]
    fn test_empty_calendar_has_one_big_gap(
        window_hours in 1i64..48
    ) {
        // INVARIANT: Empty calendar = one gap covering entire window
        let cal = Calendar::new("Empty");
        let tz = timezone::parse_timezone("UTC").unwrap();
        let start = tz.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
        let end = start + Duration::hours(window_hours);

        let gaps = gap_validation::find_gaps(&cal, start, end, Duration::minutes(0)).unwrap();

        prop_assert_eq!(gaps.len(), 1, "Empty calendar should have exactly one gap");
        prop_assert_eq!(gaps[0].start, start);
        prop_assert_eq!(gaps[0].end, end);
        prop_assert_eq!(gaps[0].duration_minutes(), window_hours * 60);
    }
    #[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")]);
}