Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
SkillsSynchronizer
100.00% covered (success)
100.00%
43 / 43
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
 synchronize
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 syncPackageSkills
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 processSkillLink
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%
2 / 2
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 * This file is part of fast-forward/dev-tools.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/dev-tools
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\DevTools\Agent\Skills;
20
21use Psr\Log\LoggerAwareInterface;
22use Psr\Log\LoggerAwareTrait;
23use Psr\Log\NullLogger;
24use Symfony\Component\Filesystem\Filesystem;
25use Symfony\Component\Finder\Finder;
26use Symfony\Component\Filesystem\Path;
27
28/**
29 * Synchronizes Fast Forward skills into consumer repositories.
30 *
31 * This class manages the creation and maintenance of symlinks from a consumer
32 * repository to the skills packaged within the fast-forward/dev-tools dependency.
33 * It handles initial sync, idempotent re-runs, and cleanup of broken links.
34 */
35 class SkillsSynchronizer implements LoggerAwareInterface
36{
37    use LoggerAwareTrait;
38
39    /**
40     * Initializes the synchronizer with an optional filesystem instance.
41     *
42     * If no filesystem is provided, a default {@see Filesystem} instance is created.
43     *
44     * @param Filesystem|null $filesystem Filesystem instance for file operations
45     * @param Finder $finder Finder instance for locating skill directories in the package
46     */
47    public function __construct(
48        private  Filesystem $filesystem = new Filesystem(),
49        private  Finder $finder = new Finder(),
50    ) {
51        $this->logger = new NullLogger();
52    }
53
54    /**
55     * Synchronizes skills from the package to the consumer repository.
56     *
57     * Ensures the consumer repository contains linked Fast Forward skills by
58     * creating symlinks to the packaged skills directory. Creates the target
59     * directory if missing, skips existing valid links, and repairs broken ones.
60     *
61     * @param string $skillsDir Absolute path to the consumer's .agents/skills directory
62     * @param string $packageSkillsPath Absolute path to the packaged skills in the dependency
63     *
64     * @return SynchronizeResult Result containing counts of created, preserved, and removed links
65     */
66    public function synchronize(string $skillsDir, string $packageSkillsPath): SynchronizeResult
67    {
68        $result = new SynchronizeResult();
69
70        if (! $this->filesystem->exists($packageSkillsPath)) {
71            $this->logger->error('No packaged skills found at: ' . $packageSkillsPath);
72            $result->markFailed();
73
74            return $result;
75        }
76
77        if (! $this->filesystem->exists($skillsDir)) {
78            $this->filesystem->mkdir($skillsDir);
79            $this->logger->info('Created .agents/skills directory.');
80        }
81
82        $this->syncPackageSkills($skillsDir, $packageSkillsPath, $result);
83
84        return $result;
85    }
86
87    /**
88     * Iterates through all packaged skills and processes each one.
89     *
90     * Uses Finder to locate skill directories in the package, then processes
91     * each as a potential symlink in the consumer repository.
92     *
93     * @param string $skillsDir Target directory for symlinks
94     * @param string $packageSkillsPath Source directory containing packaged skills
95     * @param SynchronizeResult $result Result object to track outcomes
96     */
97    private function syncPackageSkills(
98        string $skillsDir,
99        string $packageSkillsPath,
100        SynchronizeResult $result,
101    ): void {
102        $finder = $this->finder
103            ->directories()
104            ->in($packageSkillsPath)
105            ->depth('== 0');
106
107        foreach ($finder as $skillDir) {
108            $skillName = $skillDir->getFilename();
109            $targetLink = Path::makeAbsolute($skillName, $skillsDir);
110            $sourcePath = $skillDir->getRealPath();
111
112            $this->processSkillLink($skillName, $targetLink, $sourcePath, $result);
113        }
114    }
115
116    /**
117     * Routes a skill link to the appropriate handling method based on target state.
118     *
119     * Determines whether the target path needs creation, preservation, or repair
120     * based on filesystem checks, then delegates to the corresponding method.
121     *
122     * @param string $skillName Name of the skill being processed
123     * @param string $targetLink Absolute path where the symlink should exist
124     * @param string $sourcePath Absolute path to the packaged skill directory
125     * @param SynchronizeResult $result Result tracker for reporting outcomes
126     */
127    private function processSkillLink(
128        string $skillName,
129        string $targetLink,
130        string $sourcePath,
131        SynchronizeResult $result,
132    ): void {
133        if (! $this->filesystem->exists($targetLink)) {
134            $this->createNewLink($skillName, $targetLink, $sourcePath, $result);
135
136            return;
137        }
138
139        if (! $this->isSymlink($targetLink)) {
140            $this->preserveExistingNonSymlink($skillName, $result);
141
142            return;
143        }
144
145        $this->processExistingSymlink($skillName, $targetLink, $sourcePath, $result);
146    }
147
148    /**
149     * Creates a new symlink pointing to the packaged skill.
150     *
151     * This method is called when no existing item exists at the target path.
152     * Creates the symlink, logs the creation, and records it in the result.
153     *
154     * @param string $skillName Name identifying the skill
155     * @param string $targetLink Absolute path where the symlink will be created
156     * @param string $sourcePath Absolute path to the packaged skill directory
157     * @param SynchronizeResult $result Result object for tracking creation
158     */
159    private function createNewLink(
160        string $skillName,
161        string $targetLink,
162        string $sourcePath,
163        SynchronizeResult $result,
164    ): void {
165        $this->filesystem->symlink($sourcePath, $targetLink);
166        $this->logger->info('Created link: ' . $skillName . ' -> ' . $sourcePath);
167        $result->addCreatedLink($skillName);
168    }
169
170    /**
171     * Handles an existing non-symlink item at the target path.
172     *
173     * When the target exists but is a real directory (not a symlink), this method
174     * preserves it unchanged and logs the decision. Real directories are not
175     * replaced to avoid accidental data loss.
176     *
177     * @param string $skillName Name of the skill with the conflicting item
178     * @param SynchronizeResult $result Result tracker for preserved items
179     */
180    private function preserveExistingNonSymlink(string $skillName, SynchronizeResult $result): void
181    {
182        $this->logger->notice('Existing non-symlink found: ' . $skillName . ' (keeping as is, skipping link creation)');
183        $result->addPreservedLink($skillName);
184    }
185
186    /**
187     * Evaluates an existing symlink and determines whether to preserve or repair it.
188     *
189     * Reads the symlink target and checks if it points to a valid, existing path.
190     * Delegates to repair if broken, otherwise preserves the valid link in place.
191     *
192     * @param string $skillName Name of the skill with the existing symlink
193     * @param string $targetLink Absolute path to the existing symlink
194     * @param string $sourcePath Absolute path to the expected source directory
195     * @param SynchronizeResult $result Result tracker for preserved or removed links
196     */
197    private function processExistingSymlink(
198        string $skillName,
199        string $targetLink,
200        string $sourcePath,
201        SynchronizeResult $result,
202    ): void {
203        $linkPath = $this->filesystem->readlink($targetLink, true);
204
205        if (! $linkPath || ! $this->filesystem->exists($linkPath)) {
206            $this->repairBrokenLink($skillName, $targetLink, $sourcePath, $result);
207
208            return;
209        }
210
211        $this->logger->notice('Preserved existing link: ' . $skillName);
212        $result->addPreservedLink($skillName);
213    }
214
215    /**
216     * Removes a broken symlink and creates a fresh one pointing to the current source.
217     *
218     * Called when the existing symlink target either does not exist or points to
219     * an invalid path. Removes the broken link, logs the repair, records the removal,
220     * then delegates to createNewLink for the fresh symlink.
221     *
222     * @param string $skillName Name of the skill with the broken symlink
223     * @param string $targetLink Absolute path to the broken symlink
224     * @param string $sourcePath Absolute path to the current packaged skill
225     * @param SynchronizeResult $result Result tracker for removed and created items
226     */
227    private function repairBrokenLink(
228        string $skillName,
229        string $targetLink,
230        string $sourcePath,
231        SynchronizeResult $result,
232    ): void {
233        $this->filesystem->remove($targetLink);
234        $this->logger->notice('Existing link is broken: ' . $skillName . ' (removing and recreating)');
235        $result->addRemovedBrokenLink($skillName);
236
237        $this->createNewLink($skillName, $targetLink, $sourcePath, $result);
238    }
239
240    /**
241     * Checks if a path is a symbolic link.
242     *
243     * @param string $path
244     */
245    private function isSymlink(string $path): bool
246    {
247        return null !== $this->filesystem->readlink($path);
248    }
249}