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