Skip to content

Commit fe33bc9

Browse files
committed
feat: add theme suggestion service and integrate with commands #75
1 parent b05d2c6 commit fe33bc9

8 files changed

Lines changed: 486 additions & 29 deletions

File tree

.github/workflows/magento-compatibility.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,19 @@ jobs:
157157
bin/magento m:st:c --help
158158
bin/magento frontend:clean --help
159159
160+
echo "Test Theme Name Suggestions (non-interactive):"
161+
echo "Testing BuildCommand with invalid theme name:"
162+
bin/magento mageforge:theme:build Magent/lum || echo "Expected failure - BuildCommand"
163+
164+
echo "Testing WatchCommand with invalid theme name:"
165+
bin/magento mageforge:theme:watch Magent/lum --help || echo "Expected failure - WatchCommand"
166+
167+
echo "Testing CleanCommand with invalid theme name:"
168+
bin/magento mageforge:static:clean Magent/lum --dry-run || echo "Expected failure - CleanCommand"
169+
170+
echo "Testing TokensCommand with invalid theme name:"
171+
bin/magento mageforge:hyva:tokens Magent/lum --help || echo "Expected failure - TokensCommand"
172+
160173
161174
- name: Test Summary
162175
run: |
@@ -299,6 +312,19 @@ jobs:
299312
bin/magento m:st:c --help
300313
bin/magento frontend:clean --help
301314
315+
echo "Test Theme Name Suggestions (non-interactive):"
316+
echo "Testing BuildCommand with invalid theme name:"
317+
bin/magento mageforge:theme:build Magent/lum || echo "Expected failure - BuildCommand"
318+
319+
echo "Testing WatchCommand with invalid theme name:"
320+
bin/magento mageforge:theme:watch Magent/lum --help || echo "Expected failure - WatchCommand"
321+
322+
echo "Testing CleanCommand with invalid theme name:"
323+
bin/magento mageforge:static:clean Magent/lum --dry-run || echo "Expected failure - CleanCommand"
324+
325+
echo "Testing TokensCommand with invalid theme name:"
326+
bin/magento mageforge:hyva:tokens Magent/lum --help || echo "Expected failure - TokensCommand"
327+
302328
303329
- name: Test Summary
304330
run: |

src/Console/Command/AbstractCommand.php

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace OpenForgeProject\MageForge\Console\Command;
66

7+
use Laravel\Prompts\SelectPrompt;
78
use Magento\Framework\Console\Cli;
9+
use OpenForgeProject\MageForge\Service\ThemeSuggester;
810
use Symfony\Component\Console\Command\Command;
911
use Symfony\Component\Console\Input\InputInterface;
1012
use Symfony\Component\Console\Output\OutputInterface;
@@ -27,6 +29,16 @@ abstract class AbstractCommand extends Command
2729
*/
2830
protected SymfonyStyle $io;
2931

32+
/**
33+
* @var array
34+
*/
35+
private array $originalEnv = [];
36+
37+
/**
38+
* @var array
39+
*/
40+
private array $secureEnvStorage = [];
41+
3042
/**
3143
* Get the command name with proper group structure
3244
*
@@ -101,4 +113,196 @@ protected function isDebug(OutputInterface $output): bool
101113
{
102114
return $output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG;
103115
}
116+
117+
/**
118+
* Handle invalid theme with interactive suggestions
119+
*
120+
* When a theme code is invalid, this method finds similar themes using Levenshtein distance
121+
* and offers an interactive selection via Laravel Prompts (if terminal is interactive).
122+
* In non-interactive environments, suggestions are displayed as text.
123+
*
124+
* @param string $invalidTheme The invalid theme code entered by user
125+
* @param ThemeSuggester $themeSuggester Service to find similar themes
126+
* @param OutputInterface $output Output interface for terminal detection
127+
* @return string|null The selected theme code, or null if cancelled/no selection
128+
*/
129+
protected function handleInvalidThemeWithSuggestions(
130+
string $invalidTheme,
131+
ThemeSuggester $themeSuggester,
132+
OutputInterface $output
133+
): ?string {
134+
$suggestions = $themeSuggester->findSimilarThemes($invalidTheme);
135+
136+
// No suggestions found
137+
if (empty($suggestions)) {
138+
$this->io->error("Theme '$invalidTheme' is not installed and no similar themes were found.");
139+
return null;
140+
}
141+
142+
// Check if terminal is interactive
143+
if (!$this->isInteractiveTerminal($output)) {
144+
// Non-interactive fallback: display suggestions as text
145+
$this->io->error("Theme '$invalidTheme' is not installed.");
146+
$this->io->writeln("\nDid you mean one of these?");
147+
foreach ($suggestions as $suggestion) {
148+
$this->io->writeln(" - $suggestion");
149+
}
150+
return null;
151+
}
152+
153+
// Interactive mode: show prompt with suggestions
154+
$this->io->error("Theme '$invalidTheme' is not installed.");
155+
$this->io->newLine();
156+
157+
// Prepare options with "None of these" option
158+
$options = array_merge($suggestions, ['None of these']);
159+
160+
// Set environment for Docker/DDEV compatibility
161+
$this->setPromptEnvironment();
162+
163+
$prompt = new SelectPrompt(
164+
label: 'Did you mean one of these themes?',
165+
options: $options,
166+
scroll: 10,
167+
hint: 'Arrow keys to navigate, Enter to confirm'
168+
);
169+
170+
try {
171+
$selection = $prompt->prompt();
172+
\Laravel\Prompts\Prompt::terminal()->restoreTty();
173+
$this->resetPromptEnvironment();
174+
175+
// Check if user selected "None of these"
176+
if ($selection === 'None of these') {
177+
return null;
178+
}
179+
180+
return $selection;
181+
} catch (\Exception $e) {
182+
$this->resetPromptEnvironment();
183+
$this->io->error('Selection failed: ' . $e->getMessage());
184+
return null;
185+
}
186+
}
187+
188+
/**
189+
* Check if terminal is interactive (supports Laravel Prompts)
190+
*
191+
* @param OutputInterface $output
192+
* @return bool
193+
*/
194+
private function isInteractiveTerminal(OutputInterface $output): bool
195+
{
196+
// Check if output supports ANSI
197+
if (!$output->isDecorated()) {
198+
return false;
199+
}
200+
201+
// Check if STDIN is available
202+
if (!defined('STDIN') || !is_resource(STDIN)) {
203+
return false;
204+
}
205+
206+
// Check for CI environments
207+
$nonInteractiveEnvs = [
208+
'CI',
209+
'GITHUB_ACTIONS',
210+
'GITLAB_CI',
211+
'JENKINS_URL',
212+
'TEAMCITY_VERSION',
213+
];
214+
215+
foreach ($nonInteractiveEnvs as $env) {
216+
if ($this->getEnvVar($env) || $this->getServerVar($env)) {
217+
return false;
218+
}
219+
}
220+
221+
// Check if TTY is available
222+
$sttyOutput = shell_exec('stty -g 2>/dev/null');
223+
return !empty($sttyOutput);
224+
}
225+
226+
/**
227+
* Set environment variables for Laravel Prompts in Docker/DDEV
228+
*
229+
* @return void
230+
*/
231+
private function setPromptEnvironment(): void
232+
{
233+
// Store original values for restoration
234+
$this->originalEnv = [
235+
'COLUMNS' => $this->getEnvVar('COLUMNS'),
236+
'LINES' => $this->getEnvVar('LINES'),
237+
'TERM' => $this->getEnvVar('TERM'),
238+
];
239+
240+
// Set terminal dimensions for proper rendering
241+
$this->setEnvVar('COLUMNS', '100');
242+
$this->setEnvVar('LINES', '40');
243+
$this->setEnvVar('TERM', 'xterm-256color');
244+
}
245+
246+
/**
247+
* Reset environment variables to original state
248+
*
249+
* @return void
250+
*/
251+
private function resetPromptEnvironment(): void
252+
{
253+
foreach ($this->originalEnv as $key => $value) {
254+
if ($value === null) {
255+
$this->removeSecureEnvironmentValue($key);
256+
} else {
257+
$this->setEnvVar($key, $value);
258+
}
259+
}
260+
}
261+
262+
/**
263+
* Get environment variable value
264+
*
265+
* @param string $key
266+
* @return string|null
267+
*/
268+
private function getEnvVar(string $key): ?string
269+
{
270+
return getenv($key) ?: null;
271+
}
272+
273+
/**
274+
* Get server variable value
275+
*
276+
* @param string $key
277+
* @return string|null
278+
*/
279+
private function getServerVar(string $key): ?string
280+
{
281+
return $_SERVER[$key] ?? null;
282+
}
283+
284+
/**
285+
* Set environment variable securely
286+
*
287+
* @param string $key
288+
* @param string $value
289+
* @return void
290+
*/
291+
private function setEnvVar(string $key, string $value): void
292+
{
293+
$this->secureEnvStorage[$key] = $value;
294+
putenv("$key=$value");
295+
}
296+
297+
/**
298+
* Remove environment variable securely
299+
*
300+
* @param string $key
301+
* @return void
302+
*/
303+
private function removeSecureEnvironmentValue(string $key): void
304+
{
305+
unset($this->secureEnvStorage[$key]);
306+
putenv($key);
307+
}
104308
}

src/Console/Command/Static/CleanCommand.php

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OpenForgeProject\MageForge\Console\Command\AbstractCommand;
1212
use OpenForgeProject\MageForge\Model\ThemeList;
1313
use OpenForgeProject\MageForge\Model\ThemePath;
14+
use OpenForgeProject\MageForge\Service\ThemeSuggester;
1415
use Symfony\Component\Console\Input\InputArgument;
1516
use Symfony\Component\Console\Input\InputInterface;
1617
use Symfony\Component\Console\Input\InputOption;
@@ -29,11 +30,13 @@ class CleanCommand extends AbstractCommand
2930
* @param Filesystem $filesystem
3031
* @param ThemeList $themeList
3132
* @param ThemePath $themePath
33+
* @param ThemeSuggester $themeSuggester
3234
*/
3335
public function __construct(
3436
private readonly Filesystem $filesystem,
3537
private readonly ThemeList $themeList,
36-
private readonly ThemePath $themePath
38+
private readonly ThemePath $themePath,
39+
private readonly ThemeSuggester $themeSuggester
3740
) {
3841
parent::__construct();
3942
}
@@ -86,7 +89,7 @@ protected function executeCommand(InputInterface $input, OutputInterface $output
8689
return Cli::RETURN_SUCCESS;
8790
}
8891

89-
[$totalCleaned, $failedThemes] = $this->processThemes($themeCodes, $dryRun);
92+
[$totalCleaned, $failedThemes] = $this->processThemes($themeCodes, $dryRun, $output);
9093

9194
$this->displaySummary($themeCodes, $totalCleaned, $failedThemes, $dryRun);
9295

@@ -223,9 +226,10 @@ private function promptForThemes(array $options, array $themes): ?array
223226
*
224227
* @param array $themeCodes
225228
* @param bool $dryRun
229+
* @param OutputInterface $output
226230
* @return array [totalCleaned, failedThemes]
227231
*/
228-
private function processThemes(array $themeCodes, bool $dryRun): array
232+
private function processThemes(array $themeCodes, bool $dryRun, OutputInterface $output): array
229233
{
230234
$totalThemes = count($themeCodes);
231235
$totalCleaned = 0;
@@ -234,15 +238,19 @@ private function processThemes(array $themeCodes, bool $dryRun): array
234238
foreach ($themeCodes as $index => $themeName) {
235239
$currentTheme = $index + 1;
236240

237-
if (!$this->validateTheme($themeName, $failedThemes)) {
241+
// Validate and potentially correct theme name
242+
$validatedTheme = $this->validateTheme($themeName, $failedThemes, $output);
243+
244+
if ($validatedTheme === null) {
238245
continue;
239246
}
240247

241-
$this->displayThemeHeader($themeName, $currentTheme, $totalThemes);
248+
// Use validated/corrected theme name
249+
$this->displayThemeHeader($validatedTheme, $currentTheme, $totalThemes);
242250

243-
$cleaned = $this->cleanThemeDirectories($themeName, $dryRun);
251+
$cleaned = $this->cleanThemeDirectories($validatedTheme, $dryRun);
244252

245-
$this->displayThemeResult($themeName, $cleaned, $dryRun);
253+
$this->displayThemeResult($validatedTheme, $cleaned, $dryRun);
246254

247255
$totalCleaned += $cleaned;
248256
}
@@ -255,19 +263,42 @@ private function processThemes(array $themeCodes, bool $dryRun): array
255263
*
256264
* @param string $themeName
257265
* @param array &$failedThemes
258-
* @return bool
266+
* @param OutputInterface $output
267+
* @return string|null Theme code if valid or corrected, null if invalid
259268
*/
260-
private function validateTheme(string $themeName, array &$failedThemes): bool
269+
private function validateTheme(string $themeName, array &$failedThemes, OutputInterface $output): ?string
261270
{
262271
$themePath = $this->themePath->getPath($themeName);
263272

264273
if ($themePath === null) {
265-
$this->io->error(sprintf("Theme '%s' not found.", $themeName));
266-
$failedThemes[] = $themeName;
267-
return false;
274+
// Try to suggest similar themes
275+
$correctedTheme = $this->handleInvalidThemeWithSuggestions(
276+
$themeName,
277+
$this->themeSuggester,
278+
$output
279+
);
280+
281+
// If no theme was selected, mark as failed
282+
if ($correctedTheme === null) {
283+
$failedThemes[] = $themeName;
284+
return null;
285+
}
286+
287+
// Use the corrected theme code
288+
$themePath = $this->themePath->getPath($correctedTheme);
289+
290+
// Double-check the corrected theme exists
291+
if ($themePath === null) {
292+
$this->io->error(sprintf("Theme '%s' not found.", $correctedTheme));
293+
$failedThemes[] = $themeName;
294+
return null;
295+
}
296+
297+
$this->io->info("Using theme: $correctedTheme");
298+
return $correctedTheme;
268299
}
269300

270-
return true;
301+
return $themeName;
271302
}
272303

273304
/**

0 commit comments

Comments
 (0)