Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
CopyResourceCommand
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
6 / 6
29
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%
59 / 59
100.00% covered (success)
100.00%
1 / 1
20
 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 (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($targetPath)) {
232            return $this->success(
233                'Skipped existing resource {target_path}.',
234                $input,
235                [
236                    'source_path' => $sourcePath,
237                    'target_path' => $targetPath,
238                ],
239                LogLevel::NOTICE,
240            );
241        }
242
243        if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($targetPath)) {
244            $comparison = $this->fileDiffer->diff($sourcePath, $targetPath);
245
246            $this->log(
247                $comparison->getSummary(),
248                $input,
249                [
250                    'source_path' => $sourcePath,
251                    'target_path' => $targetPath,
252                ],
253                LogLevel::NOTICE,
254            );
255
256            if ($comparison->isChanged()) {
257                $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
258
259                if (null !== $consoleDiff) {
260                    $this->log(
261                        $consoleDiff,
262                        $input,
263                        [
264                            'source_path' => $sourcePath,
265                            'target_path' => $targetPath,
266                            'diff' => $comparison->getDiff(),
267                        ],
268                        LogLevel::NOTICE,
269                    );
270                }
271            }
272
273            if ($comparison->isUnchanged()) {
274                return self::SUCCESS;
275            }
276
277            if ($check) {
278                return self::FAILURE;
279            }
280
281            if ($dryRun) {
282                return self::SUCCESS;
283            }
284
285            if ($interactive && $input->isInteractive() && ! $this->shouldReplaceResource($targetPath)) {
286                return $this->success(
287                    'Skipped replacing {target_path}.',
288                    $input,
289                    [
290                        'source_path' => $sourcePath,
291                        'target_path' => $targetPath,
292                    ],
293                    LogLevel::NOTICE,
294                );
295            }
296        }
297
298        $this->filesystem->copy($sourcePath, $targetPath, $overwrite || $interactive);
299
300        return $this->success(
301            'Copied resource {target_path}.',
302            $input,
303            [
304                'source_path' => $sourcePath,
305                'target_path' => $targetPath,
306            ],
307        );
308    }
309
310    /**
311     * Prompts whether a drifted resource should be replaced.
312     *
313     * @param string $targetPath the resource path that would be replaced
314     *
315     * @return bool true when the replacement SHOULD proceed
316     */
317    private function shouldReplaceResource(string $targetPath): bool
318    {
319        $confirmation = new ConfirmationQuestion(
320            \sprintf('Replace drifted resource %s? [y/N] ', $targetPath),
321            false,
322        );
323
324        return $this->io->askQuestion($confirmation);
325    }
326}