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>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user