ScheduleListCommand::extensionHighlightMap()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 5
c 1
b 0
f 0
dl 0
loc 7
ccs 1
cts 1
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
namespace Zenstruck\ScheduleBundle\Command;
4
5
use Lorisleiva\CronTranslator\CronParsingException;
6
use Lorisleiva\CronTranslator\CronTranslator;
7
use Symfony\Component\Console\Command\Command;
8
use Symfony\Component\Console\Input\InputInterface;
9
use Symfony\Component\Console\Input\StringInput;
10
use Symfony\Component\Console\Output\OutputInterface;
11
use Symfony\Component\Console\Style\SymfonyStyle;
12
use Zenstruck\ScheduleBundle\Schedule;
13
use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandlerRegistry;
14
use Zenstruck\ScheduleBundle\Schedule\ScheduleRunner;
15
use Zenstruck\ScheduleBundle\Schedule\Task;
16
use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask;
17
18
/**
19
 * @author Kevin Bond <[email protected]>
20
 */
21
final class ScheduleListCommand extends Command
22
{
23
    protected static $defaultName = 'schedule:list';
24
25
    /** @var ScheduleRunner */
26
    private $scheduleRunner;
27
28 10
    /** @var ExtensionHandlerRegistry */
29
    private $handlerRegistry;
30 10
31 10
    public function __construct(ScheduleRunner $scheduleRunner, ExtensionHandlerRegistry $handlerRegistry)
32
    {
33 10
        $this->scheduleRunner = $scheduleRunner;
34 10
        $this->handlerRegistry = $handlerRegistry;
35
36 10
        parent::__construct();
37
    }
38
39 10
    protected function configure(): void
40 10
    {
41 10
        $this
42 10
            ->setDescription('List configured scheduled tasks')
43
            ->addOption('detail', null, null, 'Show detailed task list')
44
            ->setHelp(<<<EOF
45
Exit code 0: no issues.
46
Exit code 1: some issues.
47 10
EOF
48
            )
49 10
        ;
50
    }
51 10
52
    protected function execute(InputInterface $input, OutputInterface $output): int
53 10
    {
54 1
        $schedule = $this->scheduleRunner->buildSchedule();
55
56
        if (0 === \count($schedule->all())) {
57 9
            throw new \RuntimeException('No scheduled tasks configured.');
58
        }
59 9
60
        $io = new SymfonyStyle($input, $output);
61 9
62
        $io->title(\sprintf('<info>%d</info> Scheduled Task%s Configured', \count($schedule->all()), \count($schedule->all()) > 1 ? 's' : ''));
63 9
64
        $exit = $input->getOption('detail') ? $this->renderDetail($schedule, $io) : $this->renderTable($schedule, $io);
65 9
66
        $this->renderExtenstions($io, 'Schedule', $schedule->getExtensions());
67 9
68 3
        $scheduleIssues = \iterator_to_array($this->getScheduleIssues($schedule), false);
69
70 3
        if ($issueCount = \count($scheduleIssues)) {
71
            $io->warning(\sprintf('%d issue%s with schedule:', $issueCount, $issueCount > 1 ? 's' : ''));
72
73 9
            $exit = 1;
74
        }
75 9
76 4
        $this->renderIssues($io, ...$scheduleIssues);
77
78
        if (0 === $exit) {
79 9
            $io->success('No schedule or task issues.');
80
        }
81
82 3
        return $exit;
83
    }
84 3
85
    private function renderDetail(Schedule $schedule, SymfonyStyle $io): int
86 3
    {
87 3
        $exit = 0;
88
89 3
        foreach ($schedule->all() as $i => $task) {
90
            $io->section(\sprintf('(%d/%d) %s', $i + 1, \count($schedule->all()), $task));
91 3
92 1
            $details = [];
93
94
            foreach ($task->getContext() as $key => $value) {
95 3
                $details[] = [$key => $value];
96 3
            }
97
98 3
            $details[] = ['Task ID' => $task->getId()];
99
            $details[] = ['Task Class' => \get_class($task)];
100 3
101 1
            $details[] = [$task->getExpression()->isHashed() ? 'Calculated Frequency' : 'Frequency' => $this->renderFrequency($task)];
102
103
            if ($task->getExpression()->isHashed()) {
104 3
                $details[] = ['Raw Frequency' => $task->getExpression()->getRawValue()];
105
            }
106 3
107 3
            $details[] = ['Next Run' => $task->getNextRun()->format('D, M d, Y @ g:i (e O)')];
108
109 3
            $this->renderDefinitionList($io, $details);
110
            $this->renderExtenstions($io, 'Task', $task->getExtensions());
111 3
112 1
            $issues = \iterator_to_array($this->getTaskIssues($task), false);
113
114
            if ($issueCount = \count($issues)) {
115 3
                $io->warning(\sprintf('%d issue%s with this task:', $issueCount, $issueCount > 1 ? 's' : ''));
116
            }
117 3
118 1
            $this->renderIssues($io, ...$issues);
119
120
            if ($issueCount > 0) {
121
                $exit = 1;
122 3
            }
123
        }
124
125
        return $exit;
126
    }
127
128 3
    /**
129
     * BC - Symfony 4.4 added SymfonyStyle::definitionList().
130 3
     *
131 3
     * @param array<string[]> $list
132
     */
133 3
    private function renderDefinitionList(SymfonyStyle $io, array $list): void
134
    {
135
        if (\method_exists($io, 'definitionList')) {
136
            $io->definitionList(...$list);
137
138
            return;
139
        }
140
141
        $io->listing(\array_map(
142
            function(array $line) {
143
                return \sprintf('<info>%s:</info> %s', \array_keys($line)[0], \array_values($line)[0]);
144 7
            },
145
            $list
146
        ));
147 7
    }
148 7
149
    private function renderTable(Schedule $schedule, SymfonyStyle $io): int
150 7
    {
151 7
        /** @var array<\Throwable[]> $taskIssues */
152 7
        $taskIssues = [];
153
        $rows = [];
154 7
155 7
        foreach ($schedule->all() as $task) {
156 7
            $issues = \iterator_to_array($this->getTaskIssues($task), false);
157 7
            $taskIssues[] = $issues;
158 7
159 7
            $rows[] = [
160
                \count($issues) ? "<error>[!] {$task->getType()}</error>" : $task->getType(),
161
                $this->getHelper('formatter')->truncate($task->getDescription(), 50),
162
                \count($task->getExtensions()),
163 7
                $this->renderFrequency($task),
164
                $task->getNextRun()->format(\DATE_ATOM),
165 7
            ];
166
        }
167 7
168 3
        $taskIssues = \array_merge([], ...$taskIssues);
169
170
        $io->table(['Type', 'Description', 'Extensions', 'Frequency', 'Next Run'], $rows);
171 7
172
        if ($issueCount = \count($taskIssues)) {
173 7
            $io->warning(\sprintf('%d task issue%s:', $issueCount, $issueCount > 1 ? 's' : ''));
174
        }
175 7
176
        $this->renderIssues($io, ...$taskIssues);
177
178
        $io->note('For more details, run php bin/console schedule:list --detail');
179
180
        return \count($taskIssues) ? 1 : 0;
181 9
    }
182
183 9
    /**
184 7
     * @param object[] $extensions
185
     */
186
    private function renderExtenstions(SymfonyStyle $io, string $type, array $extensions): void
187 2
    {
188 2
        if (0 === $count = \count($extensions)) {
189
            return;
190 2
        }
191 2
192 2
        $io->comment(\sprintf('<info>%d</info> %s Extension%s:', $count, $type, $count > 1 ? 's' : ''));
193 2
        $io->listing(\array_map(
194
            function(object $extension) {
195
                if (\method_exists($extension, '__toString')) {
196
                    return \sprintf('%s <comment>(%s)</comment>',
197
                        \strtr($extension, self::extensionHighlightMap()),
0 ignored issues
show
Bug introduced by
$extension of type object is incompatible with the type string expected by parameter $str of strtr(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

197
                        \strtr(/** @scrutinizer ignore-type */ $extension, self::extensionHighlightMap()),
Loading history...
198 2
                        \get_class($extension)
199 2
                    );
200
                }
201 2
202
                return \get_class($extension);
203
            },
204
            $extensions
205
        ));
206 9
    }
207
208 9
    /**
209
     * @return \Traversable<\Throwable>
210 2
     */
211 2
    private function getScheduleIssues(Schedule $schedule): iterable
212 2
    {
213
        foreach ($schedule->getExtensions() as $extension) {
214
            try {
215
                $this->handlerRegistry->handlerFor($extension);
216
            } catch (\Throwable $e) {
217 9
                yield $e;
218
            }
219 9
        }
220 9
221
        // check for duplicated task ids
222
        $tasks = [];
223 9
224 9
        foreach ($schedule->all() as $task) {
225
            $tasks[$task->getId()][] = $task;
226 9
        }
227 9
228
        foreach ($tasks as $taskGroup) {
229
            $count = \count($taskGroup);
230 1
231
            if (1 === $count) {
232 1
                continue;
233
            }
234 9
235
            $task = $taskGroup[0];
236 2
237
            yield new \LogicException(\sprintf('Task "%s" (%s) is duplicated %d times. Make their descriptions unique to fix.', $task, $task->getExpression(), $count));
238
        }
239 2
    }
240 2
241 2
    /**
242 2
     * @return array<string,string>
243
     */
244
    private static function extensionHighlightMap(): array
245
    {
246
        return [
247
            Task::SUCCESS => \sprintf('<info>%s</info>', Task::SUCCESS),
248
            Schedule::SUCCESS => \sprintf('<info>%s</info>', Schedule::SUCCESS),
249 9
            Task::FAILURE => \sprintf('<error>%s</error>', Task::FAILURE),
250
            Schedule::FAILURE => \sprintf('<error>%s</error>', Schedule::FAILURE),
251
        ];
252 9
    }
253 4
254 4
    /**
255
     * @return \Traversable<\Throwable>
256
     */
257 9
    private function getTaskIssues(Task $task): iterable
258
    {
259 4
        try {
260 1
            $this->scheduleRunner->runnerFor($task);
261 1
        } catch (\Throwable $e) {
262
            yield $e;
263 1
        }
264 4
265 4
        if ($task instanceof CommandTask && $application = $this->getApplication()) {
266
            try {
267
                $definition = $task->createCommand($application)->getDefinition();
268
                $definition->addOptions($application->getDefinition()->getOptions());
269 9
                $input = new StringInput($task->getArguments());
270
271 2
                $input->bind($definition);
272 2
            } catch (\Throwable $e) {
273 2
                yield $e;
274
            }
275
        }
276 9
277
        foreach ($task->getExtensions() as $extension) {
278 9
            try {
279
                $this->handlerRegistry->handlerFor($extension);
280 9
            } catch (\Throwable $e) {
281 5
                yield $e;
282 4
            }
283
        }
284 4
    }
285
286
    private function renderIssues(SymfonyStyle $io, \Throwable ...$issues): void
287
    {
288 1
        foreach ($issues as $issue) {
289 1
            if (OutputInterface::VERBOSITY_NORMAL === $io->getVerbosity()) {
290
                $io->error($issue->getMessage());
291
292
                continue;
293
            }
294 9
295
            if (!$application = $this->getApplication()) {
296 9
                $io->error((string) $issue);
297
298 9
                continue;
299
            }
300 9
301
            // BC - Symfony 4.4 deprecated Application::renderException()
302
            if (\method_exists($application, 'renderThrowable')) {
303
                $application->renderThrowable($issue, $io);
304
            } else {
305 9
                $application->renderException($issue, $io);
0 ignored issues
show
Bug introduced by
The method renderException() does not exist on Symfony\Component\Console\Application. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

305
                $application->/** @scrutinizer ignore-call */ 
306
                              renderException($issue, $io);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
306
            }
307
        }
308
    }
309
310
    private function renderFrequency(Task $task): string
311
    {
312
        $expression = (string) $task->getExpression();
313
314
        if (!\class_exists(CronTranslator::class)) {
315
            return $expression;
316
        }
317
318
        try {
319
            return \sprintf('%s (%s)', $expression, CronTranslator::translate($expression));
320
        } catch (CronParsingException $e) {
321
            return $expression;
322
        }
323
    }
324
}
325