Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.06% covered (warning)
88.06%
59 / 67
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
DevTools
88.06% covered (warning)
88.06%
59 / 67
70.00% covered (warning)
70.00%
7 / 10
28.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultInputDefinition
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 doRun
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
5.51
 getWorkingDirectoryOption
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 configureWorkspaceDirectory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 runAutoUpdateWhenRequested
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 shouldRenderLogo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 isRawOutputCommand
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 isSelfUpdateCommand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTruthyAutoUpdateMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 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;
21
22use FastForward\DevTools\Console\Command\SelfUpdateCommand;
23use FastForward\DevTools\Environment\EnvironmentInterface;
24use FastForward\DevTools\Environment\RuntimeEnvironmentInterface;
25use FastForward\DevTools\Path\ManagedWorkspace;
26use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface;
27use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface;
28use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface;
29use FastForward\DevTools\SelfUpdate\VersionCheckerInterface;
30use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface;
31use Override;
32use Symfony\Component\Console\Application;
33use Symfony\Component\Console\Command\Command;
34use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
35use Symfony\Component\Console\Input\InputDefinition;
36use Symfony\Component\Console\Input\InputInterface;
37use Symfony\Component\Console\Input\InputOption;
38use Symfony\Component\Console\Output\OutputInterface;
39use Throwable;
40
41use function Safe\putenv;
42
43/**
44 * Wraps the fast-forward console tooling suite conceptually as an isolated application instance.
45 * Extending the base application, it MUST provide default command injections safely.
46 */
47 class DevTools extends Application
48{
49    private const string LOGO = <<<'LOGO'
50         ____             _____           _
51        |  _ \  _____   _|_   _|__   ___ | |___
52        | | | |/ _ \ \ / / | |/ _ \ / _ \| / __|
53        | |_| |  __/\ V /  | | (_) | (_) | \__ \
54        |____/ \___| \_/   |_|\___/ \___/|_|___/
55        ========================================
56
57        LOGO;
58
59    /**
60     * Commands that require raw output and therefore must not render the logo.
61     *
62     * @var list<string>
63     */
64    private const array RAW_OUTPUT_COMMANDS = ['changelog:next-version', 'changelog:show'];
65
66    /**
67     * Initializes the DevTools global context and dependency graph.
68     *
69     * The method MUST define default configurations and MAY accept an explicit command provider.
70     * It SHALL instruct the runner to treat the `standards` command generically as its default endpoint.
71     *
72     * @param CommandLoaderInterface $commandLoader the command loader responsible for providing command instances
73     * @param WorkingDirectorySwitcherInterface $workingDirectorySwitcher switches the process working directory
74     * @param VersionCheckNotifierInterface $versionCheckNotifier emits non-blocking version freshness warnings
75     * @param SelfUpdateRunnerInterface $selfUpdateRunner runs explicit or automatic self-update flows
76     * @param SelfUpdateScopeResolverInterface $selfUpdateScopeResolver resolves whether the active binary is global
77     * @param VersionCheckerInterface $versionChecker resolves the installed DevTools version for metadata output
78     * @param EnvironmentInterface $environment reads environment flags for optional auto-update behavior
79     * @param RuntimeEnvironmentInterface $runtimeEnvironment resolves runtime environment capabilities
80     */
81    public function __construct(
82        CommandLoaderInterface $commandLoader,
83        private  WorkingDirectorySwitcherInterface $workingDirectorySwitcher,
84        private  VersionCheckNotifierInterface $versionCheckNotifier,
85        private  SelfUpdateRunnerInterface $selfUpdateRunner,
86        private  SelfUpdateScopeResolverInterface $selfUpdateScopeResolver,
87        private  VersionCheckerInterface $versionChecker,
88        private  EnvironmentInterface $environment,
89        private  RuntimeEnvironmentInterface $runtimeEnvironment,
90    ) {
91        parent::__construct('Fast Forward Dev Tools', $this->versionChecker->getCurrentVersion());
92
93        $this->setDefaultCommand('dev-tools:standards');
94        $this->setCommandLoader($commandLoader);
95    }
96
97    /**
98     * Returns the application-level input definition with DevTools runtime options.
99     *
100     * @return InputDefinition the global application input definition
101     */
102    #[Override]
103    protected function getDefaultInputDefinition(): InputDefinition
104    {
105        $definition = parent::getDefaultInputDefinition();
106
107        $definition->addOption(new InputOption(
108            name: 'working-dir',
109            shortcut: 'd',
110            mode: InputOption::VALUE_REQUIRED,
111            description: 'Run DevTools as if it was started in the given directory.',
112        ));
113
114        $definition->addOption(new InputOption(
115            name: 'auto-update',
116            mode: InputOption::VALUE_NONE,
117            description: 'Update fast-forward/dev-tools before running the requested command.',
118        ));
119
120        $definition->addOption(new InputOption(
121            name: 'workspace-dir',
122            shortcut: 'w',
123            mode: InputOption::VALUE_REQUIRED,
124            description: 'Store generated DevTools artifacts in the given directory.',
125        ));
126
127        $definition->addOption(new InputOption(
128            name: 'no-logo',
129            mode: InputOption::VALUE_NONE,
130            description: 'Hide the startup ASCII logo.',
131        ));
132
133        return $definition;
134    }
135
136    /**
137     * Runs the application after applying global runtime options.
138     *
139     * @param InputInterface $input the application input
140     * @param OutputInterface $output the application output
141     *
142     * @return int the application status code
143     */
144    #[Override]
145    public function doRun(InputInterface $input, OutputInterface $output): int
146    {
147        if ($this->shouldRenderLogo($input)) {
148            $output->writeln(self::LOGO);
149        }
150
151        try {
152            $this->workingDirectorySwitcher->switchTo($this->getWorkingDirectoryOption($input));
153            $this->configureWorkspaceDirectory($input);
154        } catch (Throwable $throwable) {
155            $output->writeln(\sprintf('<error>%s</error>', $throwable->getMessage()));
156
157            return Command::FAILURE;
158        }
159
160        if ($this->shouldRenderLogo($input) && ! $this->isSelfUpdateCommand($input)) {
161            $this->runAutoUpdateWhenRequested($input, $output);
162            $this->versionCheckNotifier->notify($output);
163        }
164
165        return parent::doRun($input, $output);
166    }
167
168    /**
169     * Resolves the raw working-directory option before command parsing.
170     *
171     * @param InputInterface $input the application input
172     */
173    private function getWorkingDirectoryOption(InputInterface $input): ?string
174    {
175        $workingDirectory = $input->getParameterOption(['--working-dir', '-d'], null, true);
176
177        return \is_string($workingDirectory) ? $workingDirectory : null;
178    }
179
180    /**
181     * Applies the configured workspace directory before resolving command defaults.
182     *
183     * @param InputInterface $input the application input
184     */
185    private function configureWorkspaceDirectory(InputInterface $input): void
186    {
187        $workspaceDirectory = $input->getParameterOption('--workspace-dir', null, true);
188
189        if (! \is_string($workspaceDirectory) || '' === $workspaceDirectory) {
190            return;
191        }
192
193        putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=' . $workspaceDirectory);
194    }
195
196    /**
197     * Runs an explicit automatic update without letting failures block the requested command.
198     *
199     * @param InputInterface $input the application input
200     * @param OutputInterface $output the application output
201     */
202    private function runAutoUpdateWhenRequested(InputInterface $input, OutputInterface $output): void
203    {
204        $autoUpdateMode = $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '');
205
206        if (! $input->hasParameterOption('--auto-update', true) && ! $this->isTruthyAutoUpdateMode($autoUpdateMode)) {
207            return;
208        }
209
210        try {
211            $global = $this->selfUpdateScopeResolver->isGlobalInstallation();
212            $statusCode = $this->selfUpdateRunner->update($global, $output);
213        } catch (Throwable) {
214            $output->writeln('<comment>DevTools auto-update failed; continuing with the requested command.</comment>');
215
216            return;
217        }
218
219        if (Command::SUCCESS !== $statusCode) {
220            $output->writeln('<comment>DevTools auto-update failed; continuing with the requested command.</comment>');
221        }
222    }
223
224    /**
225     * Determines whether the startup logo should be rendered for this invocation.
226     *
227     * @param InputInterface $input the application input
228     */
229    private function shouldRenderLogo(InputInterface $input): bool
230    {
231        if ($this->runtimeEnvironment->isAgentPresent()) {
232            return false;
233        }
234
235        if ((bool) $input->getParameterOption('--no-logo', null, true)) {
236            return false;
237        }
238
239        if ($input->hasParameterOption('--json', true) || $input->hasParameterOption('--pretty-json', true)) {
240            return false;
241        }
242
243        return ! $this->isRawOutputCommand($input);
244    }
245
246    /**
247     * Checks whether the current command is designed for raw output mode.
248     *
249     * @param InputInterface $input the application input
250     */
251    private function isRawOutputCommand(InputInterface $input): bool
252    {
253        $commandName = $input->getFirstArgument();
254
255        if (! \is_string($commandName)) {
256            return false;
257        }
258
259        return \in_array($commandName, self::RAW_OUTPUT_COMMANDS, true);
260    }
261
262    /**
263     * Detects whether the current invocation targets the self-update command.
264     *
265     * @param InputInterface $input the application input
266     */
267    private function isSelfUpdateCommand(InputInterface $input): bool
268    {
269        return \in_array($input->getFirstArgument(), SelfUpdateCommand::getCommandNames(), true);
270    }
271
272    /**
273     * Interprets environment values that enable auto-update.
274     *
275     * @param string|null $mode the FAST_FORWARD_AUTO_UPDATE value
276     */
277    private function isTruthyAutoUpdateMode(?string $mode): bool
278    {
279        return null !== $mode && \in_array(strtolower($mode), ['1', 'true', 'yes', 'on'], true);
280    }
281}