Skip to content

Commit

Permalink
Always add P in String() method (#1)
Browse files Browse the repository at this point in the history
* Always add P in String() method
* Add duration.Format method to convert time.Duration to ISO 8601 duration string
* refactor: use constants for time values
* refactor: extract FromTimeDuration conversion into a separate method
* feat: support negative duration
  • Loading branch information
astappiev authored Apr 6, 2023
1 parent c17012f commit 3054e00
Show file tree
Hide file tree
Showing 2 changed files with 239 additions and 44 deletions.
143 changes: 116 additions & 27 deletions duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,48 @@ package duration

import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"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
Years float64
Months float64
Weeks float64
Days float64
Hours float64
Minutes float64
Seconds float64
Negative bool
}

const (
parsingPeriod = iota
parsingTime

hoursPerDay = 24
hoursPerWeek = hoursPerDay * 7
hoursPerMonth = hoursPerYear / 12
hoursPerYear = hoursPerDay * 365

nsPerSecond = 1000000000
nsPerMinute = nsPerSecond * 60
nsPerHour = nsPerMinute * 60
nsPerDay = nsPerHour * hoursPerDay
nsPerWeek = nsPerHour * hoursPerWeek
nsPerMonth = nsPerHour * hoursPerMonth
nsPerYear = nsPerHour * hoursPerYear
)

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
// 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
Expand All @@ -41,6 +53,8 @@ func Parse(d string) (*Duration, error) {

for _, char := range d {
switch char {
case '-':
duration.Negative = true
case 'P':
state = parsingPeriod
case 'T':
Expand Down Expand Up @@ -122,38 +136,104 @@ func Parse(d string) (*Duration, error) {
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
// FromTimeDuration converts the given time.Duration into duration.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
func FromTimeDuration(d time.Duration) *Duration {
duration := &Duration{}
if d == 0 {
return duration
}

if d < 0 {
d = -d
duration.Negative = true
}

if d.Hours() >= hoursPerYear {
duration.Years = math.Floor(d.Hours() / hoursPerYear)
d -= time.Duration(duration.Years) * nsPerYear
}
if d.Hours() >= hoursPerMonth {
duration.Months = math.Floor(d.Hours() / hoursPerMonth)
d -= time.Duration(duration.Months) * nsPerMonth
}
if d.Hours() >= hoursPerWeek {
duration.Weeks = math.Floor(d.Hours() / hoursPerWeek)
d -= time.Duration(duration.Weeks) * nsPerWeek
}
if d.Hours() >= hoursPerDay {
duration.Days = math.Floor(d.Hours() / hoursPerDay)
d -= time.Duration(duration.Days) * nsPerDay
}
if d.Hours() >= 1 {
duration.Hours = math.Floor(d.Hours())
d -= time.Duration(duration.Hours) * nsPerHour
}
if d.Minutes() >= 1 {
duration.Minutes = math.Floor(d.Minutes())
d -= time.Duration(duration.Minutes) * nsPerMinute
}
duration.Seconds = d.Seconds()

return duration
}

// Format formats the given time.Duration into an ISO 8601 duration string (e.g. P1DT6H5M),
// negative durations are prefixed with a minus sign, for a zero duration "PT0S" is returned.
// 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
func Format(d time.Duration) string {
return FromTimeDuration(d).String()
}

// 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))
// zero checks are here to avoid unnecessary math operations, on a durations such as `PT5M`
if duration.Years != 0 {
timeDuration += time.Duration(math.Round(duration.Years * nsPerYear))
}
if duration.Months != 0 {
timeDuration += time.Duration(math.Round(duration.Months * nsPerMonth))
}
if duration.Weeks != 0 {
timeDuration += time.Duration(math.Round(duration.Weeks * nsPerWeek))
}
if duration.Days != 0 {
timeDuration += time.Duration(math.Round(duration.Days * nsPerDay))
}
if duration.Hours != 0 {
timeDuration += time.Duration(math.Round(duration.Hours * nsPerHour))
}
if duration.Minutes != 0 {
timeDuration += time.Duration(math.Round(duration.Minutes * nsPerMinute))
}
if duration.Seconds != 0 {
timeDuration += time.Duration(math.Round(duration.Seconds * nsPerSecond))
}
if duration.Negative {
timeDuration = -timeDuration
}

return timeDuration
}

// String returns the ISO8601 duration string for the *Duration
func (duration *Duration) String() string {
d := ""
d := "P"
hasTime := false

appendD := func(designator string, value float64, isTime bool) {
if !strings.Contains(d, "P") && !isTime {
d += "P"
}

if !strings.Contains(d, "T") && isTime {
if !hasTime && isTime {
d += "T"
hasTime = true
}

d += fmt.Sprintf("%s%s", strconv.FormatFloat(value, 'f', -1, 64), designator)
d += strconv.FormatFloat(value, 'f', -1, 64) + designator
}

if duration.Years != 0 {
Expand Down Expand Up @@ -184,5 +264,14 @@ func (duration *Duration) String() string {
appendD("S", duration.Seconds, true)
}

// if the duration is zero, return "PT0S"
if d == "P" {
d += "T0S"
}

if duration.Negative {
return "-" + d
}

return d
}
140 changes: 123 additions & 17 deletions duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ func TestParse(t *testing.T) {
},
wantErr: false,
},
{
name: "negative",
args: args{d: "-PT5M"},
want: &Duration{
Minutes: 5,
Negative: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -60,15 +69,94 @@ func TestParse(t *testing.T) {
}
}

func TestFromTimeDuration(t *testing.T) {
tests := []struct {
give time.Duration
want *Duration
}{
{
give: 0,
want: &Duration{},
},
{
give: time.Minute * 94,
want: &Duration{
Hours: 1,
Minutes: 34,
},
},
{
give: -time.Second * 10,
want: &Duration{
Seconds: 10,
Negative: true,
},
},
}
for _, tt := range tests {
t.Run(tt.give.String(), func(t *testing.T) {
got := FromTimeDuration(tt.give)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Format() got = %s, want %s", got, tt.want)
}
})
}
}

func TestFormat(t *testing.T) {
tests := []struct {
give time.Duration
want string
}{
{
give: 0,
want: "PT0S",
},
{
give: time.Minute * 94,
want: "PT1H34M",
},
{
give: time.Hour * 72,
want: "P3D",
},
{
give: time.Hour * 26,
want: "P1DT2H",
},
{
give: time.Second * 465461651,
want: "P14Y9M3DT12H54M11S",
},
{
give: -time.Hour * 99544,
want: "-P11Y4M1W4D",
},
{
give: -time.Second * 10,
want: "-PT10S",
},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := Format(tt.give)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Format() got = %s, want %s", 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
Years float64
Months float64
Weeks float64
Days float64
Hours float64
Minutes float64
Seconds float64
Negative bool
}
tests := []struct {
name string
Expand Down Expand Up @@ -112,17 +200,26 @@ func TestDuration_ToTimeDuration(t *testing.T) {
},
want: time.Hour*24*7*12 + time.Hour*84,
},
{
name: "negative",
fields: fields{
Hours: 2,
Negative: true,
},
want: -time.Hour * 2,
},
}
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,
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,
Negative: tt.fields.Negative,
}
if got := duration.ToTimeDuration(); got != tt.want {
t.Errorf("ToTimeDuration() = %v, want %v", got, tt.want)
Expand All @@ -147,12 +244,21 @@ func TestDuration_String(t *testing.T) {
t.Errorf("expected: %s, got: %s", "P3Y6M4DT12H30M33.3333S", duration.String())
}

smolDuration, err := Parse("T0.0000000000001S")
smallDuration, err := Parse("T0.0000000000001S")
if err != nil {
t.Fatal(err)
}

if smallDuration.String() != "PT0.0000000000001S" {
t.Errorf("expected: %s, got: %s", "PT0.0000000000001S", smallDuration.String())
}

negativeDuration, err := Parse("-PT2H5M")
if err != nil {
t.Fatal(err)
}

if smolDuration.String() != "T0.0000000000001S" {
t.Errorf("expected: %s, got: %s", "T0.0000000000001S", smolDuration.String())
if negativeDuration.String() != "-PT2H5M" {
t.Errorf("expected: %s, got: %s", "-PT2H5M", negativeDuration.String())
}
}

0 comments on commit 3054e00

Please sign in to comment.