From e4572a0e0e512ab6c8857e007beef7eccbb247ae Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 14:39:34 +0500 Subject: [PATCH 01/10] add handle negative durations in ParseDuration Signed-off-by: Dmitry Ponomaryov --- model/time.go | 24 ++++++++++++++++++++++++ model/time_test.go | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/model/time.go b/model/time.go index 5727452c1..d8cc5de26 100644 --- a/model/time.go +++ b/model/time.go @@ -214,6 +214,13 @@ func ParseDuration(s string) (Duration, error) { var dur uint64 lastUnitPos := 0 + // Check if duration is negative. + neg := false + if s[0] == '-' { + neg = true + s = s[1:] + } + for s != "" { if !isdigit(s[0]) { return 0, fmt.Errorf("not a valid duration string: %q", orig) @@ -253,6 +260,12 @@ func ParseDuration(s string) (Duration, error) { return 0, errors.New("duration out of range") } } + + if neg { + // Return negative duration. + return Duration(-int64(dur)), nil + } + return Duration(dur), nil } @@ -265,6 +278,12 @@ func (d Duration) String() string { return "0s" } + // Handle negative duration. + neg := ms < 0 + if neg { + ms = -ms + } + f := func(unit string, mult int64, exact bool) { if exact && ms%mult != 0 { return @@ -286,6 +305,11 @@ func (d Duration) String() string { f("s", 1000, false) f("ms", 1, false) + if neg { + // Return negative duration. + return "-" + r + } + return r } diff --git a/model/time_test.go b/model/time_test.go index 70f383947..93f6bc504 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -118,6 +118,29 @@ func TestParseDuration(t *testing.T) { in: "10y", out: 10 * 365 * 24 * time.Hour, }, + { + in: "-3s", + out: -3 * time.Second, + }, { + in: "-5m", + out: -5 * time.Minute, + }, { + in: "-1h", + out: -1 * time.Hour, + }, { + in: "-2d", + out: -2 * 24 * time.Hour, + }, { + in: "-1w", + out: -7 * 24 * time.Hour, + }, { + in: "-3w2d1h", + out: -(3*7*24*time.Hour + 2*24*time.Hour + time.Hour), + expectedString: "-23d1h", + }, { + in: "-10y", + out: -10 * 365 * 24 * time.Hour, + }, } for _, c := range cases { From c99bccaf57fdc03fabe093f8c27a6a102a28bfef Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 15:17:34 +0500 Subject: [PATCH 02/10] fix TestParseBadDuration test Signed-off-by: Dmitry Ponomaryov --- model/time_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/model/time_test.go b/model/time_test.go index 93f6bc504..05c080388 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -330,7 +330,6 @@ func TestParseBadDuration(t *testing.T) { cases := []string{ "1", "1y1m1d", - "-1w", "1.5d", "d", "294y", From 264f33efabdc13eb245ee8063b7d82a7e76dac6c Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 15:22:03 +0500 Subject: [PATCH 03/10] fix linter gofumpt Signed-off-by: Dmitry Ponomaryov --- model/time_test.go | 54 ++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/model/time_test.go b/model/time_test.go index 05c080388..da8a57960 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -78,66 +78,84 @@ func TestParseDuration(t *testing.T) { in: "0", out: 0, expectedString: "0s", - }, { + }, + { in: "0w", out: 0, expectedString: "0s", - }, { + }, + { in: "0s", out: 0, - }, { + }, + { in: "324ms", out: 324 * time.Millisecond, - }, { + }, + { in: "3s", out: 3 * time.Second, - }, { + }, + { in: "5m", out: 5 * time.Minute, - }, { + }, + { in: "1h", out: time.Hour, - }, { + }, + { in: "4d", out: 4 * 24 * time.Hour, - }, { + }, + { in: "4d1h", out: 4*24*time.Hour + time.Hour, - }, { + }, + { in: "14d", out: 14 * 24 * time.Hour, expectedString: "2w", - }, { + }, + { in: "3w", out: 3 * 7 * 24 * time.Hour, - }, { + }, + { in: "3w2d1h", out: 3*7*24*time.Hour + 2*24*time.Hour + time.Hour, expectedString: "23d1h", - }, { + }, + { in: "10y", out: 10 * 365 * 24 * time.Hour, }, { in: "-3s", out: -3 * time.Second, - }, { + }, + { in: "-5m", out: -5 * time.Minute, - }, { + }, + { in: "-1h", out: -1 * time.Hour, - }, { + }, + { in: "-2d", out: -2 * 24 * time.Hour, - }, { + }, + { in: "-1w", out: -7 * 24 * time.Hour, - }, { + }, + { in: "-3w2d1h", out: -(3*7*24*time.Hour + 2*24*time.Hour + time.Hour), expectedString: "-23d1h", - }, { + }, + { in: "-10y", out: -10 * 365 * 24 * time.Hour, }, From fa9343ca105fb41c8a4eb07eada8368b42b68760 Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 16:15:07 +0500 Subject: [PATCH 04/10] create function ParseDurationAllowNegative Signed-off-by: Dmitry Ponomaryov --- model/time.go | 75 +++++++++++++++++++++++++++++++++++++++------- model/time_test.go | 28 ++++++++++++++++- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/model/time.go b/model/time.go index d8cc5de26..b168c0cf8 100644 --- a/model/time.go +++ b/model/time.go @@ -214,10 +214,68 @@ func ParseDuration(s string) (Duration, error) { var dur uint64 lastUnitPos := 0 - // Check if duration is negative. - neg := false + for s != "" { + if !isdigit(s[0]) { + return 0, fmt.Errorf("not a valid duration string: %q", orig) + } + // Consume [0-9]* + i := 0 + for ; i < len(s) && isdigit(s[i]); i++ { + } + v, err := strconv.ParseUint(s[:i], 10, 0) + if err != nil { + return 0, fmt.Errorf("not a valid duration string: %q", orig) + } + s = s[i:] + + // Consume unit. + for i = 0; i < len(s) && !isdigit(s[i]); i++ { + } + if i == 0 { + return 0, fmt.Errorf("not a valid duration string: %q", orig) + } + u := s[:i] + s = s[i:] + unit, ok := unitMap[u] + if !ok { + return 0, fmt.Errorf("unknown unit %q in duration %q", u, orig) + } + if unit.pos <= lastUnitPos { // Units must go in order from biggest to smallest. + return 0, fmt.Errorf("not a valid duration string: %q", orig) + } + lastUnitPos = unit.pos + // Check if the provided duration overflows time.Duration (> ~ 290years). + if v > 1<<63/unit.mult { + return 0, errors.New("duration out of range") + } + dur += v * unit.mult + if dur > 1<<63-1 { + return 0, errors.New("duration out of range") + } + } + + return Duration(dur), nil +} + +// ParseDurationAllowNegative parses a string into a time.Duration, assuming that a year +// always has 365d, a week always has 7d, and a day always has 24h. +// Supporting negative durations as well. +func ParseDurationAllowNegative(s string) (Duration, error) { + switch s { + case "0": + // Allow 0 without a unit. + return 0, nil + case "": + return 0, errors.New("empty duration string") + } + + orig := s + var dur uint64 + lastUnitPos := 0 + + negative := false if s[0] == '-' { - neg = true + negative = true s = s[1:] } @@ -261,8 +319,7 @@ func ParseDuration(s string) (Duration, error) { } } - if neg { - // Return negative duration. + if negative { return Duration(-int64(dur)), nil } @@ -278,9 +335,8 @@ func (d Duration) String() string { return "0s" } - // Handle negative duration. - neg := ms < 0 - if neg { + negative := ms < 0 + if negative { ms = -ms } @@ -305,8 +361,7 @@ func (d Duration) String() string { f("s", 1000, false) f("ms", 1, false) - if neg { - // Return negative duration. + if negative { return "-" + r } diff --git a/model/time_test.go b/model/time_test.go index da8a57960..cc1066f5e 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -130,6 +130,14 @@ func TestParseDuration(t *testing.T) { in: "10y", out: 10 * 365 * 24 * time.Hour, }, + } + + casesAllowNegative := []struct { + in string + out time.Duration + + expectedString string + }{ { in: "-3s", out: -3 * time.Second, @@ -161,6 +169,8 @@ func TestParseDuration(t *testing.T) { }, } + casesAllowNegative = append(casesAllowNegative, cases...) + for _, c := range cases { d, err := ParseDuration(c.in) if err != nil { @@ -177,6 +187,23 @@ func TestParseDuration(t *testing.T) { t.Errorf("Expected duration string %q but got %q", c.in, d.String()) } } + + for _, c := range casesAllowNegative { + d, err := ParseDurationAllowNegative(c.in) + if err != nil { + t.Errorf("Unexpected error on input %q", c.in) + } + if time.Duration(d) != c.out { + t.Errorf("Expected %v but got %v", c.out, d) + } + expectedString := c.expectedString + if c.expectedString == "" { + expectedString = c.in + } + if d.String() != expectedString { + t.Errorf("Expected duration string %q but got %q", c.in, d.String()) + } + } } func TestDuration_UnmarshalText(t *testing.T) { @@ -362,7 +389,6 @@ func TestParseBadDuration(t *testing.T) { if err == nil { t.Errorf("Expected error on input %s", c) } - } } From fd45bd8c99ae85976c8fdbdae3fd515e9caf4269 Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 18:54:46 +0500 Subject: [PATCH 05/10] change comments for ParseDuration and ParseDurationAllowNegative Signed-off-by: Dmitry Ponomaryov --- model/time.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/model/time.go b/model/time.go index b168c0cf8..88cab38fc 100644 --- a/model/time.go +++ b/model/time.go @@ -201,6 +201,7 @@ var unitMap = map[string]struct { // ParseDuration parses a string into a time.Duration, assuming that a year // always has 365d, a week always has 7d, and a day always has 24h. +// Negative durations are not supported. func ParseDuration(s string) (Duration, error) { switch s { case "0": @@ -257,9 +258,7 @@ func ParseDuration(s string) (Duration, error) { return Duration(dur), nil } -// ParseDurationAllowNegative parses a string into a time.Duration, assuming that a year -// always has 365d, a week always has 7d, and a day always has 24h. -// Supporting negative durations as well. +// ParseDurationAllowNegative is like ParseDuration but also accepts negative durations. func ParseDurationAllowNegative(s string) (Duration, error) { switch s { case "0": From 842ef6b063141dde3f5fe70b762cf282289300b9 Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 19:00:37 +0500 Subject: [PATCH 06/10] add implemented function ParseDuration in ParseDurationAllowNegative Signed-off-by: Dmitry Ponomaryov --- model/time.go | 63 ++++++--------------------------------------------- 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/model/time.go b/model/time.go index 88cab38fc..747a20f4b 100644 --- a/model/time.go +++ b/model/time.go @@ -260,69 +260,20 @@ func ParseDuration(s string) (Duration, error) { // ParseDurationAllowNegative is like ParseDuration but also accepts negative durations. func ParseDurationAllowNegative(s string) (Duration, error) { - switch s { - case "0": - // Allow 0 without a unit. - return 0, nil - case "": + if s == "" { return 0, errors.New("empty duration string") } - orig := s - var dur uint64 - lastUnitPos := 0 - - negative := false - if s[0] == '-' { - negative = true - s = s[1:] - } - - for s != "" { - if !isdigit(s[0]) { - return 0, fmt.Errorf("not a valid duration string: %q", orig) - } - // Consume [0-9]* - i := 0 - for ; i < len(s) && isdigit(s[i]); i++ { - } - v, err := strconv.ParseUint(s[:i], 10, 0) - if err != nil { - return 0, fmt.Errorf("not a valid duration string: %q", orig) - } - s = s[i:] - - // Consume unit. - for i = 0; i < len(s) && !isdigit(s[i]); i++ { - } - if i == 0 { - return 0, fmt.Errorf("not a valid duration string: %q", orig) - } - u := s[:i] - s = s[i:] - unit, ok := unitMap[u] - if !ok { - return 0, fmt.Errorf("unknown unit %q in duration %q", u, orig) - } - if unit.pos <= lastUnitPos { // Units must go in order from biggest to smallest. - return 0, fmt.Errorf("not a valid duration string: %q", orig) - } - lastUnitPos = unit.pos - // Check if the provided duration overflows time.Duration (> ~ 290years). - if v > 1<<63/unit.mult { - return 0, errors.New("duration out of range") - } - dur += v * unit.mult - if dur > 1<<63-1 { - return 0, errors.New("duration out of range") - } + if s[0] != '-' { + return ParseDuration(s) } - if negative { - return Duration(-int64(dur)), nil + d, err := ParseDuration(s[1:]) + if err != nil { + return 0, err } - return Duration(dur), nil + return -d, nil } func (d Duration) String() string { From ec58d49bc58cf7f271cd11ec7f6a684e4ba517f4 Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 19:06:57 +0500 Subject: [PATCH 07/10] unify negative duration formatting Signed-off-by: Dmitry Ponomaryov --- model/time.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/model/time.go b/model/time.go index 747a20f4b..f96cc0430 100644 --- a/model/time.go +++ b/model/time.go @@ -278,16 +278,17 @@ func ParseDurationAllowNegative(s string) (Duration, error) { func (d Duration) String() string { var ( - ms = int64(time.Duration(d) / time.Millisecond) - r = "" + ms = int64(time.Duration(d) / time.Millisecond) + r = "" + sign = "" ) + if ms == 0 { return "0s" } - negative := ms < 0 - if negative { - ms = -ms + if ms < 0 { + sign, ms = "-", -ms } f := func(unit string, mult int64, exact bool) { @@ -311,11 +312,7 @@ func (d Duration) String() string { f("s", 1000, false) f("ms", 1, false) - if negative { - return "-" + r - } - - return r + return sign + r } // MarshalJSON implements the json.Marshaler interface. From 06903a00cf10e018c75d3c98ffd876895f5a55ac Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 19:19:02 +0500 Subject: [PATCH 08/10] improve function TestParseDuration Signed-off-by: Dmitry Ponomaryov --- model/time_test.go | 184 +++++++++++++++++++++++++++------------------ 1 file changed, 109 insertions(+), 75 deletions(-) diff --git a/model/time_test.go b/model/time_test.go index cc1066f5e..4b1c42776 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -68,138 +68,172 @@ func TestDuration(t *testing.T) { } func TestParseDuration(t *testing.T) { - cases := []struct { - in string - out time.Duration - expectedString string - }{ + type testCase struct { + in string + out time.Duration + expectedString string + allowedNegative bool + } + + baseCases := []testCase{ { - in: "0", - out: 0, - expectedString: "0s", + in: "0", + out: 0, + expectedString: "0s", + allowedNegative: false, }, { - in: "0w", - out: 0, - expectedString: "0s", + in: "0w", + out: 0, + expectedString: "0s", + allowedNegative: false, }, { - in: "0s", - out: 0, + in: "0s", + out: 0, + expectedString: "", + allowedNegative: false, }, { - in: "324ms", - out: 324 * time.Millisecond, + in: "324ms", + out: 324 * time.Millisecond, + expectedString: "", + allowedNegative: false, }, { - in: "3s", - out: 3 * time.Second, + in: "3s", + out: 3 * time.Second, + expectedString: "", + allowedNegative: false, }, { - in: "5m", - out: 5 * time.Minute, + in: "5m", + out: 5 * time.Minute, + expectedString: "", + allowedNegative: false, }, { - in: "1h", - out: time.Hour, + in: "1h", + out: time.Hour, + expectedString: "", + allowedNegative: false, }, { - in: "4d", - out: 4 * 24 * time.Hour, + in: "4d", + out: 4 * 24 * time.Hour, + expectedString: "", + allowedNegative: false, }, { - in: "4d1h", - out: 4*24*time.Hour + time.Hour, + in: "4d1h", + out: 4*24*time.Hour + time.Hour, + expectedString: "", + allowedNegative: false, }, { - in: "14d", - out: 14 * 24 * time.Hour, - expectedString: "2w", + in: "14d", + out: 14 * 24 * time.Hour, + expectedString: "2w", + allowedNegative: false, }, { - in: "3w", - out: 3 * 7 * 24 * time.Hour, + in: "3w", + out: 3 * 7 * 24 * time.Hour, + expectedString: "", + allowedNegative: false, }, { - in: "3w2d1h", - out: 3*7*24*time.Hour + 2*24*time.Hour + time.Hour, - expectedString: "23d1h", + in: "3w2d1h", + out: 3*7*24*time.Hour + 2*24*time.Hour + time.Hour, + expectedString: "23d1h", + allowedNegative: false, }, { - in: "10y", - out: 10 * 365 * 24 * time.Hour, + in: "10y", + out: 10 * 365 * 24 * time.Hour, + expectedString: "", + allowedNegative: false, }, } - casesAllowNegative := []struct { - in string - out time.Duration - - expectedString string - }{ + negativeCases := []testCase{ { - in: "-3s", - out: -3 * time.Second, + in: "-3s", + out: -3 * time.Second, + expectedString: "", + allowedNegative: true, }, { - in: "-5m", - out: -5 * time.Minute, + in: "-5m", + out: -5 * time.Minute, + expectedString: "", + allowedNegative: true, }, { - in: "-1h", - out: -1 * time.Hour, + in: "-1h", + out: -1 * time.Hour, + expectedString: "", + allowedNegative: true, }, { - in: "-2d", - out: -2 * 24 * time.Hour, + in: "-2d", + out: -2 * 24 * time.Hour, + expectedString: "", + allowedNegative: true, }, { - in: "-1w", - out: -7 * 24 * time.Hour, + in: "-1w", + out: -7 * 24 * time.Hour, + expectedString: "", + allowedNegative: true, }, { - in: "-3w2d1h", - out: -(3*7*24*time.Hour + 2*24*time.Hour + time.Hour), - expectedString: "-23d1h", + in: "-3w2d1h", + out: -(3*7*24*time.Hour + 2*24*time.Hour + time.Hour), + expectedString: "-23d1h", + allowedNegative: true, }, { - in: "-10y", - out: -10 * 365 * 24 * time.Hour, + in: "-10y", + out: -10 * 365 * 24 * time.Hour, + expectedString: "", + allowedNegative: true, }, } - casesAllowNegative = append(casesAllowNegative, cases...) + for _, c := range baseCases { + c.allowedNegative = true + negativeCases = append(negativeCases, c) + } - for _, c := range cases { - d, err := ParseDuration(c.in) - if err != nil { - t.Errorf("Unexpected error on input %q", c.in) - } - if time.Duration(d) != c.out { - t.Errorf("Expected %v but got %v", c.out, d) - } - expectedString := c.expectedString - if c.expectedString == "" { - expectedString = c.in - } - if d.String() != expectedString { - t.Errorf("Expected duration string %q but got %q", c.in, d.String()) + allCases := append(baseCases, negativeCases...) + + for _, c := range allCases { + var ( + d Duration + err error + ) + + if c.allowedNegative { + d, err = ParseDurationAllowNegative(c.in) + } else { + d, err = ParseDuration(c.in) } - } - for _, c := range casesAllowNegative { - d, err := ParseDurationAllowNegative(c.in) if err != nil { t.Errorf("Unexpected error on input %q", c.in) } + if time.Duration(d) != c.out { t.Errorf("Expected %v but got %v", c.out, d) } + expectedString := c.expectedString - if c.expectedString == "" { + if expectedString == "" { expectedString = c.in } + if d.String() != expectedString { t.Errorf("Expected duration string %q but got %q", c.in, d.String()) } From 47c2bbfceeb857bfab8b1c17ea7855056593bd7b Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Thu, 12 Jun 2025 19:21:45 +0500 Subject: [PATCH 09/10] fix gofumpt linter Signed-off-by: Dmitry Ponomaryov --- model/time_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/model/time_test.go b/model/time_test.go index 4b1c42776..a4e9069f1 100644 --- a/model/time_test.go +++ b/model/time_test.go @@ -68,7 +68,6 @@ func TestDuration(t *testing.T) { } func TestParseDuration(t *testing.T) { - type testCase struct { in string out time.Duration From c099408138d5dbc9e2b1fcbb2d8fdb3794aaf7b0 Mon Sep 17 00:00:00 2001 From: Dmitry Ponomaryov Date: Sun, 15 Jun 2025 11:11:19 +0500 Subject: [PATCH 10/10] remove unnecessary error handling in ParseDurationAllowNegative Signed-off-by: Dmitry Ponomaryov --- model/time.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/model/time.go b/model/time.go index f96cc0430..fed9e87b9 100644 --- a/model/time.go +++ b/model/time.go @@ -260,20 +260,13 @@ func ParseDuration(s string) (Duration, error) { // ParseDurationAllowNegative is like ParseDuration but also accepts negative durations. func ParseDurationAllowNegative(s string) (Duration, error) { - if s == "" { - return 0, errors.New("empty duration string") - } - - if s[0] != '-' { + if s == "" || s[0] != '-' { return ParseDuration(s) } d, err := ParseDuration(s[1:]) - if err != nil { - return 0, err - } - return -d, nil + return -d, err } func (d Duration) String() string {