diff --git a/tstime/tstime.go b/tstime/tstime.go index 1c006355f..6e5b7f9f4 100644 --- a/tstime/tstime.go +++ b/tstime/tstime.go @@ -6,6 +6,7 @@ package tstime import ( "context" + "encoding" "strconv" "strings" "time" @@ -183,3 +184,40 @@ func (StdClock) AfterFunc(d time.Duration, f func()) TimerController { func (StdClock) Since(t time.Time) time.Duration { return time.Since(t) } + +// GoDuration is a [time.Duration] but JSON serializes with [time.Duration.String]. +// +// Note that this format is specific to Go and non-standard, +// but excels in being most humanly readable compared to alternatives. +// The wider industry still lacks consensus for the representation +// of a time duration in humanly-readable text. +// See https://go.dev/issue/71631 for more discussion. +// +// Regardless of how the industry evolves into the future, +// this type explicitly uses the Go format. +type GoDuration struct{ time.Duration } + +var ( + _ encoding.TextAppender = (*GoDuration)(nil) + _ encoding.TextMarshaler = (*GoDuration)(nil) + _ encoding.TextUnmarshaler = (*GoDuration)(nil) +) + +func (d GoDuration) AppendText(b []byte) ([]byte, error) { + // The String method is inlineable (see https://go.dev/cl/520602), + // so this may not allocate since the string does not escape. + return append(b, d.String()...), nil +} + +func (d GoDuration) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +func (d *GoDuration) UnmarshalText(b []byte) error { + d2, err := time.ParseDuration(string(b)) + if err != nil { + return err + } + d.Duration = d2 + return nil +} diff --git a/tstime/tstime_test.go b/tstime/tstime_test.go index 3ffeaf0ff..556ad4e8b 100644 --- a/tstime/tstime_test.go +++ b/tstime/tstime_test.go @@ -4,8 +4,11 @@ package tstime import ( + "encoding/json" "testing" "time" + + "tailscale.com/util/must" ) func TestParseDuration(t *testing.T) { @@ -34,3 +37,17 @@ func TestParseDuration(t *testing.T) { } } } + +func TestGoDuration(t *testing.T) { + wantDur := GoDuration{time.Hour + time.Minute + time.Second + time.Millisecond + time.Microsecond + time.Nanosecond} + gotJSON := string(must.Get(json.Marshal(wantDur))) + wantJSON := `"1h1m1.001001001s"` + if gotJSON != wantJSON { + t.Errorf("json.Marshal(%v) = %s, want %s", wantDur, gotJSON, wantJSON) + } + var gotDur GoDuration + must.Do(json.Unmarshal([]byte(wantJSON), &gotDur)) + if gotDur != wantDur { + t.Errorf("json.Unmarshal(%s) = %v, want %v", wantJSON, gotDur, wantDur) + } +}