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