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