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