1// Package duration provides human-friendly duration parsing.
2package duration
3
4import (
5 "fmt"
6 "regexp"
7 "strconv"
8 "strings"
9 "time"
10)
11
12// Common duration constants for human-friendly units.
13const (
14 Day = 24 * time.Hour
15 Week = 7 * Day
16 Month = 30 * Day // Approximate
17 Year = 365 * Day // Approximate
18)
19
20// unitMultipliers maps human-friendly unit suffixes to their duration values.
21var unitMultipliers = map[string]time.Duration{
22 "d": Day,
23 "w": Week,
24 "M": Month, // Capital M for months to distinguish from minutes
25 "y": Year,
26}
27
28// durationPattern matches duration components like "1y", "6M", "2w", "3d".
29var durationPattern = regexp.MustCompile(`(\d+)([ywMd])`)
30
31// Parse extends time.ParseDuration to support human-friendly units:
32// - d (days) = 24h
33// - w (weeks) = 7d
34// - M (months) = 30d (approximate)
35// - y (years) = 365d (approximate)
36//
37// Standard Go duration units (ns, us, ms, s, m, h) are also supported.
38// Compound durations like "1y6M" or "2w3d" or "1d12h" work as expected.
39//
40// Special case: "0" returns 0 duration (useful for never-expiring tokens).
41//
42// Examples:
43//
44// Parse("1d") // 24 hours
45// Parse("2w") // 14 days
46// Parse("1M") // 30 days
47// Parse("1y") // 365 days
48// Parse("1y6M") // 1 year and 6 months
49// Parse("2w3d") // 2 weeks and 3 days
50// Parse("1d12h") // 1 day and 12 hours
51// Parse("24h") // standard Go duration
52// Parse("0") // zero duration
53func Parse(s string) (time.Duration, error) {
54 s = strings.TrimSpace(s)
55
56 if s == "" {
57 return 0, fmt.Errorf("empty duration string")
58 }
59
60 // Special case: "0" means no expiry
61 if s == "0" {
62 return 0, nil
63 }
64
65 // Check if the string contains any human-friendly units
66 if !containsHumanUnits(s) {
67 // Fall back to standard Go parsing
68 d, err := time.ParseDuration(s)
69 if err != nil {
70 return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
71 }
72 return d, nil
73 }
74
75 // Parse human-friendly units
76 return parseHumanDuration(s)
77}
78
79// containsHumanUnits checks if the string contains any human-friendly unit suffixes.
80func containsHumanUnits(s string) bool {
81 for unit := range unitMultipliers {
82 if strings.Contains(s, unit) {
83 return true
84 }
85 }
86 return false
87}
88
89// parseHumanDuration parses a duration string containing human-friendly units.
90func parseHumanDuration(s string) (time.Duration, error) {
91 var total time.Duration
92 remaining := s
93
94 // First, extract all human-friendly units (y, M, w, d)
95 matches := durationPattern.FindAllStringSubmatch(remaining, -1)
96 if len(matches) > 0 {
97 for _, match := range matches {
98 value, err := strconv.ParseInt(match[1], 10, 64)
99 if err != nil {
100 return 0, fmt.Errorf("invalid duration value %q in %q", match[1], s)
101 }
102
103 unit := match[2]
104 multiplier, ok := unitMultipliers[unit]
105 if !ok {
106 return 0, fmt.Errorf("unknown duration unit %q in %q", unit, s)
107 }
108
109 total += time.Duration(value) * multiplier
110 }
111
112 // Remove the matched human-friendly parts
113 remaining = durationPattern.ReplaceAllString(remaining, "")
114 }
115
116 // If there's remaining content, try to parse it as standard Go duration
117 remaining = strings.TrimSpace(remaining)
118 if remaining != "" {
119 d, err := time.ParseDuration(remaining)
120 if err != nil {
121 return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
122 }
123 total += d
124 }
125
126 return total, nil
127}