diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e660e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Kyle McGough + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/duration.go b/duration.go new file mode 100644 index 0000000..ec88110 --- /dev/null +++ b/duration.go @@ -0,0 +1,139 @@ +package duration + +import ( + "errors" + "math" + "strconv" + "time" + "unicode" +) + +// Duration holds all the smaller units that make up the duration +type Duration struct { + Years float64 + Months float64 + Weeks float64 + Days float64 + Hours float64 + Minutes float64 + Seconds float64 +} + +const ( + parsingPeriod = iota + parsingTime +) + +var ( + // ErrUnexpectedInput is returned when an input in the duration string does not match expectations + ErrUnexpectedInput = errors.New("unexpected input") +) + +// Parse attempts to parse the given duration string into a *Duration +// if parsing fails an error is returned instead +func Parse(d string) (*Duration, error) { + state := parsingPeriod + duration := &Duration{} + num := "" + var err error + + for _, char := range d { + switch char { + case 'P': + state = parsingPeriod + case 'T': + state = parsingTime + case 'Y': + if state != parsingPeriod { + return nil, ErrUnexpectedInput + } + + duration.Years, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + case 'M': + if state == parsingPeriod { + duration.Months, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + } else if state == parsingTime { + duration.Minutes, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + } + case 'W': + if state != parsingPeriod { + return nil, ErrUnexpectedInput + } + + duration.Weeks, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + case 'D': + if state != parsingPeriod { + return nil, ErrUnexpectedInput + } + + duration.Days, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + case 'H': + if state != parsingTime { + return nil, ErrUnexpectedInput + } + + duration.Hours, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + case 'S': + if state != parsingTime { + return nil, ErrUnexpectedInput + } + + duration.Seconds, err = strconv.ParseFloat(num, 64) + if err != nil { + return nil, err + } + num = "" + default: + if unicode.IsNumber(char) || char == '.' { + num += string(char) + continue + } + + return nil, ErrUnexpectedInput + } + } + + return duration, nil +} + +// ToTimeDuration converts the *Duration to the standard library's time.Duration +// note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy +// since obviously those things vary month to month and year to year +// I used the values that Google's search provided me with as I couldn't find anything concrete on what they should be +func (duration *Duration) ToTimeDuration() time.Duration { + var timeDuration time.Duration + + timeDuration += time.Duration(math.Round(duration.Years * 3.154e+16)) + timeDuration += time.Duration(math.Round(duration.Months * 2.628e+15)) + timeDuration += time.Duration(math.Round(duration.Weeks * 6.048e+14)) + timeDuration += time.Duration(math.Round(duration.Days * 8.64e+13)) + timeDuration += time.Duration(math.Round(duration.Hours * 3.6e+12)) + timeDuration += time.Duration(math.Round(duration.Minutes * 6e+10)) + timeDuration += time.Duration(math.Round(duration.Seconds * 1e+9)) + + return timeDuration +} diff --git a/duration_test.go b/duration_test.go new file mode 100644 index 0000000..f8f1444 --- /dev/null +++ b/duration_test.go @@ -0,0 +1,132 @@ +package duration + +import ( + "reflect" + "testing" + "time" +) + +func TestParse(t *testing.T) { + type args struct { + d string + } + tests := []struct { + name string + args args + want *Duration + wantErr bool + }{ + { + name: "period-only", + args: args{d: "P4Y"}, + want: &Duration{ + Years: 4, + }, + wantErr: false, + }, + { + name: "time-only-decimal", + args: args{d: "T2.5S"}, + want: &Duration{ + Seconds: 2.5, + }, + wantErr: false, + }, + { + name: "full", + args: args{d: "P3Y6M4DT12H30M5.5S"}, + want: &Duration{ + Years: 3, + Months: 6, + Days: 4, + Hours: 12, + Minutes: 30, + Seconds: 5.5, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.args.d) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDuration_ToTimeDuration(t *testing.T) { + type fields struct { + Years float64 + Months float64 + Weeks float64 + Days float64 + Hours float64 + Minutes float64 + Seconds float64 + } + tests := []struct { + name string + fields fields + want time.Duration + }{ + { + name: "seconds", + fields: fields{ + Seconds: 33.3, + }, + want: time.Second*33 + time.Millisecond*300, + }, + { + name: "hours, minutes, and seconds", + fields: fields{ + Hours: 2, + Minutes: 33, + Seconds: 17, + }, + want: time.Hour*2 + time.Minute*33 + time.Second*17, + }, + { + name: "days", + fields: fields{ + Days: 2, + }, + want: time.Hour * 24 * 2, + }, + { + name: "weeks", + fields: fields{ + Weeks: 1, + }, + want: time.Hour * 24 * 7, + }, + { + name: "fractional weeks", + fields: fields{ + Weeks: 12.5, + }, + want: time.Hour*24*7*12 + time.Hour*84, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + duration := &Duration{ + Years: tt.fields.Years, + Months: tt.fields.Months, + Weeks: tt.fields.Weeks, + Days: tt.fields.Days, + Hours: tt.fields.Hours, + Minutes: tt.fields.Minutes, + Seconds: tt.fields.Seconds, + } + if got := duration.ToTimeDuration(); got != tt.want { + t.Errorf("ToTimeDuration() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f4d17c7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/sosodev/duration + +go 1.17 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b57ebd7 --- /dev/null +++ b/readme.md @@ -0,0 +1,52 @@ +# duration + +It's a Go module for parsing [ISO 8601 durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) and converting them to the often much more useful `time.Duration`. + +## why? + +ISO 8601 is a pretty common standard and sometimes these durations show up in the wild. + +## installation + +`go get github.com/sosodev/duration` + +## usage + +```go +package main + +import ( + "fmt" + "time" + "github.com/sosodev/duration" +) + +func main() { + d, err := duration.Parse("P3Y6M4DT12H30M5.5S") + if err != nil { + panic(err) + } + + fmt.Println(d.Years) // 3 + fmt.Println(d.Months) // 6 + fmt.Println(d.Days) // 4 + fmt.Println(d.Hours) // 12 + fmt.Println(d.Minutes) // 30 + fmt.Println(d.Seconds) // 5.5 + + d, err = duration.Parse("T33.3S") + if err != nil { + panic(err) + } + + fmt.Println(d.ToTimeDuration() == time.Second*33+time.Millisecond*300) // true +} +``` + +## correctness + +This module aims to implement the ISO8601 duration specification correctly. It properly supports fractional units and has unit tests +that assert the correctness of it's parsing and conversion to a `time.Duration`. + +With that said durations with months or years specified will be converted to `time.Duration` with a little fuzziness. Since I +couldn't find a standard value, and they obviously vary, for those I used `2.628e+15` nanoseconds for a month and `3.154e+16` nanoseconds for a year.