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