diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 22:46:01 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 22:46:01 +0200 |
| commit | 0906167aaed5dfab38cefe3fd001187a9c44006e (patch) | |
| tree | c410a41db31eb300fe0b1d44d5a87dd0825bdb54 | |
| parent | 1f2302b5487e92e261a6b14d9707d744a207b765 (diff) | |
Task 352: add time format parser
| -rw-r--r-- | internal/timefmt/parse.go | 67 | ||||
| -rw-r--r-- | internal/timefmt/parse_test.go | 103 |
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) + } + }) + } +} |
