Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.32% covered (success)
97.32%
109 / 112
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateComposerJsonCommand
97.32% covered (success)
97.32%
109 / 112
83.33% covered (warning)
83.33%
5 / 6
24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
10
 shouldUpdateComposerJson
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 updatedComposerJsonContents
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
8.10
 getScripts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Filesystem\FilesystemInterface;
26use FastForward\DevTools\GrumPhp\ManagedConfigPathSynchronizer;
27use FastForward\DevTools\Path\DevToolsPathResolver;
28use FastForward\DevTools\Resource\FileDiffer;
29use FastForward\DevTools\SelfUpdate\VersionCheckerInterface;
30use Psr\Log\LogLevel;
31use Symfony\Component\Config\FileLocatorInterface;
32use Symfony\Component\Console\Attribute\AsCommand;
33use Symfony\Component\Console\Command\Command;
34use Symfony\Component\Console\Input\InputInterface;
35use Symfony\Component\Console\Input\InputOption;
36use Symfony\Component\Console\Output\OutputInterface;
37use Symfony\Component\Console\Question\ConfirmationQuestion;
38use Symfony\Component\Console\Style\SymfonyStyle;
39
40use function Safe\json_decode;
41use function Safe\json_encode;
42use function Safe\preg_match;
43
44/**
45 * Updates composer.json with the Fast Forward dev-tools integration metadata.
46 */
47#[AsCommand(
48    name: 'dev-tools:sync:composer',
49    description: 'Updates composer.json with Fast Forward dev-tools scripts and metadata.',
50    aliases: ['composer.json', 'update-composer-json'],
51)]
52 class UpdateComposerJsonCommand extends Command
53{
54    use HasJsonOption;
55    use LogsCommandResults;
56
57    /**
58     * Creates a new UpdateComposerJsonCommand instance.
59     *
60     * @param ComposerJsonInterface $composer the composer.json metadata accessor
61     * @param FilesystemInterface $filesystem the filesystem used to read and write composer.json
62     * @param FileLocatorInterface $fileLocator the locator used to resolve packaged configuration files
63     * @param ManagedConfigPathSynchronizer $managedConfigPathSynchronizer synchronizes managed GrumPHP metadata
64     * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes
65     * @param SymfonyStyle $io the input/output service used to interact with the user
66     * @param VersionCheckerInterface $versionChecker
67     */
68    public function __construct(
69        private  ComposerJsonInterface $composer,
70        private  FilesystemInterface $filesystem,
71        private  FileLocatorInterface $fileLocator,
72        private  ManagedConfigPathSynchronizer $managedConfigPathSynchronizer,
73        private  FileDiffer $fileDiffer,
74        private  SymfonyStyle $io,
75        private  VersionCheckerInterface $versionChecker,
76    ) {
77        parent::__construct();
78    }
79
80    /**
81     * Configures the composer file option.
82     */
83    protected function configure(): void
84    {
85        $this->setHelp('This command adds or updates composer.json scripts and managed dev-tools metadata.');
86
87        $this->addJsonOption()
88            ->addOption(
89                name: 'file',
90                shortcut: 'f',
91                mode: InputOption::VALUE_OPTIONAL,
92                description: 'Path to the composer.json file to update.',
93                default: 'composer.json',
94            )
95            ->addOption(
96                name: 'dry-run',
97                mode: InputOption::VALUE_NONE,
98                description: 'Preview composer.json synchronization without writing the file.',
99            )
100            ->addOption(
101                name: 'check',
102                mode: InputOption::VALUE_NONE,
103                description: 'Report composer.json drift and exit non-zero when updates are required.',
104            )
105            ->addOption(
106                name: 'interactive',
107                mode: InputOption::VALUE_NONE,
108                description: 'Prompt before updating composer.json.',
109            );
110    }
111
112    /**
113     * Updates composer.json when the target file exists.
114     *
115     * @param InputInterface $input the command input
116     * @param OutputInterface $output the command output
117     *
118     * @return int the command status code
119     */
120    protected function execute(InputInterface $input, OutputInterface $output): int
121    {
122        $file = (string) $input->getOption('file');
123        $dryRun = (bool) $input->getOption('dry-run');
124        $check = (bool) $input->getOption('check');
125        $interactive = (bool) $input->getOption('interactive');
126
127        if (! $this->filesystem->exists($file)) {
128            return $this->success(
129                'Composer file {file} does not exist.',
130                $input,
131                [
132                    'file' => $file,
133                ],
134                LogLevel::NOTICE,
135            );
136        }
137
138        $currentContents = $this->filesystem->readFile($file);
139        $updatedContents = $this->updatedComposerJsonContents($currentContents, $file);
140        $comparison = $this->fileDiffer->diffContents(
141            'generated dev-tools composer.json configuration',
142            $file,
143            $updatedContents,
144            $currentContents,
145            \sprintf('Updating managed file %s from generated dev-tools composer.json configuration.', $file),
146        );
147
148        $this->log($comparison->getSummary(), $input, [
149            'file' => $file,
150        ], LogLevel::NOTICE);
151
152        if ($comparison->isChanged()) {
153            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
154
155            if (null !== $consoleDiff) {
156                $this->log($consoleDiff, $input, [
157                    'file' => $file,
158                    'diff' => $comparison->getDiff(),
159                ], LogLevel::NOTICE);
160            }
161        }
162
163        if ($comparison->isUnchanged()) {
164            return self::SUCCESS;
165        }
166
167        if ($check) {
168            return self::FAILURE;
169        }
170
171        if ($dryRun) {
172            return self::SUCCESS;
173        }
174
175        if ($interactive && $input->isInteractive() && ! $this->shouldUpdateComposerJson($file)) {
176            return $this->success('Skipped updating {file}.', $input, [
177                'file' => $file,
178            ], LogLevel::NOTICE,);
179        }
180
181        $this->filesystem->dumpFile($file, $updatedContents);
182
183        return $this->success('Updated composer.json dev-tools configuration.', $input, [
184            'file' => $file,
185        ]);
186    }
187
188    /**
189     * Prompts whether composer.json should be updated.
190     *
191     * @param string $file the composer.json path that would be updated
192     *
193     * @return bool true when the update SHOULD proceed
194     */
195    private function shouldUpdateComposerJson(string $file): bool
196    {
197        $confirmationMessage = \sprintf(
198            'composer.json file %s has changes. Do you want to update it with the new dev-tools configuration?',
199            $file,
200        );
201
202        $confirmation = new ConfirmationQuestion($confirmationMessage, false);
203
204        return $this->io->askQuestion($confirmation);
205    }
206
207    /**
208     * Builds the managed composer.json payload.
209     *
210     * @param string $currentContents the current composer.json file contents
211     * @param string $file the path being updated, used to resolve local README checks
212     *
213     * @return string the composer.json payload with managed sections applied
214     */
215    private function updatedComposerJsonContents(string $currentContents, string $file): string
216    {
217        $composerJsonData = json_decode($currentContents, true, 512, \JSON_THROW_ON_ERROR);
218
219        if (! \is_array($composerJsonData)) {
220            $composerJsonData = [];
221        }
222
223        $scripts = $composerJsonData['scripts'] ?? [];
224        if (! \is_array($scripts)) {
225            $scripts = [];
226        }
227
228        foreach ($this->getScripts($composerJsonData['name'] ?? '') as $name => $command) {
229            $scripts[$name] = $command;
230        }
231
232        $composerJsonData['scripts'] = $scripts;
233
234        if ('' === $this->composer->getReadme() && $this->filesystem->exists(
235            'README.md',
236            \dirname($file)
237        ) && ! isset($composerJsonData['readme'])) {
238            $composerJsonData['readme'] = 'README.md';
239        }
240
241        $extra = $composerJsonData['extra'] ?? [];
242        if (! \is_array($extra)) {
243            $extra = [];
244        }
245
246        $composerJsonData['extra'] = $this->managedConfigPathSynchronizer->synchronize(
247            $extra,
248            \dirname($file),
249            $this->fileLocator->locate('grumphp.yml', DevToolsPathResolver::getPackagePath())
250        );
251
252        return json_encode(
253            $composerJsonData,
254            \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
255        ) . "\n";
256    }
257
258    /**
259     * Returns the Composer scripts managed by this command.
260     *
261     * @param string $packageName
262     *
263     * @return array<string, string> the script name to command map
264     */
265    private function getScripts(string $packageName): array
266    {
267        $version = $this->versionChecker->getCurrentVersion();
268
269        if (1 !== preg_match('/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/', $version)) {
270            $version = '2.0.0';
271        }
272
273        return [
274            'dev-tools' => 'fast-forward/dev-tools' === $packageName
275                ? '@php bin/dev-tools'
276                : 'cpx --skip-local fast-forward/dev-tools:' . ltrim($version, 'v'),
277            'dev-tools:fix' => '@dev-tools --fix',
278        ];
279    }
280}