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