Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.68% covered (success)
92.68%
114 / 123
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
LicenseCommand
92.68% covered (success)
92.68%
114 / 123
75.00% covered (warning)
75.00%
3 / 4
15.09
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%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 execute
90.43% covered (success)
90.43%
85 / 94
0.00% covered (danger)
0.00%
0 / 1
12.13
 shouldWriteLicense
100.00% covered (success)
100.00%
2 / 2
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\License\GeneratorInterface;
26use FastForward\DevTools\Resource\FileDiffer;
27use Psr\Log\LogLevel;
28use Symfony\Component\Console\Attribute\AsCommand;
29use Symfony\Component\Console\Command\Command;
30use Symfony\Component\Console\Input\InputInterface;
31use Symfony\Component\Console\Input\InputOption;
32use Symfony\Component\Console\Output\OutputInterface;
33use Symfony\Component\Console\Question\ConfirmationQuestion;
34use Symfony\Component\Console\Style\SymfonyStyle;
35
36/**
37 * Generates and copies LICENSE files to projects.
38 *
39 * This command generates a LICENSE file if one does not exist and a supported
40 * license is declared in composer.json.
41 */
42#[AsCommand(
43    name: 'license:generate',
44    description: 'Generates a LICENSE file from composer.json license information.',
45    aliases: ['LICENSE.md', 'license'],
46)]
47 class LicenseCommand extends Command
48{
49    use HasJsonOption;
50    use LogsCommandResults;
51
52    /**
53     * Creates a new LicenseCommand instance.
54     *
55     * @param GeneratorInterface $generator the generator component
56     * @param FilesystemInterface $filesystem the filesystem component
57     * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes
58     * @param SymfonyStyle $io the input/output service used to interact with the user
59     */
60    public function __construct(
61        private  GeneratorInterface $generator,
62        private  FilesystemInterface $filesystem,
63        private  FileDiffer $fileDiffer,
64        private  SymfonyStyle $io,
65    ) {
66        parent::__construct();
67    }
68
69    /**
70     * {@inheritDoc}
71     */
72    protected function configure(): void
73    {
74        $this->setHelp(
75            'This command generates a LICENSE file if one does not exist and a supported license is declared in'
76            . ' composer.json.'
77        );
78
79        $this->addJsonOption()
80            ->addOption(
81                name: 'target',
82                mode: InputOption::VALUE_OPTIONAL,
83                description: 'The target path for the generated LICENSE file.',
84                default: 'LICENSE',
85            )
86            ->addOption(
87                name: 'dry-run',
88                mode: InputOption::VALUE_NONE,
89                description: 'Preview LICENSE generation without writing the file.',
90            )
91            ->addOption(
92                name: 'check',
93                mode: InputOption::VALUE_NONE,
94                description: 'Report LICENSE drift and exit non-zero when changes are required.',
95            )
96            ->addOption(
97                name: 'interactive',
98                mode: InputOption::VALUE_NONE,
99                description: 'Prompt before writing LICENSE changes.',
100            );
101    }
102
103    /**
104     * Executes the license generation process.
105     *
106     * Generates a LICENSE file if one does not exist and a supported license is declared in composer.json.
107     *
108     * @param InputInterface $input the input interface
109     * @param OutputInterface $output the output interface
110     *
111     * @return int the status code
112     */
113    protected function execute(InputInterface $input, OutputInterface $output): int
114    {
115        $targetPath = $this->filesystem->getAbsolutePath($input->getOption('target'));
116        $dryRun = (bool) $input->getOption('dry-run');
117        $check = (bool) $input->getOption('check');
118        $interactive = (bool) $input->getOption('interactive');
119        $existingContent = $this->filesystem->exists($targetPath) ? $this->filesystem->readFile($targetPath) : null;
120        $generatedContent = $this->generator->generateContent();
121
122        if (null === $generatedContent) {
123            $this->log(
124                'No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.',
125                $input,
126                [
127                    'target_path' => $targetPath,
128                ],
129                LogLevel::NOTICE,
130            );
131
132            return $this->success(
133                'LICENSE generation was skipped because no supported license metadata was available.',
134                $input,
135                [
136                    'target_path' => $targetPath,
137                ],
138                LogLevel::NOTICE,
139            );
140        }
141
142        $comparison = $this->fileDiffer->diffContents(
143            'generated LICENSE content',
144            $targetPath,
145            $generatedContent,
146            $existingContent,
147            null === $existingContent
148                ? \sprintf('Managed file %s will be created from generated LICENSE content.', $targetPath)
149                : \sprintf('Updating managed file %s from generated LICENSE content.', $targetPath),
150        );
151
152        $this->log($comparison->getSummary(), $input, [
153            'target_path' => $targetPath,
154        ], LogLevel::NOTICE);
155
156        if ($comparison->isChanged()) {
157            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
158
159            if (null !== $consoleDiff) {
160                $this->log(
161                    $consoleDiff,
162                    $input,
163                    [
164                        'target_path' => $targetPath,
165                        'diff' => $comparison->getDiff(),
166                    ],
167                    LogLevel::NOTICE,
168                );
169            }
170        }
171
172        if ($comparison->isUnchanged()) {
173            return $this->success(
174                'LICENSE already matches the generated content.',
175                $input,
176                [
177                    'target_path' => $targetPath,
178                ],
179            );
180        }
181
182        if ($check) {
183            return $this->failure(
184                'LICENSE requires synchronization updates.',
185                $input,
186                [
187                    'target_path' => $targetPath,
188                ],
189                $targetPath,
190            );
191        }
192
193        if ($dryRun) {
194            return $this->success(
195                'LICENSE generation preview completed.',
196                $input,
197                [
198                    'target_path' => $targetPath,
199                ],
200                LogLevel::NOTICE,
201            );
202        }
203
204        if ($interactive && $input->isInteractive() && ! $this->shouldWriteLicense($targetPath)) {
205            $this->log('Skipped updating {target_path}.', $input, [
206                'target_path' => $targetPath,
207            ], LogLevel::NOTICE);
208
209            return $this->success(
210                'LICENSE generation was skipped.',
211                $input,
212                [
213                    'target_path' => $targetPath,
214                ],
215                LogLevel::NOTICE,
216            );
217        }
218
219        $this->filesystem->dumpFile($targetPath, $generatedContent);
220
221        return $this->success(
222            '{file_name} file generated successfully at {target_path}.',
223            $input,
224            [
225                'file_name' => basename($targetPath),
226                'target_path' => $targetPath,
227            ],
228        );
229    }
230
231    /**
232     * Prompts whether the generated LICENSE should be written.
233     *
234     * @param string $targetPath the license path that would be written
235     *
236     * @return bool true when the write SHOULD proceed
237     */
238    private function shouldWriteLicense(string $targetPath): bool
239    {
240        $confirmation = new ConfirmationQuestion(\sprintf('Write managed file %s? [y/N] ', $targetPath), false);
241
242        return $this->io->askQuestion($confirmation);
243    }
244}