Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
PackagedDirectorySynchronizer
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
9 / 9
17
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
 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%
7 / 7
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
 normalizeRelativeSourcePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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\LoggerInterface;
25use Symfony\Component\Filesystem\Path;
26
27/**
28 * Synchronizes one packaged directory of symlinked entries into a consumer repository.
29 */
30  class PackagedDirectorySynchronizer
31{
32    /**
33     * Initializes the synchronizer with a filesystem and finder factory.
34     *
35     * @param FilesystemInterface $filesystem Filesystem instance for file operations
36     * @param FinderFactoryInterface $finderFactory Factory for locating packaged directories
37     * @param LoggerInterface $logger Logger for recording synchronization actions and decisions
38     */
39    public function __construct(
40        private FilesystemInterface $filesystem,
41        private FinderFactoryInterface $finderFactory,
42        private LoggerInterface $logger,
43    ) {}
44
45    /**
46     * Synchronizes packaged directory entries into the consumer repository.
47     *
48     * @param string $targetDir Absolute path to the consumer directory to populate
49     * @param string $packagePath Absolute path to the packaged directory to mirror
50     * @param string $directoryLabel Human-readable directory label used in log messages
51     *
52     * @return SynchronizeResult Result containing counts of created, preserved, and removed links
53     */
54    public function synchronize(string $targetDir, string $packagePath, string $directoryLabel): SynchronizeResult
55    {
56        $result = new SynchronizeResult();
57
58        if (! $this->filesystem->exists($packagePath)) {
59            $this->logger->error('No packaged ' . $directoryLabel . ' found at: ' . $packagePath);
60            $result->markFailed();
61
62            return $result;
63        }
64
65        if (! $this->filesystem->exists($targetDir)) {
66            $this->filesystem->mkdir($targetDir);
67            $this->logger->info('Created ' . $directoryLabel . ' directory.');
68        }
69
70        $finder = $this->finderFactory
71            ->create()
72            ->in($packagePath)
73            ->depth('== 0');
74
75        foreach ($finder as $packagedEntry) {
76            $entryName = $packagedEntry->getFilename();
77            $targetLink = Path::makeAbsolute($entryName, $targetDir);
78            $sourcePath = $packagedEntry->getRealPath();
79            $isDirectory = $packagedEntry->isDir();
80
81            $this->processLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
82        }
83
84        return $result;
85    }
86
87    /**
88     * Routes an entry link to the appropriate handling method based on target state.
89     *
90     * @param string $entryName Name of the packaged entry being processed
91     * @param string $targetLink Absolute path where the symlink should exist
92     * @param string $sourcePath Absolute path to the packaged source directory
93     * @param SynchronizeResult $result Result tracker for reporting outcomes
94     * @param bool $isDirectory
95     */
96    private function processLink(
97        string $entryName,
98        string $targetLink,
99        string $sourcePath,
100        bool $isDirectory,
101        SynchronizeResult $result,
102    ): void {
103        if (! $this->filesystem->exists($targetLink)) {
104            $this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
105
106            return;
107        }
108
109        if (! $this->isSymlink($targetLink)) {
110            $this->preserveExistingNonSymlink($entryName, $result);
111
112            return;
113        }
114
115        $this->processExistingSymlink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
116    }
117
118    /**
119     * Creates a new symlink pointing to the packaged entry.
120     *
121     * @param string $entryName Name identifying the entry
122     * @param string $targetLink Absolute path where the symlink will be created
123     * @param string $sourcePath Absolute path to the packaged directory
124     * @param SynchronizeResult $result Result object for tracking creation
125     * @param bool $isDirectory
126     */
127    private function createNewLink(
128        string $entryName,
129        string $targetLink,
130        string $sourcePath,
131        bool $isDirectory,
132        SynchronizeResult $result,
133    ): void {
134        $relativeSourcePath = $this->normalizeRelativeSourcePath(
135            $this->filesystem->makePathRelative($sourcePath, $this->filesystem->getDirectory($targetLink)),
136            $isDirectory,
137        );
138
139        $this->filesystem->symlink($relativeSourcePath, $targetLink);
140        $this->logger->info('Created link: ' . $entryName . ' -> ' . $relativeSourcePath);
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     * @param bool $isDirectory
166     */
167    private function processExistingSymlink(
168        string $entryName,
169        string $targetLink,
170        string $sourcePath,
171        bool $isDirectory,
172        SynchronizeResult $result,
173    ): void {
174        $linkPath = $this->filesystem->readlink($targetLink, true);
175
176        if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
177            $this->repairBrokenLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
178
179            return;
180        }
181
182        $this->logger->notice('Preserved existing link: ' . $entryName);
183        $result->addPreservedLink($entryName);
184    }
185
186    /**
187     * Removes a broken symlink and creates a fresh one pointing to the current source.
188     *
189     * @param string $entryName Name of the entry with the broken symlink
190     * @param string $targetLink Absolute path to the broken symlink
191     * @param string $sourcePath Absolute path to the current packaged directory
192     * @param SynchronizeResult $result Result tracker for removed and created items
193     * @param bool $isDirectory
194     */
195    private function repairBrokenLink(
196        string $entryName,
197        string $targetLink,
198        string $sourcePath,
199        bool $isDirectory,
200        SynchronizeResult $result,
201    ): void {
202        $this->filesystem->remove($targetLink);
203        $this->logger->notice('Existing link is broken: ' . $entryName . ' (removing and recreating)');
204        $result->addRemovedBrokenLink($entryName);
205
206        $this->createNewLink($entryName, $targetLink, $sourcePath, $isDirectory, $result);
207    }
208
209    /**
210     * Normalizes a relative symlink target emitted by Symfony path helpers.
211     *
212     * Files MUST NOT keep the trailing slash that directory-oriented path helpers
213     * may append, otherwise link creation treats them as non-existent directories.
214     *
215     * @param string $relativeSourcePath Relative path from the consumer target directory to the packaged source
216     * @param bool $isDirectory Whether the packaged source is a directory
217     *
218     * @return string Normalized relative symlink target
219     */
220    private function normalizeRelativeSourcePath(string $relativeSourcePath, bool $isDirectory): string
221    {
222        if ($isDirectory) {
223            return $relativeSourcePath;
224        }
225
226        return rtrim($relativeSourcePath, '/');
227    }
228
229    /**
230     * Checks if a path is a symbolic link.
231     *
232     * @param string $path the target path
233     *
234     * @return bool whether the path is a symbolic link
235     */
236    private function isSymlink(string $path): bool
237    {
238        return null !== $this->filesystem->readlink($path);
239    }
240}