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 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Support\Schedule; | |
| 6 | ||
| 7 | use Cron\CronExpression; | |
| 8 | use DateInterval; | |
| 9 | use DateTimeImmutable; | |
| 10 | use DateTimeZone; | |
| 11 | use InvalidArgumentException; | |
| 12 | use RuntimeException; | |
| 13 | use Stewart\Contracts\Exception\ScheduleError; | |
| 14 | use Stewart\Contracts\Exception\ScheduleException; | |
| 15 | use Stewart\Contracts\Schedule\TimeOfDay; | |
| 16 | ||
| 17 | /** @internal */ | |
| 18 | final 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 | } |