akiflow_rust 0.4.11

Wrapper for the rrule crate to be used in Akiflow.
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
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
use crate::helpers::fix_timezone;
use chrono::{Datelike, TimeZone};
use chrono_tz::Tz;

/// Patches a recurrence (RRULE) to reflect moving one occurrence from prev_datetime to new_datetime.
///
/// Semantics and timezone rules:
/// - prev_datetime and new_datetime are iso date or datetime. IMPORTANT: the datetime should be in local timezone, and therefore without the trailing Z.
/// - tzid is the calendar timezone context. All calendar comparisons (weekday, monthday, month) are
///   performed after converting prev_datetime and new_datetime to tzid. (this is mostly useful for the computation of the until,
///    the passed date and datetime are already assumed to be in local time)
/// - The produced changes are as if the rule was authored in tzid. For example, moving an event
///   to a different local day (in tzid) will update BYDAY/BYMONTHDAY to match the new local date.
/// - If the rule has an UNTIL:
///   - UNTIL with trailing Z (e.g., 20250617T220000Z) is interpreted as a UTC datetime and compared in UTC.
///   - UNTIL as date-only (e.g., 20250617) is interpreted as local date in tzid at 00:00 and then converted to UTC for comparison.
///   - If UNTIL is before new_datetime, it will be updated using the same shape (date-only stays date-only; datetime Z stays datetime Z).
///
/// What gets updated:
/// - Weekly (FREQ=WEEKLY): updates BYDAY when the local weekday changes. For multi-day lists, replaces only
///   the matching old day with the new one (if not already present).
/// - Monthly (FREQ=MONTHLY):
///   - If BYMONTHDAY is present, updates the day value(s) that match the old day (supports multiple comma-separated values).
///   - If BYDAY contains a position (e.g., 1MO, -1FR), updates both the weekday and the position, handling "last" week (-1) correctly.
/// - Yearly (FREQ=YEARLY):
///   - If BYMONTHDAY contains a single day value, updates it when the local monthday changes.
///   - If BYMONTH is present with a single value and the local month changes, updates BYMONTH accordingly.
///   - If BYMONTH is not present and the local month changes, appends BYMONTH with the new month.
///   - Note: yearly handling currently targets the single-day birthday-like use case; lists are not modified.
///
/// Other behavior:
/// - If the input contains multiple lines (e.g., EXDATE + RRULE), only the RRULE line is modified/returned.
/// - Parameters unrelated to the above remain untouched (e.g., INTERVAL, COUNT unless UNTIL update requires ignoring COUNT elsewhere).
///
/// Arguments:
/// - `recurrence`: full recurrence string or RRULE line.
/// - `prev_datetime`: previous occurrence date or datetime (local, no timezone). format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
/// - `new_datetime`: new occurrence date or datetime (local, no timezone). format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
/// - `tzid`: timezone ID used for calendar logic.
///
/// Returns: Modified RRULE string (single line).
pub fn patch_rrule(
    recurrence: &str,
    prev_datetime: &str,
    new_datetime: &str,
    tzid: &str,
) -> String {
    // Extract only the RRULE part if the recurrence contains multiple parts
    let rrule_part = if recurrence.contains("\n") {
        recurrence
            .lines()
            .find(|line| line.starts_with("RRULE:"))
            .unwrap_or(recurrence)
    } else {
        recurrence
    };

    // Parse input strings as local date or datetime (no timezone allowed)
    fn parse_local(dt_str: &str) -> Result<(chrono::NaiveDateTime, bool), String> {
        // Try full datetime without timezone: YYYY-MM-DDTHH:MM:SS
        if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S") {
            return Ok((ndt, false));
        }
        // Try date-only: YYYY-MM-DD
        if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt_str, "%Y-%m-%d") {
            let ndt = nd.and_hms_opt(0, 0, 0).ok_or("Invalid date")?;
            return Ok((ndt, true));
        }
        Err(
            "Invalid datetime format. Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no timezone)"
                .to_string(),
        )
    }

    let (prev_naive, _prev_is_date) =
        parse_local(prev_datetime).expect("prev_datetime has invalid format");
    let (new_naive, new_is_date) =
        parse_local(new_datetime).expect("new_datetime has invalid format");

    // Apply timezone and convert to that local timezone for all calendar comparisons
    let fixed_tz: String = fix_timezone(tzid, Some("UTC"));
    let tz: Tz = fixed_tz.parse().unwrap_or(chrono_tz::UTC);

    let prev_dt = tz
        .from_local_datetime(&prev_naive)
        .single()
        .expect("Ambiguous/non-existent local time for prev_datetime");
    let new_dt = tz
        .from_local_datetime(&new_naive)
        .single()
        .expect("Ambiguous/non-existent local time for new_datetime");

    // Get days of week and month in the passed timezone
    let prev_day_of_week = prev_dt.format("%u").to_string();
    let new_day_of_week = new_dt.format("%u").to_string();
    let prev_day_of_month = prev_dt.day();
    let new_day_of_month = new_dt.day();

    print!(
        "Prev day of week: {}, new day of week: {}\n",
        prev_dt, new_dt
    );

    // If days of week and month are the same, no changes needed
    if prev_day_of_week == new_day_of_week && prev_day_of_month == new_day_of_month {
        return rrule_part.to_string();
    }

    // Map numeric day of week to BYDAY format (MO, TU, WE, etc.)
    let prev_byday = match prev_day_of_week.as_str() {
        "1" => "MO",
        "2" => "TU",
        "3" => "WE",
        "4" => "TH",
        "5" => "FR",
        "6" => "SA",
        "7" => "SU",
        _ => return rrule_part.to_string(), // Invalid day, return unchanged
    };

    let new_byday = match new_day_of_week.as_str() {
        "1" => "MO",
        "2" => "TU",
        "3" => "WE",
        "4" => "TH",
        "5" => "FR",
        "6" => "SA",
        "7" => "SU",
        _ => return rrule_part.to_string(), // Invalid day, return unchanged
    };

    // Check frequency
    let is_weekly = rrule_part.contains("FREQ=WEEKLY");
    let is_monthly = rrule_part.contains("FREQ=MONTHLY");
    let is_yearly = rrule_part.contains("FREQ=YEARLY");

    if !is_weekly && !is_monthly && !is_yearly {
        return rrule_part.to_string();
    }

    let mut modified_recurrence = rrule_part.to_string();

    // Yearly: if BYMONTHDAY present and it's a specific day-of-month in a specific month, update day and possibly month
    if is_yearly {
        let bymonthday_key = "BYMONTHDAY=";
        if let Some(bymonthday_pos) = rrule_part.find(bymonthday_key) {
            let bymonthday_start = bymonthday_pos + bymonthday_key.len();
            let bymonthday_rest = &rrule_part[bymonthday_start..];
            let bymonthday_len = bymonthday_rest.find(';').unwrap_or(bymonthday_rest.len());
            let bymonthday_val = &bymonthday_rest[..bymonthday_len];
            // Only handle single day for the yearly birthday-like use case
            if !bymonthday_val.contains(',') {
                // Update BYMONTHDAY if it matches previous day
                if bymonthday_val == prev_day_of_month.to_string() {
                    let new_bymonthday = new_day_of_month.to_string();
                    modified_recurrence = splice_value(
                        &modified_recurrence,
                        bymonthday_start,
                        bymonthday_len,
                        &new_bymonthday,
                    );
                }
                // If BYMONTH exists and month changed, update it; if not present, we may add it to reflect the new month
                let bymonth_key = "BYMONTH=";
                if let Some(bymonth_pos) = modified_recurrence.find(bymonth_key) {
                    let bymonth_start = bymonth_pos + bymonth_key.len();
                    let bymonth_rest = &modified_recurrence[bymonth_start..];
                    let bymonth_len = bymonth_rest.find(';').unwrap_or(bymonth_rest.len());
                    let bymonth_val = &bymonth_rest[..bymonth_len];
                    // Only handle single month value
                    if !bymonth_val.contains(',') {
                        let prev_month = prev_dt.month();
                        let new_month = new_dt.month();
                        if bymonth_val == prev_month.to_string() && prev_month != new_month {
                            let new_bymonth = new_month.to_string();
                            modified_recurrence = splice_value(
                                &modified_recurrence,
                                bymonth_start,
                                bymonth_len,
                                &new_bymonth,
                            );
                        }
                    }
                } else {
                    // If BYMONTH is not present, but day changed across months, append BYMONTH to lock to the new month
                    let prev_month = prev_dt.month();
                    let new_month = new_dt.month();
                    if prev_month != new_month {
                        // Insert ;BYMONTH=new_month at the end
                        if modified_recurrence.ends_with('\n')
                            || modified_recurrence.ends_with('\r')
                        {
                            modified_recurrence.push_str(&format!("BYMONTH={}", new_month));
                        } else if modified_recurrence.contains(';')
                            || modified_recurrence.contains(':')
                        {
                            modified_recurrence.push_str(&format!(";BYMONTH={}", new_month));
                        } else {
                            modified_recurrence.push_str(&format!("BYMONTH={}", new_month));
                        }
                    }
                }
                return update_until_if_needed_tz(&modified_recurrence, new_dt, &tz, new_is_date);
            }
        }
        // If yearly but no applicable BYMONTHDAY, return unchanged
        return rrule_part.to_string();
    }

    // Handle monthly recurrences with BYMONTHDAY
    if is_monthly && prev_day_of_month != new_day_of_month {
        let bymonthday_key = "BYMONTHDAY=";
        if let Some(bymonthday_pos) = rrule_part.find(bymonthday_key) {
            let bymonthday_start = bymonthday_pos + bymonthday_key.len();
            let bymonthday_rest = &rrule_part[bymonthday_start..];
            let bymonthday_len = bymonthday_rest.find(';').unwrap_or(bymonthday_rest.len());
            let bymonthday_val = &bymonthday_rest[..bymonthday_len];

            // Check if this is a single day or multiple days
            if bymonthday_val.contains(',') {
                // Handle multiple BYMONTHDAY values
                let days: Vec<&str> = bymonthday_val.split(',').collect();
                let prev_day_str = prev_day_of_month.to_string();

                // Check if the old day is in the list
                if days.contains(&prev_day_str.as_str()) {
                    // Replace only the matching day, keeping others unchanged
                    let new_days: Vec<String> = days
                        .iter()
                        .map(|&day| {
                            if day == prev_day_str {
                                new_day_of_month.to_string()
                            } else {
                                day.to_string()
                            }
                        })
                        .collect();

                    let new_bymonthday = new_days.join(",");
                    modified_recurrence = splice_value(
                        rrule_part,
                        bymonthday_start,
                        bymonthday_len,
                        &new_bymonthday,
                    );
                    return update_until_if_needed_tz(
                        &modified_recurrence,
                        new_dt,
                        &tz,
                        new_is_date,
                    );
                }
            } else {
                // Single BYMONTHDAY value
                if bymonthday_val == prev_day_of_month.to_string() {
                    // Update BYMONTHDAY to the new day of month
                    let new_bymonthday = new_day_of_month.to_string();
                    modified_recurrence = splice_value(
                        rrule_part,
                        bymonthday_start,
                        bymonthday_len,
                        &new_bymonthday,
                    );
                    return update_until_if_needed_tz(
                        &modified_recurrence,
                        new_dt,
                        &tz,
                        new_is_date,
                    );
                }
            }
        }
    }

    // Find BYDAY in the recurrence string
    let byday_key = "BYDAY=";
    let Some(byday_pos) = rrule_part.find(byday_key) else {
        // No BYDAY found, return unchanged
        return rrule_part.to_string();
    };

    let byday_start = byday_pos + byday_key.len();
    let byday_rest = &rrule_part[byday_start..];
    let byday_len = byday_rest.find(';').unwrap_or(byday_rest.len());
    let byday_val = &byday_rest[..byday_len];

    // Check if it's a single day or multiple days
    let days: Vec<&str> = byday_val.split(',').collect();

    // Handle single day case
    if days.len() == 1 {
        if is_monthly {
            // For monthly recurrences, check if BYDAY has position prefix
            let has_position_prefix = days[0].len() > 2
                && (days[0].starts_with('-') || days[0].chars().next().unwrap().is_ascii_digit());

            if has_position_prefix {
                // Handle position-based BYDAY (e.g., 1MO, -1FR)
                let prev_week_of_month = (prev_day_of_month as f32 / 7.0).ceil() as i32;
                let new_week_of_month = (new_day_of_month as f32 / 7.0).ceil() as i32;

                // Check if we're dealing with last week of month
                let days_in_prev_month =
                    days_in_month(prev_dt.date_naive().year(), prev_dt.date_naive().month());
                let is_prev_last_week = prev_day_of_month + 7 > days_in_prev_month;

                let days_in_new_month =
                    days_in_month(new_dt.date_naive().year(), new_dt.date_naive().month());
                let is_new_last_week = new_day_of_month + 7 > days_in_new_month;

                // Format for position-based BYDAY
                let prev_position_byday = if is_prev_last_week {
                    format!("-1{}", prev_byday)
                } else {
                    format!("{}{}", prev_week_of_month, prev_byday)
                };

                let new_position_byday = if is_new_last_week {
                    format!("-1{}", new_byday)
                } else {
                    format!("{}{}", new_week_of_month, new_byday)
                };

                // Check if the old day matches and update if needed
                if days[0] == prev_position_byday {
                    modified_recurrence = splice_value(
                        &modified_recurrence,
                        byday_start,
                        byday_len,
                        &new_position_byday,
                    );
                }
            } else {
                // Simple BYDAY without position
                if days[0] == prev_byday {
                    modified_recurrence =
                        splice_value(&modified_recurrence, byday_start, byday_len, new_byday);
                }
            }
        } else {
            // Weekly recurrence - simple case
            if days[0] == prev_byday {
                modified_recurrence =
                    splice_value(&modified_recurrence, byday_start, byday_len, new_byday);
            }
        }
    } else {
        // Handle multiple days case
        let (old_day, new_day) = if is_monthly {
            // For monthly recurrences, check if BYDAY has position prefix
            let has_position_prefix = days.iter().any(|&day| {
                day.len() > 2
                    && (day.starts_with('-') || day.chars().next().unwrap().is_ascii_digit())
            });

            if has_position_prefix {
                // Handle position-based BYDAY
                let prev_week_of_month = (prev_day_of_month as f32 / 7.0).ceil() as i32;
                let new_week_of_month = (new_day_of_month as f32 / 7.0).ceil() as i32;

                // Check if we're dealing with last week of month
                let days_in_prev_month =
                    days_in_month(prev_dt.date_naive().year(), prev_dt.date_naive().month());
                let is_prev_last_week = prev_day_of_month + 7 > days_in_prev_month;

                let days_in_new_month =
                    days_in_month(new_dt.date_naive().year(), new_dt.date_naive().month());
                let is_new_last_week = new_day_of_month + 7 > days_in_new_month;

                let prev_position_byday = if is_prev_last_week {
                    format!("-1{}", prev_byday)
                } else {
                    format!("{}{}", prev_week_of_month, prev_byday)
                };

                let new_position_byday = if is_new_last_week {
                    format!("-1{}", new_byday)
                } else {
                    format!("{}{}", new_week_of_month, new_byday)
                };

                (prev_position_byday, new_position_byday)
            } else {
                // Simple BYDAY without position
                (prev_byday.to_string(), new_byday.to_string())
            }
        } else {
            // Weekly recurrence - simple case
            (prev_byday.to_string(), new_byday.to_string())
        };

        // Update multiple days list if needed
        if days.contains(&old_day.as_str()) && !days.contains(&new_day.as_str()) {
            let mut new_days: Vec<String> = days
                .iter()
                .filter(|&&day| day != old_day.as_str())
                .map(|&day| day.to_string())
                .collect();
            new_days.push(new_day);
            new_days.sort(); // Sort for consistency
            let new_byday_val = new_days.join(",");
            modified_recurrence =
                splice_value(&modified_recurrence, byday_start, byday_len, &new_byday_val);
        }
    }

    // Check and update UNTIL if needed
    update_until_if_needed_tz(&modified_recurrence, new_dt, &tz, new_is_date)
}

/// Helper function to check and update the UNTIL parameter if needed
fn update_until_if_needed_tz(
    recurrence: &str,
    new_local: chrono::DateTime<Tz>,
    _tz: &Tz,
    new_is_date: bool,
) -> String {
    let until_key = "UNTIL=";
    let mut current = recurrence.to_string();
    let new_utc = new_local.with_timezone(&chrono::Utc);
    let new_until_str = if new_is_date {
        new_local.format("%Y%m%d").to_string()
    } else {
        new_utc.format("%Y%m%dT%H%M%SZ").to_string()
    };

    if let Some(until_pos) = current.find(until_key) {
        let until_start = until_pos + until_key.len();
        let until_rest = &current[until_start..];
        let until_len = until_rest.find(';').unwrap_or(until_rest.len());
        let until_val = &until_rest[..until_len];

        // Compare existing UNTIL with new date/time and update only if existing is before new
        let should_update = if until_val.ends_with('Z') && until_val.len() == 16 {
            // existing: UTC datetime
            let year: i32 = until_val[0..4].parse().unwrap_or(0);
            let month: u32 = until_val[4..6].parse().unwrap_or(0);
            let day: u32 = until_val[6..8].parse().unwrap_or(0);
            let hour: u32 = until_val[9..11].parse().unwrap_or(0);
            let min: u32 = until_val[11..13].parse().unwrap_or(0);
            let sec: u32 = until_val[13..15].parse().unwrap_or(0);
            if let Some(existing) = chrono::Utc
                .with_ymd_and_hms(year, month, day, hour, min, sec)
                .single()
            {
                existing < new_utc
            } else {
                false
            }
        } else if until_val.len() == 8 {
            // existing: date only
            let y1: i32 = until_val[0..4].parse().unwrap_or(0);
            let m1: u32 = until_val[4..6].parse().unwrap_or(0);
            let d1: u32 = until_val[6..8].parse().unwrap_or(0);
            let existing_num = y1 as i64 * 10000 + m1 as i64 * 100 + d1 as i64;
            let y2 = new_local.year();
            let m2 = new_local.month() as i64;
            let d2 = new_local.day() as i64;
            let new_num = y2 as i64 * 10000 + m2 * 100 + d2;
            existing_num < new_num
        } else {
            // Unknown format; be conservative and do not update
            false
        };

        if should_update {
            current = splice_value(&current, until_start, until_len, &new_until_str);
        }
        return current;
    }

    // No UNTIL in the string: do not append. UNTIL should be recomputed only if previously present.
    current
}

fn splice_value(orig: &str, start: usize, len: usize, replacement: &str) -> String {
    let mut out = String::with_capacity(orig.len() + replacement.len().saturating_sub(len));
    out.push_str(&orig[..start]);
    out.push_str(replacement);
    out.push_str(&orig[start + len..]);
    out
}

/// Returns the number of days in a given month and year
fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 => {
            // February: check for leap year
            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
                29
            } else {
                28
            }
        }
        _ => 30, // Default for invalid months
    }
}

#[cfg(test)]
mod tests4 {
    // patch_rrule tests

    #[test]
    fn no_change_when_same_day_of_week() {
        // Monday -> Monday (different dates but same day of week)
        let prev_s = "2025-09-01T10:00:00"; // Monday
        let new_s = "2025-09-08T10:00:00"; // Monday (next week)

        let input = "RRULE:FREQ=WEEKLY;BYDAY=MO";
        let result = super::patch_rrule(input, prev_s, new_s, "UTC");

        assert_eq!(result, input);
    }

    #[test]
    fn changes_single_day_weekly_recurrence() {
        // Tuesday -> Thursday
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "RRULE:FREQ=WEEKLY;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_multiple_days_weekly_recurrence() {
        // Monday -> Wednesday (with multiple days)
        let prev_s = "2025-09-01T10:00:00"; // Monday
        let new_s = "2025-09-03T10:00:00"; // Wednesday

        let input = "RRULE:FREQ=WEEKLY;BYDAY=MO,FR";
        let expected = "RRULE:FREQ=WEEKLY;BYDAY=FR,WE";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn no_change_when_new_day_already_in_multiple_days() {
        // Monday -> Wednesday (but Wednesday already in list)
        let prev_s = "2025-09-01T10:00:00"; // Monday
        let new_s = "2025-09-03T10:00:00"; // Wednesday

        let input = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, input);
    }

    #[test]
    fn updates_until_when_lower_than_new_date() {
        // Tuesday -> Thursday with UNTIL before new date; when new is a date-only string, UNTIL remains date-only
        let prev_s = "2025-09-02"; // Tuesday
        let new_s = "2025-09-25"; // Thursday (later date)

        let input = "RRULE:FREQ=WEEKLY;UNTIL=20250920;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;UNTIL=20250925;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_until_with_time_when_lower_than_new_date() {
        // Tuesday -> Thursday with UNTIL before new date
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-25T10:00:00"; // Thursday (later date)

        let input = "RRULE:FREQ=WEEKLY;UNTIL=20250920T100000Z;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;UNTIL=20250925T100000Z;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_recurrence_with_simple_byday() {
        // Tuesday -> Thursday with FREQ=MONTHLY
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "RRULE:FREQ=MONTHLY;BYDAY=TU";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_recurrence_with_position_byday() {
        // First Tuesday -> First Thursday
        let prev_s = "2025-09-02T10:00:00"; // First Tuesday of September
        let new_s = "2025-09-04T10:00:00"; // First Thursday of September

        let input = "RRULE:FREQ=MONTHLY;BYDAY=1TU";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=1TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_recurrence_with_last_day_position() {
        // Last Tuesday -> Last Thursday
        let prev_s = "2025-09-30T10:00:00"; // Last Tuesday of September
        let new_s = "2025-09-25T10:00:00"; // Last Thursday of September

        let input = "RRULE:FREQ=MONTHLY;BYDAY=-1TU";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=-1TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_complex_monthly_rrule() {
        // Second Tuesday -> Second Friday
        let prev_s = "2025-09-09T10:00:00"; // Second Tuesday of September
        let new_s = "2025-09-12T10:00:00"; // Second Friday of September

        let input = "RRULE:FREQ=MONTHLY;WKST=SU;UNTIL=20250930T235959Z;INTERVAL=2;BYDAY=2TU";
        let expected = "RRULE:FREQ=MONTHLY;WKST=SU;UNTIL=20250930T235959Z;INTERVAL=2;BYDAY=2FR";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_multiline_recurrence_with_exdate() {
        // Tuesday -> Thursday with EXDATE
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "EXDATE;TZID=America/Denver:20250212T140000\nRRULE:FREQ=MONTHLY;BYDAY=TU";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_complex_rrule() {
        // Tuesday -> Friday with complex rule
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-05T10:00:00"; // Friday

        let input = "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20250930T235959Z;INTERVAL=2;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20250930T235959Z;INTERVAL=2;BYDAY=FR";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_bymonthday() {
        // Day 1 -> Day 2 with FREQ=MONTHLY;BYMONTHDAY=1
        let prev_s = "2025-09-01T10:00:00"; // 1st of September
        let new_s = "2025-09-02T10:00:00"; // 2nd of September

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=2";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_bymonthday_with_other_params() {
        // Day 15 -> Day 20 with additional parameters
        let prev_s = "2025-09-15T10:00:00"; // 15th of September
        let new_s = "2025-09-20T10:00:00"; // 20th of September

        let input = "RRULE:FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=15;COUNT=10";
        let expected = "RRULE:FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=20;COUNT=10";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn updates_monthly_bymonthday_with_until() {
        // Day 5 -> Day 10 with UNTIL before new date
        let prev_s = "2025-09-05T10:00:00"; // 5th of September
        let new_s = "2025-10-10T10:00:00"; // 10th of October

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=5;UNTIL=20250930T235959Z";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=10;UNTIL=20251010T100000Z";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn no_change_when_bymonthday_doesnt_match() {
        // Day 5 -> Day 10 but BYMONTHDAY=15
        let prev_s = "2025-09-05T10:00:00"; // 5th of September
        let new_s = "2025-09-10T10:00:00"; // 10th of September

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=15";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, input);
    }

    #[test]
    fn handles_multiline_recurrence_with_bymonthday() {
        // Day 1 -> Day 2 with EXDATE
        let prev_s = "2025-09-01T10:00:00"; // 1st of September
        let new_s = "2025-09-02T10:00:00"; // 2nd of September

        let input = "EXDATE;TZID=America/Denver:20250212T140000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=2";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_multiple_bymonthday_values() {
        // Day 5 -> Day 6 with multiple BYMONTHDAY values
        let prev_s = "2025-09-05T10:00:00"; // 5th of September
        let new_s = "2025-09-06T10:00:00"; // 6th of September

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,5,15";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,6,15";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_combined_byday_and_bymonthday() {
        // Tuesday on 2nd -> Thursday on 4th with both BYDAY and BYMONTHDAY
        // Note: Current implementation prioritizes BYMONTHDAY over BYDAY when both are present
        let prev_s = "2025-09-02T10:00:00"; // Tuesday, 2nd
        let new_s = "2025-09-04T10:00:00"; // Thursday, 4th

        let input = "RRULE:FREQ=MONTHLY;BYDAY=TU;BYMONTHDAY=2";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=TU;BYMONTHDAY=4";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_invalid_recurrence_rule() {
        // Test with an invalid recurrence rule
        // Note: Current implementation still attempts to process the rule if it contains BYDAY
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "INVALID:FREQ=WEEKLY;BYDAY=TU";
        let expected = "INVALID:FREQ=WEEKLY;BYDAY=TH";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected); // Current implementation updates BYDAY regardless of prefix
    }

    #[test]
    fn handles_leap_year_edge_case() {
        // February 29th in leap year -> March 1st
        let prev_s = "2024-02-29T10:00:00"; // Leap year
        let new_s = "2024-03-01T10:00:00";

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=29";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=1";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_yearly_recurrence() {
        // Test with yearly recurrence
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "RRULE:FREQ=YEARLY;BYDAY=TU;BYMONTH=9";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, input); // Should return unchanged as there's no BYMONTHDAY to adjust
    }

    #[test]
    fn handles_month_transition_with_different_days() {
        // January 31st -> February 28th
        let prev_s = "2025-01-31T10:00:00";
        let new_s = "2025-02-28T10:00:00";

        let input = "RRULE:FREQ=MONTHLY;BYMONTHDAY=31";
        let expected = "RRULE:FREQ=MONTHLY;BYMONTHDAY=28";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_multiple_position_based_byday() {
        // First and third Tuesday -> First and third Thursday
        // Note: Current implementation only updates the position that matches the current day
        let prev_s = "2025-09-02T10:00:00"; // First Tuesday
        let new_s = "2025-09-04T10:00:00"; // First Thursday

        let input = "RRULE:FREQ=MONTHLY;BYDAY=1TU,3TU";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=1TH,3TU";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_simultaneous_day_of_week_and_month_change() {
        // Monday 1st -> Wednesday 10th (both day of week and day of month change)
        // Note: Current implementation prioritizes BYMONTHDAY over BYDAY when both are present
        let prev_s = "2025-09-01T10:00:00"; // Monday, 1st
        let new_s = "2025-09-10T10:00:00"; // Wednesday, 10th

        let input = "RRULE:FREQ=MONTHLY;BYDAY=MO;BYMONTHDAY=1";
        let expected = "RRULE:FREQ=MONTHLY;BYDAY=MO;BYMONTHDAY=10";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn handles_empty_recurrence_string() {
        // Test with empty string
        let prev_s = "2025-09-02T10:00:00";
        let new_s = "2025-09-04T10:00:00";

        let input = "";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, ""); // Should return empty string
    }

    #[test]
    fn handles_recurrence_without_freq() {
        // Test with recurrence rule missing FREQ
        let prev_s = "2025-09-02T10:00:00"; // Tuesday
        let new_s = "2025-09-04T10:00:00"; // Thursday

        let input = "RRULE:BYDAY=TU;COUNT=10";

        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, input); // Should return unchanged
    }

    #[test]
    fn yearly_bymonthday_updates_day_same_month() {
        // 10 May -> 20 May, yearly birthday-like
        let prev_s = "2025-05-10T10:00:00";
        let new_s = "2025-05-20T10:00:00";
        let input = "RRULE:FREQ=YEARLY;BYMONTHDAY=10;BYMONTH=5";
        let expected = "RRULE:FREQ=YEARLY;BYMONTHDAY=20;BYMONTH=5";
        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn yearly_bymonthday_updates_day_and_month_when_month_changes() {
        // 31 Jan -> 1 Feb: update BYMONTHDAY and BYMONTH
        let prev_s = "2025-01-31T10:00:00";
        let new_s = "2025-02-01T10:00:00";
        let input = "RRULE:FREQ=YEARLY;BYMONTHDAY=31;BYMONTH=1";
        let expected = "RRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=2";
        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn yearly_bymonthday_appends_bymonth_if_missing_on_month_change() {
        // 31 Jan -> 1 Feb: add BYMONTH if not present
        let prev_s = "2025-01-31T10:00:00";
        let new_s = "2025-02-01T10:00:00";
        let input = "RRULE:FREQ=YEARLY;BYMONTHDAY=31";
        // We expect ;BYMONTH=2 to be appended (order may vary but implementation appends at end)
        let expected = "RRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=2";
        let result = super::patch_rrule(input, prev_s, new_s, "UTC");
        assert_eq!(result, expected);
    }

    #[test]
    fn timezone_changes_are_applied_in_passed_tz_weekly() {
        // Using America/Los_Angeles (UTC-7 in September). Local Tue -> Wed should update BYDAY
        // prev local: 2025-09-02 21:00 PDT (Tue)
        // new local:  2025-09-03 21:00 PDT (Wed)
        let prev_s = "2025-09-02T21:00:00";
        let new_s = "2025-09-03T21:00:00";
        let input = "RRULE:FREQ=WEEKLY;BYDAY=TU";
        // Since UNTIL should not be appended when absent, we expect no UNTIL parameter here
        let expected = "RRULE:FREQ=WEEKLY;BYDAY=WE";
        let result = super::patch_rrule(input, prev_s, new_s, "America/Los_Angeles");
        assert_eq!(result, expected);
    }

    #[test]
    fn until_date_only_is_interpreted_in_tz_and_updated() {
        // UNTIL=YYYYMMDD should be interpreted in tz at 00:00 and compared against new local date
        // tz: America/Los_Angeles; moving a Tuesday one week ahead should update UNTIL date
        let prev_s = "2025-09-02"; // local date Tue
        let new_s = "2025-09-09"; // next Tue
        let input = "RRULE:FREQ=WEEKLY;UNTIL=20250902;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;UNTIL=20250909;BYDAY=TU";
        let result = super::patch_rrule(input, prev_s, new_s, "America/Los_Angeles");
        assert_eq!(result, expected);
    }

    #[test]
    fn same_local_day_different_utc_no_change() {
        // America/Los_Angeles in September is PDT (UTC-7).
        // These two datetimes are on the same local calendar day (2025-09-02) in LA,
        // but map to different UTC dates (2025-09-02Z vs 2025-09-03Z).
        // Since patch_rrule uses the provided tz for calendar fields, no change is expected.
        let prev_s = "2025-09-02T00:30:00"; // 2025-09-02 07:30:00Z
        let new_s = "2025-09-02T23:30:00"; // 2025-09-03 06:30:00Z
        let input = "RRULE:FREQ=WEEKLY;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;BYDAY=TU";
        let result = super::patch_rrule(input, prev_s, new_s, "America/Los_Angeles");
        assert_eq!(result, expected);
    }

    #[test]
    fn same_utc_day_different_local_changes_byday() {
        // Both map to the same UTC calendar date (2025-09-03Z),
        // but local dates differ in America/Los_Angeles (2025-09-02 vs 2025-09-03),
        // therefore BYDAY should change from TU to WE.
        let prev_s = "2025-09-02T20:30:00"; // LA local -> 2025-09-03T03:30:00Z (Tue local)
        let new_s = "2025-09-03T00:30:00"; // LA local -> 2025-09-03T07:30:00Z (Wed local)
        let input = "RRULE:FREQ=WEEKLY;BYDAY=TU";
        let expected = "RRULE:FREQ=WEEKLY;BYDAY=WE";
        let result = super::patch_rrule(input, prev_s, new_s, "America/Los_Angeles");
        assert_eq!(result, expected);
    }
}