Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions date.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package sprig

import (
"fmt"
"math"
"reflect"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -150,3 +154,178 @@ func mustToDate(fmt, str string) (time.Time, error) {
func unixEpoch(date time.Time) string {
return strconv.FormatInt(date.Unix(), 10)
}

// -----------------------------------------------------------------------------
// Duration helpers (numeric-only returns)
// -----------------------------------------------------------------------------

// asDuration converts common template values into a time.Duration.
//
// Supported inputs:
// - time.Duration
// - string duration values parsed by time.ParseDuration (e.g. "1h2m3s")
// - numeric strings treated as seconds (e.g. "2.5")
// - ints and uints treated as seconds
// - floats treated as seconds
func asDuration(v interface{}) (time.Duration, error) {
switch x := v.(type) {
case time.Duration:
return x, nil

case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, fmt.Errorf("empty duration")
}
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return time.Duration(f * float64(time.Second)), nil
}
return 0, fmt.Errorf("could not parse duration %q", x)

case nil:
return 0, fmt.Errorf("invalid duration")
}

rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return time.Duration(rv.Int()) * time.Second, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
u := rv.Uint()
if u > uint64(math.MaxInt64) {
return 0, fmt.Errorf("duration seconds overflow: %d", u)
}
return time.Duration(int64(u)) * time.Second, nil
case reflect.Float32, reflect.Float64:
return time.Duration(rv.Float() * float64(time.Second)), nil
default:
return 0, fmt.Errorf("unsupported duration type %T", v)
}
}

// mustToDuration takes an interface, parses a duration, and returns a time.Duration.
// It will panic if there is an error.
//
// This is designed to be called from a template when need to ensure that a
// duration is valid.
func mustToDuration(v interface{}) time.Duration {
d, err := asDuration(v)
if err != nil {
panic(err)
}
return d
}

// durationSeconds converts a duration to seconds (float64).
// On error it returns 0.
func durationSeconds(v interface{}) float64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Seconds()
}

// durationMilliseconds converts a duration to milliseconds (int64).
// On error it returns 0.
func durationMilliseconds(v interface{}) int64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Milliseconds()
}

// durationMicroseconds converts a duration to microseconds (int64).
func durationMicroseconds(v interface{}) int64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Microseconds()
}

// durationNanoseconds converts a duration to nanoseconds (int64).
// On error it returns 0.
func durationNanoseconds(v interface{}) int64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Nanoseconds()
}

// durationMinutes converts a duration to minutes (float64).
func durationMinutes(v interface{}) float64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Minutes()
}

// durationHours converts a duration to hours (float64).
// On error it returns 0.
func durationHours(v interface{}) float64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Hours()
}

// durationDays converts a duration to days (float64). (Not in Go's stdlib; handy in templates.)
// On error it returns 0.
func durationDays(v interface{}) float64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Hours() / 24.0
}

// durationWeeks converts a duration to weeks (float64). (Not in Go's stdlib; handy in templates.)
// On error it returns 0.
func durationWeeks(v interface{}) float64 {
d, err := asDuration(v)
if err != nil {
return 0
}
return d.Hours() / 24.0 / 7.0
}

// durationRoundTo rounds v to the nearest multiple of m.
// Returns a time.Duration.
//
// v and m accept the same forms as asDuration (e.g. "2h13m", "30s").
// On error, it returns time.Duration(0). If m is invalid, it returns v.
func durationRoundTo(v interface{}, m interface{}) time.Duration {
d, err := asDuration(v)
if err != nil {
return 0
}
mul, err := asDuration(m)
if err != nil {
return d
}
return d.Round(mul)
}

// durationTruncateTo truncates v toward zero to a multiple of m.
// Returns a time.Duration.
//
// On error, it returns time.Duration(0). If m is invalid, it returns v.
func durationTruncateTo(v interface{}, m interface{}) time.Duration {
d, err := asDuration(v)
if err != nil {
return 0
}
mul, err := asDuration(m)
if err != nil {
return d
}
return d.Truncate(mul)
}
177 changes: 177 additions & 0 deletions date_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package sprig

import (
"math"
"strings"
"testing"
"text/template"
"time"

"github.com/stretchr/testify/require"
)

func TestHtmlDate(t *testing.T) {
Expand Down Expand Up @@ -145,3 +150,175 @@ func TestDateMustModifyReturnsErr(t *testing.T) {
t.Error("expected err, got nil")
}
}

func TestDurationHelpers(t *testing.T) {
tests := []struct {
name string
tpl string
vars interface{}
expect string
}{{
name: "durationSeconds parses duration string",
tpl: `{{ durationSeconds "1m30s" }}`,
expect: `90`,
}, {
tpl: `{{ mustToDuration 30 }}`,
expect: `30s`,
}, {
tpl: `{{ mustToDuration "1m30s" }}`,
expect: `1m30s`,
}, {
name: "durationSeconds parses numeric string as seconds",
tpl: `{{ durationSeconds "2.5" }}`,
expect: `2.5`,
}, {
name: "durationSeconds trims whitespace around numeric string",
tpl: `{{ durationSeconds " 2.5 " }}`,
expect: `2.5`,
}, {
name: "durationSeconds int treated as seconds",
tpl: `{{ durationSeconds 2 }}`,
expect: `2`,
}, {
name: "durationSeconds float treated as seconds",
tpl: `{{ durationSeconds 2.5 }}`,
expect: `2.5`,
}, {
name: "durationSeconds uint treated as seconds",
tpl: `{{ durationSeconds . }}`,
vars: uint(2),
expect: `2`,
}, {
name: "durationSeconds time.Duration passthrough",
tpl: `{{ durationSeconds . }}`,
vars: 1500 * time.Millisecond,
expect: `1.5`,
}, {
name: "invalid duration string returns 0",
tpl: `{{ durationSeconds "nope" }}`,
expect: `0`,
}, {
name: "empty duration string returns 0",
tpl: `{{ durationSeconds "" }}`,
expect: `0`,
}, {
name: "whitespace-only duration string returns 0",
tpl: `{{ durationSeconds " " }}`,
expect: `0`,
}, {
name: "nil returns 0",
tpl: `{{ durationSeconds . }}`,
vars: nil,
expect: `0`,
}, {
name: "durationSeconds uint overflow returns 0",
tpl: `{{ durationSeconds . }}`,
vars: uint64(math.MaxInt64) + 1,
expect: `0`,
}, {
name: "durationMilliseconds int seconds",
tpl: `{{ durationMilliseconds 2 }}`,
expect: `2000`,
}, {
name: "durationMilliseconds float seconds",
tpl: `{{ durationMilliseconds 1.5 }}`,
expect: `1500`,
}, {
name: "durationMicroseconds int seconds",
tpl: `{{ durationMicroseconds 2 }}`,
expect: `2000000`,
}, {
name: "durationNanoseconds int seconds",
tpl: `{{ durationNanoseconds 2 }}`,
expect: `2000000000`,
}, {
name: "durationMinutes parses duration string",
tpl: `{{ durationMinutes "90s" }}`,
expect: `1.5`,
}, {
name: "durationHours parses duration string",
tpl: `{{ durationHours "90m" }}`,
expect: `1.5`,
}, {
name: "durationDays parses duration string",
tpl: `{{ durationDays "36h" }}`,
expect: `1.5`,
}, {
name: "durationDays numeric seconds",
tpl: `{{ durationDays 86400 }}`,
expect: `1`,
}, {
name: "durationRoundTo numeric seconds",
tpl: `{{ durationRoundTo 93 60 }}`, // 93s rounded to 60s = 120s
expect: `2m0s`,
}, {
name: "durationTruncateTo numeric seconds",
tpl: `{{ durationTruncateTo 93 60 }}`, // 93s truncated to 60s = 60s
expect: `1m0s`,
}, {
name: "durationRoundTo accepts duration-string multiplier",
tpl: `{{ durationRoundTo "93s" "1m" }}`,
expect: `2m0s`,
}, {
name: "durationTruncateTo accepts duration-string multiplier",
tpl: `{{ durationTruncateTo "93s" "1m" }}`,
expect: `1m0s`,
}, {
name: "durationRoundTo invalid m returns v unchanged",
tpl: `{{ durationRoundTo "93s" "nope" }}`,
expect: `1m33s`,
}, {
name: "durationTruncateTo invalid m returns v unchanged",
tpl: `{{ durationTruncateTo "93s" "nope" }}`,
expect: `1m33s`,
}, {
name: "durationRoundTo zero m returns v unchanged",
tpl: `{{ durationRoundTo "93s" 0 }}`,
expect: `1m33s`,
}, {
name: "durationTruncateTo negative m returns v unchanged",
tpl: `{{ durationTruncateTo "93s" -1 }}`,
expect: `1m33s`,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b strings.Builder
err := template.Must(template.New("test").Funcs(TxtFuncMap()).Parse(tt.tpl)).Execute(&b, tt.vars)
require.NoError(t, err, tt.tpl)
require.Equal(t, tt.expect, b.String(), tt.tpl)
})
}

mustErrTests := []struct {
name string
tpl string
vars interface{}
}{{
name: "mustToDuration invalid string",
tpl: `{{ mustToDuration "nope" }}`,
}, {
name: "mustToDuration empty string",
tpl: `{{ mustToDuration "" }}`,
}, {
name: "mustToDuration whitespace string",
tpl: `{{ mustToDuration " " }}`,
}, {
name: "mustToDuration unsupported type",
tpl: `{{ mustToDuration . }}`,
vars: []int{1, 2, 3},
}, {
name: "mustToDuration uint overflow",
tpl: `{{ mustToDuration . }}`,
vars: uint64(math.MaxInt64) + 1,
},
}

for _, tt := range mustErrTests {
t.Run(tt.name, func(t *testing.T) {
var b strings.Builder
err := template.Must(template.New("test").Funcs(TxtFuncMap()).Parse(tt.tpl)).Execute(&b, tt.vars)
require.Error(t, err, tt.tpl)
})
}
}
Loading