From 5e26cfbeadc9d606e921e2c0046a32744c5ea6cc Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 7 Oct 2026 12:30:24 +1000 Subject: [PATCH] feat(logs): add support for days unit (d) in --since/until filters for logs --- internal/cli/logs/duration.go | 66 ++++++++++++++++++++++++++++++ internal/cli/logs/duration_test.go | 62 ++++++++++++++++++++++++++++ internal/cli/logs/logs.go | 6 ++- internal/cli/logs/time.go | 2 +- internal/cli/logs/time_test.go | 18 +++++--- 5 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 internal/cli/logs/duration.go create mode 100644 internal/cli/logs/duration_test.go diff --git a/internal/cli/logs/duration.go b/internal/cli/logs/duration.go new file mode 100644 index 00000000..e260da3b --- /dev/null +++ b/internal/cli/logs/duration.go @@ -0,0 +1,66 @@ +package logs + +import ( + "fmt" + "math/big" + "strings" + "time" +) + +// Duration is an extended standard time.Duration that also supports days as a unit. +type Duration = time.Duration + +// ParseDuration parses a Go duration with the additional unit d, meaning exactly 24 hours. +// Days can be fractional or combined with other units, such as "1.5d" or "2d3h". +func ParseDuration(value string) (Duration, error) { + if !strings.Contains(value, "d") { + return time.ParseDuration(value) + } + + rest := value + var normalised strings.Builder + if len(rest) > 0 && (rest[0] == '-' || rest[0] == '+') { + normalised.WriteByte(rest[0]) + rest = rest[1:] + } + for len(rest) > 0 { + // Each component is a decimal number followed by a unit. Only the leading sign is allowed. + numberEnd := 0 + for numberEnd < len(rest) && (isDigit(rest[numberEnd]) || rest[numberEnd] == '.') { + numberEnd++ + } + unitEnd := numberEnd + for unitEnd < len(rest) && !isDigit(rest[unitEnd]) && rest[unitEnd] != '.' { + unitEnd++ + } + if numberEnd == 0 || unitEnd == numberEnd { + return 0, fmt.Errorf("time: invalid duration '%s'", value) + } + number, unit := rest[:numberEnd], rest[numberEnd:unitEnd] + if unit == "d" { + // Use exact decimal arithmetic to avoid float rounding and retain nanosecond precision. + days, ok := new(big.Rat).SetString(number) + if !ok { + return 0, fmt.Errorf("time: invalid duration '%s'", value) + } + days.Mul(days, new(big.Rat).SetInt64(int64(24*time.Hour))) + nanoseconds := new(big.Int).Quo(days.Num(), days.Denom()) + normalised.WriteString(nanoseconds.String()) + normalised.WriteString("ns") + } else { + normalised.WriteString(rest[:unitEnd]) + } + rest = rest[unitEnd:] + } + + // The standard parser validates the remaining units and checks the total for overflow. + duration, err := time.ParseDuration(normalised.String()) + if err != nil { + return 0, fmt.Errorf("time: invalid duration '%s': %w", value, err) + } + return duration, nil +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} diff --git a/internal/cli/logs/duration_test.go b/internal/cli/logs/duration_test.go new file mode 100644 index 00000000..6b1e608b --- /dev/null +++ b/internal/cli/logs/duration_test.go @@ -0,0 +1,62 @@ +package logs + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDuration(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + input string + want time.Duration + }{ + {"0", 0}, + {"2m30s", 150 * time.Second}, + {"1µs", time.Microsecond}, + {"1μs", time.Microsecond}, + {"2d", 48 * time.Hour}, + {"010d", 240 * time.Hour}, + {"08d", 192 * time.Hour}, + {"2d3h", 51 * time.Hour}, + {"3h2d", 51 * time.Hour}, + {"1d1d", 48 * time.Hour}, + {"1.5d", 36 * time.Hour}, + {".5d", 12 * time.Hour}, + {"1.d", 24 * time.Hour}, + {"-2d3h", -51 * time.Hour}, + {"+2d", 48 * time.Hour}, + {"0d", 0}, + {"0d1ns", time.Nanosecond}, + {"0.000000000001d", 86 * time.Nanosecond}, + {"-0.000000000001d", -86 * time.Nanosecond}, + {"106751d23h47m16.854775807s", time.Duration(math.MaxInt64)}, + {"-106751d23h47m16.854775808s", time.Duration(math.MinInt64)}, + } { + t.Run(tt.input, func(t *testing.T) { + actual, err := ParseDuration(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, actual) + }) + } +} + +func TestParseDuration_Invalid(t *testing.T) { + t.Parallel() + + for _, input := range []string{ + "", "d", "2d3", "1..2d", ".d", "1d-2h", "1d+2h", "1d 2h", "1day", "2D", "1w", + "1e2d", "1d2w", "106752d", "-106752d", "106751d23h47m16.854775808s", + "-106751d23h47m16.854775809s", "999999999999999999999999999d", + } { + t.Run(input, func(t *testing.T) { + _, err := ParseDuration(input) + require.Error(t, err) + }) + } +} diff --git a/internal/cli/logs/logs.go b/internal/cli/logs/logs.go index bb0773d6..c0b05c0e 100644 --- a/internal/cli/logs/logs.go +++ b/internal/cli/logs/logs.go @@ -41,8 +41,10 @@ func Flags(options *Options) *pflag.FlagSet { set.StringVar(&options.Since, "since", "", "Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+ "Examples:\n"+ - " --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+ - " --since 1h Relative duration (1 hour ago)\n"+ + " --since 1h45m Relative duration (1 hour 45 minutes ago)\n"+ + " Supported units: d (day = 24h), h (hour), m (minute),\n"+ + " s (second), ms (millisecond),\n"+ + " us/µs (microsecond), ns (nanosecond)\n"+ " --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+ " --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+ " --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+ diff --git a/internal/cli/logs/time.go b/internal/cli/logs/time.go index 12ec73df..7d76e0e7 100644 --- a/internal/cli/logs/time.go +++ b/internal/cli/logs/time.go @@ -28,7 +28,7 @@ func timestamp(value string, now time.Time) (string, error) { return "", nil } // A bare zero is the Unix epoch, matching Docker's log filters. - if duration, err := time.ParseDuration(value); value != "0" && err == nil { + if duration, err := ParseDuration(value); value != "0" && err == nil { return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil } diff --git a/internal/cli/logs/time_test.go b/internal/cli/logs/time_test.go index f3556478..eca6f0e4 100644 --- a/internal/cli/logs/time_test.go +++ b/internal/cli/logs/time_test.go @@ -14,8 +14,9 @@ func TestTimeRange(t *testing.T) { location, err := time.LoadLocation("Australia/Sydney") require.NoError(t, err) - // January is daylight-saving time, but July timestamps must use the winter offset. - now := time.Date(2026, 1, 15, 12, 0, 0, 123456789, location) + // October is daylight-saving time, but July timestamps must use the winter offset. + // Daylight saving starts on 4 October. Day durations must still mean 24 elapsed hours. + now := time.Date(2026, 10, 5, 12, 0, 0, 123456789, location) tests := []struct { input string want string @@ -35,8 +36,13 @@ func TestTimeRange(t *testing.T) { {"1763953966", "2025-11-24T03:12:46Z"}, {"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"}, {"0", "1970-01-01T00:00:00Z"}, - {"2m30s", "2026-01-15T00:57:30.123456789Z"}, - {"-1h", "2026-01-15T02:00:00.123456789Z"}, + {"2m30s", "2026-10-05T00:57:30.123456789Z"}, + {"1d", "2026-10-04T01:00:00.123456789Z"}, + {"2d", "2026-10-03T01:00:00.123456789Z"}, + {"2d3h", "2026-10-02T22:00:00.123456789Z"}, + {"1.5d", "2026-10-03T13:00:00.123456789Z"}, + {"-2d", "2026-10-07T01:00:00.123456789Z"}, + {"-1h", "2026-10-05T02:00:00.123456789Z"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { @@ -60,8 +66,8 @@ func TestTimeRange(t *testing.T) { since, until, err := TimeRange("3h", "1h30m", now) require.NoError(t, err) - assert.Equal(t, "2026-01-14T22:00:00.123456789Z", since) - assert.Equal(t, "2026-01-14T23:30:00.123456789Z", until) + assert.Equal(t, "2026-10-04T22:00:00.123456789Z", since) + assert.Equal(t, "2026-10-04T23:30:00.123456789Z", until) } func TestTimeRange_Invalid(t *testing.T) {