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