diff --git a/README.md b/README.md index 6b360af..17eeca2 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,49 @@ JSON Example: } ``` +## Adding Support for Other IDEs / AI Agents + +Boost works with many popular IDEs and AI agents out of the box. If your coding tool isn't supported yet, you can create your own code environment and integrate it with Boost. To do this, create a class that extends `Laravel\Boost\Install\CodeEnvironment\CodeEnvironment` and implement one or both of the following contracts depending on what you need: + +- `Laravel\Boost\Contracts\Agent` - Adds support for AI guidelines. +- `Laravel\Boost\Contracts\McpClient` - Adds support for MCP. + +### Writing the Code Environment + +```php +> */ + private array $codeEnvironments = [ + 'phpstorm' => PhpStorm::class, + 'vscode' => VSCode::class, + 'cursor' => Cursor::class, + 'claudecode' => ClaudeCode::class, + 'codex' => Codex::class, + 'copilot' => Copilot::class, + ]; + + /** + * @param class-string $className + */ + public function registerCodeEnvironment(string $key, string $className): void + { + if (array_key_exists($key, $this->codeEnvironments)) { + throw new InvalidArgumentException("Code environment '{$key}' is already registered"); + } + + $this->codeEnvironments[$key] = $className; + } + + /** + * @return array> + */ + public function getCodeEnvironments(): array + { + return $this->codeEnvironments; + } +} diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 4633d3b..9ade989 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -30,6 +30,8 @@ public function register(): void return; } + $this->app->singleton(BoostManager::class, fn (): BoostManager => new BoostManager); + $this->app->singleton(Roster::class, function () { $lockFiles = [ base_path('composer.lock'), diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 212ec26..84d42cf 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -261,7 +261,7 @@ protected function selectBoostFeatures(): Collection protected function selectAiGuidelines(): Collection { $options = app(GuidelineComposer::class)->guidelines() - ->reject(fn (array $guideline) => $guideline['third_party'] === false); + ->reject(fn (array $guideline): bool => $guideline['third_party'] === false); if ($options->isEmpty()) { return collect(); @@ -270,7 +270,7 @@ protected function selectAiGuidelines(): Collection return collect(multiselect( label: 'Which third-party AI guidelines do you want to install?', // @phpstan-ignore-next-line - options: $options->mapWithKeys(function (array $guideline, string $name) { + options: $options->mapWithKeys(function (array $guideline, string $name): array { $humanName = str_replace('/core', '', $name); return [$name => "{$humanName} (~{$guideline['tokens']} tokens) {$guideline['description']}"]; @@ -308,13 +308,13 @@ protected function selectTargetAgents(): Collection /** * Get configuration settings for contract-specific selection behavior. * - * @return array{scroll: int, required: bool, displayMethod: string} + * @return array{required: bool, displayMethod: string} */ protected function getSelectionConfig(string $contractClass): array { return match ($contractClass) { - Agent::class => ['scroll' => 5, 'required' => false, 'displayMethod' => 'agentName'], - McpClient::class => ['scroll' => 5, 'required' => true, 'displayMethod' => 'displayName'], + Agent::class => ['required' => false, 'displayMethod' => 'agentName'], + McpClient::class => ['required' => true, 'displayMethod' => 'displayName'], default => throw new InvalidArgumentException("Unsupported contract class: {$contractClass}"), }; } @@ -361,7 +361,7 @@ protected function selectCodeEnvironments(string $contractClass, string $label, label: $label, options: $options->toArray(), default: $defaults === [] ? $detectedDefaults : $defaults, - scroll: $config['scroll'], + scroll: $options->count(), required: $config['required'], hint: $defaults === [] || $detectedDefaults === [] ? '' : sprintf('Auto-detected %s for you', Arr::join(array_map(function ($className) use ($availableEnvironments, $config) { @@ -447,11 +447,11 @@ protected function installGuidelines(): void ); $this->config->setEditors( - $this->selectedTargetMcpClient->map(fn (McpClient $mcpClient) => $mcpClient->name())->values()->toArray() + $this->selectedTargetMcpClient->map(fn (McpClient $mcpClient): string => $mcpClient->name())->values()->toArray() ); $this->config->setAgents( - $this->selectedTargetAgents->map(fn (Agent $agent) => $agent->name())->values()->toArray() + $this->selectedTargetAgents->map(fn (Agent $agent): string => $agent->name())->values()->toArray() ); $this->config->setGuidelines( diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index 4082a47..ed66ee2 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -4,8 +4,8 @@ namespace Laravel\Boost\Install\CodeEnvironment; -use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\Facades\Process; +use Laravel\Boost\BoostManager; use Laravel\Boost\Contracts\Agent; use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\Detection\DetectionStrategyFactory; @@ -93,19 +93,13 @@ public function mcpInstallationStrategy(): McpInstallationStrategy return McpInstallationStrategy::FILE; } - public static function fromName(string $name): ?static + public static function fromName(string $name): ?CodeEnvironment { $detectionFactory = app(DetectionStrategyFactory::class); + $boostManager = app(BoostManager::class); - foreach ([ - ClaudeCode::class, - Codex::class, - Copilot::class, - Cursor::class, - PhpStorm::class, - VSCode::class, - ] as $class) { - /** @var class-string $class */ + foreach ($boostManager->getCodeEnvironments() as $class) { + /** @var class-string $class */ $instance = new $class($detectionFactory); if ($instance->name() === $name) { return $instance; @@ -140,8 +134,6 @@ public function mcpConfigKey(): string * * @param array $args * @param array $env - * - * @throws FileNotFoundException */ public function installMcp(string $key, string $command, array $args = [], array $env = []): bool { @@ -198,8 +190,6 @@ protected function installShellMcp(string $key, string $command, array $args = [ * * @param array $args * @param array $env - * - * @throws FileNotFoundException */ protected function installFileMcp(string $key, string $command, array $args = [], array $env = []): bool { diff --git a/src/Install/CodeEnvironmentsDetector.php b/src/Install/CodeEnvironmentsDetector.php index ad446e6..c88d962 100644 --- a/src/Install/CodeEnvironmentsDetector.php +++ b/src/Install/CodeEnvironmentsDetector.php @@ -6,29 +6,15 @@ use Illuminate\Container\Container; use Illuminate\Support\Collection; -use Laravel\Boost\Install\CodeEnvironment\ClaudeCode; +use Laravel\Boost\BoostManager; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; -use Laravel\Boost\Install\CodeEnvironment\Codex; -use Laravel\Boost\Install\CodeEnvironment\Copilot; -use Laravel\Boost\Install\CodeEnvironment\Cursor; -use Laravel\Boost\Install\CodeEnvironment\PhpStorm; -use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\Enums\Platform; class CodeEnvironmentsDetector { - /** @var array> */ - private array $programs = [ - 'phpstorm' => PhpStorm::class, - 'vscode' => VSCode::class, - 'cursor' => Cursor::class, - 'claudecode' => ClaudeCode::class, - 'codex' => Codex::class, - 'copilot' => Copilot::class, - ]; - public function __construct( - private readonly Container $container + private readonly Container $container, + private readonly BoostManager $boostManager ) {} /** @@ -55,8 +41,8 @@ public function discoverSystemInstalledCodeEnvironments(): array public function discoverProjectInstalledCodeEnvironments(string $basePath): array { return $this->getCodeEnvironments() - ->filter(fn ($program): bool => $program->detectInProject($basePath)) - ->map(fn ($program): string => $program->name()) + ->filter(fn (CodeEnvironment $program): bool => $program->detectInProject($basePath)) + ->map(fn (CodeEnvironment $program): string => $program->name()) ->values() ->toArray(); } @@ -68,6 +54,7 @@ public function discoverProjectInstalledCodeEnvironments(string $basePath): arra */ public function getCodeEnvironments(): Collection { - return collect($this->programs)->map(fn (string $className) => $this->container->make($className)); + return collect($this->boostManager->getCodeEnvironments()) + ->map(fn (string $className) => $this->container->make($className)); } } diff --git a/src/Support/Composer.php b/src/Support/Composer.php index 2599402..57dcafd 100644 --- a/src/Support/Composer.php +++ b/src/Support/Composer.php @@ -9,11 +9,11 @@ class Composer public static function packagesDirectories(): array { return collect(static::packages()) - ->mapWithKeys(fn (string $key, string $package) => [$package => implode(DIRECTORY_SEPARATOR, [ + ->mapWithKeys(fn (string $key, string $package): array => [$package => implode(DIRECTORY_SEPARATOR, [ base_path('vendor'), str_replace('/', DIRECTORY_SEPARATOR, $package), ])]) - ->filter(fn (string $path) => is_dir($path)) + ->filter(fn (string $path): bool => is_dir($path)) ->toArray(); } @@ -33,19 +33,19 @@ public static function packages(): array return collect($composerData['require'] ?? []) ->merge($composerData['require-dev'] ?? []) - ->mapWithKeys(fn (string $key, string $package) => [$package => $key]) + ->mapWithKeys(fn (string $key, string $package): array => [$package => $key]) ->toArray(); } public static function packagesDirectoriesWithBoostGuidelines(): array { return collect(Composer::packagesDirectories()) - ->map(fn (string $path) => implode(DIRECTORY_SEPARATOR, [ + ->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, [ $path, 'resources', 'boost', 'guidelines', - ]))->filter(fn (string $path) => is_dir($path)) + ]))->filter(fn (string $path): bool => is_dir($path)) ->toArray(); } } diff --git a/tests/Feature/BoostFacadeTest.php b/tests/Feature/BoostFacadeTest.php new file mode 100644 index 0000000..23453ed --- /dev/null +++ b/tests/Feature/BoostFacadeTest.php @@ -0,0 +1,34 @@ +toBeInstanceOf(BoostManager::class); +}); + +it('Boost Facade registers code environments via facade', function (): void { + Boost::registerCodeEnvironment('example1', ExampleCodeEnvironment::class); + Boost::registerCodeEnvironment('example2', ExampleCodeEnvironment::class); + $registered = Boost::getFacadeRoot()->getCodeEnvironments(); + + expect($registered)->toHaveKey('example1') + ->and($registered['example1'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('example2') + ->and($registered['example2'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('Boost Facade maintains registration state across facade calls', function (): void { + Boost::registerCodeEnvironment('persistent', 'Test\Persistent'); + + $registered = Boost::getFacadeRoot()->getCodeEnvironments(); + + expect($registered)->toHaveKey('persistent') + ->and($registered['persistent'])->toBe('Test\Persistent'); +}); diff --git a/tests/Feature/BoostServiceProviderTest.php b/tests/Feature/BoostServiceProviderTest.php index c852de1..cd2e5bb 100644 --- a/tests/Feature/BoostServiceProviderTest.php +++ b/tests/Feature/BoostServiceProviderTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Illuminate\Support\Facades\Config; +use Laravel\Boost\Boost; +use Laravel\Boost\BoostManager; use Laravel\Boost\BoostServiceProvider; beforeEach(function (): void { @@ -74,3 +76,34 @@ }); }); }); + +describe('BoostManager registration', function (): void { + beforeEach(function (): void { + Config::set('boost.enabled', true); + app()->detectEnvironment(fn (): string => 'local'); + $provider = new BoostServiceProvider(app()); + $provider->register(); + $provider->boot(app('router')); + }); + + it('registers BoostManager in the container', function (): void { + expect(app()->bound(BoostManager::class))->toBeTrue() + ->and(app(BoostManager::class))->toBeInstanceOf(BoostManager::class); + }); + + it('registers BoostManager as a singleton', function (): void { + Config::set('boost.enabled', true); + $instance1 = app(BoostManager::class); + $instance2 = app(BoostManager::class); + + expect($instance1)->toBe($instance2); + }); + + it('binds Boost facade to the same BoostManager instance', function (): void { + Config::set('boost.enabled', true); + $containerInstance = app(BoostManager::class); + $facadeInstance = Boost::getFacadeRoot(); + + expect($facadeInstance)->toBe($containerInstance); + }); +}); diff --git a/tests/Unit/BoostManagerTest.php b/tests/Unit/BoostManagerTest.php new file mode 100644 index 0000000..6a6071c --- /dev/null +++ b/tests/Unit/BoostManagerTest.php @@ -0,0 +1,65 @@ +getCodeEnvironments(); + + expect($registered)->toMatchArray([ + 'phpstorm' => PhpStorm::class, + 'vscode' => VSCode::class, + 'cursor' => Cursor::class, + 'claudecode' => ClaudeCode::class, + 'codex' => Codex::class, + 'copilot' => Copilot::class, + ]); +}); + +it('can register a single code environment', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('example', ExampleCodeEnvironment::class); + + $registered = $manager->getCodeEnvironments(); + + expect($registered)->toHaveKey('example') + ->and($registered['example'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('can register multiple code environments', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('example1', ExampleCodeEnvironment::class); + $manager->registerCodeEnvironment('example2', ExampleCodeEnvironment::class); + + $registered = $manager->getCodeEnvironments(); + + expect($registered)->toHaveKey('example1')->toHaveKey('example2') + ->and($registered['example1'])->toBe(ExampleCodeEnvironment::class) + ->and($registered['example2'])->toBe(ExampleCodeEnvironment::class) + ->and($registered)->toHaveKey('phpstorm'); +}); + +it('throws an exception when registering a duplicate key', function (): void { + $manager = new BoostManager; + + expect(fn () => $manager->registerCodeEnvironment('phpstorm', ExampleCodeEnvironment::class)) + ->toThrow(InvalidArgumentException::class, "Code environment 'phpstorm' is already registered"); +}); + +it('throws exception when registering custom environment with a duplicate key', function (): void { + $manager = new BoostManager; + $manager->registerCodeEnvironment('custom', ExampleCodeEnvironment::class); + + expect(fn () => $manager->registerCodeEnvironment('custom', ExampleCodeEnvironment::class)) + ->toThrow(InvalidArgumentException::class, "Code environment 'custom' is already registered"); +}); diff --git a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php index cf80d84..2e11b26 100644 --- a/tests/Unit/Install/CodeEnvironmentsDetectorTest.php +++ b/tests/Unit/Install/CodeEnvironmentsDetectorTest.php @@ -2,259 +2,139 @@ declare(strict_types=1); +use Illuminate\Container\Container; +use Illuminate\Support\Collection; +use Laravel\Boost\BoostManager; +use Laravel\Boost\Install\CodeEnvironment\ClaudeCode; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; +use Laravel\Boost\Install\CodeEnvironment\Codex; +use Laravel\Boost\Install\CodeEnvironment\Copilot; +use Laravel\Boost\Install\CodeEnvironment\Cursor; +use Laravel\Boost\Install\CodeEnvironment\PhpStorm; +use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\CodeEnvironmentsDetector; use Laravel\Boost\Install\Enums\Platform; beforeEach(function (): void { - $this->container = new \Illuminate\Container\Container; - $this->detector = new CodeEnvironmentsDetector($this->container); + $this->container = new Container; + $this->boostManager = new BoostManager; + $this->detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); }); -test('discoverSystemInstalledCodeEnvironments returns detected programs', function (): void { - // Create mock programs - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); - $program1->shouldReceive('name')->andReturn('phpstorm'); - - $program2 = Mockery::mock(CodeEnvironment::class); - $program2->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $program2->shouldReceive('name')->andReturn('vscode'); - - $program3 = Mockery::mock(CodeEnvironment::class); - $program3->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); - $program3->shouldReceive('name')->andReturn('cursor'); - - // Mock all other programs that might be instantiated - $otherProgram = Mockery::mock(CodeEnvironment::class); - $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $otherProgram->shouldReceive('name')->andReturn('other'); - - // Bind mocked programs to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program2); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $program3); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Codex::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverSystemInstalledCodeEnvironments(); - - expect($detected)->toBe(['phpstorm', 'cursor']); -}); - -test('discoverSystemInstalledCodeEnvironments returns empty array when no programs detected', function (): void { - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $program1->shouldReceive('name')->andReturn('phpstorm'); - - // Mock all other programs that might be instantiated - $otherProgram = Mockery::mock(CodeEnvironment::class); - $otherProgram->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); - $otherProgram->shouldReceive('name')->andReturn('other'); - - // Bind mocked program to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Cursor::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Codex::class, fn () => $otherProgram); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\Copilot::class, fn () => $otherProgram); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverSystemInstalledCodeEnvironments(); - - expect($detected)->toBeEmpty(); -}); - -test('discoverProjectInstalledCodeEnvironments detects programs in project', function (): void { - $basePath = '/path/to/project'; - - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(true); - $program1->shouldReceive('name')->andReturn('vscode'); - - $program2 = Mockery::mock(CodeEnvironment::class); - $program2->shouldReceive('detectInProject')->with($basePath)->andReturn(false); - $program2->shouldReceive('name')->andReturn('phpstorm'); - - $program3 = Mockery::mock(CodeEnvironment::class); - $program3->shouldReceive('detectInProject')->with($basePath)->andReturn(true); - $program3->shouldReceive('name')->andReturn('claudecode'); - - // Bind mocked programs to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\PhpStorm::class, fn () => $program2); - $container->bind(\Laravel\Boost\Install\CodeEnvironment\ClaudeCode::class, fn () => $program3); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - - expect($detected)->toBe(['vscode', 'claudecode']); -}); - -test('discoverProjectInstalledCodeEnvironments returns empty array when no programs detected in project', function (): void { - $basePath = '/path/to/project'; - - $program1 = Mockery::mock(CodeEnvironment::class); - $program1->shouldReceive('detectInProject')->with($basePath)->andReturn(false); - $program1->shouldReceive('name')->andReturn('vscode'); - - // Bind mocked program to container - $container = new \Illuminate\Container\Container; - $container->bind(\Laravel\Boost\Install\CodeEnvironment\VSCode::class, fn () => $program1); - - $detector = new CodeEnvironmentsDetector($container); - $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - - expect($detected)->toBeEmpty(); -}); - -test('discoverProjectInstalledCodeEnvironments detects applications by directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.vscode'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('vscode'); - - // Cleanup - rmdir($tempDir.'/.vscode'); - rmdir($tempDir); -}); - -test('discoverProjectInstalledCodeEnvironments detects applications with mixed type', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/CLAUDE.md', 'test'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('claude_code'); - - unlink($tempDir.'/CLAUDE.md'); - rmdir($tempDir); +afterEach(function (): void { + Mockery::close(); }); -test('discoverProjectInstalledCodeEnvironments detects copilot with nested file path', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.github'); - file_put_contents($tempDir.'/.github/copilot-instructions.md', 'test'); +it('returns collection of all registered code environments', function (): void { + $codeEnvironments = $this->detector->getCodeEnvironments(); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + expect($codeEnvironments)->toBeInstanceOf(Collection::class) + ->and($codeEnvironments->count())->toBe(6) + ->and($codeEnvironments->keys()->toArray())->toBe([ + 'phpstorm', 'vscode', 'cursor', 'claudecode', 'codex', 'copilot', + ]); - expect($detected)->toContain('copilot'); - - // Cleanup - unlink($tempDir.'/.github/copilot-instructions.md'); - rmdir($tempDir.'/.github'); - rmdir($tempDir); + $codeEnvironments->each(function ($environment): void { + expect($environment)->toBeInstanceOf(CodeEnvironment::class); + }); }); -test('discoverProjectInstalledCodeEnvironments detects claude code with directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.claude'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); +it('returns an array of detected environment names for system discovery', function (): void { + $mockPhpStorm = Mockery::mock(CodeEnvironment::class); + $mockPhpStorm->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $mockPhpStorm->shouldReceive('name')->andReturn('phpstorm'); - expect($detected)->toContain('claude_code'); + $mockVSCode = Mockery::mock(CodeEnvironment::class); + $mockVSCode->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockVSCode->shouldReceive('name')->andReturn('vscode'); - rmdir($tempDir.'/.claude'); - rmdir($tempDir); -}); + $mockCursor = Mockery::mock(CodeEnvironment::class); + $mockCursor->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(true); + $mockCursor->shouldReceive('name')->andReturn('cursor'); -test('discoverProjectInstalledCodeEnvironments detects phpstorm with idea directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.idea'); + $mockOther = Mockery::mock(CodeEnvironment::class); + $mockOther->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockOther->shouldReceive('name')->andReturn('other'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockPhpStorm); + $this->container->bind(VSCode::class, fn () => $mockVSCode); + $this->container->bind(Cursor::class, fn () => $mockCursor); + $this->container->bind(ClaudeCode::class, fn () => $mockOther); + $this->container->bind(Codex::class, fn () => $mockOther); + $this->container->bind(Copilot::class, fn () => $mockOther); - expect($detected)->toContain('phpstorm'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); - // Cleanup - rmdir($tempDir.'/.idea'); - rmdir($tempDir); + expect($detected)->toBe(['phpstorm', 'cursor']); }); -test('discoverProjectInstalledCodeEnvironments detects phpstorm with junie directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.junie'); +it('returns an empty array when no environments are detected for system discovery', function (): void { + $mockEnvironment = Mockery::mock(CodeEnvironment::class); + $mockEnvironment->shouldReceive('detectOnSystem')->with(Mockery::type(Platform::class))->andReturn(false); + $mockEnvironment->shouldReceive('name')->andReturn('mock'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockEnvironment); + $this->container->bind(VSCode::class, fn () => $mockEnvironment); + $this->container->bind(Cursor::class, fn () => $mockEnvironment); + $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); + $this->container->bind(Codex::class, fn () => $mockEnvironment); + $this->container->bind(Copilot::class, fn () => $mockEnvironment); - expect($detected)->toContain('phpstorm'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverSystemInstalledCodeEnvironments(); - // Cleanup - rmdir($tempDir.'/.junie'); - rmdir($tempDir); + expect($detected)->toBe([]); }); -test('discoverProjectInstalledCodeEnvironments detects cursor with cursor directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.cursor'); +it('returns an array of detected environment names for project discovery', function (): void { + $basePath = '/test/project'; - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $mockVSCode = Mockery::mock(CodeEnvironment::class); + $mockVSCode->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $mockVSCode->shouldReceive('name')->andReturn('vscode'); - expect($detected)->toContain('cursor'); + $mockPhpStorm = Mockery::mock(CodeEnvironment::class); + $mockPhpStorm->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockPhpStorm->shouldReceive('name')->andReturn('phpstorm'); - // Cleanup - rmdir($tempDir.'/.cursor'); - rmdir($tempDir); -}); + $mockClaudeCode = Mockery::mock(CodeEnvironment::class); + $mockClaudeCode->shouldReceive('detectInProject')->with($basePath)->andReturn(true); + $mockClaudeCode->shouldReceive('name')->andReturn('claudecode'); -test('discoverProjectInstalledCodeEnvironments detects codex with codex directory', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.codex'); + $mockOther = Mockery::mock(CodeEnvironment::class); + $mockOther->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockOther->shouldReceive('name')->andReturn('other'); - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $this->container->bind(PhpStorm::class, fn () => $mockPhpStorm); + $this->container->bind(VSCode::class, fn () => $mockVSCode); + $this->container->bind(Cursor::class, fn () => $mockOther); + $this->container->bind(ClaudeCode::class, fn () => $mockClaudeCode); + $this->container->bind(Codex::class, fn () => $mockOther); + $this->container->bind(Copilot::class, fn () => $mockOther); - expect($detected)->toContain('codex'); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - rmdir($tempDir.'/.codex'); - rmdir($tempDir); + expect($detected)->toBe(['vscode', 'claudecode']); }); -test('discoverProjectInstalledCodeEnvironments detects codex with AGENTS.md file', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - file_put_contents($tempDir.'/AGENTS.md', 'test'); +it('returns an empty array when no environments are detected for project discovery', function (): void { + $basePath = '/empty/project'; - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); + $mockEnvironment = Mockery::mock(CodeEnvironment::class); + $mockEnvironment->shouldReceive('detectInProject')->with($basePath)->andReturn(false); + $mockEnvironment->shouldReceive('name')->andReturn('mock'); - expect($detected)->toContain('codex'); - - unlink($tempDir.'/AGENTS.md'); - rmdir($tempDir); -}); + $this->container->bind(PhpStorm::class, fn () => $mockEnvironment); + $this->container->bind(VSCode::class, fn () => $mockEnvironment); + $this->container->bind(Cursor::class, fn () => $mockEnvironment); + $this->container->bind(ClaudeCode::class, fn () => $mockEnvironment); + $this->container->bind(Codex::class, fn () => $mockEnvironment); + $this->container->bind(Copilot::class, fn () => $mockEnvironment); -test('discoverProjectInstalledCodeEnvironments handles multiple detections', function (): void { - $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); - mkdir($tempDir); - mkdir($tempDir.'/.vscode'); - mkdir($tempDir.'/.cursor'); - file_put_contents($tempDir.'/CLAUDE.md', 'test'); - - $detected = $this->detector->discoverProjectInstalledCodeEnvironments($tempDir); - - expect($detected)->toContain('vscode') - ->and($detected)->toContain('cursor') - ->and($detected)->toContain('claude_code') - ->and(count($detected))->toBeGreaterThanOrEqual(3); + $detector = new CodeEnvironmentsDetector($this->container, $this->boostManager); + $detected = $detector->discoverProjectInstalledCodeEnvironments($basePath); - // Cleanup - rmdir($tempDir.'/.vscode'); - rmdir($tempDir.'/.cursor'); - unlink($tempDir.'/CLAUDE.md'); - rmdir($tempDir); + expect($detected)->toBe([]); }); diff --git a/tests/Unit/Install/ExampleCodeEnvironment.php b/tests/Unit/Install/ExampleCodeEnvironment.php new file mode 100644 index 0000000..34a64f0 --- /dev/null +++ b/tests/Unit/Install/ExampleCodeEnvironment.php @@ -0,0 +1,43 @@ + 'which example']; + } + + public function projectDetectionConfig(): array + { + return ['paths' => ['.example']]; + } + + public function mcpConfigPath(): string + { + return '.example/config.json'; + } + + public function guidelinesPath(): string + { + return 'EXAMPLE.md'; + } +} diff --git a/tests/Unit/Support/ComposerTest.php b/tests/Unit/Support/ComposerTest.php index 04def20..69f86fa 100644 --- a/tests/Unit/Support/ComposerTest.php +++ b/tests/Unit/Support/ComposerTest.php @@ -2,12 +2,12 @@ use Laravel\Boost\Support\Config; -afterEach(function () { - (new Config(__DIR__))->flush(); +afterEach(function (): void { + (new Config)->flush(); }); it('may store and retrieve guidelines', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getGuidelines())->toBeEmpty(); @@ -22,7 +22,7 @@ }); it('may store and retrieve agents', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getAgents())->toBeEmpty(); @@ -37,7 +37,7 @@ }); it('may store and retrieve editors', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getEditors())->toBeEmpty(); @@ -52,7 +52,7 @@ }); it('may store and retrieve herd mcp installation status', function (): void { - $config = new Config(__DIR__); + $config = new Config; expect($config->getHerdMcp())->toBeFalse(); diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index 4c97907..69f86fa 100644 --- a/tests/Unit/Support/ConfigTest.php +++ b/tests/Unit/Support/ConfigTest.php @@ -2,7 +2,7 @@ use Laravel\Boost\Support\Config; -afterEach(function () { +afterEach(function (): void { (new Config)->flush(); });