Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.36% covered (warning)
74.36%
87 / 117
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiCommand
74.36% covered (warning)
74.36%
87 / 117
57.14% covered (warning)
57.14%
4 / 7
25.09
0.00% covered (danger)
0.00%
0 / 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%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 execute
85.96% covered (warning)
85.96%
49 / 57
0.00% covered (danger)
0.00%
0 / 1
10.28
 isDefaultWikiTarget
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 normalizeProjectRelativePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 initializeWikiSubmodule
32.26% covered (danger)
32.26%
10 / 31
0.00% covered (danger)
0.00%
0 / 1
5.80
 getGitRepositoryUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\Composer\Json\ComposerJsonInterface;
24use FastForward\DevTools\Console\Input\HasCacheOption;
25use FastForward\DevTools\Console\Input\HasJsonOption;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Git\GitClientInterface;
28use FastForward\DevTools\Path\DevToolsPathResolver;
29use FastForward\DevTools\Process\ProcessBuilderInterface;
30use FastForward\DevTools\Process\ProcessQueueInterface;
31use FastForward\DevTools\Path\ManagedWorkspace;
32use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface;
33use Psr\Log\LogLevel;
34use Symfony\Component\Console\Attribute\AsCommand;
35use Symfony\Component\Console\Command\Command;
36use Symfony\Component\Console\Input\InputInterface;
37use Symfony\Component\Console\Input\InputOption;
38use Symfony\Component\Console\Output\BufferedOutput;
39use Symfony\Component\Console\Output\OutputInterface;
40use Symfony\Component\Filesystem\Path;
41
42use function Safe\getcwd;
43
44/**
45 * Handles the generation of API documentation for the project.
46 * This class MUST NOT be extended and SHALL utilize phpDocumentor to accomplish its task.
47 */
48#[AsCommand(
49    name: 'github:wiki',
50    description: 'Generates API documentation in Markdown format.',
51    aliases: ['.github/wiki', 'wiki'],
52)]
53 class WikiCommand extends Command
54{
55    use HasCacheOption;
56    use HasJsonOption;
57    use LogsCommandResults;
58
59    /**
60     * @var string the default phpDocumentor Markdown template path relative to the consumer project
61     */
62    private const string DEFAULT_TEMPLATE = 'vendor/saggre/phpdocumentor-markdown/themes/markdown';
63
64    /**
65     * Creates a new WikiCommand instance.
66     *
67     * @param ComposerJsonInterface $composer the composer.json accessor
68     * @param ProcessBuilderInterface $processBuilder
69     * @param ProcessQueueInterface $processQueue
70     * @param FilesystemInterface $filesystem the filesystem used to inspect the wiki target
71     * @param GitClientInterface $gitClient
72     * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver
73     */
74    public function __construct(
75        private  ProcessBuilderInterface $processBuilder,
76        private  ProcessQueueInterface $processQueue,
77        private  ComposerJsonInterface $composer,
78        private  FilesystemInterface $filesystem,
79        private  GitClientInterface $gitClient,
80        private  ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver,
81    ) {
82        parent::__construct();
83    }
84
85    /**
86     * Configures the command instance.
87     *
88     * The method MUST set up the name and description. It MAY accept an optional `--target` option
89     * pointing to an alternative configuration target path.
90     *
91     * @return void
92     */
93    protected function configure(): void
94    {
95        $this->setHelp('This command generates API documentation in Markdown format using phpDocumentor. ');
96        $this
97            ->addJsonOption()
98            ->addCacheOption('Whether to enable phpDocumentor caching.')
99            ->addCacheDirOption(
100                description: 'Path to the cache directory for phpDocumentor.',
101                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC),
102            )
103            ->addOption(
104                name: 'target',
105                shortcut: 't',
106                mode: InputOption::VALUE_OPTIONAL,
107                description: 'Path to the output directory for the generated Markdown documentation.',
108                default: ProjectCapabilitiesResolverInterface::DEFAULT_WIKI_TARGET,
109            )
110            ->addOption(
111                name: 'init',
112                mode: InputOption::VALUE_NONE,
113                description: 'Initialize the configured wiki target as a Git submodule.',
114            );
115    }
116
117    /**
118     * Executes the generation of the documentation files in Markdown format.
119     *
120     * This method MUST compile arguments based on PSR-4 namespaces to feed into phpDocumentor.
121     * It SHOULD provide feedback on generation progress, and SHALL return `self::SUCCESS` on success.
122     *
123     * @param InputInterface $input the input details for the command
124     * @param OutputInterface $output the output mechanism for logging
125     *
126     * @return int the final execution status code
127     */
128    protected function execute(InputInterface $input, OutputInterface $output): int
129    {
130        $jsonOutput = $this->isJsonOutput($input);
131        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
132        $target = (string) $input->getOption('target');
133        $isDefaultWikiTarget = $this->isDefaultWikiTarget($target);
134        $cacheEnabled = $this->isCacheEnabled($input);
135
136        if ($input->getOption('init')) {
137            return $this->initializeWikiSubmodule($input, $target, $processOutput);
138        }
139
140        $this->log('Generating wiki documentation...', $input);
141
142        $projectCapabilities = $this->projectCapabilitiesResolver->resolve(wikiTarget: $target);
143
144        if ($isDefaultWikiTarget && ! $projectCapabilities->hasWikiTarget()) {
145            return $this->success(
146                'Skipping wiki documentation generation because the wiki target does not exist at {target}.',
147                $input,
148                [
149                    'target' => $target,
150                ],
151                LogLevel::WARNING,
152            );
153        }
154
155        if (! $projectCapabilities->canGenerateApiDocumentation()) {
156            return $this->success(
157                'Skipping wiki documentation generation because no autoloaded PHP API directories were detected.',
158                $input,
159                [],
160                LogLevel::WARNING,
161            );
162        }
163
164        $processBuilder = $this->processBuilder
165            ->withArgument('--ansi')
166            ->withArgument('--visibility', 'public,protected')
167            ->withArgument('--template', DevToolsPathResolver::getPreferredVendorPath(self::DEFAULT_TEMPLATE))
168            ->withArgument('--title', $this->composer->getDescription())
169            ->withArgument('--target', $target);
170
171        if ($cacheEnabled) {
172            $processBuilder = $processBuilder->withArgument('--cache-folder', $input->getOption('cache-dir'));
173        }
174
175        foreach ($projectCapabilities->getApiDirectories() as $path) {
176            $processBuilder = $processBuilder->withArgument(
177                '--directory',
178                $this->filesystem->getAbsolutePath($path)
179            );
180        }
181
182        if (null !== $defaultPackageName = $projectCapabilities->getDefaultPackageName()) {
183            $processBuilder = $processBuilder->withArgument('--defaultpackagename', $defaultPackageName);
184        }
185
186        $this->processQueue->add(
187            process: $processBuilder->build([DevToolsPathResolver::getPreferredToolBinaryPath('phpdoc')]),
188            label: 'Generating Wiki with phpDocumentor',
189        );
190
191        $result = $this->processQueue->run($processOutput);
192
193        if (self::SUCCESS === $result) {
194            return $this->success('Wiki documentation generated successfully.', $input, [
195                'output' => $processOutput,
196            ]);
197        }
198
199        return $this->failure(
200            'Wiki documentation generation failed.',
201            $input,
202            [
203                'output' => $processOutput,
204            ],
205            (string) $input->getOption('target'),
206        );
207    }
208
209    /**
210     * Detects whether a target option still points at the default wiki target path.
211     *
212     * @param string $target the wiki target option received from the CLI
213     *
214     * @return bool true when the provided path is equivalent to the default wiki target
215     */
216    private function isDefaultWikiTarget(string $target): bool
217    {
218        return $this->normalizeProjectRelativePath($target) === $this->normalizeProjectRelativePath(
219            ProjectCapabilitiesResolverInterface::DEFAULT_WIKI_TARGET
220        );
221    }
222
223    /**
224     * Normalizes a project-relative path for resilient default-option comparisons.
225     *
226     * @param string $path the project-relative path to normalize
227     *
228     * @return string the normalized project-relative path
229     */
230    private function normalizeProjectRelativePath(string $path): string
231    {
232        $normalizedPath = str_replace('\\', '/', $path);
233
234        while (str_starts_with($normalizedPath, './')) {
235            $normalizedPath = substr($normalizedPath, 2);
236        }
237
238        return rtrim($normalizedPath, '/');
239    }
240
241    /**
242     * Adds the repository wiki as a Git submodule when the target path is missing.
243     *
244     * @param string $target the configured wiki target path
245     * @param OutputInterface $output the output used for process feedback
246     * @param InputInterface $input
247     *
248     * @return int the command status code
249     */
250    private function initializeWikiSubmodule(InputInterface $input, string $target, OutputInterface $output): int
251    {
252        $wikiSubmodulePath = (string) $this->filesystem->getAbsolutePath($target);
253
254        if ($this->filesystem->exists($wikiSubmodulePath)) {
255            return $this->success(
256                'Wiki submodule already exists at {wiki_submodule_path}.',
257                $input,
258                [
259                    'input' => $input,
260                    'wiki_submodule_path' => $wikiSubmodulePath,
261                ],
262            );
263        }
264
265        $repositoryUrl = $this->getGitRepositoryUrl();
266        $wikiRepoUrl = str_replace('.git', '.wiki.git', $repositoryUrl);
267
268        $this->processQueue->add(
269            $this->processBuilder
270                ->withArgument('submodule')
271                ->withArgument('add')
272                ->withArgument($wikiRepoUrl)
273                ->withArgument(Path::makeRelative($wikiSubmodulePath, getcwd()))
274                ->build('git'),
275            label: 'Initializing Wiki Submodule with Git',
276        );
277
278        $result = $this->processQueue->run($output);
279
280        if (self::SUCCESS === $result) {
281            return $this->success('Wiki submodule initialized successfully.', $input, [
282                'wiki_submodule_path' => $wikiSubmodulePath,
283                'wiki_repository_url' => $wikiRepoUrl,
284            ]);
285        }
286
287        return $this->failure('Wiki submodule initialization failed.', $input, [
288            'wiki_submodule_path' => $wikiSubmodulePath,
289            'wiki_repository_url' => $wikiRepoUrl,
290        ], $target);
291    }
292
293    /**
294     * Resolves the current repository remote origin URL.
295     *
296     * @return string the Git remote origin URL
297     */
298    private function getGitRepositoryUrl(): string
299    {
300        return $this->gitClient->getConfig('remote.origin.url', getcwd());
301    }
302}