Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
157 / 157
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
CopyResourceCommand
100.00% covered (success)
100.00%
157 / 157
100.00% covered (success)
100.00%
6 / 6
33
100.00% covered (success)
100.00%
1 / 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%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 copyDirectory
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 copyFile
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
1 / 1
24
 shouldReplaceResource
100.00% covered (success)
100.00%
5 / 5
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\FinderFactoryInterface;
25use FastForward\DevTools\Filesystem\FilesystemInterface;
26use FastForward\DevTools\Resource\FileDiffer;
27use Psr\Log\LogLevel;
28use Symfony\Component\Config\FileLocatorInterface;
29use Symfony\Component\Console\Attribute\AsCommand;
30use Symfony\Component\Console\Command\Command;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\OutputInterface;
34use Symfony\Component\Console\Question\ConfirmationQuestion;
35use Symfony\Component\Console\Style\SymfonyStyle;
36use Symfony\Component\Filesystem\Path;
37
38/**
39 * Copies packaged or local resources into the consumer repository.
40 */
41#[AsCommand(
42    name: 'dev-tools:sync:copy',
43    description: 'Copies a file or directory resource into the current project.',
44    aliases: ['copy-resource']
45)]
46 class CopyResourceCommand extends Command
47{
48    use HasJsonOption;
49    use LogsCommandResults;
50
51    /**
52     * Creates a new CopyResourceCommand instance.
53     *
54     * @param FilesystemInterface $filesystem the filesystem used for copy operations
55     * @param FileLocatorInterface $fileLocator the locator used to resolve source resources
56     * @param FinderFactoryInterface $finderFactory the factory used to create finders for directory resources
57     * @param FileDiffer $fileDiffer the service used to summarize overwrite changes
58     * @param SymfonyStyle $io the input/output service used to interact with the user
59     */
60    public function __construct(
61        private  FilesystemInterface $filesystem,
62        private  FileLocatorInterface $fileLocator,
63        private  FinderFactoryInterface $finderFactory,
64        private  FileDiffer $fileDiffer,
65        private  SymfonyStyle $io,
66    ) {
67        parent::__construct();
68    }
69
70    /**
71     * Configures source, target, and overwrite controls.
72     */
73    protected function configure(): void
74    {
75        $this->setHelp(
76            'This command copies a configured source file or every file in a source directory into the target'
77            . ' path.'
78        );
79
80        $this->addJsonOption()
81            ->addOption(
82                name: 'source',
83                shortcut: 's',
84                mode: InputOption::VALUE_REQUIRED,
85                description: 'Source file or directory to copy.',
86            )
87            ->addOption(
88                name: 'target',
89                shortcut: 't',
90                mode: InputOption::VALUE_REQUIRED,
91                description: 'Target file or directory path.',
92            )
93            ->addOption(
94                name: 'overwrite',
95                shortcut: 'o',
96                mode: InputOption::VALUE_NONE,
97                description: 'Overwrite existing target files.',
98            )
99            ->addOption(
100                name: 'dry-run',
101                mode: InputOption::VALUE_NONE,
102                description: 'Preview copied resources without writing files.',
103            )
104            ->addOption(
105                name: 'check',
106                mode: InputOption::VALUE_NONE,
107                description: 'Report copied-resource drift and exit non-zero when changes are required.',
108            )
109            ->addOption(
110                name: 'interactive',
111                mode: InputOption::VALUE_NONE,
112                description: 'Prompt before replacing drifted resources.',
113            );
114    }
115
116    /**
117     * Copies the requested resource.
118     *
119     * @param InputInterface $input the input containing source and target paths
120     * @param OutputInterface $output the output used to report copy results
121     *
122     * @return int the command status code
123     */
124    protected function execute(InputInterface $input, OutputInterface $output): int
125    {
126        $source = (string) $input->getOption('source');
127        $target = (string) $input->getOption('target');
128        $overwrite = (bool) $input->getOption('overwrite');
129        $dryRun = (bool) $input->getOption('dry-run');
130        $check = (bool) $input->getOption('check');
131        $interactive = (bool) $input->getOption('interactive');
132
133        if ('' === $source || '' === $target) {
134            return $this->failure('The --source and --target options are required.', $input);
135        }
136
137        $sourcePath = $this->fileLocator->locate($source);
138        $targetPath = (string) $this->filesystem->getAbsolutePath($target);
139
140        if (is_dir($sourcePath)) {
141            return $this->copyDirectory(
142                $sourcePath,
143                $targetPath,
144                $overwrite,
145                $dryRun,
146                $check,
147                $interactive,
148                $input,
149                $output
150            );
151        }
152
153        return $this->copyFile($sourcePath, $targetPath, $overwrite, $dryRun, $check, $interactive, $input, $output);
154    }
155
156    /**
157     * Copies every file from a source directory into the target directory.
158     *
159     * @param string $sourcePath the resolved source directory
160     * @param string $targetPath the resolved target directory
161     * @param bool $overwrite whether existing files MAY be overwritten
162     * @param bool $dryRun whether the command is previewing changes only
163     * @param bool $check whether the command SHOULD fail on detected drift
164     * @param bool $interactive whether the command SHOULD prompt before overwriting drifted files
165     * @param InputInterface $input the originating command input
166     * @param OutputInterface $output the output used to report copy results
167     *
168     * @return int the command status code
169     */
170    private function copyDirectory(
171        string $sourcePath,
172        string $targetPath,
173        bool $overwrite,
174        bool $dryRun,
175        bool $check,
176        bool $interactive,
177        InputInterface $input,
178        OutputInterface $output
179    ): int {
180        $files = $this->finderFactory
181            ->create()
182            ->files()
183            ->in($sourcePath);
184
185        $status = self::SUCCESS;
186
187        foreach ($files as $file) {
188            $destination = Path::join($targetPath, $file->getRelativePathname());
189            $status = max(
190                $status,
191                $this->copyFile(
192                    $file->getRealPath(),
193                    $destination,
194                    $overwrite,
195                    $dryRun,
196                    $check,
197                    $interactive,
198                    $input,
199                    $output
200                ),
201            );
202        }
203
204        return $status;
205    }
206
207    /**
208     * Copies a single file when the target does not exist or overwrite is enabled.
209     *
210     * @param string $sourcePath the resolved source file
211     * @param string $targetPath the resolved target file
212     * @param bool $overwrite whether an existing target file MAY be overwritten
213     * @param bool $dryRun whether the command is previewing changes only
214     * @param bool $check whether the command SHOULD fail on detected drift
215     * @param bool $interactive whether the command SHOULD prompt before overwriting drifted files
216     * @param InputInterface $input the originating command input
217     * @param OutputInterface $output the output used to report copy results
218     *
219     * @return int the command status code
220     */
221    private function copyFile(
222        string $sourcePath,
223        string $targetPath,
224        bool $overwrite,
225        bool $dryRun,
226        bool $check,
227        bool $interactive,
228        InputInterface $input,
229        OutputInterface $output,
230    ): int {
231        if (($dryRun || $check) && ! $this->filesystem->exists($targetPath)) {
232            $this->log(
233                'Resource {target_path} is missing and would be created.',
234                $input,
235                [
236                    'source_path' => $sourcePath,
237                    'target_path' => $targetPath,
238                ],
239                LogLevel::NOTICE,
240            );
241
242            return $check ? self::FAILURE : self::SUCCESS;
243        }
244
245        if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($targetPath)) {
246            return $this->success(
247                'Skipped existing resource {target_path}.',
248                $input,
249                [
250                    'source_path' => $sourcePath,
251                    'target_path' => $targetPath,
252                ],
253                LogLevel::NOTICE,
254            );
255        }
256
257        if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($targetPath)) {
258            $comparison = $this->fileDiffer->diff($sourcePath, $targetPath);
259
260            $this->log(
261                $comparison->getSummary(),
262                $input,
263                [
264                    'source_path' => $sourcePath,
265                    'target_path' => $targetPath,
266                ],
267                LogLevel::NOTICE,
268            );
269
270            if ($comparison->isChanged()) {
271                $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
272
273                if (null !== $consoleDiff) {
274                    $this->log(
275                        $consoleDiff,
276                        $input,
277                        [
278                            'source_path' => $sourcePath,
279                            'target_path' => $targetPath,
280                            'diff' => $comparison->getDiff(),
281                        ],
282                        LogLevel::NOTICE,
283                    );
284                }
285            }
286
287            if ($comparison->isUnchanged()) {
288                return self::SUCCESS;
289            }
290
291            if ($check) {
292                return self::FAILURE;
293            }
294
295            if ($dryRun) {
296                return self::SUCCESS;
297            }
298
299            if ($interactive && $input->isInteractive() && ! $this->shouldReplaceResource($targetPath)) {
300                return $this->success(
301                    'Skipped replacing {target_path}.',
302                    $input,
303                    [
304                        'source_path' => $sourcePath,
305                        'target_path' => $targetPath,
306                    ],
307                    LogLevel::NOTICE,
308                );
309            }
310        }
311
312        $this->filesystem->copy($sourcePath, $targetPath, $overwrite || $interactive);
313
314        return $this->success(
315            'Copied resource {target_path}.',
316            $input,
317            [
318                'source_path' => $sourcePath,
319                'target_path' => $targetPath,
320            ],
321        );
322    }
323
324    /**
325     * Prompts whether a drifted resource should be replaced.
326     *
327     * @param string $targetPath the resource path that would be replaced
328     *
329     * @return bool true when the replacement SHOULD proceed
330     */
331    private function shouldReplaceResource(string $targetPath): bool
332    {
333        $confirmation = new ConfirmationQuestion(
334            \sprintf('Replace drifted resource %s? [y/N] ', $targetPath),
335            false,
336        );
337
338        return $this->io->askQuestion($confirmation);
339    }
340}