Files
kvitt/server/crates/kvitt-domain/src/recurring.rs
T
jacobh2 06f48ef9e9 M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:20 +02:00

306 lines
9.5 KiB
Rust

//! Recurring periods and the `next_occurrence_date` of a template (SPEC §9.5).
//!
//! Pure calendar arithmetic on `YYYY-MM` periods and `YYYY-MM-DD` dates; no clock and no time zone. The occurrence
//! *instant* (the clamped date at 06:00 Europe/Stockholm) is the server's concern; this module only fixes which
//! period and which calendar date.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
/// A month, `YYYY-MM`. Orders chronologically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Period {
year: u16,
month: u8,
}
/// Why a recurrence input is malformed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecurrenceError {
/// A period that is not `YYYY-MM` with month 01 to 12 (years 0001 to 9999).
PeriodInvalid,
/// `day_of_month` outside 1 to 31 (SPEC §9.6).
DayOfMonthOutOfRange,
}
impl fmt::Display for RecurrenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::PeriodInvalid => "period_invalid",
Self::DayOfMonthOutOfRange => "day_of_month_out_of_range",
})
}
}
impl std::error::Error for RecurrenceError {}
impl Period {
/// A period, if `month` is 1 to 12 and `year` 1 to 9999.
pub fn new(year: u16, month: u8) -> Option<Self> {
((1..=9999).contains(&year) && (1..=12).contains(&month)).then_some(Self { year, month })
}
pub fn year(self) -> u16 {
self.year
}
pub fn month(self) -> u8 {
self.month
}
/// The following month, or `None` after 9999-12.
pub fn next(self) -> Option<Self> {
if self.month == 12 {
Self::new(self.year.checked_add(1)?, 1)
} else {
Self::new(self.year, self.month + 1)
}
}
/// Number of days in this month (Gregorian leap years).
pub fn days(self) -> u8 {
match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if is_leap(self.year) => 29,
_ => 28,
}
}
/// The occurrence date of `day_of_month` in this month, clamped to its last day (`31` in April is `30`), as
/// `YYYY-MM-DD`.
pub fn clamped_date(self, day_of_month: u8) -> Result<String, RecurrenceError> {
if !(1..=31).contains(&day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
Ok(format!("{self}-{:02}", day_of_month.min(self.days())))
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}", self.year, self.month)
}
}
impl FromStr for Period {
type Err = RecurrenceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s.as_bytes();
let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
if bytes.len() != 7 || bytes[4] != b'-' || !digits(0..4) || !digits(5..7) {
return Err(RecurrenceError::PeriodInvalid);
}
let year: u16 = s[0..4]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
let month: u8 = s[5..7]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
Self::new(year, month).ok_or(RecurrenceError::PeriodInvalid)
}
}
fn is_leap(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
/// The template fields `next_occurrence_date` depends on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceSchedule {
pub day_of_month: u8,
pub start_period: String,
pub end_period: Option<String>,
pub last_generated_period: Option<String>,
pub paused: bool,
pub deleted: bool,
}
/// The first period that may still be generated: the month after `last_generated_period`, but not before
/// `start_period`; `None` when that is after `end_period` (SPEC §9.5). Paused and deleted are not considered here.
pub fn next_candidate_period(
start_period: &str,
end_period: Option<&str>,
last_generated_period: Option<&str>,
) -> Result<Option<Period>, RecurrenceError> {
let start: Period = start_period.parse()?;
let end: Option<Period> = end_period.map(str::parse).transpose()?;
let after_last: Option<Period> = match last_generated_period {
Some(last) => match last.parse::<Period>()?.next() {
Some(p) => Some(p),
None => return Ok(None),
},
None => None,
};
let candidate = after_last.map_or(start, |p| p.max(start));
Ok(match end {
Some(end) if candidate > end => None,
_ => Some(candidate),
})
}
/// `next_occurrence_date` of the RecurringTemplate wire form (SPEC §9.5): the clamped date of the first candidate
/// period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `None` when paused, deleted or
/// past `end_period`.
pub fn next_occurrence_date(
schedule: &RecurrenceSchedule,
) -> Result<Option<String>, RecurrenceError> {
if !(1..=31).contains(&schedule.day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
let candidate = next_candidate_period(
&schedule.start_period,
schedule.end_period.as_deref(),
schedule.last_generated_period.as_deref(),
)?;
if schedule.paused || schedule.deleted {
return Ok(None);
}
candidate
.map(|p| p.clamped_date(schedule.day_of_month))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
fn schedule(day: u8, start: &str, end: Option<&str>, last: Option<&str>) -> RecurrenceSchedule {
RecurrenceSchedule {
day_of_month: day,
start_period: start.to_owned(),
end_period: end.map(str::to_owned),
last_generated_period: last.map(str::to_owned),
paused: false,
deleted: false,
}
}
fn next(s: &RecurrenceSchedule) -> Option<String> {
next_occurrence_date(s).unwrap()
}
#[test]
fn never_generated_starts_at_start_period() {
assert_eq!(
next(&schedule(25, "2026-11", None, None)).as_deref(),
Some("2026-11-25")
);
}
#[test]
fn after_last_generated_period() {
assert_eq!(
next(&schedule(1, "2026-01", None, Some("2026-10"))).as_deref(),
Some("2026-11-01")
);
}
#[test]
fn year_boundary() {
assert_eq!(
next(&schedule(15, "2026-01", None, Some("2026-12"))).as_deref(),
Some("2027-01-15")
);
}
#[test]
fn start_period_moved_past_last_generated() {
// Changing start_period never regenerates past periods; a later start wins over last + 1.
assert_eq!(
next(&schedule(10, "2027-03", None, Some("2026-10"))).as_deref(),
Some("2027-03-10")
);
}
#[test]
fn clamped_to_month_end() {
assert_eq!(
next(&schedule(31, "2026-04", None, None)).as_deref(),
Some("2026-04-30")
);
assert_eq!(
next(&schedule(31, "2027-02", None, None)).as_deref(),
Some("2027-02-28")
);
assert_eq!(
next(&schedule(30, "2028-02", None, None)).as_deref(),
Some("2028-02-29")
);
assert_eq!(
next(&schedule(29, "2100-02", None, None)).as_deref(),
Some("2100-02-28")
);
assert_eq!(
next(&schedule(29, "2000-02", None, None)).as_deref(),
Some("2000-02-29")
);
}
#[test]
fn end_period_inclusive_and_past() {
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-10"))).as_deref(),
Some("2026-11-05")
);
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-11"))),
None
);
assert_eq!(next(&schedule(5, "2026-12", Some("2026-11"), None)), None);
}
#[test]
fn paused_or_deleted_is_none() {
let mut s = schedule(5, "2026-01", None, None);
s.paused = true;
assert_eq!(next(&s), None);
s.paused = false;
s.deleted = true;
assert_eq!(next(&s), None);
}
#[test]
fn malformed_input() {
let bad = |s: RecurrenceSchedule, e| assert_eq!(next_occurrence_date(&s), Err(e));
bad(
schedule(0, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(32, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(1, "2026-13", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-1", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", Some("x"), None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", None, Some("2026-00")),
RecurrenceError::PeriodInvalid,
);
}
#[test]
fn period_round_trip_and_order() {
let p: Period = "2026-10".parse().unwrap();
assert_eq!(p.to_string(), "2026-10");
assert!(p < "2026-11".parse().unwrap());
assert!(p > "2025-12".parse().unwrap());
assert_eq!(p.next().map(|n| n.to_string()).as_deref(), Some("2026-11"));
assert_eq!("9999-12".parse::<Period>().unwrap().next(), None);
}
}