Skip to content

Commit

Permalink
tada
Browse files Browse the repository at this point in the history
  • Loading branch information
sosodev committed Jan 24, 2022
0 parents commit 957cba5
Show file tree
Hide file tree
Showing 5 changed files with 347 additions and 0 deletions.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
139 changes: 139 additions & 0 deletions duration.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions duration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/sosodev/duration

go 1.17
52 changes: 52 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 957cba5

Please sign in to comment.