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