summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:46:01 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:46:01 +0200
commit0906167aaed5dfab38cefe3fd001187a9c44006e (patch)
treec410a41db31eb300fe0b1d44d5a87dd0825bdb54
parent1f2302b5487e92e261a6b14d9707d744a207b765 (diff)
Task 352: add time format parser
-rw-r--r--internal/timefmt/parse.go67
-rw-r--r--internal/timefmt/parse_test.go103
2 files changed, 170 insertions, 0 deletions
diff --git a/internal/timefmt/parse.go b/internal/timefmt/parse.go
new file mode 100644
index 0000000..a79df16
--- /dev/null
+++ b/internal/timefmt/parse.go
@@ -0,0 +1,67 @@
+package timefmt
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var unixTimestampPattern = regexp.MustCompile(`^[+-]?\d+$`)
+
+var localLayouts = []string{
+ "2006-01-02",
+ "2006-01-02T15:04",
+ "2006-01-02 15:04",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+}
+
+// Parse converts timestamp/date text into a time value.
+func Parse(value string) (time.Time, error) {
+ return ParseAt(value, time.Now())
+}
+
+// ParseAt behaves like Parse but uses a provided "now" reference for relative values.
+func ParseAt(value string, now time.Time) (time.Time, error) {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return time.Time{}, errors.New("time value must not be empty")
+ }
+
+ lower := strings.ToLower(trimmed)
+ switch lower {
+ case "today":
+ return startOfDay(now), nil
+ case "yesterday":
+ return startOfDay(now.AddDate(0, 0, -1)), nil
+ }
+
+ if unixTimestampPattern.MatchString(trimmed) {
+ seconds, err := strconv.ParseInt(trimmed, 10, 64)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("parse unix timestamp %q: %w", value, err)
+ }
+ return time.Unix(seconds, 0), nil
+ }
+
+ if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil {
+ return parsed, nil
+ }
+
+ for _, layout := range localLayouts {
+ parsed, err := time.ParseInLocation(layout, trimmed, now.Location())
+ if err == nil {
+ return parsed, nil
+ }
+ }
+
+ return time.Time{}, fmt.Errorf("unsupported time format %q", value)
+}
+
+func startOfDay(value time.Time) time.Time {
+ year, month, day := value.Date()
+ return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
+}
diff --git a/internal/timefmt/parse_test.go b/internal/timefmt/parse_test.go
new file mode 100644
index 0000000..63974fa
--- /dev/null
+++ b/internal/timefmt/parse_test.go
@@ -0,0 +1,103 @@
+package timefmt
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseAtRelativeValues(t *testing.T) {
+ now := time.Date(2026, 3, 3, 15, 4, 5, 0, time.FixedZone("EET", 2*3600))
+
+ today, err := ParseAt("today", now)
+ if err != nil {
+ t.Fatalf("ParseAt(today) error = %v", err)
+ }
+ wantToday := time.Date(2026, 3, 3, 0, 0, 0, 0, now.Location())
+ if !today.Equal(wantToday) {
+ t.Fatalf("ParseAt(today) = %v, want %v", today, wantToday)
+ }
+
+ yesterday, err := ParseAt("yesterday", now)
+ if err != nil {
+ t.Fatalf("ParseAt(yesterday) error = %v", err)
+ }
+ wantYesterday := time.Date(2026, 3, 2, 0, 0, 0, 0, now.Location())
+ if !yesterday.Equal(wantYesterday) {
+ t.Fatalf("ParseAt(yesterday) = %v, want %v", yesterday, wantYesterday)
+ }
+}
+
+func TestParseUnixTimestamp(t *testing.T) {
+ got, err := ParseAt("1714424400", time.Now())
+ if err != nil {
+ t.Fatalf("ParseAt(unix) error = %v", err)
+ }
+
+ want := time.Unix(1714424400, 0)
+ if !got.Equal(want) {
+ t.Fatalf("ParseAt(unix) = %v, want %v", got, want)
+ }
+}
+
+func TestParseISOValues(t *testing.T) {
+ loc := time.FixedZone("EET", 2*3600)
+ now := time.Date(2026, 3, 3, 12, 0, 0, 0, loc)
+
+ tests := []struct {
+ name string
+ input string
+ want time.Time
+ }{
+ {
+ name: "date only",
+ input: "2024-01-15",
+ want: time.Date(2024, 1, 15, 0, 0, 0, 0, loc),
+ },
+ {
+ name: "datetime minutes",
+ input: "2024-01-15T09:30",
+ want: time.Date(2024, 1, 15, 9, 30, 0, 0, loc),
+ },
+ {
+ name: "datetime with seconds and space",
+ input: "2024-01-15 09:30:45",
+ want: time.Date(2024, 1, 15, 9, 30, 45, 0, loc),
+ },
+ {
+ name: "rfc3339",
+ input: "2024-01-15T09:30:00Z",
+ want: time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC),
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ got, err := ParseAt(test.input, now)
+ if err != nil {
+ t.Fatalf("ParseAt(%q) error = %v", test.input, err)
+ }
+ if !got.Equal(test.want) {
+ t.Fatalf("ParseAt(%q) = %v, want %v", test.input, got, test.want)
+ }
+ })
+ }
+}
+
+func TestParseInvalidValues(t *testing.T) {
+ inputs := []string{
+ "",
+ " ",
+ "banana",
+ "2024-99-99",
+ }
+
+ for _, input := range inputs {
+ input := input
+ t.Run(input, func(t *testing.T) {
+ if _, err := ParseAt(input, time.Now()); err == nil {
+ t.Fatalf("ParseAt(%q) error = nil, want error", input)
+ }
+ })
+ }
+}