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