aven-core 0.1.26

Core library for the Aven local-first task manager
Documentation
use std::fmt;

use chrono::{
    DateTime, Datelike, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc,
};
use chrono_tz::{GapInfo, Tz};

use super::rule::{RecurrenceFrequency, RecurrenceRule, last_day_of_month};
use super::{RecurrenceDuePolicy, RecurrenceSchedule, RecurrenceSlot};

pub fn is_slot(rule: &RecurrenceRule, start_on: NaiveDate, date: NaiveDate) -> bool {
    rule.matches(start_on, date)
}

pub fn next_slot_after(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    slot_on_or_after(rule, start_on, date.checked_add_days(Days::new(1))?)
}

pub fn live_slot_on(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    now: DateTime<Utc>,
    timezone: &super::TimeZoneId,
) -> Option<NaiveDate> {
    let zone = timezone.timezone();
    let local_today = now.with_timezone(&zone).date_naive();
    let mut slot = slot_on_or_before(rule, start_on, local_today)?;
    loop {
        if resolve_local(zone, slot.and_time(NaiveTime::MIN)).ok()? <= now {
            return Some(slot);
        }
        let before = slot.checked_sub_days(Days::new(1))?;
        slot = slot_on_or_before(rule, start_on, before)?;
    }
}

pub fn projection_slot_at(
    schedule: &RecurrenceSchedule,
    at: DateTime<Utc>,
) -> Result<NaiveDate, RecurrenceScheduleError> {
    if let Some(slot) = live_slot_on(&schedule.rule, schedule.start_on, at, &schedule.timezone) {
        return Ok(slot);
    }
    schedule
        .slots_on_or_after(schedule.start_on)
        .next()
        .ok_or(RecurrenceScheduleError::DateOverflow)
}

pub fn slot_cutoff(
    schedule: &RecurrenceSchedule,
    slot_on: NaiveDate,
) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
    require_slot(schedule, slot_on)?;
    let next = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
        .ok_or(RecurrenceScheduleError::DateOverflow)?;
    resolve_local(schedule.timezone.timezone(), next.and_time(NaiveTime::MIN))
}

pub fn slot_values(
    schedule: &RecurrenceSchedule,
    slot_on: NaiveDate,
) -> Result<RecurrenceSlot, RecurrenceScheduleError> {
    require_slot(schedule, slot_on)?;
    let zone = schedule.timezone.timezone();
    let boundary = resolve_local(zone, slot_on.and_time(NaiveTime::MIN))?;
    let available_local_time = schedule.available_local_time.unwrap_or(NaiveTime::MIN);
    let available_at = resolve_local(zone, slot_on.and_time(available_local_time))?;
    let due_on = match schedule.due_policy {
        RecurrenceDuePolicy::SameDay => Some(slot_on.format("%Y-%m-%d").to_string()),
        RecurrenceDuePolicy::None => None,
    };
    Ok(RecurrenceSlot {
        scheduled_on: slot_on,
        boundary_at: format_utc(boundary),
        available_at: format_utc(available_at),
        due_on,
    })
}

#[derive(Debug, Clone)]
pub struct RecurrenceSlotIter<'a> {
    rule: &'a RecurrenceRule,
    start_on: NaiveDate,
    candidate: Option<NaiveDate>,
}

impl<'a> RecurrenceSlotIter<'a> {
    pub(crate) fn new(
        rule: &'a RecurrenceRule,
        start_on: NaiveDate,
        on_or_after: NaiveDate,
    ) -> Self {
        Self {
            rule,
            start_on,
            candidate: Some(on_or_after.max(start_on)),
        }
    }
}

impl Iterator for RecurrenceSlotIter<'_> {
    type Item = NaiveDate;

    fn next(&mut self) -> Option<Self::Item> {
        let slot = slot_on_or_after(self.rule, self.start_on, self.candidate?)?;
        self.candidate = slot.checked_add_days(Days::new(1));
        Some(slot)
    }
}

pub(crate) fn slot_on_or_after(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    let target = date.max(start_on);
    match rule.frequency() {
        RecurrenceFrequency::Daily => daily_slot_on_or_after(rule.interval(), start_on, target),
        RecurrenceFrequency::Weekly => weekly_slot_on_or_after(rule, start_on, target),
        RecurrenceFrequency::Monthly => {
            month_slot_on_or_after(i64::from(rule.interval()), start_on, target)
        }
        RecurrenceFrequency::Yearly => {
            month_slot_on_or_after(i64::from(rule.interval()) * 12, start_on, target)
        }
    }
}

pub(crate) fn slot_on_or_before(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    if date < start_on {
        return None;
    }
    match rule.frequency() {
        RecurrenceFrequency::Daily => daily_slot_on_or_before(rule.interval(), start_on, date),
        RecurrenceFrequency::Weekly => weekly_slot_on_or_before(rule, start_on, date),
        RecurrenceFrequency::Monthly => {
            month_slot_on_or_before(i64::from(rule.interval()), start_on, date)
        }
        RecurrenceFrequency::Yearly => {
            month_slot_on_or_before(i64::from(rule.interval()) * 12, start_on, date)
        }
    }
}

fn daily_slot_on_or_after(
    interval: u32,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    let elapsed = date.signed_duration_since(start_on).num_days();
    let interval = i64::from(interval);
    let periods = elapsed.checked_add(interval - 1)?.checked_div(interval)?;
    add_days(start_on, periods.checked_mul(interval)?)
}

fn daily_slot_on_or_before(
    interval: u32,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    let elapsed = date.signed_duration_since(start_on).num_days();
    let interval = i64::from(interval);
    add_days(
        start_on,
        elapsed.checked_div(interval)?.checked_mul(interval)?,
    )
}

fn month_slot_on_or_after(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
    let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
    let mut index = difference.checked_div(period)?;
    let mut slot = month_slot(start_on, period, index)?;
    if slot < date {
        index = index.checked_add(1)?;
        slot = month_slot(start_on, period, index)?;
    }
    Some(slot)
}

fn month_slot_on_or_before(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
    let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
    let mut index = difference.checked_div(period)?;
    let mut slot = month_slot(start_on, period, index)?;
    if slot > date {
        index = index.checked_sub(1)?;
        if index < 0 {
            return None;
        }
        slot = month_slot(start_on, period, index)?;
    }
    Some(slot)
}

fn month_slot(start_on: NaiveDate, period: i64, index: i64) -> Option<NaiveDate> {
    let ordinal = month_ordinal(start_on).checked_add(period.checked_mul(index)?)?;
    let year = i32::try_from(ordinal.div_euclid(12)).ok()?;
    let month = u32::try_from(ordinal.rem_euclid(12) + 1).ok()?;
    let first = NaiveDate::from_ymd_opt(year, month, 1)?;
    NaiveDate::from_ymd_opt(year, month, start_on.day().min(last_day_of_month(first)))
}

fn weekly_slot_on_or_after(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    let anchor_monday = monday_of(start_on)?;
    let target_monday = monday_of(date)?;
    let target_week = target_monday
        .signed_duration_since(anchor_monday)
        .num_days()
        .checked_div(7)?;
    let interval = i64::from(rule.interval());
    let mut block = target_week.checked_div(interval)?;
    loop {
        let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
        for weekday in rule.weekdays_set().iter() {
            let candidate = add_days(monday, i64::from(weekday.num_days_from_monday()))?;
            if candidate >= start_on && candidate >= date {
                return Some(candidate);
            }
        }
        block = block.checked_add(1)?;
    }
}

fn weekly_slot_on_or_before(
    rule: &RecurrenceRule,
    start_on: NaiveDate,
    date: NaiveDate,
) -> Option<NaiveDate> {
    let anchor_monday = monday_of(start_on)?;
    let target_monday = monday_of(date)?;
    let target_week = target_monday
        .signed_duration_since(anchor_monday)
        .num_days()
        .checked_div(7)?;
    let interval = i64::from(rule.interval());
    let mut block = target_week.checked_div(interval)?;
    loop {
        let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
        for weekday in rule.weekdays_set().iter().rev() {
            if let Some(candidate) = add_days(monday, i64::from(weekday.num_days_from_monday()))
                && candidate >= start_on
                && candidate <= date
            {
                return Some(candidate);
            }
        }
        block = block.checked_sub(1)?;
        if block < 0 {
            return None;
        }
    }
}

fn month_ordinal(date: NaiveDate) -> i64 {
    i64::from(date.year()) * 12 + i64::from(date.month0())
}

fn monday_of(date: NaiveDate) -> Option<NaiveDate> {
    date.checked_sub_days(Days::new(u64::from(date.weekday().num_days_from_monday())))
}

fn add_weeks(date: NaiveDate, weeks: i64) -> Option<NaiveDate> {
    add_days(date, weeks.checked_mul(7)?)
}

fn add_days(date: NaiveDate, days: i64) -> Option<NaiveDate> {
    date.checked_add_days(Days::new(u64::try_from(days).ok()?))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecurrenceScheduleError {
    NotScheduledSlot,
    DateOverflow,
    UnresolvableLocalTime,
}

impl fmt::Display for RecurrenceScheduleError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::NotScheduledSlot => "date is not a slot in the recurrence schedule",
            Self::DateOverflow => "recurrence schedule exceeds the supported date range",
            Self::UnresolvableLocalTime => {
                "recurrence local time is outside the time-zone rule range"
            }
        })
    }
}

impl std::error::Error for RecurrenceScheduleError {}

fn require_slot(
    schedule: &RecurrenceSchedule,
    slot_on: NaiveDate,
) -> Result<(), RecurrenceScheduleError> {
    if is_slot(&schedule.rule, schedule.start_on, slot_on) {
        Ok(())
    } else {
        Err(RecurrenceScheduleError::NotScheduledSlot)
    }
}

fn resolve_local(zone: Tz, local: NaiveDateTime) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
    let zoned = match zone.from_local_datetime(&local) {
        LocalResult::Single(value) => value,
        LocalResult::Ambiguous(first, second) => first.min(second),
        LocalResult::None => GapInfo::new(&local, &zone)
            .and_then(|gap| gap.end)
            .ok_or(RecurrenceScheduleError::UnresolvableLocalTime)?,
    };
    Ok(zoned.with_timezone(&Utc))
}

fn format_utc(value: DateTime<Utc>) -> String {
    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}