diff --git a/date.go b/date.go index ed022dda..95fef9b0 100644 --- a/date.go +++ b/date.go @@ -1,7 +1,11 @@ package sprig import ( + "fmt" + "math" + "reflect" "strconv" + "strings" "time" ) @@ -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) +} diff --git a/date_test.go b/date_test.go index cf4d4a88..b5e77a9e 100644 --- a/date_test.go +++ b/date_test.go @@ -1,8 +1,13 @@ package sprig import ( + "math" + "strings" "testing" + "text/template" "time" + + "github.com/stretchr/testify/require" ) func TestHtmlDate(t *testing.T) { @@ -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) + }) + } +} diff --git a/docs/date.md b/docs/date.md index 9f02651e..d81863b7 100644 --- a/docs/date.md +++ b/docs/date.md @@ -47,6 +47,59 @@ Same as `date`, but with a timezone. dateInZone "2006-01-02" (now) "UTC" ``` +## unixEpoch + +Returns the seconds since the unix epoch for a `time.Time`. + +``` +now | unixEpoch +``` + +## dateModify, mustDateModify + +The `dateModify` takes a modification and a date and returns the timestamp. + +Subtract an hour and thirty minutes from the current time: + +``` +now | date_modify "-1.5h" +``` + +If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. + +## htmlDate + +The `htmlDate` function formats a date for inserting into an HTML date picker +input field. + +``` +now | htmlDate +``` + +## htmlDateInZone + +Same as htmlDate, but with a timezone. + +``` +htmlDateInZone (now) "UTC" +``` + +## toDate, mustToDate + +`toDate` converts a string to a date. The first argument is the date layout and +the second the date string. If the string can't be convert it returns the zero +value. +`mustToDate` will return an error in case the string cannot be converted. + +This is useful when you want to convert a string date to another format +(using pipe). The example below converts "2017-12-31" to "31/12/2017". + +``` +toDate "2006-01-02" "2017-12-31" | date "02/01/2006" +``` + +# Duration Functions + ## duration Formats a given amount of seconds as a `time.Duration`. @@ -74,53 +127,122 @@ This returns 3mo durationRound "2400h10m5s" ``` -## unixEpoch +## mustToDuration -Returns the seconds since the unix epoch for a `time.Time`. +`mustToDuration` parses a duration-like value into a `time.Duration` and panics on error. + +**Go equivalent:** `time.ParseDuration`, `time.Duration` construction + +Supported inputs include Go duration strings (e.g. `"2h30m"`), numeric strings treated as seconds (e.g. `"1.5"`), and numeric values treated as seconds. ``` -now | unixEpoch +mustToDuration "2h30m" ``` -## dateModify, mustDateModify +``` +mustToDuration "1.5" +``` -The `dateModify` takes a modification and a date and returns the timestamp. +## durationSeconds -Subtract an hour and thirty minutes from the current time: +Converts a duration to seconds as a floating-point number. + +**Go equivalent:** `time.Duration.Seconds()` ``` -now | date_modify "-1.5h" +mustToDuration "1m30s" | durationSeconds ``` -If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. +## durationMilliseconds -## htmlDate +Converts a duration to milliseconds as an integer. -The `htmlDate` function formats a date for inserting into an HTML date picker -input field. +**Go equivalent:** `time.Duration.Milliseconds()` ``` -now | htmlDate +mustToDuration "1s" | durationMilliseconds ``` -## htmlDateInZone +## durationMicroseconds -Same as htmlDate, but with a timezone. +Converts a duration to microseconds as an integer. + +**Go equivalent:** `time.Duration.Microseconds()` ``` -htmlDateInZone (now) "UTC" +mustToDuration "1s" | durationMicroseconds ``` -## toDate, mustToDate +## durationNanoseconds -`toDate` converts a string to a date. The first argument is the date layout and -the second the date string. If the string can't be convert it returns the zero -value. -`mustToDate` will return an error in case the string cannot be converted. +Converts a duration to nanoseconds as an integer. -This is useful when you want to convert a string date to another format -(using pipe). The example below converts "2017-12-31" to "31/12/2017". +**Go equivalent:** `time.Duration.Nanoseconds()` ``` -toDate "2006-01-02" "2017-12-31" | date "02/01/2006" +mustToDuration "1ms" | durationNanoseconds +``` + +## durationMinutes + +Converts a duration to minutes as a floating-point number. + +**Go equivalent:** `time.Duration.Minutes()` + +``` +mustToDuration "90s" | durationMinutes +``` + +## durationHours + +Converts a duration to hours as a floating-point number. + +**Go equivalent:** `time.Duration.Hours()` + +``` +mustToDuration "90m" | durationHours +``` + +## durationDays + +Converts a duration to days as a floating-point number. + +``` +mustToDuration "36h" | durationDays +``` + +## durationWeeks + +Converts a duration to weeks as a floating-point number. + +``` +mustToDuration "336h" | durationWeeks +``` + +## durationRoundTo + +Rounds a duration to the nearest multiple of another duration. + +**Go equivalent:** `time.Duration.Round()` + +``` +mustToDuration "1h15m30s" | durationRoundTo "10m" +``` + +``` +mustToDuration "2h29m" | durationRoundTo "1h" +``` + +## durationTruncateTo + +Truncates a duration toward zero to a multiple of another duration. + +**Go equivalent:** `time.Duration.Truncate()` + +``` +mustToDuration "1h15m30s" | durationTruncateTo "10m" +``` + +``` +mustToDuration "2h59m" | durationTruncateTo "1h" ``` diff --git a/functions.go b/functions.go index cda47d26..94966974 100644 --- a/functions.go +++ b/functions.go @@ -97,22 +97,33 @@ var genericMap = map[string]interface{}{ "hello": func() string { return "Hello!" }, // Date functions - "ago": dateAgo, - "date": date, - "date_in_zone": dateInZone, - "date_modify": dateModify, - "dateInZone": dateInZone, - "dateModify": dateModify, - "duration": duration, - "durationRound": durationRound, - "htmlDate": htmlDate, - "htmlDateInZone": htmlDateInZone, - "must_date_modify": mustDateModify, - "mustDateModify": mustDateModify, - "mustToDate": mustToDate, - "now": time.Now, - "toDate": toDate, - "unixEpoch": unixEpoch, + "ago": dateAgo, + "date": date, + "dateInZone": dateInZone, + "dateModify": dateModify, + "date_in_zone": dateInZone, + "date_modify": dateModify, + "duration": duration, + "durationDays": durationDays, + "durationHours": durationHours, + "durationMicroseconds": durationMicroseconds, + "durationMilliseconds": durationMilliseconds, + "durationMinutes": durationMinutes, + "durationNanoseconds": durationNanoseconds, + "durationRound": durationRound, + "durationRoundTo": durationRoundTo, + "durationSeconds": durationSeconds, + "durationTruncateTo": durationTruncateTo, + "durationWeeks": durationWeeks, + "htmlDate": htmlDate, + "htmlDateInZone": htmlDateInZone, + "mustDateModify": mustDateModify, + "mustToDate": mustToDate, + "mustToDuration": mustToDuration, + "must_date_modify": mustDateModify, + "now": time.Now, + "toDate": toDate, + "unixEpoch": unixEpoch, // Strings "abbrev": abbrev,