aven-core 0.1.21

Core library for the Aven local-first task manager
Documentation
use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Utc, Weekday};

use super::*;

fn date(year: i32, month: u32, day: u32) -> NaiveDate {
    NaiveDate::from_ymd_opt(year, month, day).unwrap()
}

fn time(hour: u32, minute: u32) -> NaiveTime {
    NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
}

fn utc(value: &str) -> DateTime<Utc> {
    DateTime::parse_from_rfc3339(value)
        .unwrap()
        .with_timezone(&Utc)
}

fn schedule(
    rule: RecurrenceRule,
    zone: &str,
    start_on: NaiveDate,
    available_local_time: Option<NaiveTime>,
    due_policy: RecurrenceDuePolicy,
) -> RecurrenceSchedule {
    RecurrenceSchedule::new(
        rule,
        zone.parse().unwrap(),
        start_on,
        available_local_time,
        due_policy,
    )
}

#[test]
fn recurrence_series_ids_and_timezones_validate_ingress() {
    assert!("0123456789ABCDEF".parse::<RecurrenceSeriesId>().is_ok());
    assert!("0123456789ABCDE".parse::<RecurrenceSeriesId>().is_err());
    assert!("0123456789ABCDEI".parse::<RecurrenceSeriesId>().is_err());
    assert!("Europe/Stockholm".parse::<TimeZoneId>().is_ok());
    assert!("Europe/Not_A_Zone".parse::<TimeZoneId>().is_err());
    assert!(serde_json::from_str::<TimeZoneId>("\"local\"").is_err());
}

#[test]
fn rules_reject_values_outside_the_fixed_schedule_lattice() {
    assert!(
        RecurrenceRule::new(
            RecurrenceFrequency::Weekly,
            0,
            WeekdaySet::from_weekdays([Weekday::Mon]),
        )
        .is_err()
    );
    assert!(RecurrenceRule::weekly_on([]).is_err());
    assert!(RecurrenceRule::new(RecurrenceFrequency::Weekly, 1, WeekdaySet::default()).is_err());
    assert!(RecurrenceRule::new(RecurrenceFrequency::Daily, 2, WeekdaySet::default(),).is_err());
    assert!(RecurrenceRule::new(RecurrenceFrequency::Monthly, 2, WeekdaySet::default()).is_err());
    assert!(
        RecurrenceRule::new(
            RecurrenceFrequency::Monthly,
            1,
            WeekdaySet::from_weekdays([Weekday::Mon]),
        )
        .is_err()
    );
    assert!(
        serde_json::from_str::<RecurrenceRule>(
            r#"{"frequency":"weekly","interval":1,"weekdays":"fri,mon"}"#,
        )
        .is_err()
    );
    assert!(
        serde_json::from_str::<RecurrenceRule>(
            r#"{"frequency":"weekly","interval":1,"weekdays":"mon,mon"}"#,
        )
        .is_err()
    );
}

#[test]
fn daily_and_weekday_iteration_crosses_leap_dates() {
    let daily = schedule(
        RecurrenceRule::daily(),
        "UTC",
        date(2028, 2, 28),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        daily
            .slots_on_or_after(date(2028, 2, 28))
            .take(3)
            .collect::<Vec<_>>(),
        vec![date(2028, 2, 28), date(2028, 2, 29), date(2028, 3, 1)]
    );

    let weekdays = schedule(
        RecurrenceRule::weekdays(),
        "UTC",
        date(2026, 7, 17),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        weekdays
            .slots_on_or_after(date(2026, 7, 17))
            .take(4)
            .collect::<Vec<_>>(),
        vec![
            date(2026, 7, 17),
            date(2026, 7, 20),
            date(2026, 7, 21),
            date(2026, 7, 22),
        ]
    );
}

#[test]
fn monthly_iteration_clamps_to_each_month_without_drifting() {
    let monthly = schedule(
        RecurrenceRule::monthly(),
        "UTC",
        date(2027, 1, 31),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        monthly
            .slots_on_or_after(monthly.start_on)
            .take(4)
            .collect::<Vec<_>>(),
        vec![
            date(2027, 1, 31),
            date(2027, 2, 28),
            date(2027, 3, 31),
            date(2027, 4, 30),
        ]
    );

    let leap_year = schedule(
        RecurrenceRule::monthly(),
        "UTC",
        date(2028, 1, 31),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        leap_year
            .slots_on_or_after(date(2028, 2, 1))
            .take(2)
            .collect::<Vec<_>>(),
        vec![date(2028, 2, 29), date(2028, 3, 31)]
    );
    assert!(is_slot(
        &leap_year.rule,
        leap_year.start_on,
        date(2028, 2, 29)
    ));
    assert!(!is_slot(
        &leap_year.rule,
        leap_year.start_on,
        date(2028, 2, 28)
    ));
    assert_eq!(
        next_slot_after(&leap_year.rule, leap_year.start_on, date(2028, 1, 31)),
        Some(date(2028, 2, 29))
    );
    assert_eq!(
        live_slot_on(
            &leap_year.rule,
            leap_year.start_on,
            utc("2028-04-01T12:00:00Z"),
            &leap_year.timezone,
        ),
        Some(date(2028, 3, 31))
    );

    let twenty_eighth = schedule(
        RecurrenceRule::monthly(),
        "UTC",
        date(2027, 2, 28),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        twenty_eighth
            .slots_on_or_after(twenty_eighth.start_on)
            .take(3)
            .collect::<Vec<_>>(),
        vec![date(2027, 2, 28), date(2027, 3, 28), date(2027, 4, 28),]
    );
}

#[test]
fn monthly_iteration_uses_the_original_day_across_year_boundaries() {
    let monthly = schedule(
        RecurrenceRule::monthly(),
        "UTC",
        date(2027, 12, 30),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        monthly
            .slots_on_or_after(date(2027, 12, 31))
            .take(3)
            .collect::<Vec<_>>(),
        vec![date(2028, 1, 30), date(2028, 2, 29), date(2028, 3, 30),]
    );
}

#[test]
fn weekly_sets_use_monday_anchored_multi_week_intervals() {
    let rule = RecurrenceRule::every_n_weeks_on(2, [Weekday::Mon, Weekday::Thu]).unwrap();
    let schedule = schedule(
        rule,
        "UTC",
        date(2026, 7, 15),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        schedule
            .slots_on_or_after(date(2026, 7, 1))
            .take(6)
            .collect::<Vec<_>>(),
        vec![
            date(2026, 7, 16),
            date(2026, 7, 27),
            date(2026, 7, 30),
            date(2026, 8, 10),
            date(2026, 8, 13),
            date(2026, 8, 24),
        ]
    );
    assert!(!is_slot(&rule, schedule.start_on, date(2026, 7, 13)));
    assert!(is_slot(&rule, schedule.start_on, date(2026, 7, 16)));
}

#[test]
fn weekly_shorthand_uses_the_start_weekday() {
    let start_on = date(2026, 7, 15);
    let rule = RecurrenceRule::weekly(start_on.weekday());
    assert_eq!(
        RecurrenceSchedule::new(
            rule,
            "UTC".parse().unwrap(),
            start_on,
            None,
            RecurrenceDuePolicy::SameDay,
        )
        .slots_on_or_after(start_on)
        .take(3)
        .collect::<Vec<_>>(),
        vec![date(2026, 7, 15), date(2026, 7, 22), date(2026, 7, 29)]
    );
}

#[test]
fn weekly_live_slots_end_at_the_next_scheduled_boundary() {
    let schedule = schedule(
        RecurrenceRule::weekly(Weekday::Fri),
        "Europe/Stockholm",
        date(2026, 7, 3),
        Some(time(9, 0)),
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        live_slot_on(
            &schedule.rule,
            schedule.start_on,
            utc("2026-07-09T21:59:59Z"),
            &schedule.timezone,
        ),
        Some(date(2026, 7, 3))
    );
    assert_eq!(
        live_slot_on(
            &schedule.rule,
            schedule.start_on,
            utc("2026-07-09T22:00:00Z"),
            &schedule.timezone,
        ),
        Some(date(2026, 7, 10))
    );
    assert_eq!(
        slot_cutoff(&schedule, date(2026, 7, 3)).unwrap(),
        utc("2026-07-09T22:00:00Z")
    );
}

#[test]
fn nonexistent_availability_advances_to_the_first_valid_instant() {
    let schedule = schedule(
        RecurrenceRule::daily(),
        "Europe/Stockholm",
        date(2026, 3, 29),
        Some(time(2, 30)),
        RecurrenceDuePolicy::SameDay,
    );
    let slot = slot_values(&schedule, date(2026, 3, 29)).unwrap();
    assert_eq!(slot.boundary_at, "2026-03-28T23:00:00Z");
    assert_eq!(slot.available_at, "2026-03-29T01:00:00Z");
    assert_eq!(slot.due_on.as_deref(), Some("2026-03-29"));
}

#[test]
fn repeated_availability_uses_the_earlier_instant() {
    let schedule = schedule(
        RecurrenceRule::daily(),
        "Europe/Stockholm",
        date(2026, 10, 25),
        Some(time(2, 30)),
        RecurrenceDuePolicy::None,
    );
    let slot = slot_values(&schedule, date(2026, 10, 25)).unwrap();
    assert_eq!(slot.available_at, "2026-10-25T00:30:00Z");
    assert_eq!(slot.due_on, None);
}

#[test]
fn slot_expansion_rejects_dates_outside_the_rule() {
    let schedule = schedule(
        RecurrenceRule::weekly(Weekday::Mon),
        "UTC",
        date(2026, 7, 20),
        None,
        RecurrenceDuePolicy::SameDay,
    );
    assert_eq!(
        slot_values(&schedule, date(2026, 7, 21)).unwrap_err(),
        RecurrenceScheduleError::NotScheduledSlot
    );
    assert_eq!(
        live_slot_on(
            &schedule.rule,
            schedule.start_on,
            utc("2026-07-19T23:59:59Z"),
            &schedule.timezone,
        ),
        None
    );
}

#[test]
fn independent_replicas_derive_byte_identical_occurrence_values() {
    let workspace_id: crate::ids::WorkspaceId = "0123456789ABCDEF".parse().unwrap();
    let series_id: RecurrenceSeriesId = "ZYXWVTSRQPNMKJHG".parse().unwrap();
    let schedule = schedule(
        RecurrenceRule::weekly_on([Weekday::Mon, Weekday::Wed, Weekday::Fri]).unwrap(),
        "America/New_York",
        date(2028, 2, 28),
        Some(time(9, 15)),
        RecurrenceDuePolicy::SameDay,
    );

    let replica_a =
        derive_occurrence_identity(&workspace_id, &series_id, &schedule, date(2028, 3, 1)).unwrap();
    let replica_b =
        derive_occurrence_identity(&workspace_id, &series_id, &schedule, date(2028, 3, 1)).unwrap();
    assert_eq!(
        serde_json::to_vec(&replica_a).unwrap(),
        serde_json::to_vec(&replica_b).unwrap()
    );
    assert_eq!(replica_a.task_id.as_str(), "5YRVRHYJ8XWCX9FE");
    assert_eq!(replica_a.task_change_id, "ASXSYZE398NVZWPE");
    assert_eq!(replica_a.occurrence_change_id, "3TTRAPANPRCKAHRY");
    assert_eq!(replica_a.field_version_seeds.task, "RJT435ECKYW1M2KR");
    assert_eq!(replica_a.field_version_seeds.occurrence, "FVHPMBRNJJ4PBV1S");
    assert_ne!(replica_a.task_id.as_str(), replica_a.task_change_id);
    assert_ne!(replica_a.task_change_id, replica_a.occurrence_change_id);
    assert_ne!(
        replica_a.field_version_seeds.task,
        replica_a.field_version_seeds.occurrence
    );
    assert_eq!(replica_a.created_at, "2028-03-01T05:00:00Z");
    assert_eq!(replica_a.updated_at, replica_a.created_at);
    assert_eq!(replica_a.occurrence_link.projected_at, replica_a.created_at);
}