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