Skip to main content

rustledger_core/
calendar.rs

1//! Calendar period truncation — the single definition of "which month/quarter/
2//! week/year is this date in, and when does that period start".
3//!
4//! Two consumers need exactly this arithmetic and had independently derived it:
5//! BQL's `DATE_TRUNC` (and the `GROUP BY` period bucketing built on it) and the
6//! budget report's per-interval accrual. The formulas are small enough to look
7//! harmless — `(month - 1) / 3 * 3 + 1` for a quarter, subtract
8//! `weekday().to_monday_zero_offset()` for an ISO week — and a previous
9//! duplication sweep inside the query crate alone found the quarter formula
10//! written twice and diverging.
11//!
12//! The failure mode of two copies is quiet: `rledger report budget` on a weekly
13//! budget and `SELECT ... GROUP BY DATE_TRUNC('WEEK', date)` over the same
14//! ledger would answer with different week boundaries, and no test in either
15//! crate would fail. Anything that changes the calendar rules — a configurable
16//! first-day-of-week, a fiscal-year quarter offset — must change one place.
17
18use crate::NaiveDate;
19
20/// A calendar period. Periods are anchored to the calendar, never to an
21/// arbitrary start date: months begin on the 1st, quarters on Jan/Apr/Jul/Oct 1,
22/// years on Jan 1, and weeks on the ISO Monday.
23///
24/// # Deliberate divergence from Fava (quarters)
25///
26/// Fava's `_IntervalQuarter.get_prev` tests `date.month > i` where it needs
27/// `>=`, so it puts April in Q1, July in Q2 and October in Q3 — every quarter
28/// boundary month falls into the preceding quarter. Reported as
29/// beancount/fava#2318. rustledger anchors quarters correctly, per the
30/// project's Python-compatibility policy: match correct behavior, not bugs.
31/// A `custom "budget"` on a `"quarterly"` interval therefore accrues over
32/// different boundaries than Fava's budget view for those three months.
33#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
34pub enum CalendarPeriod {
35    /// One calendar day.
36    Day,
37    /// An ISO week, starting Monday.
38    Week,
39    /// A calendar month (28, 29, 30 or 31 days).
40    Month,
41    /// A calendar quarter, anchored at Jan/Apr/Jul/Oct 1.
42    Quarter,
43    /// A calendar year (365 or 366 days).
44    Year,
45}
46
47/// Zero-based quarter index for a 1-based month (0 => Q1).
48///
49/// `month1` must be 1-based (`jiff`'s `Date::month()` guarantees `1..=12`); 0
50/// would wrap in release builds, so debug builds assert.
51#[must_use]
52pub const fn quarter_index0(month1: u32) -> u32 {
53    debug_assert!(month1 >= 1);
54    (month1 - 1) / 3
55}
56
57impl CalendarPeriod {
58    /// The first day of the period containing `day`.
59    ///
60    /// Falls back to `day` itself if the truncated date would be out of range,
61    /// which keeps this total rather than panicking on ledger-derived dates.
62    ///
63    /// For month, quarter and year that fallback is unreachable: the 1st of a
64    /// real month always exists. For WEEK it is reachable at the very start of
65    /// the representable calendar, where the containing ISO week begins before
66    /// `NaiveDate::MIN` and there is no Monday to return; the result then is the
67    /// day itself, NOT a week start. No ledger reaches that date, but the
68    /// saturation is real and this says so rather than implying stricter
69    /// semantics than the code provides.
70    #[must_use]
71    pub fn start_of(self, day: NaiveDate) -> NaiveDate {
72        match self {
73            Self::Day => day,
74            Self::Week => day
75                .checked_sub(
76                    jiff::Span::new().days(i64::from(day.weekday().to_monday_zero_offset())),
77                )
78                .unwrap_or(day),
79            Self::Month => NaiveDate::new(day.year(), day.month(), 1).unwrap_or(day),
80            Self::Quarter => {
81                let month1 = quarter_index0(u32::from(day.month().unsigned_abs())) * 3 + 1;
82                i8::try_from(month1)
83                    .ok()
84                    .and_then(|m| NaiveDate::new(day.year(), m, 1).ok())
85                    .unwrap_or(day)
86            }
87            Self::Year => NaiveDate::new(day.year(), 1, 1).unwrap_or(day),
88        }
89    }
90
91    /// The first day of the period after the one starting at `start`, or `None`
92    /// when that date is outside the representable range.
93    ///
94    /// `start` is expected to be a period start (the output of [`Self::start_of`]);
95    /// the difference between this and `start` is the period's true calendar
96    /// length, which is what makes a per-day accrual divide by 28/29/30/31 for a
97    /// month and 365/366 for a year.
98    ///
99    /// Returning `None` rather than saturating to `start` is deliberate: a
100    /// caller measuring the period's length would otherwise get zero and, after
101    /// the usual `.max(1)` guard, divide by a single day — inflating a yearly
102    /// budget by ~365x near the end of the representable range, silently and
103    /// with no error anywhere.
104    #[must_use]
105    pub fn next_start(self, start: NaiveDate) -> Option<NaiveDate> {
106        let span = match self {
107            Self::Day => jiff::Span::new().days(1),
108            Self::Week => jiff::Span::new().days(7),
109            Self::Month => jiff::Span::new().months(1),
110            Self::Quarter => jiff::Span::new().months(3),
111            Self::Year => jiff::Span::new().years(1),
112        };
113        start.checked_add(span).ok()
114    }
115
116    /// The calendar length, in days, of the period starting at `start`.
117    ///
118    /// Equal to the gap to [`Self::next_start`] wherever that date exists. The
119    /// final period of the representable calendar has no representable next
120    /// start, but its LENGTH is still well defined, and this returns it: a day
121    /// and a week are fixed-length, and month, quarter and year boundaries all
122    /// coincide with the end of the calendar, so the span to the last
123    /// representable day is the whole period.
124    ///
125    /// Pro-rata accrual needs the length, not the boundary date. Deriving the
126    /// length from `next_start` alone forced callers to give up on the final
127    /// period entirely — a budget was reported as `0.00` for a window inside
128    /// it, which reads as "nothing budgeted" rather than "cannot say".
129    #[must_use]
130    pub fn period_days(self, start: NaiveDate) -> i64 {
131        let days_between = |a: NaiveDate, b: NaiveDate| {
132            i64::from(a.until((jiff::Unit::Day, b)).map_or(0, |s| s.get_days()))
133        };
134        if let Some(next) = self.next_start(start) {
135            return days_between(start, next);
136        }
137        match self {
138            Self::Day => 1,
139            Self::Week => 7,
140            // `+ 1` because `MAX` is the last day IN the period, not the first
141            // day after it.
142            Self::Month | Self::Quarter | Self::Year => days_between(start, NaiveDate::MAX) + 1,
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::naive_date;
151
152    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
153        naive_date(y, m, day).unwrap()
154    }
155
156    #[test]
157    fn quarters_anchor_to_jan_apr_jul_oct() {
158        for (month, want_month) in [
159            (1, 1),
160            (2, 1),
161            (3, 1),
162            (4, 4),
163            (5, 4),
164            (6, 4),
165            (7, 7),
166            (8, 7),
167            (9, 7),
168            (10, 10),
169            (11, 10),
170            (12, 10),
171        ] {
172            assert_eq!(
173                CalendarPeriod::Quarter.start_of(d(2024, month, 15)),
174                d(2024, want_month, 1),
175                "month {month}"
176            );
177        }
178    }
179
180    #[test]
181    fn weeks_anchor_to_iso_monday() {
182        // 2024-03-07 is a Thursday; its ISO week began Monday 2024-03-04.
183        assert_eq!(CalendarPeriod::Week.start_of(d(2024, 3, 7)), d(2024, 3, 4));
184        assert_eq!(CalendarPeriod::Week.start_of(d(2024, 3, 4)), d(2024, 3, 4));
185        // A week spanning a year boundary keeps its Monday in the old year.
186        assert_eq!(
187            CalendarPeriod::Week.start_of(d(2025, 1, 1)),
188            d(2024, 12, 30)
189        );
190    }
191
192    /// Near the end of the representable range the next period start does not
193    /// exist. Saturating to `start` would make the period look zero days long,
194    /// and a per-day accrual would then divide by one.
195    #[test]
196    fn next_start_is_none_past_the_representable_range() {
197        let last = NaiveDate::MAX;
198        assert_eq!(CalendarPeriod::Year.next_start(last), None);
199        assert_eq!(CalendarPeriod::Month.next_start(last), None);
200    }
201
202    #[test]
203    fn month_and_year_truncate_and_advance() {
204        assert_eq!(
205            CalendarPeriod::Month.start_of(d(2024, 2, 29)),
206            d(2024, 2, 1)
207        );
208        assert_eq!(
209            CalendarPeriod::Year.start_of(d(2024, 12, 31)),
210            d(2024, 1, 1)
211        );
212        // Leap February is 29 days long, not 28 or 30.
213        let feb = CalendarPeriod::Month.start_of(d(2024, 2, 10));
214        assert_eq!(CalendarPeriod::Month.next_start(feb), Some(d(2024, 3, 1)));
215        // A leap year is 366 days.
216        let y = CalendarPeriod::Year.start_of(d(2024, 6, 1));
217        assert_eq!(CalendarPeriod::Year.next_start(y), Some(d(2025, 1, 1)));
218    }
219}