Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.95% covered (warning)
81.95%
227 / 277
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FundingCommand
81.95% covered (warning)
81.95%
227 / 277
71.43% covered (warning)
71.43%
5 / 7
39.40
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%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
4
 handleComposerFile
90.00% covered (success)
90.00%
72 / 80
0.00% covered (danger)
0.00%
0 / 1
10.10
 handleFundingFile
57.14% covered (warning)
57.14%
56 / 98
0.00% covered (danger)
0.00%
0 / 1
26.30
 shouldWriteManagedFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 normalizeComposerFile
100.00% covered (success)
100.00%
11 / 11
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 Composer\Command\BaseCommand;
23use Composer\Factory;
24use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
25use FastForward\DevTools\Console\Input\HasJsonOption;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Funding\ComposerFundingCodec;
28use FastForward\DevTools\Funding\FundingProfileMerger;
29use FastForward\DevTools\Funding\FundingYamlCodec;
30use FastForward\DevTools\Process\ProcessBuilderInterface;
31use FastForward\DevTools\Process\ProcessQueueInterface;
32use FastForward\DevTools\Resource\FileDiffer;
33use Psr\Log\LoggerInterface;
34use Symfony\Component\Console\Attribute\AsCommand;
35use Symfony\Component\Console\Input\InputInterface;
36use Symfony\Component\Console\Input\InputOption;
37use Symfony\Component\Console\Output\OutputInterface;
38
39/**
40 * Synchronizes funding metadata between composer.json and .github/FUNDING.yml.
41 */
42#[AsCommand(
43    name: 'funding',
44    description: 'Synchronizes funding metadata between composer.json and .github/FUNDING.yml.',
45    help: 'This command merges supported funding entries across composer.json and .github/FUNDING.yml while preserving unsupported providers.'
46)]
47 class FundingCommand extends BaseCommand implements LoggerAwareCommandInterface
48{
49    use HasJsonOption;
50    use LogsCommandResults;
51
52    /**
53     * Creates a new FundingCommand instance.
54     *
55     * @param FilesystemInterface $filesystem the filesystem used to read and write funding metadata files
56     * @param ComposerFundingCodec $composerFundingCodec the codec used to parse and render Composer funding metadata
57     * @param FundingYamlCodec $fundingYamlCodec the codec used to parse and render GitHub funding YAML metadata
58     * @param FundingProfileMerger $fundingProfileMerger the merger used to synchronize normalized funding profiles
59     * @param FileDiffer $fileDiffer the differ used to summarize managed-file drift
60     * @param ProcessBuilderInterface $processBuilder the process builder used to normalize composer.json after updates
61     * @param ProcessQueueInterface $processQueue the process queue used to execute composer normalize
62     * @param LoggerInterface $logger the output-aware logger
63     */
64    public function __construct(
65        private  FilesystemInterface $filesystem,
66        private  ComposerFundingCodec $composerFundingCodec,
67        private  FundingYamlCodec $fundingYamlCodec,
68        private  FundingProfileMerger $fundingProfileMerger,
69        private  FileDiffer $fileDiffer,
70        private  ProcessBuilderInterface $processBuilder,
71        private  ProcessQueueInterface $processQueue,
72        private  LoggerInterface $logger,
73    ) {
74        parent::__construct();
75    }
76
77    /**
78     * Configures command options.
79     */
80    protected function configure(): void
81    {
82        $this->addJsonOption()
83            ->addOption(
84                name: 'composer-file',
85                mode: InputOption::VALUE_OPTIONAL,
86                description: 'Path to the composer.json file to synchronize.',
87                default: Factory::getComposerFile(),
88            )
89            ->addOption(
90                name: 'funding-file',
91                mode: InputOption::VALUE_OPTIONAL,
92                description: 'Path to the .github/FUNDING.yml file to synchronize.',
93                default: '.github/FUNDING.yml',
94            )
95            ->addOption(
96                name: 'dry-run',
97                mode: InputOption::VALUE_NONE,
98                description: 'Preview funding metadata synchronization without writing files.',
99            )
100            ->addOption(
101                name: 'check',
102                mode: InputOption::VALUE_NONE,
103                description: 'Report funding metadata drift and exit non-zero when updates are required.',
104            )
105            ->addOption(
106                name: 'interactive',
107                mode: InputOption::VALUE_NONE,
108                description: 'Prompt before applying funding metadata updates.',
109            );
110    }
111
112    /**
113     * Synchronizes funding metadata across Composer and GitHub files.
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        $composerFile = (string) $input->getOption('composer-file');
123        $fundingFile = (string) $input->getOption('funding-file');
124        $dryRun = (bool) $input->getOption('dry-run');
125        $check = (bool) $input->getOption('check');
126        $interactive = (bool) $input->getOption('interactive');
127
128        $this->logger->info('Synchronizing funding metadata...', [
129            'input' => $input,
130        ]);
131
132        if (! $this->filesystem->exists($composerFile)) {
133            $this->notice(
134                'Composer file {composer_file} does not exist. Skipping funding synchronization.',
135                $input,
136                [
137                    'composer_file' => $composerFile,
138                    'funding_file' => $fundingFile,
139                ],
140            );
141
142            return $this->success(
143                'Funding synchronization was skipped because composer.json was not found.',
144                $input,
145                [
146                    'composer_file' => $composerFile,
147                    'funding_file' => $fundingFile,
148                ],
149                'notice',
150            );
151        }
152
153        $composerContents = $this->filesystem->readFile($composerFile);
154        $fundingContents = $this->filesystem->exists($fundingFile) ? $this->filesystem->readFile($fundingFile) : null;
155
156        $mergedProfile = $this->fundingProfileMerger->merge(
157            $this->composerFundingCodec->parse($composerContents),
158            $this->fundingYamlCodec->parse($fundingContents),
159        );
160
161        $updatedComposerContents = $this->composerFundingCodec->dump($composerContents, $mergedProfile);
162        $updatedFundingContents = $mergedProfile->hasYamlContent()
163            ? $this->fundingYamlCodec->dump($mergedProfile)
164            : null;
165
166        $composerStatus = $this->handleComposerFile(
167            $composerFile,
168            $composerContents,
169            $updatedComposerContents,
170            $dryRun,
171            $check,
172            $interactive,
173            $input,
174            $output,
175        );
176
177        $fundingStatus = $this->handleFundingFile(
178            $fundingFile,
179            $fundingContents,
180            $updatedFundingContents,
181            $dryRun,
182            $check,
183            $interactive,
184            $input,
185            $output,
186        );
187
188        return max($composerStatus, $fundingStatus);
189    }
190
191    /**
192     * Handles composer.json synchronization reporting and writes.
193     *
194     * @param string $composerFile
195     * @param string $composerContents
196     * @param string $updatedComposerContents
197     * @param bool $dryRun
198     * @param bool $check
199     * @param bool $interactive
200     * @param InputInterface $input
201     * @param OutputInterface $output
202     *
203     * @return int the command status code
204     */
205    private function handleComposerFile(
206        string $composerFile,
207        string $composerContents,
208        string $updatedComposerContents,
209        bool $dryRun,
210        bool $check,
211        bool $interactive,
212        InputInterface $input,
213        OutputInterface $output,
214    ): int {
215        $comparison = $this->fileDiffer->diffContents(
216            'generated funding metadata synchronization',
217            $composerFile,
218            $updatedComposerContents,
219            $composerContents,
220            \sprintf('Updating managed file %s from generated funding metadata synchronization.', $composerFile),
221        );
222
223        $this->logger->notice(
224            $comparison->getSummary(),
225            [
226                'input' => $input,
227                'composer_file' => $composerFile,
228            ],
229        );
230
231        if ($comparison->isChanged()) {
232            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
233
234            if (null !== $consoleDiff) {
235                $this->logger->notice(
236                    $consoleDiff,
237                    [
238                        'input' => $input,
239                        'composer_file' => $composerFile,
240                        'diff' => $comparison->getDiff(),
241                    ],
242                );
243            }
244        }
245
246        if ($comparison->isUnchanged()) {
247            return $this->success(
248                '{composer_file} already matches the synchronized funding metadata.',
249                $input,
250                [
251                    'composer_file' => $composerFile,
252                ],
253            );
254        }
255
256        if ($check) {
257            return $this->failure(
258                '{composer_file} requires synchronized funding metadata updates.',
259                $input,
260                [
261                    'composer_file' => $composerFile,
262                ],
263                $composerFile,
264            );
265        }
266
267        if ($dryRun) {
268            return $this->success(
269                'Funding synchronization preview completed for {composer_file}.',
270                $input,
271                [
272                    'composer_file' => $composerFile,
273                ],
274                'notice',
275            );
276        }
277
278        if ($interactive && $input->isInteractive() && ! $this->shouldWriteManagedFile($composerFile)) {
279            $this->notice('Skipped updating {composer_file}.', $input, [
280                'composer_file' => $composerFile,
281            ]);
282
283            return $this->success(
284                'Funding synchronization was skipped for {composer_file}.',
285                $input,
286                [
287                    'composer_file' => $composerFile,
288                ],
289                'notice',
290            );
291        }
292
293        $this->filesystem->dumpFile($composerFile, $updatedComposerContents);
294
295        if (self::SUCCESS !== $this->normalizeComposerFile($composerFile, $output)) {
296            return $this->failure(
297                'Composer normalization failed after updating {composer_file}.',
298                $input,
299                [
300                    'composer_file' => $composerFile,
301                ],
302                $composerFile,
303            );
304        }
305
306        return $this->success(
307            'Updated funding metadata in {composer_file}.',
308            $input,
309            [
310                'composer_file' => $composerFile,
311            ],
312        );
313    }
314
315    /**
316     * Handles .github/FUNDING.yml synchronization reporting and writes.
317     *
318     * @param string $fundingFile
319     * @param ?string $currentFundingContents
320     * @param ?string $updatedFundingContents
321     * @param bool $dryRun
322     * @param bool $check
323     * @param bool $interactive
324     * @param InputInterface $input
325     * @param OutputInterface $output
326     *
327     * @return int the command status code
328     */
329    private function handleFundingFile(
330        string $fundingFile,
331        ?string $currentFundingContents,
332        ?string $updatedFundingContents,
333        bool $dryRun,
334        bool $check,
335        bool $interactive,
336        InputInterface $input,
337        OutputInterface $output,
338    ): int {
339        if (null === $updatedFundingContents && null === $currentFundingContents) {
340            $this->notice(
341                'No supported funding metadata found. Skipping .github/FUNDING.yml synchronization.',
342                $input,
343                [
344                    'funding_file' => $fundingFile,
345                ],
346            );
347
348            return $this->success(
349                'Funding synchronization found no supported GitHub funding metadata to write.',
350                $input,
351                [
352                    'funding_file' => $fundingFile,
353                ],
354                'notice',
355            );
356        }
357
358        if (null === $updatedFundingContents) {
359            return $this->success(
360                'No GitHub funding file changes were required.',
361                $input,
362                [
363                    'funding_file' => $fundingFile,
364                ],
365            );
366        }
367
368        $comparison = $this->fileDiffer->diffContents(
369            'generated funding metadata synchronization',
370            $fundingFile,
371            $updatedFundingContents,
372            $currentFundingContents,
373            null === $currentFundingContents
374                ? \sprintf(
375                    'Managed file %s will be created from generated funding metadata synchronization.',
376                    $fundingFile
377                )
378                : \sprintf('Updating managed file %s from generated funding metadata synchronization.', $fundingFile),
379        );
380
381        $this->logger->notice($comparison->getSummary(), [
382            'input' => $input,
383            'funding_file' => $fundingFile,
384        ]);
385
386        if ($comparison->isChanged()) {
387            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
388
389            if (null !== $consoleDiff) {
390                $this->logger->notice(
391                    $consoleDiff,
392                    [
393                        'input' => $input,
394                        'funding_file' => $fundingFile,
395                        'diff' => $comparison->getDiff(),
396                    ],
397                );
398            }
399        }
400
401        if ($comparison->isUnchanged()) {
402            return $this->success(
403                '{funding_file} already matches the synchronized funding metadata.',
404                $input,
405                [
406                    'funding_file' => $fundingFile,
407                ],
408            );
409        }
410
411        if ($check) {
412            return $this->failure(
413                '{funding_file} requires synchronized funding metadata updates.',
414                $input,
415                [
416                    'funding_file' => $fundingFile,
417                ],
418                $fundingFile,
419            );
420        }
421
422        if ($dryRun) {
423            return $this->success(
424                'Funding synchronization preview completed for {funding_file}.',
425                $input,
426                [
427                    'funding_file' => $fundingFile,
428                ],
429                'notice',
430            );
431        }
432
433        if ($interactive && $input->isInteractive() && ! $this->shouldWriteManagedFile($fundingFile)) {
434            $this->notice('Skipped updating {funding_file}.', $input, [
435                'funding_file' => $fundingFile,
436            ]);
437
438            return $this->success(
439                'Funding synchronization was skipped for {funding_file}.',
440                $input,
441                [
442                    'funding_file' => $fundingFile,
443                ],
444                'notice',
445            );
446        }
447
448        $this->filesystem->mkdir($this->filesystem->dirname($fundingFile));
449        $this->filesystem->dumpFile($fundingFile, $updatedFundingContents);
450
451        return $this->success(
452            'Updated funding metadata in {funding_file}.',
453            $input,
454            [
455                'funding_file' => $fundingFile,
456            ],
457        );
458    }
459
460    /**
461     * Prompts whether a managed file should be written.
462     *
463     * @param string $targetFile
464     *
465     * @return bool true when the write SHOULD proceed
466     */
467    private function shouldWriteManagedFile(string $targetFile): bool
468    {
469        return $this->getIO()
470            ->askConfirmation(\sprintf('Update managed file %s? [y/N] ', $targetFile), false);
471    }
472
473    /**
474     * Normalizes a composer manifest after funding metadata changes.
475     *
476     * @param string $composerFile the composer manifest path
477     * @param OutputInterface $output the command output
478     *
479     * @return int the normalization status code
480     */
481    private function normalizeComposerFile(string $composerFile, OutputInterface $output): int
482    {
483        $processBuilder = $this->processBuilder
484            ->withArgument('--ansi')
485            ->withArgument('--no-update-lock');
486
487        $workingDirectory = $this->filesystem->dirname($composerFile);
488
489        if ('.' !== $workingDirectory) {
490            $processBuilder = $processBuilder->withArgument('--working-dir', $workingDirectory);
491        }
492
493        $composerBasename = $this->filesystem->basename($composerFile);
494
495        if ('composer.json' !== $composerBasename) {
496            $processBuilder = $processBuilder->withArgument('--file', $composerBasename);
497        }
498
499        $this->processQueue->add($processBuilder->build('composer normalize'));
500
501        return $this->processQueue->run($output);
502    }
503}