Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
PackagedDirectorySynchronizer
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
9 / 9
16
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
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 synchronize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 processLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 createNewLink
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 preserveExistingNonSymlink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 processExistingSymlink
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 repairBrokenLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isSymlink
100.00% covered (success)
100.00%
1 / 1
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\Sync;
21
22use FastForward\DevTools\Filesystem\FinderFactoryInterface;
23use FastForward\DevTools\Filesystem\FilesystemInterface;
24use Psr\Log\LoggerAwareInterface;
25use Psr\Log\LoggerInterface;
26use Symfony\Component\Filesystem\Path;
27
28/**
29 * Synchronizes one packaged directory of symlinked entries into a consumer repository.
30 */
31 class PackagedDirectorySynchronizer implements LoggerAwareInterface
32{
33    /**
34     * Initializes the synchronizer with a filesystem and finder factory.
35     *
36     * @param FilesystemInterface $filesystem Filesystem instance for file operations
37     * @param FinderFactoryInterface $finderFactory Factory for locating packaged directories
38     * @param LoggerInterface $logger Logger for recording synchronization actions and decisions
39     */
40    public function __construct(
41        private  FilesystemInterface $filesystem,
42        private  FinderFactoryInterface $finderFactory,
43        private LoggerInterface $logger,
44    ) {}
45
46    /**
47     * {@inheritDoc}
48     */
49    public function setLogger(LoggerInterface $logger): void
50    {
51        $this->logger = $logger;
52    }
53
54    /**
55     * Synchronizes packaged directory entries into the consumer repository.
56     *
57     * @param string $targetDir Absolute path to the consumer directory to populate
58     * @param string $packagePath Absolute path to the packaged directory to mirror
59     * @param string $directoryLabel Human-readable directory label used in log messages
60     *
61     * @return SynchronizeResult Result containing counts of created, preserved, and removed links
62     */
63    public function synchronize(string $targetDir, string $packagePath, string $directoryLabel): SynchronizeResult
64    {
65        $result = new SynchronizeResult();
66
67        if (! $this->filesystem->exists($packagePath)) {
68            $this->logger->error('No packaged ' . $directoryLabel . ' found at: ' . $packagePath);
69            $result->markFailed();
70
71            return $result;
72        }
73
74        if (! $this->filesystem->exists($targetDir)) {
75            $this->filesystem->mkdir($targetDir);
76            $this->logger->info('Created ' . $directoryLabel . ' directory.');
77        }
78
79        $finder = $this->finderFactory
80            ->create()
81            ->directories()
82            ->in($packagePath)
83            ->depth('== 0');
84
85        foreach ($finder as $packagedDirectory) {
86            $entryName = $packagedDirectory->getFilename();
87            $targetLink = Path::makeAbsolute($entryName, $targetDir);
88            $sourcePath = $packagedDirectory->getRealPath();
89
90            $this->processLink($entryName, $targetLink, $sourcePath, $result);
91        }
92
93        return $result;
94    }
95
96    /**
97     * Routes an entry link to the appropriate handling method based on target state.
98     *
99     * @param string $entryName Name of the packaged entry being processed
100     * @param string $targetLink Absolute path where the symlink should exist
101     * @param string $sourcePath Absolute path to the packaged source directory
102     * @param SynchronizeResult $result Result tracker for reporting outcomes
103     */
104    private function processLink(
105        string $entryName,
106        string $targetLink,
107        string $sourcePath,
108        SynchronizeResult $result,
109    ): void {
110        if (! $this->filesystem->exists($targetLink)) {
111            $this->createNewLink($entryName, $targetLink, $sourcePath, $result);
112
113            return;
114        }
115
116        if (! $this->isSymlink($targetLink)) {
117            $this->preserveExistingNonSymlink($entryName, $result);
118
119            return;
120        }
121
122        $this->processExistingSymlink($entryName, $targetLink, $sourcePath, $result);
123    }
124
125    /**
126     * Creates a new symlink pointing to the packaged entry.
127     *
128     * @param string $entryName Name identifying the entry
129     * @param string $targetLink Absolute path where the symlink will be created
130     * @param string $sourcePath Absolute path to the packaged directory
131     * @param SynchronizeResult $result Result object for tracking creation
132     */
133    private function createNewLink(
134        string $entryName,
135        string $targetLink,
136        string $sourcePath,
137        SynchronizeResult $result,
138    ): void {
139        $this->filesystem->symlink($sourcePath, $targetLink);
140        $this->logger->info('Created link: ' . $entryName . ' -> ' . $sourcePath);
141        $result->addCreatedLink($entryName);
142    }
143
144    /**
145     * Handles an existing non-symlink item at the target path.
146     *
147     * @param string $entryName Name of the entry with the conflicting item
148     * @param SynchronizeResult $result Result tracker for preserved items
149     */
150    private function preserveExistingNonSymlink(string $entryName, SynchronizeResult $result): void
151    {
152        $this->logger->notice(
153            'Existing non-symlink found: ' . $entryName . ' (keeping as is, skipping link creation)'
154        );
155        $result->addPreservedLink($entryName);
156    }
157
158    /**
159     * Evaluates an existing symlink and determines whether to preserve or repair it.
160     *
161     * @param string $entryName Name of the entry with the existing symlink
162     * @param string $targetLink Absolute path to the existing symlink
163     * @param string $sourcePath Absolute path to the expected source directory
164     * @param SynchronizeResult $result Result tracker for preserved or removed links
165     */
166    private function processExistingSymlink(
167        string $entryName,
168        string $targetLink,
169        string $sourcePath,
170        SynchronizeResult $result,
171    ): void {
172        $linkPath = $this->filesystem->readlink($targetLink, true);
173
174        if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
175            $this->repairBrokenLink($entryName, $targetLink, $sourcePath, $result);
176
177            return;
178        }
179
180        $this->logger->notice('Preserved existing link: ' . $entryName);
181        $result->addPreservedLink($entryName);
182    }
183
184    /**
185     * Removes a broken symlink and creates a fresh one pointing to the current source.
186     *
187     * @param string $entryName Name of the entry with the broken symlink
188     * @param string $targetLink Absolute path to the broken symlink
189     * @param string $sourcePath Absolute path to the current packaged directory
190     * @param SynchronizeResult $result Result tracker for removed and created items
191     */
192    private function repairBrokenLink(
193        string $entryName,
194        string $targetLink,
195        string $sourcePath,
196        SynchronizeResult $result,
197    ): void {
198        $this->filesystem->remove($targetLink);
199        $this->logger->notice('Existing link is broken: ' . $entryName . ' (removing and recreating)');
200        $result->addRemovedBrokenLink($entryName);
201
202        $this->createNewLink($entryName, $targetLink, $sourcePath, $result);
203    }
204
205    /**
206     * Checks if a path is a symbolic link.
207     *
208     * @param string $path the target path
209     *
210     * @return bool whether the path is a symbolic link
211     */
212    private function isSymlink(string $path): bool
213    {
214        return null !== $this->filesystem->readlink($path);
215    }
216}