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