Lines 91.30% 21 / 23
Functions and Methods 80.00% 4 / 5
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
CronSchedule 91.30% 21 / 23 80.00% 4 / 5 10.07 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 parse 100.00% 5 / 5 100.00% 1 / 1 2
 findNextOccurrenceAfter 86.66% 13 / 15 0.00% 0 / 1 5.06
 describe 100.00% 1 / 1 100.00% 1 / 1 1
 isRecurring 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Support\Schedule;
6
7use Cron\CronExpression;
8use DateInterval;
9use DateTimeImmutable;
10use DateTimeZone;
11use InvalidArgumentException;
12use RuntimeException;
13use Stewart\Contracts\Exception\ScheduleError;
14use Stewart\Contracts\Exception\ScheduleException;
15use Stewart\Contracts\Schedule\TimeOfDay;
16
17/** @internal */
18final readonly class CronSchedule implements WallClockSchedule
19{
20    // Bounds expressions no calendar satisfies, such as "0 0 30 2 *".
21    private const int MAX_CANDIDATE_EVALUATIONS = 10_000;
22
23    // DST gaps last hours, so a candidate over a day past the first always resolves; stop there.
24    private const string DST_RESOLUTION_HORIZON = 'P1D';
25
26    private function __construct(
27        public string $expression,
28        private CronExpression $parsed,
29    ) {}
30
31    /** @throws ScheduleException */
32    public static function parse(string $expression): self
33    {
34        try {
35            $parsed = new CronExpression($expression);
36        } catch (InvalidArgumentException $e) {
37            throw ScheduleException::createForReason(ScheduleError::CronInvalid, $e, expression: $expression);
38        }
39
40        $parsed->setMaxIterationCount(self::MAX_CANDIDATE_EVALUATIONS);
41
42        return new self($expression, $parsed);
43    }
44
45    public function findNextOccurrenceAfter(DateTimeImmutable $after): ?DateTimeImmutable
46    {
47        $zone = $after->getTimezone();
48        // Walk wall time as UTC (no transitions); TimeOfDay::resolveOnDay applies the DST rule.
49        $cursor = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $after->format('Y-m-d H:i:s'), new DateTimeZone('UTC'));
50
51        if ($cursor === false) {
52            return null;
53        }
54
55        $horizon = null;
56
57        do {
58            try {
59                $occurrence = DateTimeImmutable::createFromInterface($this->parsed->getNextRunDate($cursor, 0, false, 'UTC'));
60            } catch (RuntimeException) {
61                return null;
62            }
63
64            $horizon ??= $occurrence->add(new DateInterval(self::DST_RESOLUTION_HORIZON));
65            $candidate = TimeOfDay::fromDateTime($occurrence)->resolveOnDay($occurrence, $zone);
66
67            if ($candidate !== null && $candidate > $after) {
68                return $candidate;
69            }
70
71            $cursor = $occurrence;
72        } while ($cursor <= $horizon);
73
74        return null;
75    }
76
77    public function describe(): string
78    {
79        return \sprintf('cron "%s"', $this->expression);
80    }
81
82    public function isRecurring(): bool
83    {
84        return true;
85    }
86}