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