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