duration.go raw
1 package duration
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "math"
8 "strconv"
9 "strings"
10 "time"
11 "unicode"
12 )
13
14 // Duration holds all the smaller units that make up the duration
15 type Duration struct {
16 Years float64
17 Months float64
18 Weeks float64
19 Days float64
20 Hours float64
21 Minutes float64
22 Seconds float64
23 Negative bool
24 }
25
26 const (
27 parsingPeriod = iota
28 parsingTime
29
30 hoursPerDay = 24
31 hoursPerWeek = hoursPerDay * 7
32 hoursPerMonth = hoursPerYear / 12
33 hoursPerYear = hoursPerDay * 365
34
35 nsPerSecond = 1000000000
36 nsPerMinute = nsPerSecond * 60
37 nsPerHour = nsPerMinute * 60
38 nsPerDay = nsPerHour * hoursPerDay
39 nsPerWeek = nsPerHour * hoursPerWeek
40 nsPerMonth = nsPerHour * hoursPerMonth
41 nsPerYear = nsPerHour * hoursPerYear
42 )
43
44 var (
45 // ErrUnexpectedInput is returned when an input in the duration string does not match expectations
46 ErrUnexpectedInput = errors.New("unexpected input")
47 )
48
49 // Parse attempts to parse the given duration string into a *Duration,
50 // if parsing fails an error is returned instead.
51 func Parse(d string) (*Duration, error) {
52 state := parsingPeriod
53 duration := &Duration{}
54 num := ""
55 var err error
56
57 switch {
58 case strings.HasPrefix(d, "P"): // standard duration
59 case strings.HasPrefix(d, "-P"): // negative duration
60 duration.Negative = true
61 d = strings.TrimPrefix(d, "-") // remove the negative sign
62 default:
63 return nil, ErrUnexpectedInput
64 }
65
66 for _, char := range d {
67 switch char {
68 case 'P':
69 if state != parsingPeriod {
70 return nil, ErrUnexpectedInput
71 }
72 case 'T':
73 state = parsingTime
74 case 'Y':
75 if state != parsingPeriod {
76 return nil, ErrUnexpectedInput
77 }
78
79 duration.Years, err = strconv.ParseFloat(num, 64)
80 if err != nil {
81 return nil, err
82 }
83 num = ""
84 case 'M':
85 if state == parsingPeriod {
86 duration.Months, err = strconv.ParseFloat(num, 64)
87 if err != nil {
88 return nil, err
89 }
90 num = ""
91 } else if state == parsingTime {
92 duration.Minutes, err = strconv.ParseFloat(num, 64)
93 if err != nil {
94 return nil, err
95 }
96 num = ""
97 }
98 case 'W':
99 if state != parsingPeriod {
100 return nil, ErrUnexpectedInput
101 }
102
103 duration.Weeks, err = strconv.ParseFloat(num, 64)
104 if err != nil {
105 return nil, err
106 }
107 num = ""
108 case 'D':
109 if state != parsingPeriod {
110 return nil, ErrUnexpectedInput
111 }
112
113 duration.Days, err = strconv.ParseFloat(num, 64)
114 if err != nil {
115 return nil, err
116 }
117 num = ""
118 case 'H':
119 if state != parsingTime {
120 return nil, ErrUnexpectedInput
121 }
122
123 duration.Hours, err = strconv.ParseFloat(num, 64)
124 if err != nil {
125 return nil, err
126 }
127 num = ""
128 case 'S':
129 if state != parsingTime {
130 return nil, ErrUnexpectedInput
131 }
132
133 duration.Seconds, err = strconv.ParseFloat(num, 64)
134 if err != nil {
135 return nil, err
136 }
137 num = ""
138 default:
139 if unicode.IsNumber(char) || char == '.' {
140 num += string(char)
141 continue
142 }
143
144 return nil, ErrUnexpectedInput
145 }
146 }
147
148 return duration, nil
149 }
150
151 // FromTimeDuration converts the given time.Duration into duration.Duration.
152 // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
153 // since obviously those things vary month to month and year to year.
154 func FromTimeDuration(d time.Duration) *Duration {
155 duration := &Duration{}
156 if d == 0 {
157 return duration
158 }
159
160 if d < 0 {
161 d = -d
162 duration.Negative = true
163 }
164
165 if d.Hours() >= hoursPerYear {
166 duration.Years = math.Floor(d.Hours() / hoursPerYear)
167 d -= time.Duration(duration.Years) * nsPerYear
168 }
169 if d.Hours() >= hoursPerMonth {
170 duration.Months = math.Floor(d.Hours() / hoursPerMonth)
171 d -= time.Duration(duration.Months) * nsPerMonth
172 }
173 if d.Hours() >= hoursPerWeek {
174 duration.Weeks = math.Floor(d.Hours() / hoursPerWeek)
175 d -= time.Duration(duration.Weeks) * nsPerWeek
176 }
177 if d.Hours() >= hoursPerDay {
178 duration.Days = math.Floor(d.Hours() / hoursPerDay)
179 d -= time.Duration(duration.Days) * nsPerDay
180 }
181 if d.Hours() >= 1 {
182 duration.Hours = math.Floor(d.Hours())
183 d -= time.Duration(duration.Hours) * nsPerHour
184 }
185 if d.Minutes() >= 1 {
186 duration.Minutes = math.Floor(d.Minutes())
187 d -= time.Duration(duration.Minutes) * nsPerMinute
188 }
189 duration.Seconds = d.Seconds()
190
191 return duration
192 }
193
194 // Format formats the given time.Duration into an ISO 8601 duration string (e.g., P1DT6H5M),
195 // negative durations are prefixed with a minus sign, for a zero duration "PT0S" is returned.
196 // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
197 // since obviously those things vary month to month and year to year.
198 func Format(d time.Duration) string {
199 return FromTimeDuration(d).String()
200 }
201
202 // ToTimeDuration converts the *Duration to the standard library's time.Duration.
203 // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
204 // since obviously those things vary month to month and year to year.
205 func (duration *Duration) ToTimeDuration() time.Duration {
206 var timeDuration time.Duration
207
208 // zero checks are here to avoid unnecessary math operations, on a duration such as `PT5M`
209 if duration.Years != 0 {
210 timeDuration += time.Duration(math.Round(duration.Years * nsPerYear))
211 }
212 if duration.Months != 0 {
213 timeDuration += time.Duration(math.Round(duration.Months * nsPerMonth))
214 }
215 if duration.Weeks != 0 {
216 timeDuration += time.Duration(math.Round(duration.Weeks * nsPerWeek))
217 }
218 if duration.Days != 0 {
219 timeDuration += time.Duration(math.Round(duration.Days * nsPerDay))
220 }
221 if duration.Hours != 0 {
222 timeDuration += time.Duration(math.Round(duration.Hours * nsPerHour))
223 }
224 if duration.Minutes != 0 {
225 timeDuration += time.Duration(math.Round(duration.Minutes * nsPerMinute))
226 }
227 if duration.Seconds != 0 {
228 timeDuration += time.Duration(math.Round(duration.Seconds * nsPerSecond))
229 }
230 if duration.Negative {
231 timeDuration = -timeDuration
232 }
233
234 return timeDuration
235 }
236
237 // String returns the ISO8601 duration string for the *Duration
238 func (duration *Duration) String() string {
239 d := "P"
240 hasTime := false
241
242 appendD := func(designator string, value float64, isTime bool) {
243 if !hasTime && isTime {
244 d += "T"
245 hasTime = true
246 }
247
248 d += strconv.FormatFloat(value, 'f', -1, 64) + designator
249 }
250
251 if duration.Years != 0 {
252 appendD("Y", duration.Years, false)
253 }
254
255 if duration.Months != 0 {
256 appendD("M", duration.Months, false)
257 }
258
259 if duration.Weeks != 0 {
260 appendD("W", duration.Weeks, false)
261 }
262
263 if duration.Days != 0 {
264 appendD("D", duration.Days, false)
265 }
266
267 if duration.Hours != 0 {
268 appendD("H", duration.Hours, true)
269 }
270
271 if duration.Minutes != 0 {
272 appendD("M", duration.Minutes, true)
273 }
274
275 if duration.Seconds != 0 {
276 appendD("S", duration.Seconds, true)
277 }
278
279 // if the duration is zero, return "PT0S"
280 if d == "P" {
281 d += "T0S"
282 }
283
284 if duration.Negative {
285 return "-" + d
286 }
287
288 return d
289 }
290
291 // MarshalJSON satisfies the Marshaler interface by return a valid JSON string representation of the duration
292 func (duration Duration) MarshalJSON() ([]byte, error) {
293 return json.Marshal(duration.String())
294 }
295
296 // UnmarshalJSON satisfies the Unmarshaler interface by return a valid JSON string representation of the duration
297 func (duration *Duration) UnmarshalJSON(source []byte) error {
298 durationString := ""
299 err := json.Unmarshal(source, &durationString)
300 if err != nil {
301 return err
302 }
303
304 parsed, err := Parse(durationString)
305 if err != nil {
306 return fmt.Errorf("failed to parse duration: %w", err)
307 }
308
309 *duration = *parsed
310 return nil
311 }
312