diff --git a/go.mod b/go.mod index a7deb91..d225086 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/luthersystems/lutherauth-sdk-go v0.0.7 github.com/luthersystems/raymond v1.1.1-0.20200710185833-e77462cef10d github.com/luthersystems/shiroclient-sdk-go v0.13.1 - github.com/nyaruka/phonenumbers v1.1.7 + github.com/nyaruka/phonenumbers v1.2.2 github.com/prometheus/client_golang v1.16.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index 8001786..ebd38fe 100644 --- a/go.sum +++ b/go.sum @@ -174,8 +174,8 @@ github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJ github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/nyaruka/phonenumbers v1.1.7 h1:5UUI9hE79Kk0dymSquXbMYB7IlNDNhvu2aNlJpm9et8= -github.com/nyaruka/phonenumbers v1.1.7/go.mod h1:DC7jZd321FqUe+qWSNcHi10tyIyGNXGcNbfkPvdp1Vs= +github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY= +github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= diff --git a/libdates/civil.go b/libdates/civil.go new file mode 100644 index 0000000..687c212 --- /dev/null +++ b/libdates/civil.go @@ -0,0 +1,252 @@ +// Package libdates provides a canonical (years, months, days) difference between +// two civil dates with DoS-safe, O(1) algorithms and explicit control over +// month-rollover semantics. +// +// # Overview +// +// This package computes a canonical (years, months, days) difference between +// two civil dates using the rule: +// +// 1. Choose the maximum whole-months M such that addMonths(start, M) <= end +// (where addMonths encodes your month-rollover policy, e.g., cc:add-months). +// 2. The leftover days is the civil-day count between that anchor date and end. +// +// This matches specs like "max whole months, then days" (no ad-hoc EOM special- +// casing). Leap-day and end-of-month behavior is entirely defined by the provided +// addMonths policy (defaults to time.AddDate(0, m, 0) clamping). +// +// # Civil dates +// +// A "civil date" is a calendar date expressed as Year–Month–Day *without* any +// clock time, time zone, or daylight-saving-time effects. We treat civil dates +// in the proleptic Gregorian calendar (the same model used by Go's time package +// for Year 1..9999). In this model: +// +// - Each successive calendar day increases the civil day count by exactly 1. +// - There are no DST gaps or repeats (we operate at UTC midnight). +// - Historical calendar cutovers (e.g., Julian→Gregorian) are ignored. +// +// The implementation uses a civil serial-day function to compute day deltas, +// avoiding time.Duration arithmetic (which can overflow for large spans) and +// avoiding DST/time zone anomalies. +// +// Performance & safety +// +// The algorithm is O(1): it computes an arithmetic month span, anchors once via +// addMonths, and applies at most one backward or forward correction. Leftover +// days use the civil serial-day function, not time.Duration. Guards are provided +// for start>end, year range, and configurable "mega-span" limits. +package libdates + +import ( + "errors" + "time" +) + +// YMDiff is the canonical (years, months, days) such that applying it to start +// (using your month-rollover semantics) yields end: +// +// M = years*12 + months +// anchor = addMonths(start, M) +// anchor <= end +// days = civilDays(end) - civilDays(anchor) +// +// Invariants: +// - Years >= 0, Months in [0, 11], Days >= 0. +// - If start == end, YMDiff{0,0,0}. +// - If addMonths == time.AddDate(0,m,0), leap/EOM behavior will follow Go's +// clamping rules (e.g., Jan 31 + 1 month = Feb 29 in leap years, else Feb 28). +type YMDiff struct { + Years int + Months int + Days int +} + +// AddMonthsFn is an injection point for your exact month-rollover semantics. +// If nil, DiffYMD/DiffYMDOpts use time.AddDate(0, m, 0) (Go's clamping). +// +// Examples of policies you might mirror here: +// - cc:add-months from your ELPS runtime. +// - A business-specific rule for leap days or EOM alignment. +type AddMonthsFn func(time.Time, int) time.Time + +// DiffOptions configures DiffYMDOpts. +// +// MaxSpanMonths / MaxSpanYears bound the allowed span for DoS-safety. +// If MaxSpanMonths > 0 it is used; otherwise MaxSpanYears applies. +// MaxSpanDays (optional) can cap the leftover-days component. +type DiffOptions struct { + AddMonths AddMonthsFn + MaxSpanMonths int // e.g., 24000 (≈ 2000 years) + MaxSpanYears int // e.g., 2000 (used only if MaxSpanMonths <= 0) + MaxSpanDays int // optional cap on leftover days; 0 = no cap +} + +var ( + // ErrStartAfterEnd indicates start > end. + ErrStartAfterEnd = errors.New("start after end") + // ErrYearOutOfRange indicates a date outside the supported civil range + // [0001-01-01, 9999-12-31]. + ErrYearOutOfRange = errors.New("date out of supported range [0001-01-01, 9999-12-31]") + // ErrSpanTooLarge indicates the span exceeds configured mega-span limits. + ErrSpanTooLarge = errors.New("date span exceeds configured maximum") +) + +// DiffYMD computes the canonical (years, months, days) between start and end, +// using the "max whole months, then days" rule with the provided AddMonthsFn +// (or Go clamping if nil). It enforces a default mega-span guard of ~2000 years. +// +// Semantics: +// - Normalize both inputs to UTC midnight (no DST artifacts). +// - Compute arithmetic month span M0. +// - Anchor = addMonths(start, M0). If anchor > end, decrement M by 1. +// If addMonths(start, M+1) <= end, increment M by 1. +// - Years = M / 12, Months = M % 12. +// - Days = civilDays(end) - civilDays(anchor). +// +// Complexity: O(1). No day-by-day loops. No time.Duration subtraction. +// Safety: Guards against start > end, year range, and mega spans. +// +// Example: +// +// diff, err := DiffYMD(time.Date(2024,2,29,0,0,0,0,time.UTC), +// time.Date(2025,2,28,0,0,0,0,time.UTC), nil) +// // Using Go clamping, diff == {Years:0, Months:11, Days:30} +// +// To precisely match another runtime (e.g., cc:add-months), inject it via DiffYMDOpts. +func DiffYMD(start, end time.Time, addMonths AddMonthsFn) (YMDiff, error) { + return DiffYMDOpts(start, end, DiffOptions{ + AddMonths: addMonths, + MaxSpanYears: 2000, // default guard; adjust to taste + MaxSpanMonths: 0, // unset => use MaxSpanYears + MaxSpanDays: 0, // unset + }) +} + +// DiffYMDOpts is DiffYMD with explicit options. +// +// Use cases: +// - Plug in a custom AddMonthsFn to mirror cc:add-months so ELPS and Go agree. +// - Tighten or relax DoS guards (MaxSpanMonths/Years/Days). +// +// Guarantees (assuming AddMonthsFn is deterministic and monotone w.r.t. months): +// - Anchor monotonicity: addMonths(start, M) <= end and addMonths(start, M+1) > end. +// - Canonicalization: returned (Y,M,D) is unique for the given AddMonthsFn. +// - Stability: identical inputs and policy yield identical outputs. +func DiffYMDOpts(start, end time.Time, opts DiffOptions) (YMDiff, error) { + addMonths := opts.AddMonths + if addMonths == nil { + addMonths = func(t time.Time, m int) time.Time { return t.AddDate(0, m, 0) } + } + + // Normalize to UTC midnight (monotone civil dates; no DST artifacts). + s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) + e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC) + + // Guards + if s.After(e) { + return YMDiff{}, ErrStartAfterEnd + } + if !inCivilRange(s) || !inCivilRange(e) { + return YMDiff{}, ErrYearOutOfRange + } + + // Mega-span guard (months first; else years). + monthsAbs := absInt((e.Year()-s.Year())*12 + int(e.Month()-s.Month())) + if opts.MaxSpanMonths > 0 && monthsAbs > opts.MaxSpanMonths { + return YMDiff{}, ErrSpanTooLarge + } + if opts.MaxSpanMonths <= 0 && opts.MaxSpanYears > 0 { + if absInt(e.Year()-s.Year()) > opts.MaxSpanYears { + return YMDiff{}, ErrSpanTooLarge + } + } + + // Initial arithmetic month span. + m := (e.Year()-s.Year())*12 + int(e.Month()-s.Month()) + anchor := addMonths(s, m) + + // At most one step back/forward to satisfy "max whole months <= end". + if anchor.After(e) { + m-- + anchor = addMonths(s, m) + } + if anPlus := addMonths(s, m+1); !anPlus.After(e) { + m++ + anchor = anPlus + } + + // Leftover days via civil serial (monotone; no duration overflow). + ad := civilDays(anchor.Year(), anchor.Month(), anchor.Day()) + ed := civilDays(e.Year(), e.Month(), e.Day()) + dayDelta := int(ed - ad) + if dayDelta < 0 { + // Defensive: should not happen; pull back one month and recompute once. + m-- + anchor = addMonths(s, m) + ad = civilDays(anchor.Year(), anchor.Month(), anchor.Day()) + dayDelta = int(ed - ad) + } + + return YMDiff{ + Years: m / 12, + Months: m % 12, + Days: dayDelta, + }, nil +} + +// Apply applies a YMDiff to a start date using the provided month policy, +// reconstructing the end date (useful for property-based tests). +// It mirrors the same semantics used by DiffYMD/DiffYMDOpts. +func (d YMDiff) Apply(start time.Time, addMonths AddMonthsFn) time.Time { + if addMonths == nil { + addMonths = func(t time.Time, m int) time.Time { return t.AddDate(0, m, 0) } + } + s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) + m := d.Years*12 + d.Months + anchor := addMonths(s, m) + return anchor.AddDate(0, 0, d.Days) +} + +// Helpers + +func inCivilRange(t time.Time) bool { + y := t.Year() + return y >= 1 && y <= 9999 +} + +func absInt(x int) int { + if x < 0 { + return -x + } + return x +} + +// civilDays converts a civil date to a serial day count (proleptic Gregorian). +// Howard Hinnant's algorithm (public domain), adapted for int64 and year 1..9999. +// +// We intentionally avoid any epoch offset: callers subtract two civilDays values +// to obtain day deltas, so the absolute zero-point is irrelevant. +func civilDays(y int, m time.Month, d int) int64 { + yy := int64(y) + mm := int64(m) + dd := int64(d) + if mm <= 2 { + yy-- + mm += 12 + } + era := floorDiv(yy, 400) + yoe := yy - era*400 + doy := (153*(mm-3)+2)/5 + dd - 1 + doe := yoe*365 + yoe/4 - yoe/100 + doy + return era*146097 + doe +} + +func floorDiv(a, b int64) int64 { + q := a / b + r := a % b + if (r != 0) && ((r > 0) != (b > 0)) { + q-- + } + return q +} diff --git a/libdates/civil_test.go b/libdates/civil_test.go new file mode 100644 index 0000000..978651b --- /dev/null +++ b/libdates/civil_test.go @@ -0,0 +1,304 @@ +package libdates + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parseDate is a helper to parse YYYY-MM-DD format dates. +func parseDate(s string) time.Time { + t, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return t +} + +// TestYMDBetweenDates_MonthlyIncrements tests that adding N months and then +// computing the difference yields (0, N, 0) for various start dates. +// This mirrors the ELPS test that iterates over start/end dates of each month. +func TestYMDBetweenDates_MonthlyIncrements(t *testing.T) { + startDates := []string{ + "2024-01-01", + "2024-01-31", + "2024-02-01", + "2024-02-28", + "2024-02-29", // leap year + "2024-03-01", + "2024-03-31", + "2024-04-01", + "2024-04-30", + "2024-05-01", + "2024-05-31", + "2024-06-01", + "2024-06-30", + "2024-07-01", + "2024-07-31", + "2024-08-01", + "2024-08-31", + "2024-09-01", + "2024-09-30", + "2024-10-01", + "2024-10-31", + "2024-11-01", + "2024-11-30", + "2024-12-01", + "2024-12-31", + } + + for _, startDateStr := range startDates { + startDate := parseDate(startDateStr) + for monthsToAdd := 1; monthsToAdd <= 12; monthsToAdd++ { + endDate := startDate.AddDate(0, monthsToAdd, 0) + diff, err := DiffYMD(startDate, endDate, nil) + require.NoError(t, err, "start=%s months=%d", startDateStr, monthsToAdd) + + // Expect Years=0, Months=monthsToAdd (or Years=1, Months=monthsToAdd-12 if >= 12), Days=0 + expectedYears := monthsToAdd / 12 + expectedMonths := monthsToAdd % 12 + assert.Equal(t, expectedYears, diff.Years, + "start=%s months=%d", startDateStr, monthsToAdd) + assert.Equal(t, expectedMonths, diff.Months, + "start=%s months=%d", startDateStr, monthsToAdd) + assert.Equal(t, 0, diff.Days, + "start=%s months=%d end=%s", startDateStr, monthsToAdd, endDate.Format("2006-01-02")) + } + } +} + +// TestYMDBetweenDates_RandomCases tests various random date pairs with expected outputs. +func TestYMDBetweenDates_RandomCases(t *testing.T) { + tests := []struct { + start string + end string + expectedYears int + expectedMonths int + expectedDays int + }{ + // Same date + {"2020-01-01", "2020-01-01", 0, 0, 0}, + // Multi-year span + {"2025-10-31", "2030-12-31", 5, 2, 0}, + // Single month + {"2020-02-28", "2020-03-28", 0, 1, 0}, + {"2020-07-31", "2020-08-31", 0, 1, 0}, + // Two months plus a day + {"2020-06-30", "2020-08-31", 0, 2, 1}, + // Three months + {"2020-06-30", "2020-09-30", 0, 3, 0}, + // Two months + {"2020-01-31", "2020-03-31", 0, 2, 0}, + // Four years, two months + {"2020-01-31", "2024-03-31", 4, 2, 0}, + // Four years, one month, 16 days + {"2020-02-15", "2024-03-31", 4, 1, 16}, + // Leap day to next month + {"2024-02-29", "2024-03-29", 0, 1, 0}, + // Multi-year span with days + {"2017-07-14", "2024-01-24", 6, 6, 10}, + } + + for _, tt := range tests { + t.Run(tt.start+"_to_"+tt.end, func(t *testing.T) { + start := parseDate(tt.start) + end := parseDate(tt.end) + diff, err := DiffYMD(start, end, nil) + require.NoError(t, err) + assert.Equal(t, tt.expectedYears, diff.Years, "Years mismatch") + assert.Equal(t, tt.expectedMonths, diff.Months, "Months mismatch") + assert.Equal(t, tt.expectedDays, diff.Days, "Days mismatch") + }) + } +} + +// TestYMDBetweenDates_LeapYearEdgeCases tests edge cases around leap years, +// particularly Feb 29 + 1 year behavior. +func TestYMDBetweenDates_LeapYearEdgeCases(t *testing.T) { + tests := []struct { + name string + start string + end string + expectedYears int + expectedMonths int + expectedDays int + }{ + { + name: "Feb 29 2024 to Feb 28 2025", + start: "2024-02-29", + end: "2025-02-28", + expectedYears: 0, + expectedMonths: 11, + expectedDays: 30, + }, + { + // Note: Go's AddDate(0, 13, 0) on Feb 29, 2024 gives Mar 29, 2025 exactly. + // So the maximum whole months is 13 (1 year, 1 month), not 12 (1 year). + // This differs from ELPS cc:add-months which may have different semantics. + // Go: Feb 29, 2024 + 12 months = Mar 1, 2025 (overflow) + // Go: Feb 29, 2024 + 13 months = Mar 29, 2025 (exact match) + name: "Feb 29 2024 to Mar 29 2025", + start: "2024-02-29", + end: "2025-03-29", + expectedYears: 1, + expectedMonths: 1, + expectedDays: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start := parseDate(tt.start) + end := parseDate(tt.end) + diff, err := DiffYMD(start, end, nil) + require.NoError(t, err) + assert.Equal(t, tt.expectedYears, diff.Years, "Years mismatch") + assert.Equal(t, tt.expectedMonths, diff.Months, "Months mismatch") + assert.Equal(t, tt.expectedDays, diff.Days, "Days mismatch") + }) + } +} + +// TestYMDiff_Apply tests the Apply method to ensure round-tripping. +func TestYMDiff_Apply(t *testing.T) { + tests := []struct { + start string + end string + }{ + {"2020-01-01", "2020-01-01"}, + {"2020-01-15", "2024-05-20"}, + {"2024-02-29", "2025-02-28"}, + {"2017-07-14", "2024-01-24"}, + } + + for _, tt := range tests { + t.Run(tt.start+"_to_"+tt.end, func(t *testing.T) { + start := parseDate(tt.start) + end := parseDate(tt.end) + + diff, err := DiffYMD(start, end, nil) + require.NoError(t, err) + + reconstructed := diff.Apply(start, nil) + assert.Equal(t, end, reconstructed, "Apply should reconstruct the end date") + }) + } +} + +// TestDiffYMD_Errors tests error conditions. +func TestDiffYMD_Errors(t *testing.T) { + t.Run("StartAfterEnd", func(t *testing.T) { + start := parseDate("2024-01-15") + end := parseDate("2024-01-10") + _, err := DiffYMD(start, end, nil) + assert.ErrorIs(t, err, ErrStartAfterEnd) + }) + + t.Run("YearOutOfRange", func(t *testing.T) { + start := time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(10001, 1, 1, 0, 0, 0, 0, time.UTC) + _, err := DiffYMD(start, end, nil) + assert.ErrorIs(t, err, ErrYearOutOfRange) + }) + + t.Run("SpanTooLarge", func(t *testing.T) { + start := parseDate("0100-01-01") + end := parseDate("3000-01-01") + _, err := DiffYMDOpts(start, end, DiffOptions{ + MaxSpanYears: 100, + }) + assert.ErrorIs(t, err, ErrSpanTooLarge) + }) +} + +// TestDiffYMDOpts_CustomAddMonths tests using a custom AddMonthsFn. +func TestDiffYMDOpts_CustomAddMonths(t *testing.T) { + // Custom policy: always go to the 15th of the target month + customAddMonths := func(t time.Time, months int) time.Time { + result := t.AddDate(0, months, 0) + return time.Date(result.Year(), result.Month(), 15, 0, 0, 0, 0, time.UTC) + } + + start := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC) + + diff, err := DiffYMDOpts(start, end, DiffOptions{ + AddMonths: customAddMonths, + }) + require.NoError(t, err) + + // With custom policy that always lands on the 15th, we expect clean months + assert.Equal(t, 0, diff.Years) + assert.Equal(t, 2, diff.Months) + assert.Equal(t, 0, diff.Days) +} + +// TestCivilDays tests the civilDays helper function. +func TestCivilDays(t *testing.T) { + tests := []struct { + date1 string + date2 string + expectedDiff int64 + }{ + // Same date + {"2024-01-01", "2024-01-01", 0}, + // One day apart + {"2024-01-01", "2024-01-02", 1}, + // Across month boundary + {"2024-01-31", "2024-02-01", 1}, + // Across year boundary + {"2023-12-31", "2024-01-01", 1}, + // Leap year + {"2024-02-28", "2024-03-01", 2}, // 2024 is a leap year + {"2023-02-28", "2023-03-01", 1}, // 2023 is not + // Multi-year span + {"2020-01-01", "2024-01-01", 1461}, // 4 years with 1 leap year = 365*4 + 1 + } + + for _, tt := range tests { + t.Run(tt.date1+"_to_"+tt.date2, func(t *testing.T) { + d1 := parseDate(tt.date1) + d2 := parseDate(tt.date2) + + days1 := civilDays(d1.Year(), d1.Month(), d1.Day()) + days2 := civilDays(d2.Year(), d2.Month(), d2.Day()) + + diff := days2 - days1 + assert.Equal(t, tt.expectedDiff, diff) + }) + } +} + +// TestYMDiff_Invariants tests that the YMDiff maintains proper invariants. +func TestYMDiff_Invariants(t *testing.T) { + tests := []string{ + "2020-01-01", + "2024-02-29", + "2023-12-31", + "2025-06-15", + } + + for _, startStr := range tests { + start := parseDate(startStr) + // Test various month offsets + for months := 0; months <= 36; months++ { + end := start.AddDate(0, months, 0) + diff, err := DiffYMD(start, end, nil) + require.NoError(t, err, "start=%s months=%d", startStr, months) + + // Invariant 1: Years >= 0, Months in [0, 11], Days >= 0 + assert.GreaterOrEqual(t, diff.Years, 0, "Years should be >= 0") + assert.GreaterOrEqual(t, diff.Months, 0, "Months should be >= 0") + assert.LessOrEqual(t, diff.Months, 11, "Months should be <= 11") + assert.GreaterOrEqual(t, diff.Days, 0, "Days should be >= 0") + + // Invariant 2: Applying the diff should get us back to end + reconstructed := diff.Apply(start, nil) + assert.Equal(t, end, reconstructed, + "start=%s months=%d end=%s", startStr, months, end.Format("2006-01-02")) + } + } +} +