Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
46 / 48
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractCommand
95.83% covered (success)
95.83%
46 / 48
88.89% covered (warning)
88.89%
8 / 9
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 runProcess
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
3.01
 getCurrentWorkingDirectory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getAbsolutePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getConfigFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 runCommand
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getPsr4Namespaces
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getProjectName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getProjectDescription
100.00% covered (success)
100.00%
3 / 3
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\Command;
20
21use Symfony\Component\Console\Helper\ProcessHelper;
22use Composer\Command\BaseCommand;
23use Composer\InstalledVersions;
24use Symfony\Component\Console\Input\ArrayInput;
25use Symfony\Component\Console\Input\InputInterface;
26use Symfony\Component\Console\Output\OutputInterface;
27use Symfony\Component\Filesystem\Filesystem;
28use Symfony\Component\Filesystem\Path;
29use Symfony\Component\Process\Process;
30
31use function Safe\getcwd;
32
33/**
34 * Provides a base configuration and common utilities for Composer commands.
35 * Extending classes MUST rely on this base abstraction to interact with the console
36 * application gracefully, and SHALL adhere to the expected return types for commands.
37 */
38abstract class AbstractCommand extends BaseCommand
39{
40    /**
41     * @var Filesystem The filesystem instance used for file operations. This property MUST be utilized for interacting with the file system securely.
42     */
43    protected readonly Filesystem $filesystem;
44
45    /**
46     * Constructs a new AbstractCommand instance.
47     *
48     * The method MAY accept a Filesystem instance; if omitted, it SHALL instantiate a new one.
49     *
50     * @param Filesystem|null $filesystem the filesystem utility to use
51     */
52    public function __construct(?Filesystem $filesystem = null)
53    {
54        $this->filesystem = $filesystem ?? new Filesystem();
55
56        parent::__construct();
57    }
58
59    /**
60     * Executes a given system process gracefully and outputs its buffer.
61     *
62     * The method MUST execute the provided command ensuring the output is channeled
63     * to the OutputInterface. It SHOULD leverage TTY if supported. If the process
64     * fails, it MUST return `self::FAILURE`; otherwise, it SHALL return `self::SUCCESS`.
65     *
66     * @param Process $command the configured process instance to run
67     * @param OutputInterface $output the output interface to log warnings or results
68     *
69     * @return int the status code of the command execution
70     */
71    protected function runProcess(Process $command, OutputInterface $output): int
72    {
73        /** @var ProcessHelper $processHelper */
74        $processHelper = $this->getHelper('process');
75
76        $command = $command->setWorkingDirectory($this->getCurrentWorkingDirectory());
77        $callback = null;
78
79        if (Process::isTtySupported()) {
80            $command->setTty(true);
81        } else {
82            $output->writeln(
83                '<comment>Warning: TTY is not supported. The command may not display output as expected.</comment>'
84            );
85
86            $callback = function (string $type, string $buffer) use ($output): void {
87                $output->write($buffer);
88            };
89        }
90
91        $process = $processHelper->run(output: $output, cmd: $command, callback: $callback);
92
93        if (! $process->isSuccessful()) {
94            $output->writeln(\sprintf(
95                '<error>Command "%s" failed with exit code %d. Please check the output above for details.</error>',
96                $command->getCommandLine(),
97                $command->getExitCode()
98            ));
99
100            return self::FAILURE;
101        }
102
103        return self::SUCCESS;
104    }
105
106    /**
107     * Retrieves the current working directory of the application.
108     *
109     * The method MUST return the initial working directory defined by the application.
110     * If not available, it SHALL fall back to the safe current working directory.
111     *
112     * @return string the absolute path to the current working directory
113     */
114    protected function getCurrentWorkingDirectory(): string
115    {
116        return $this->getApplication()
117            ->getInitialWorkingDirectory() ?: getcwd();
118    }
119
120    /**
121     * Computes the absolute path for a given relative or absolute path.
122     *
123     * This method MUST return the exact path if it is already absolute.
124     * If relative, it SHALL make it absolute relying on the current working directory.
125     *
126     * @param string $relativePath the path to evaluate or resolve
127     *
128     * @return string the resolved absolute path
129     */
130    protected function getAbsolutePath(string $relativePath): string
131    {
132        if ($this->filesystem->isAbsolutePath($relativePath)) {
133            return $relativePath;
134        }
135
136        return Path::makeAbsolute($relativePath, $this->getCurrentWorkingDirectory());
137    }
138
139    /**
140     * Determines the correct absolute path to a configuration file.
141     *
142     * The method MUST attempt to resolve the configuration file locally in the working directory.
143     * If absent and not forced, it SHALL provide the default equivalent from the package itself.
144     *
145     * @param string $filename the name of the configuration file
146     * @param bool $force determines whether to bypass fallback and forcefully return the local file path
147     *
148     * @return string the resolved absolute path to the configuration file
149     */
150    protected function getConfigFile(string $filename, bool $force = false): string
151    {
152        $rootPackagePath = $this->getCurrentWorkingDirectory();
153
154        if ($force || $this->filesystem->exists($rootPackagePath . '/' . $filename)) {
155            return Path::makeAbsolute($filename, $rootPackagePath);
156        }
157
158        $devToolsPackagePath = InstalledVersions::getInstallPath('fast-forward/dev-tools');
159
160        return Path::makeAbsolute($filename, $devToolsPackagePath);
161    }
162
163    /**
164     * Configures and executes a registered console command by name.
165     *
166     * The method MUST look up the command from the application and run it. It SHALL ignore generic
167     * validation errors and route the custom input and output correctly.
168     *
169     * @param string $commandName the name of the required command
170     * @param array|InputInterface $input the input arguments or array definition
171     * @param OutputInterface $output the interface for buffering output
172     *
173     * @return int the status code resulting from the dispatched command
174     */
175    protected function runCommand(string $commandName, array|InputInterface $input, OutputInterface $output): int
176    {
177        $application = $this->getApplication();
178
179        $command = $application->find($commandName);
180        $command->ignoreValidationErrors();
181
182        if (\is_array($input)) {
183            $input = new ArrayInput($input);
184        }
185
186        return $command->run($input, $output);
187    }
188
189    /**
190     * Retrieves configured PSR-4 namespaces from the composer configuration.
191     *
192     * This method SHALL parse the underlying `composer.json` using the Composer instance,
193     * and MUST provide an empty array if no specific paths exist.
194     *
195     * @return array the PSR-4 namespaces mappings
196     */
197    protected function getPsr4Namespaces(): array
198    {
199        $composer = $this->requireComposer();
200        $autoload = $composer->getPackage()
201            ->getAutoload();
202
203        return $autoload['psr-4'] ?? [];
204    }
205
206    /**
207     * Computes the human-readable title or description of the current application.
208     *
209     * The method SHOULD utilize the package description as the title, but MUST provide
210     * the raw package name as a fallback mechanism.
211     *
212     * @return string the computed title or description string
213     */
214    protected function getProjectName(): string
215    {
216        $composer = $this->requireComposer();
217        $package = $composer->getPackage();
218
219        return $package->getName();
220    }
221
222    /**
223     * Computes the human-readable description of the current application.
224     *
225     * The method SHOULD utilize the package description as the title, but MUST provide
226     * the raw package name as a fallback mechanism.
227     *
228     * @return string the computed title or description string
229     */
230    protected function getProjectDescription(): string
231    {
232        $composer = $this->requireComposer();
233        $package = $composer->getPackage();
234
235        return $package->getDescription();
236    }
237}