06f48ef9e9
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>
306 lines
9.5 KiB
Rust
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);
|
|
}
|
|
}
|