Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cron/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
)

// ParseOption is a configuration option for creating a parser. Most options specify which
Expand Down Expand Up @@ -115,9 +116,13 @@ func (p Parser) Parse(spec string) (Schedule, error) {
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
var err error

i := strings.Index(spec, " ")
i := strings.IndexFunc(spec, unicode.IsSpace)
eq := strings.Index(spec, "=")

if i == -1 {
return nil, fmt.Errorf("timezone prefix %s is not followed by a schedule", spec)
}

loc, err = time.LoadLocation(spec[eq+1 : i])
if err != nil {
return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err)
Expand Down
55 changes: 55 additions & 0 deletions cron/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ func TestParseScheduleErrors(t *testing.T) {
{"@unrecognized", "unrecognized descriptor"},
{"* * * *", "expected 5 to 6 fields"},
{"", "empty spec string"},
{"TZ=UTC", "not followed by a schedule"},
{"CRON_TZ=Asia/Tokyo", "not followed by a schedule"},
{"TZ=", "not followed by a schedule"},
}
for _, c := range tests {
actual, err := secondParser.Parse(c.expr)
Expand All @@ -168,6 +171,7 @@ func TestParseSchedule(t *testing.T) {
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)},
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)},
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
{secondParser, "CRON_TZ=Asia/Tokyo\t0 5 * * * *", every5min(tokyo)},
{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
{secondParser, "@every 5ms", ConstantDelaySchedule{5 * time.Millisecond}},
{secondParser, "@every 5ns", ConstantDelaySchedule{5 * time.Nanosecond}},
Expand Down Expand Up @@ -409,3 +413,54 @@ func annual(loc *time.Location) *SpecSchedule {
Location: loc,
}
}

// A timezone-prefixed schedule holds its local wall clock across a DST
// transition, shifting the underlying UTC instant by itself.
func TestParseScheduleTimezoneDST(t *testing.T) {
rome, err := time.LoadLocation("Europe/Rome")
if err != nil {
t.Fatalf("loading Europe/Rome: %v", err)
}

sched, err := secondParser.Parse("CRON_TZ=Europe/Rome 0 0 9 * * *")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Europe/Rome leaves DST on 2026-10-25.
next := time.Date(2026, 10, 23, 12, 0, 0, 0, rome)
for _, exp := range []struct{ local, utc string }{
{"2026-10-24T09:00:00+02:00", "2026-10-24T07:00:00Z"},
{"2026-10-25T09:00:00+01:00", "2026-10-25T08:00:00Z"},
{"2026-10-26T09:00:00+01:00", "2026-10-26T08:00:00Z"},
} {
next = sched.Next(next)
if got := next.In(rome).Format(time.RFC3339); got != exp.local {
t.Errorf("local => expected %s, got %s", exp.local, got)
}

if got := next.UTC().Format(time.RFC3339); got != exp.utc {
t.Errorf("utc => expected %s, got %s", exp.utc, got)
}
}
}

// A constant delay has no wall clock for a timezone to apply to, so the prefix
// is accepted but has no effect.
func TestParseScheduleTimezoneIgnoredForEvery(t *testing.T) {
from := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)

withTZ, err := secondParser.Parse("CRON_TZ=Europe/Rome @every 1h")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

without, err := secondParser.Parse("@every 1h")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !withTZ.Next(from).Equal(without.Next(from)) {
t.Errorf("expected %v, got %v", without.Next(from), withTZ.Next(from))
}
}
Loading