master
go 240 lines 5.37 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package confopt
4
5 import (
6 "encoding/json"
7 "fmt"
8 "regexp"
9 "strconv"
10 "strings"
11 "time"
12 )
13
14 var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`)
15
16 // ParseDuration parses a duration string with units.
17 func ParseDuration(s string) (time.Duration, error) {
18 orig := s
19
20 if s = strings.ReplaceAll(s, " ", ""); s == "" {
21 return 0, fmt.Errorf("empty duration string")
22 }
23
24 neg := s[0] == '-'
25 if neg {
26 s = s[1:]
27 }
28
29 unitMap := map[string]time.Duration{
30 "d": 24 * time.Hour,
31 "w": 7 * 24 * time.Hour,
32 "wk": 7 * 24 * time.Hour,
33 "mo": 30 * 24 * time.Hour,
34 "M": 30 * 24 * time.Hour,
35 "y": 365 * 24 * time.Hour,
36 }
37
38 matches := reDuration.FindAllStringSubmatch(s, -1)
39
40 if len(matches) == 0 {
41 return 0, fmt.Errorf("invalid duration format: '%s'", orig)
42 }
43
44 var total time.Duration
45
46 for _, m := range matches {
47 value, unit := m[1], m[2]
48
49 val, err := strconv.ParseFloat(value, 64)
50 if err != nil {
51 return 0, fmt.Errorf("invalid number: %s", value)
52 }
53
54 if multiplier, ok := unitMap[unit]; ok {
55 total += time.Duration(val * float64(multiplier))
56 } else {
57 dur, err := time.ParseDuration(value + unit)
58 if err != nil {
59 return 0, fmt.Errorf("invalid duration unit: %s", value+unit)
60 }
61 total += dur
62 }
63 }
64
65 if neg {
66 total = -total
67 }
68
69 return total, nil
70 }
71
72 type Duration time.Duration
73
74 func (d Duration) Duration() time.Duration {
75 return time.Duration(d)
76 }
77
78 func (d Duration) String() string {
79 return d.Duration().String()
80 }
81
82 func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
83 var s string
84
85 if err := unmarshal(&s); err != nil {
86 return err
87 }
88
89 if v, err := ParseDuration(s); err == nil {
90 *d = Duration(v)
91 return nil
92 }
93 if v, err := strconv.ParseInt(s, 10, 64); err == nil {
94 *d = Duration(time.Duration(v) * time.Second)
95 return nil
96 }
97 if v, err := strconv.ParseFloat(s, 64); err == nil {
98 *d = Duration(v * float64(time.Second))
99 return nil
100 }
101
102 return fmt.Errorf("unparsable duration format '%s'", s)
103 }
104
105 func (d Duration) MarshalYAML() (any, error) {
106 seconds := float64(d) / float64(time.Second)
107 return seconds, nil
108 }
109
110 func (d *Duration) UnmarshalJSON(b []byte) error {
111 // Try as JSON string first (handles quoted values like "30m", "5s")
112 var s string
113 if err := json.Unmarshal(b, &s); err == nil {
114 if v, err := ParseDuration(s); err == nil {
115 *d = Duration(v)
116 return nil
117 }
118 // Try as numeric string (interpret as seconds)
119 if v, err := strconv.ParseFloat(s, 64); err == nil {
120 *d = Duration(v * float64(time.Second))
121 return nil
122 }
123 }
124
125 // Try as JSON number (handles unquoted values like 5, 1.5)
126 var f float64
127 if err := json.Unmarshal(b, &f); err == nil {
128 *d = Duration(f * float64(time.Second))
129 return nil
130 }
131
132 return fmt.Errorf("unparsable duration format '%s'", string(b))
133 }
134
135 func (d Duration) MarshalJSON() ([]byte, error) {
136 seconds := float64(d) / float64(time.Second)
137 return json.Marshal(seconds)
138 }
139
140 // LongDuration is like Duration but marshals to a human-friendly string (e.g., "12h", "30m", "1d").
141 // Unmarshal accepts both strings ("12h", "1d") and numbers (seconds).
142 type LongDuration time.Duration
143
144 func (d LongDuration) Duration() time.Duration {
145 return time.Duration(d)
146 }
147
148 func (d LongDuration) String() string {
149 return formatDuration(time.Duration(d))
150 }
151
152 func (d *LongDuration) UnmarshalYAML(unmarshal func(any) error) error {
153 var tmp Duration
154 if err := tmp.UnmarshalYAML(unmarshal); err != nil {
155 return err
156 }
157 *d = LongDuration(tmp)
158 return nil
159 }
160
161 func (d LongDuration) MarshalYAML() (any, error) {
162 return formatDuration(time.Duration(d)), nil
163 }
164
165 func (d *LongDuration) UnmarshalJSON(b []byte) error {
166 var tmp Duration
167 if err := tmp.UnmarshalJSON(b); err != nil {
168 return err
169 }
170 *d = LongDuration(tmp)
171 return nil
172 }
173
174 func (d LongDuration) MarshalJSON() ([]byte, error) {
175 return json.Marshal(formatDuration(time.Duration(d)))
176 }
177
178 // formatDuration formats a duration as a human-friendly string.
179 // Uses the largest unit that produces a clean integer value, with preference
180 // for fractional seconds over milliseconds when value >= 1s.
181 // Supported units: y (365d), mo (30d), w (7d), d (24h), h, m, s, ms.
182 func formatDuration(d time.Duration) string {
183 if d == 0 {
184 return "0s"
185 }
186
187 neg := d < 0
188 if neg {
189 d = -d
190 }
191
192 // Units from largest to smallest (excluding ms - handled separately)
193 units := []struct {
194 suffix string
195 value time.Duration
196 }{
197 {"y", 365 * 24 * time.Hour},
198 {"mo", 30 * 24 * time.Hour},
199 {"w", 7 * 24 * time.Hour},
200 {"d", 24 * time.Hour},
201 {"h", time.Hour},
202 {"m", time.Minute},
203 {"s", time.Second},
204 }
205
206 // Find the largest unit that divides evenly
207 for _, u := range units {
208 if d >= u.value && d%u.value == 0 {
209 val := d / u.value
210 if neg {
211 return fmt.Sprintf("-%d%s", val, u.suffix)
212 }
213 return fmt.Sprintf("%d%s", val, u.suffix)
214 }
215 }
216
217 // For values >= 1s without clean division, prefer fractional seconds over ms
218 if d >= time.Second {
219 seconds := float64(d) / float64(time.Second)
220 if neg {
221 return fmt.Sprintf("-%.3gs", seconds)
222 }
223 return fmt.Sprintf("%.3gs", seconds)
224 }
225
226 // For sub-second values, try milliseconds
227 if d >= time.Millisecond && d%time.Millisecond == 0 {
228 val := d / time.Millisecond
229 if neg {
230 return fmt.Sprintf("-%dms", val)
231 }
232 return fmt.Sprintf("%dms", val)
233 }
234
235 // Fallback to Go's standard format for sub-millisecond precision
236 if neg {
237 return "-" + d.String()
238 }
239 return d.String()
240 }