|
| 1 | +// Package libdates provides a canonical (years, months, days) difference between |
| 2 | +// two civil dates with DoS-safe, O(1) algorithms and explicit control over |
| 3 | +// month-rollover semantics. |
| 4 | +// |
| 5 | +// # Overview |
| 6 | +// |
| 7 | +// This package computes a canonical (years, months, days) difference between |
| 8 | +// two civil dates using the rule: |
| 9 | +// |
| 10 | +// 1. Choose the maximum whole-months M such that addMonths(start, M) <= end |
| 11 | +// (where addMonths encodes your month-rollover policy, e.g., cc:add-months). |
| 12 | +// 2. The leftover days is the civil-day count between that anchor date and end. |
| 13 | +// |
| 14 | +// This matches specs like "max whole months, then days" (no ad-hoc EOM special- |
| 15 | +// casing). Leap-day and end-of-month behavior is entirely defined by the provided |
| 16 | +// addMonths policy (defaults to time.AddDate(0, m, 0) clamping). |
| 17 | +// |
| 18 | +// # Civil dates |
| 19 | +// |
| 20 | +// A "civil date" is a calendar date expressed as Year–Month–Day *without* any |
| 21 | +// clock time, time zone, or daylight-saving-time effects. We treat civil dates |
| 22 | +// in the proleptic Gregorian calendar (the same model used by Go's time package |
| 23 | +// for Year 1..9999). In this model: |
| 24 | +// |
| 25 | +// - Each successive calendar day increases the civil day count by exactly 1. |
| 26 | +// - There are no DST gaps or repeats (we operate at UTC midnight). |
| 27 | +// - Historical calendar cutovers (e.g., Julian→Gregorian) are ignored. |
| 28 | +// |
| 29 | +// The implementation uses a civil serial-day function to compute day deltas, |
| 30 | +// avoiding time.Duration arithmetic (which can overflow for large spans) and |
| 31 | +// avoiding DST/time zone anomalies. |
| 32 | +// |
| 33 | +// Performance & safety |
| 34 | +// |
| 35 | +// The algorithm is O(1): it computes an arithmetic month span, anchors once via |
| 36 | +// addMonths, and applies at most one backward or forward correction. Leftover |
| 37 | +// days use the civil serial-day function, not time.Duration. Guards are provided |
| 38 | +// for start>end, year range, and configurable "mega-span" limits. |
| 39 | +package libdates |
| 40 | + |
| 41 | +import ( |
| 42 | + "errors" |
| 43 | + "time" |
| 44 | +) |
| 45 | + |
| 46 | +// YMDiff is the canonical (years, months, days) such that applying it to start |
| 47 | +// (using your month-rollover semantics) yields end: |
| 48 | +// |
| 49 | +// M = years*12 + months |
| 50 | +// anchor = addMonths(start, M) |
| 51 | +// anchor <= end |
| 52 | +// days = civilDays(end) - civilDays(anchor) |
| 53 | +// |
| 54 | +// Invariants: |
| 55 | +// - Years >= 0, Months in [0, 11], Days >= 0. |
| 56 | +// - If start == end, YMDiff{0,0,0}. |
| 57 | +// - If addMonths == time.AddDate(0,m,0), leap/EOM behavior will follow Go's |
| 58 | +// clamping rules (e.g., Jan 31 + 1 month = Feb 29 in leap years, else Feb 28). |
| 59 | +type YMDiff struct { |
| 60 | + Years int |
| 61 | + Months int |
| 62 | + Days int |
| 63 | +} |
| 64 | + |
| 65 | +// AddMonthsFn is an injection point for your exact month-rollover semantics. |
| 66 | +// If nil, DiffYMD/DiffYMDOpts use time.AddDate(0, m, 0) (Go's clamping). |
| 67 | +// |
| 68 | +// Examples of policies you might mirror here: |
| 69 | +// - cc:add-months from your ELPS runtime. |
| 70 | +// - A business-specific rule for leap days or EOM alignment. |
| 71 | +type AddMonthsFn func(time.Time, int) time.Time |
| 72 | + |
| 73 | +// DiffOptions configures DiffYMDOpts. |
| 74 | +// |
| 75 | +// MaxSpanMonths / MaxSpanYears bound the allowed span for DoS-safety. |
| 76 | +// If MaxSpanMonths > 0 it is used; otherwise MaxSpanYears applies. |
| 77 | +// MaxSpanDays (optional) can cap the leftover-days component. |
| 78 | +type DiffOptions struct { |
| 79 | + AddMonths AddMonthsFn |
| 80 | + MaxSpanMonths int // e.g., 24000 (≈ 2000 years) |
| 81 | + MaxSpanYears int // e.g., 2000 (used only if MaxSpanMonths <= 0) |
| 82 | + MaxSpanDays int // optional cap on leftover days; 0 = no cap |
| 83 | +} |
| 84 | + |
| 85 | +var ( |
| 86 | + // ErrStartAfterEnd indicates start > end. |
| 87 | + ErrStartAfterEnd = errors.New("start after end") |
| 88 | + // ErrYearOutOfRange indicates a date outside the supported civil range |
| 89 | + // [0001-01-01, 9999-12-31]. |
| 90 | + ErrYearOutOfRange = errors.New("date out of supported range [0001-01-01, 9999-12-31]") |
| 91 | + // ErrSpanTooLarge indicates the span exceeds configured mega-span limits. |
| 92 | + ErrSpanTooLarge = errors.New("date span exceeds configured maximum") |
| 93 | +) |
| 94 | + |
| 95 | +// DiffYMD computes the canonical (years, months, days) between start and end, |
| 96 | +// using the "max whole months, then days" rule with the provided AddMonthsFn |
| 97 | +// (or Go clamping if nil). It enforces a default mega-span guard of ~2000 years. |
| 98 | +// |
| 99 | +// Semantics: |
| 100 | +// - Normalize both inputs to UTC midnight (no DST artifacts). |
| 101 | +// - Compute arithmetic month span M0. |
| 102 | +// - Anchor = addMonths(start, M0). If anchor > end, decrement M by 1. |
| 103 | +// If addMonths(start, M+1) <= end, increment M by 1. |
| 104 | +// - Years = M / 12, Months = M % 12. |
| 105 | +// - Days = civilDays(end) - civilDays(anchor). |
| 106 | +// |
| 107 | +// Complexity: O(1). No day-by-day loops. No time.Duration subtraction. |
| 108 | +// Safety: Guards against start > end, year range, and mega spans. |
| 109 | +// |
| 110 | +// Example: |
| 111 | +// |
| 112 | +// diff, err := DiffYMD(time.Date(2024,2,29,0,0,0,0,time.UTC), |
| 113 | +// time.Date(2025,2,28,0,0,0,0,time.UTC), nil) |
| 114 | +// // Using Go clamping, diff == {Years:0, Months:11, Days:30} |
| 115 | +// |
| 116 | +// To precisely match another runtime (e.g., cc:add-months), inject it via DiffYMDOpts. |
| 117 | +func DiffYMD(start, end time.Time, addMonths AddMonthsFn) (YMDiff, error) { |
| 118 | + return DiffYMDOpts(start, end, DiffOptions{ |
| 119 | + AddMonths: addMonths, |
| 120 | + MaxSpanYears: 2000, // default guard; adjust to taste |
| 121 | + MaxSpanMonths: 0, // unset => use MaxSpanYears |
| 122 | + MaxSpanDays: 0, // unset |
| 123 | + }) |
| 124 | +} |
| 125 | + |
| 126 | +// DiffYMDOpts is DiffYMD with explicit options. |
| 127 | +// |
| 128 | +// Use cases: |
| 129 | +// - Plug in a custom AddMonthsFn to mirror cc:add-months so ELPS and Go agree. |
| 130 | +// - Tighten or relax DoS guards (MaxSpanMonths/Years/Days). |
| 131 | +// |
| 132 | +// Guarantees (assuming AddMonthsFn is deterministic and monotone w.r.t. months): |
| 133 | +// - Anchor monotonicity: addMonths(start, M) <= end and addMonths(start, M+1) > end. |
| 134 | +// - Canonicalization: returned (Y,M,D) is unique for the given AddMonthsFn. |
| 135 | +// - Stability: identical inputs and policy yield identical outputs. |
| 136 | +func DiffYMDOpts(start, end time.Time, opts DiffOptions) (YMDiff, error) { |
| 137 | + addMonths := opts.AddMonths |
| 138 | + if addMonths == nil { |
| 139 | + addMonths = func(t time.Time, m int) time.Time { return t.AddDate(0, m, 0) } |
| 140 | + } |
| 141 | + |
| 142 | + // Normalize to UTC midnight (monotone civil dates; no DST artifacts). |
| 143 | + s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) |
| 144 | + e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC) |
| 145 | + |
| 146 | + // Guards |
| 147 | + if s.After(e) { |
| 148 | + return YMDiff{}, ErrStartAfterEnd |
| 149 | + } |
| 150 | + if !inCivilRange(s) || !inCivilRange(e) { |
| 151 | + return YMDiff{}, ErrYearOutOfRange |
| 152 | + } |
| 153 | + |
| 154 | + // Mega-span guard (months first; else years). |
| 155 | + monthsAbs := absInt((e.Year()-s.Year())*12 + int(e.Month()-s.Month())) |
| 156 | + if opts.MaxSpanMonths > 0 && monthsAbs > opts.MaxSpanMonths { |
| 157 | + return YMDiff{}, ErrSpanTooLarge |
| 158 | + } |
| 159 | + if opts.MaxSpanMonths <= 0 && opts.MaxSpanYears > 0 { |
| 160 | + if absInt(e.Year()-s.Year()) > opts.MaxSpanYears { |
| 161 | + return YMDiff{}, ErrSpanTooLarge |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + // Initial arithmetic month span. |
| 166 | + m := (e.Year()-s.Year())*12 + int(e.Month()-s.Month()) |
| 167 | + anchor := addMonths(s, m) |
| 168 | + |
| 169 | + // At most one step back/forward to satisfy "max whole months <= end". |
| 170 | + if anchor.After(e) { |
| 171 | + m-- |
| 172 | + anchor = addMonths(s, m) |
| 173 | + } |
| 174 | + if anPlus := addMonths(s, m+1); !anPlus.After(e) { |
| 175 | + m++ |
| 176 | + anchor = anPlus |
| 177 | + } |
| 178 | + |
| 179 | + // Leftover days via civil serial (monotone; no duration overflow). |
| 180 | + ad := civilDays(anchor.Year(), anchor.Month(), anchor.Day()) |
| 181 | + ed := civilDays(e.Year(), e.Month(), e.Day()) |
| 182 | + dayDelta := int(ed - ad) |
| 183 | + if dayDelta < 0 { |
| 184 | + // Defensive: should not happen; pull back one month and recompute once. |
| 185 | + m-- |
| 186 | + anchor = addMonths(s, m) |
| 187 | + ad = civilDays(anchor.Year(), anchor.Month(), anchor.Day()) |
| 188 | + dayDelta = int(ed - ad) |
| 189 | + } |
| 190 | + |
| 191 | + return YMDiff{ |
| 192 | + Years: m / 12, |
| 193 | + Months: m % 12, |
| 194 | + Days: dayDelta, |
| 195 | + }, nil |
| 196 | +} |
| 197 | + |
| 198 | +// Apply applies a YMDiff to a start date using the provided month policy, |
| 199 | +// reconstructing the end date (useful for property-based tests). |
| 200 | +// It mirrors the same semantics used by DiffYMD/DiffYMDOpts. |
| 201 | +func (d YMDiff) Apply(start time.Time, addMonths AddMonthsFn) time.Time { |
| 202 | + if addMonths == nil { |
| 203 | + addMonths = func(t time.Time, m int) time.Time { return t.AddDate(0, m, 0) } |
| 204 | + } |
| 205 | + s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) |
| 206 | + m := d.Years*12 + d.Months |
| 207 | + anchor := addMonths(s, m) |
| 208 | + return anchor.AddDate(0, 0, d.Days) |
| 209 | +} |
| 210 | + |
| 211 | +// Helpers |
| 212 | + |
| 213 | +func inCivilRange(t time.Time) bool { |
| 214 | + y := t.Year() |
| 215 | + return y >= 1 && y <= 9999 |
| 216 | +} |
| 217 | + |
| 218 | +func absInt(x int) int { |
| 219 | + if x < 0 { |
| 220 | + return -x |
| 221 | + } |
| 222 | + return x |
| 223 | +} |
| 224 | + |
| 225 | +// civilDays converts a civil date to a serial day count (proleptic Gregorian). |
| 226 | +// Howard Hinnant's algorithm (public domain), adapted for int64 and year 1..9999. |
| 227 | +// |
| 228 | +// We intentionally avoid any epoch offset: callers subtract two civilDays values |
| 229 | +// to obtain day deltas, so the absolute zero-point is irrelevant. |
| 230 | +func civilDays(y int, m time.Month, d int) int64 { |
| 231 | + yy := int64(y) |
| 232 | + mm := int64(m) |
| 233 | + dd := int64(d) |
| 234 | + if mm <= 2 { |
| 235 | + yy-- |
| 236 | + mm += 12 |
| 237 | + } |
| 238 | + era := floorDiv(yy, 400) |
| 239 | + yoe := yy - era*400 |
| 240 | + doy := (153*(mm-3)+2)/5 + dd - 1 |
| 241 | + doe := yoe*365 + yoe/4 - yoe/100 + doy |
| 242 | + return era*146097 + doe |
| 243 | +} |
| 244 | + |
| 245 | +func floorDiv(a, b int64) int64 { |
| 246 | + q := a / b |
| 247 | + r := a % b |
| 248 | + if (r != 0) && ((r > 0) != (b > 0)) { |
| 249 | + q-- |
| 250 | + } |
| 251 | + return q |
| 252 | +} |
0 commit comments