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
139 changes: 121 additions & 18 deletions src/Illuminate/Console/Scheduling/ScheduleListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

use Cron\CronExpression;
use DateTimeZone;
use Illuminate\Console\Application;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Console\Terminal;

class ScheduleListCommand extends Command
{
Expand All @@ -23,6 +25,13 @@ class ScheduleListCommand extends Command
*/
protected $description = 'List the scheduled commands';

/**
* The terminal width resolver callback.
*
* @var \Closure|null
*/
protected static $terminalWidthResolver;

/**
* Execute the console command.
*
Expand All @@ -33,25 +42,119 @@ class ScheduleListCommand extends Command
*/
public function handle(Schedule $schedule)
{
foreach ($schedule->events() as $event) {
$rows[] = [
$event->command,
$event->expression,
$event->description,
(new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
->format('Y-m-d H:i:s P'),
$event->mutex->exists($event) ? 'Yes' : '',
];
$events = collect($schedule->events());
$terminalWidth = $this->getTerminalWidth();
$expressionSpacing = $this->getCronExpressionSpacing($events);

$events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing) {
$expression = $this->formatCronExpression($event->expression, $expressionSpacing);

$command = $event->command;

if (! $this->output->isVerbose()) {
$command = str_replace(
Application::artisanBinary(),
preg_replace("#['\"]#", '', Application::artisanBinary()),
str_replace(Application::phpBinary(), 'php', $event->command)
);
}

$command = mb_strlen($command) > 1 ? "{$command} " : '';

$nextDueDateLabel = 'Next Due:';

$nextDueDate = Carbon::create((new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
);

$nextDueDate = $this->output->isVerbose()
? $nextDueDate->format('Y-m-d H:i:s P')
: $nextDueDate->diffForHumans();

$hasMutex = $event->mutex->exists($event) ? 'Has Mutex › ' : '';

$dots = str_repeat('.', max(
$terminalWidth - mb_strlen($expression.$command.$nextDueDateLabel.$nextDueDate.$hasMutex) - 8, 0
));

// Highlight the parameters...
$command = preg_replace("#(=['\"]?)([^'\"]+)(['\"]?)#", '$1<fg=yellow;options=bold>$2</>$3', $command);

return [sprintf(
' <fg=yellow>%s</> %s<fg=#6C7280>%s %s%s %s</>',
$expression,
$command,
$dots,
$hasMutex,
$nextDueDateLabel,
$nextDueDate
), $this->output->isVerbose() && mb_strlen($event->description) > 1 ? sprintf(
' <fg=#6C7280>%s%s %s</>',
str_repeat(' ', mb_strlen($expression) + 2),
'⇁',
$event->description
) : ''];
});

if ($events->isEmpty()) {
return $this->comment('No scheduled tasks have been defined.');
}

$this->table([
'Command',
'Interval',
'Description',
'Next Due',
'Has Mutex',
], $rows ?? []);
$this->output->writeln(
$events->flatten()->filter()->prepend('')->push('')->toArray()
);
}

/**
* Gets the spacing to be used on each event row.
*
* @param \Illuminate\Support\Collection $events
* @return array<int, int>
*/
private function getCronExpressionSpacing($events)
{
$rows = $events->map(fn ($event) => array_map('mb_strlen', explode(' ', $event->expression)));

return collect($rows[0] ?? [])->keys()->map(fn ($key) => $rows->max($key));
}

/**
* Formats the cron expression based on the spacing provided.
*
* @param string $expression
* @param array<int, int> $spacing
* @return string
*/
private function formatCronExpression($expression, $spacing)
{
$expression = explode(' ', $expression);

return collect($spacing)
->map(fn ($length, $index) => $expression[$index] = str_pad($expression[$index], $length))
->implode(' ');
}

/**
* Get the terminal width.
*
* @return int
*/
public static function getTerminalWidth()
{
return is_null(static::$terminalWidthResolver)
? (new Terminal)->getWidth()
: call_user_func(static::$terminalWidthResolver);
}

/**
* Set a callback that should be used when resolving the terminal width.
*
* @param \Closure|null $resolver
* @return void
*/
public static function resolveTerminalWidthUsing($resolver)
{
static::$terminalWidthResolver = $resolver;
}
}
64 changes: 64 additions & 0 deletions tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Illuminate\Tests\Integration\Console\Scheduling;

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\ScheduleListCommand;
use Illuminate\Support\Carbon;
use Illuminate\Support\ProcessUtils;
use Orchestra\Testbench\TestCase;

class ScheduleListCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

Carbon::setTestNow(now()->startOfYear());
ScheduleListCommand::resolveTerminalWidthUsing(fn () => 80);

$this->schedule = $this->app->make(Schedule::class);
}

public function testDisplaySchedule()
{
$this->schedule->call(fn () => '')->everyMinute();
$this->schedule->command(FooCommand::class)->quarterly();
$this->schedule->command('inspire')->twiceDaily(14, 18);
$this->schedule->command('foobar', ['a' => 'b'])->everyMinute();

$this->artisan(ScheduleListCommand::class)
->assertSuccessful()
->expectsOutput(' * * * * * ............................ Next Due: 1 minute from now')
->expectsOutput(' 0 0 1 1-12/3 * php artisan foo:command .... Next Due: 3 months from now')
->expectsOutput(' 0 14,18 * * * php artisan inspire ........ Next Due: 14 hours from now')
->expectsOutput(' * * * * * php artisan foobar a='.ProcessUtils::escapeArgument('b').' ... Next Due: 1 minute from now');
}

public function testDisplayScheduleInVerboseMode()
{
$this->schedule->command(FooCommand::class)->everyMinute();

$this->artisan(ScheduleListCommand::class, ['-v' => true])
->assertSuccessful()
->expectsOutputToContain('Next Due: '.now()->setMinutes(1)->format('Y-m-d H:i:s P'))
->expectsOutput(' ⇁ This is the description of the command.');
}

protected function tearDown(): void
{
parent::tearDown();

putenv('SHELL_VERBOSITY');

ScheduleListCommand::resolveTerminalWidthUsing(null);
}
}

class FooCommand extends Command
{
protected $signature = 'foo:command';

protected $description = 'This is the description of the command.';
}