Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
85 / 85
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
UpdateComposerJsonCommand
100.00% covered (success)
100.00%
85 / 85
100.00% covered (success)
100.00%
5 / 5
17
100.00% covered (success)
100.00%
1 / 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%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
13
 shouldUpdateComposerJson
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 scripts
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 Composer\Command\BaseCommand;
24use Composer\Factory;
25use Composer\Json\JsonManipulator;
26use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
27use FastForward\DevTools\Console\Input\HasJsonOption;
28use FastForward\DevTools\Filesystem\FilesystemInterface;
29use FastForward\DevTools\Path\DevToolsPathResolver;
30use FastForward\DevTools\Resource\FileDiffer;
31use Psr\Log\LoggerInterface;
32use Psr\Log\LogLevel;
33use Symfony\Component\Config\FileLocatorInterface;
34use Symfony\Component\Console\Attribute\AsCommand;
35use Symfony\Component\Console\Input\InputInterface;
36use Symfony\Component\Console\Input\InputOption;
37use Symfony\Component\Console\Output\OutputInterface;
38use Symfony\Component\Filesystem\Path;
39
40use function Safe\getcwd;
41
42/**
43 * Updates composer.json with the Fast Forward dev-tools integration metadata.
44 */
45#[AsCommand(
46    name: 'update-composer-json',
47    description: 'Updates composer.json with Fast Forward dev-tools scripts and metadata.',
48    help: 'This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.'
49)]
50 class UpdateComposerJsonCommand extends BaseCommand implements LoggerAwareCommandInterface
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 FileDiffer $fileDiffer
62     * @param LoggerInterface $logger the output-aware logger
63     */
64    public function __construct(
65        private  ComposerJsonInterface $composer,
66        private  FilesystemInterface $filesystem,
67        private  FileLocatorInterface $fileLocator,
68        private  FileDiffer $fileDiffer,
69        private  LoggerInterface $logger,
70    ) {
71        parent::__construct();
72    }
73
74    /**
75     * Configures the composer file option.
76     */
77    protected function configure(): void
78    {
79        $this->addJsonOption()
80            ->addOption(
81                name: 'file',
82                shortcut: 'f',
83                mode: InputOption::VALUE_OPTIONAL,
84                description: 'Path to the composer.json file to update.',
85                default: Factory::getComposerFile(),
86            )
87            ->addOption(
88                name: 'dry-run',
89                mode: InputOption::VALUE_NONE,
90                description: 'Preview composer.json synchronization without writing the file.',
91            )
92            ->addOption(
93                name: 'check',
94                mode: InputOption::VALUE_NONE,
95                description: 'Report composer.json drift and exit non-zero when updates are required.',
96            )
97            ->addOption(
98                name: 'interactive',
99                mode: InputOption::VALUE_NONE,
100                description: 'Prompt before updating composer.json.',
101            );
102    }
103
104    /**
105     * Updates composer.json when the target file exists.
106     *
107     * @param InputInterface $input the command input
108     * @param OutputInterface $output the command output
109     *
110     * @return int the command status code
111     */
112    protected function execute(InputInterface $input, OutputInterface $output): int
113    {
114        $file = (string) $input->getOption('file');
115        $dryRun = (bool) $input->getOption('dry-run');
116        $check = (bool) $input->getOption('check');
117        $interactive = (bool) $input->getOption('interactive');
118
119        if (! $this->filesystem->exists($file)) {
120            return $this->success(
121                'Composer file {file} does not exist.',
122                $input,
123                [
124                    'file' => $file,
125                ],
126                LogLevel::NOTICE,
127            );
128        }
129
130        $currentContents = $this->filesystem->readFile($file);
131        $manipulator = new JsonManipulator($currentContents);
132        $grumphpConfig = DevToolsPathResolver::getPackagePath('grumphp.yml');
133
134        foreach ($this->scripts() as $name => $command) {
135            $manipulator->addSubNode('scripts', $name, $command);
136        }
137
138        if ('' === $this->composer->getReadme() && $this->filesystem->exists('README.md', \dirname($file))) {
139            $manipulator->addProperty('readme', 'README.md');
140        }
141
142        $manipulator->addSubNode('extra', 'grumphp', [
143            'config-default-path' => Path::makeRelative($grumphpConfig, getcwd()),
144        ], true);
145
146        $updatedContents = $manipulator->getContents();
147        $comparison = $this->fileDiffer->diffContents(
148            'generated dev-tools composer.json configuration',
149            $file,
150            $updatedContents,
151            $currentContents,
152            \sprintf('Updating managed file %s from generated dev-tools composer.json configuration.', $file),
153        );
154
155        $this->notice($comparison->getSummary(), $input, [
156            'file' => $file,
157        ]);
158
159        if ($comparison->isChanged()) {
160            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
161
162            if (null !== $consoleDiff) {
163                $this->notice($consoleDiff, $input, [
164                    'file' => $file,
165                    'diff' => $comparison->getDiff(),
166                ]);
167            }
168        }
169
170        if ($comparison->isUnchanged()) {
171            return self::SUCCESS;
172        }
173
174        if ($check) {
175            return self::FAILURE;
176        }
177
178        if ($dryRun) {
179            return self::SUCCESS;
180        }
181
182        if ($interactive && $input->isInteractive() && ! $this->shouldUpdateComposerJson($file)) {
183            return $this->success('Skipped updating {file}.', $input, [
184                'file' => $file,
185            ], LogLevel::NOTICE,);
186        }
187
188        $this->filesystem->dumpFile($file, $updatedContents);
189
190        return $this->success('Updated composer.json dev-tools configuration.', $input, [
191            'file' => $file,
192        ]);
193    }
194
195    /**
196     * Prompts whether composer.json should be updated.
197     *
198     * @param string $file the composer.json path that would be updated
199     *
200     * @return bool true when the update SHOULD proceed
201     */
202    private function shouldUpdateComposerJson(string $file): bool
203    {
204        return $this->getIO()
205            ->askConfirmation(\sprintf('Update managed file %s? [y/N] ', $file), false);
206    }
207
208    /**
209     * Returns the Composer scripts managed by this command.
210     *
211     * @return array<string, string> the script name to command map
212     */
213    private function scripts(): array
214    {
215        return [
216            'dev-tools' => 'dev-tools',
217            'dev-tools:fix' => '@dev-tools --fix',
218        ];
219    }
220}