diff --git a/.gitignore b/.gitignore
index a9fe37fd..38dd47a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ vendor
node_modules
.php-cs-fixer.cache
.context
+.templates
+.researches
runtime
mcp-*.log
.env
diff --git a/composer.json b/composer.json
index af841069..c272f240 100644
--- a/composer.json
+++ b/composer.json
@@ -43,7 +43,8 @@
"logiscape/mcp-sdk-php": "^1.0",
"league/route": "^6.2",
"laminas/laminas-diactoros": "^3.5",
- "monolog/monolog": "^3.9"
+ "monolog/monolog": "^3.9",
+ "cocur/slugify": "^4.6"
},
"require-dev": {
"buggregator/trap": "^1.13",
diff --git a/context.yaml b/context.yaml
index 489e36f4..fcb57a3d 100644
--- a/context.yaml
+++ b/context.yaml
@@ -35,6 +35,33 @@ documents:
- vendor/spiral/files/src/FilesInterface.php
showTreeView: true
+ - description: Research Templates
+ outputPath: core/drafling.md
+ sources:
+ - type: file
+ sourcePaths:
+ - src/Research/Config
+ - src/Research/Domain
+ - src/Research/Repository
+ - src/Research/Service
+ - src/Research/Storage/StorageDriverInterface.php
+
+ - description: Research FileStorage
+ outputPath: drafling/file-storage.md
+ sources:
+ - type: file
+ sourcePaths:
+ - src/Research/Storage
+
+ - description: Research MCP
+ outputPath: drafling/mcp.md
+ sources:
+ - type: file
+ sourcePaths:
+ - src/Research/MCP
+ - src/McpServer/McpServerBootloader.php
+
+
- description: "Changes in the Project"
outputPath: "changes.md"
sources:
diff --git a/docs/config/readme.md b/docs/config/readme.md
new file mode 100644
index 00000000..388afbf7
--- /dev/null
+++ b/docs/config/readme.md
@@ -0,0 +1,501 @@
+# CTX Configuration System Developer Guide
+
+## Overview
+
+The CTX Configuration System provides a robust, extensible architecture for loading, parsing, and managing configuration files. It supports multiple formats (JSON, YAML, PHP), complex import mechanisms, variable substitution, and plugin-based extensibility.
+
+## Architecture Components
+
+The system follows a layered architecture with clear separation of concerns:
+
+```
+Butschster\ContextGenerator\Config\ConfigurationProvider (Entry Point)
+├── Butschster\ContextGenerator\Config\Loader\ConfigLoaderFactory (Creates appropriate loaders)
+│ ├── Butschster\ContextGenerator\Config\Loader\ConfigLoader (Handles single config files)
+│ └── Butschster\ContextGenerator\Config\Loader\CompositeConfigLoader (Combines multiple loaders)
+├── Butschster\ContextGenerator\Config\Reader\ConfigReaderRegistry (File format readers)
+│ ├── Butschster\ContextGenerator\Config\Reader\JsonReader
+│ ├── Butschster\ContextGenerator\Config\Reader\YamlReader
+│ ├── Butschster\ContextGenerator\Config\Reader\PhpReader
+│ └── Butschster\ContextGenerator\Config\Reader\StringJsonReader
+├── Butschster\ContextGenerator\Config\Parser\ConfigParser (Processes configuration data)
+│ └── Butschster\ContextGenerator\Config\Parser\ParserPluginRegistry (Extensible parsing plugins)
+│ ├── Butschster\ContextGenerator\Config\Import\ImportParserPlugin
+│ ├── Butschster\ContextGenerator\Config\Parser\VariablesParserPlugin
+│ └── Butschster\ContextGenerator\Config\Exclude\ExcludeParserPlugin
+└── Butschster\ContextGenerator\Config\Registry\ConfigRegistry (Final configuration container)
+```
+
+## Key Design Principles
+
+### 1. Plugin-Based Extensibility
+The system uses plugins to handle different configuration sections, making it easy to add new functionality without modifying core components.
+
+### 2. Format Agnostic
+Supports multiple configuration formats through a reader pattern, allowing users to choose their preferred format.
+
+### 3. Import & Composition
+Complex configurations can be split across multiple files and composed through imports, supporting both local files and remote URLs.
+
+### 4. Variable Substitution
+Dynamic configuration through environment variables and custom variables.
+
+### 5. Error Resilience
+Graceful handling of missing files, circular imports, and parsing errors.
+
+## When to Use Different Components
+
+### ConfigurationProvider
+**Use when:** You need to load configuration from different sources (inline JSON, file paths, default locations).
+
+```php
+use Butschster\ContextGenerator\Config\ConfigurationProvider;
+
+// From specific file
+$loader = $provider->fromPath('/path/to/config.yaml');
+
+// From inline JSON
+$loader = $provider->fromString('{"documents": [...]}');
+
+// From default location
+$loader = $provider->fromDefaultLocation();
+```
+
+### ConfigLoader vs CompositeConfigLoader
+**ConfigLoader:** Single configuration file
+**CompositeConfigLoader:** Try multiple file formats/locations
+
+The factory automatically creates composite loaders when scanning directories.
+
+### Reader Selection
+The system automatically selects the appropriate reader based on file extension:
+- `.json` → `Butschster\ContextGenerator\Config\Reader\JsonReader`
+- `.yaml`, `.yml` → `Butschster\ContextGenerator\Config\Reader\YamlReader`
+- `.php` → `Butschster\ContextGenerator\Config\Reader\PhpReader`
+
+### Parser Plugins
+**ImportParserPlugin:** Handles `import` sections, resolves file dependencies
+**VariablesParserPlugin:** Processes `variables` sections for substitution
+**ExcludeParserPlugin:** Manages file exclusion patterns
+
+## Configuration Loading Flow
+
+```mermaid
+sequenceDiagram
+ participant App as Application
+ participant CP as ConfigurationProvider
+ participant CLF as ConfigLoaderFactory
+ participant CL as ConfigLoader
+ participant RR as ReaderRegistry
+ participant R as Reader
+ participant P as ConfigParser
+ participant PPR as ParserPluginRegistry
+ participant IP as ImportParserPlugin
+ participant VP as VariablesParserPlugin
+ participant CR as ConfigRegistry
+
+ App->>CP: fromPath(configPath)
+ CP->>CLF: create(configPath)
+ CLF->>CL: new ConfigLoader(reader, parser)
+ CL->>RR: get(extension)
+ RR->>R: appropriate reader
+
+ App->>CL: load()
+ CL->>R: read(configPath)
+ R-->>CL: raw config array
+
+ CL->>P: parse(config)
+ P->>PPR: getPlugins()
+ PPR-->>P: [ImportPlugin, VariablesPlugin, ...]
+
+ loop For each plugin
+ P->>IP: updateConfig(config, rootPath)
+ IP->>IP: resolve imports
+ IP-->>P: updated config
+
+ P->>VP: parse(config, rootPath)
+ VP->>VP: extract variables
+ VP-->>P: variables registry
+ end
+
+ P-->>CL: ConfigRegistry
+ CL-->>App: ConfigRegistry
+```
+
+## Import System Deep Dive
+
+The import system allows configuration composition across multiple files and sources.
+
+### Local File Imports
+```yaml
+import:
+ - path: "services/api/context.yaml"
+ pathPrefix: "/api"
+ docs: ["*.md"] # Selective import
+```
+
+### URL Imports
+```yaml
+import:
+ - type: url
+ url: "https://example.com/shared-config.yaml"
+ ttl: 600
+ headers:
+ Authorization: "Bearer ${API_TOKEN}"
+```
+
+### Wildcard Imports
+```yaml
+import:
+ - path: "services/*/context.yaml"
+ - path: "modules/**/*.yaml"
+```
+
+### Import Resolution Process
+
+```mermaid
+sequenceDiagram
+ participant IP as ImportParserPlugin
+ participant IR as ImportResolver
+ participant ISP as ImportSourceProvider
+ participant IS as ImportSource
+ participant WPF as WildcardPathFinder
+ participant CMP as ConfigMergerProvider
+
+ IP->>IR: resolveImports(config, basePath)
+
+ loop For each import
+ IR->>ISP: findSourceForConfig(sourceConfig)
+ ISP-->>IR: ImportSource
+
+ alt Wildcard path
+ IR->>WPF: findMatchingPaths(pattern)
+ WPF-->>IR: matching file paths
+ loop For each match
+ IR->>IS: load(config)
+ IS-->>IR: imported config
+ end
+ else Regular import
+ IR->>IS: load(config)
+ IS-->>IR: imported config
+ end
+
+ IR->>IR: processSelectiveImports()
+ IR->>IR: applyPathPrefixes()
+ end
+
+ IR->>CMP: mergeConfigurations(mainConfig, ...imports)
+ CMP-->>IR: merged config
+ IR-->>IP: ResolvedConfig
+```
+
+## Variable System
+
+### Variable Resolution Priority
+1. **Custom Configuration Variables** (highest priority)
+2. **Environment Variables**
+3. **Predefined Variables** (lowest priority)
+
+### Built-in Variables
+```yaml
+variables:
+ project_name: "My Project"
+ version: "1.0.0"
+
+documents:
+ - description: "${project_name} Documentation"
+ outputPath: "docs/${version}/overview.md"
+ sources:
+ - type: text
+ content: |
+ # ${project_name} v${version}
+ Generated on: ${DATETIME}
+ User: ${USER}
+ System: ${OS}
+```
+
+### Environment Variable Integration
+```bash
+# CLI usage
+ctx --env=.env.local
+
+# Or in configuration
+MCP_FILE_OPERATIONS=true ctx server
+```
+
+## Exclusion System
+
+The exclusion system allows filtering out unwanted files and directories.
+
+### Pattern Types
+```yaml
+exclude:
+ patterns:
+ - "**/*.tmp" # Glob patterns
+ - "node_modules/**" # Directory exclusions
+ - "*.log" # File extensions
+ paths:
+ - "tests" # Exact path matches
+ - "vendor/cache" # Directory paths
+```
+
+### Exclusion Resolution
+```mermaid
+sequenceDiagram
+ participant ER as ExcludeRegistry
+ participant PE as PatternExclusion
+ participant PATH as PathExclusion
+ participant PM as PathMatcher
+
+ Note over ER: shouldExclude(path)
+
+ loop For each exclusion pattern
+ alt Pattern-based exclusion
+ ER->>PE: matches(path)
+ PE->>PM: isMatch(path)
+ PM-->>PE: boolean result
+ PE-->>ER: boolean result
+ else Path-based exclusion
+ ER->>PATH: matches(path)
+ PATH-->>ER: boolean result
+ end
+ end
+
+ ER-->>ER: return final decision
+```
+
+## Adding Custom Parser Plugins
+
+### 1. Implement ConfigParserPluginInterface
+
+```php
+use Butschster\ContextGenerator\Config\Parser\ConfigParserPluginInterface;
+use Butschster\ContextGenerator\Config\Registry\RegistryInterface;
+
+final readonly class CustomParserPlugin implements ConfigParserPluginInterface
+{
+ public function getConfigKey(): string
+ {
+ return 'custom-section';
+ }
+
+ public function supports(array $config): bool
+ {
+ return isset($config['custom-section']);
+ }
+
+ public function parse(array $config, string $rootPath): ?RegistryInterface
+ {
+ // Parse your custom section
+ $customRegistry = new CustomRegistry();
+ // ... parsing logic
+ return $customRegistry;
+ }
+
+ public function updateConfig(array $config, string $rootPath): array
+ {
+ // Modify config if needed (e.g., for imports)
+ return $config;
+ }
+}
+```
+
+### 2. Register the Plugin
+
+```php
+use Butschster\ContextGenerator\Config\Parser\ParserPluginRegistry;
+
+// In a bootloader
+public function boot(ParserPluginRegistry $registry): void
+{
+ $registry->register(new CustomParserPlugin());
+}
+```
+
+## Error Handling Strategies
+
+### 1. Graceful Degradation
+The system continues processing other components when one fails:
+
+```php
+use Butschster\ContextGenerator\Config\Exception\ConfigLoaderException;
+use Butschster\ContextGenerator\Config\Registry\ConfigRegistry;
+
+try {
+ $registry = $loader->load();
+} catch (ConfigLoaderException $e) {
+ $this->logger->error('Config loading failed', ['error' => $e->getMessage()]);
+ // Return minimal viable configuration
+ return new ConfigRegistry();
+}
+```
+
+### 2. Circular Import Detection
+Prevents infinite loops in import chains:
+
+```php
+use Butschster\ContextGenerator\Config\Import\CircularImportDetector;
+
+$detector->beginProcessing($importPath);
+try {
+ // Process import
+} finally {
+ $detector->endProcessing($importPath);
+}
+```
+
+### 3. Validation at Multiple Layers
+- **Reader Level:** File format validation
+- **Parser Level:** Structure validation
+- **Registry Level:** Semantic validation
+
+## Performance Considerations
+
+### 1. Caching Strategies
+- **URL Imports:** TTL-based caching to avoid repeated network requests
+- **File Watching:** Consider implementing file modification time checks for development
+
+### 2. Lazy Loading
+- **Import Resolution:** Only resolve imports when configuration is actually used
+- **Plugin Registration:** Defer heavy initialization until needed
+
+### 3. Memory Management
+- **Registry Cleanup:** Clear unused registries after processing
+- **Stream Processing:** For large configuration files, consider streaming readers
+
+## Testing Configuration Loading
+
+### 1. Unit Testing Individual Components
+
+```php
+use Butschster\ContextGenerator\Config\Reader\JsonReader;
+use PHPUnit\Framework\TestCase;
+
+public function testJsonReader(): void
+{
+ $reader = new JsonReader($this->files, $this->logger);
+ $config = $reader->read('/path/to/test.json');
+
+ $this->assertIsArray($config);
+ $this->assertArrayHasKey('documents', $config);
+}
+```
+
+### 2. Integration Testing Full Flow
+
+```php
+use Butschster\ContextGenerator\Config\ConfigurationProvider;
+use Butschster\ContextGenerator\Config\Registry\ConfigRegistry;
+
+public function testConfigurationLoading(): void
+{
+ $provider = $this->getConfigurationProvider();
+ $loader = $provider->fromPath('/path/to/config');
+ $registry = $loader->load();
+
+ $this->assertInstanceOf(ConfigRegistry::class, $registry);
+ $this->assertTrue($registry->has('documents'));
+}
+```
+
+### 3. Testing Import Resolution
+
+```php
+use Butschster\ContextGenerator\Config\Import\ImportResolver;
+
+public function testImportResolution(): void
+{
+ // Create test files with imports
+ $this->files->write('/tmp/main.yaml', $this->getMainConfig());
+ $this->files->write('/tmp/imported.yaml', $this->getImportedConfig());
+
+ $resolver = $this->getImportResolver();
+ $result = $resolver->resolveImports($config, '/tmp');
+
+ $this->assertArrayHasKey('documents', $result->config);
+ $this->assertCount(2, $result->config['documents']); // Main + imported
+}
+```
+
+## Common Patterns and Best Practices
+
+### 1. Configuration Composition
+```yaml
+# Main configuration
+import:
+ - path: "base/common.yaml"
+ - path: "environments/${ENV}.yaml"
+ - path: "features/*.yaml"
+
+documents:
+ - description: "Project-specific documentation"
+ # ... project-specific sources
+```
+
+### 2. Environment-Specific Overrides
+```yaml
+# base/common.yaml
+variables:
+ api_url: "https://api.example.com"
+
+# environments/dev.yaml
+variables:
+ api_url: "https://dev-api.example.com"
+```
+
+### 3. Modular Plugin Architecture
+```php
+use Butschster\ContextGenerator\Config\Parser\ConfigParserPluginInterface;
+
+abstract class BaseParserPlugin implements ConfigParserPluginInterface
+{
+ public function supports(array $config): bool
+ {
+ return isset($config[$this->getConfigKey()]);
+ }
+
+ public function updateConfig(array $config, string $rootPath): array
+ {
+ return $config; // Most plugins don't modify config
+ }
+}
+```
+
+## Debugging Configuration Issues
+
+### 1. Enable Debug Logging
+```php
+use Monolog\Logger;
+use Monolog\Handler\StreamHandler;
+use Butschster\ContextGenerator\Config\ConfigurationProvider;
+
+$logger = new Logger('config');
+$logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG));
+
+$provider = new ConfigurationProvider($factory, $dirs, $logger);
+```
+
+### 2. Inspect Registry Contents
+```php
+use Butschster\ContextGenerator\Config\Registry\ConfigRegistry;
+
+$registry = $loader->load();
+foreach ($registry->all() as $type => $typeRegistry) {
+ echo "Registry: {$type}\n";
+ echo json_encode($typeRegistry, JSON_PRETTY_PRINT) . "\n";
+}
+```
+
+### 3. Validate Import Resolution
+```php
+use Butschster\ContextGenerator\Config\Import\ImportResolver;
+
+$resolver = new ImportResolver(/* ... */);
+$result = $resolver->resolveImports($config, $basePath);
+echo "Resolved imports:\n";
+foreach ($result->imports as $import) {
+ echo "- {$import->getPath()}\n";
+}
+```
+
+This configuration system provides a solid foundation for complex, maintainable configuration management while remaining flexible enough to adapt to evolving requirements.
\ No newline at end of file
diff --git a/src/Application/Kernel.php b/src/Application/Kernel.php
index 96b28294..4fd82840 100644
--- a/src/Application/Kernel.php
+++ b/src/Application/Kernel.php
@@ -20,6 +20,7 @@
use Butschster\ContextGenerator\Application\Bootloader\SchemaMapperBootloader;
use Butschster\ContextGenerator\Application\Bootloader\SourceFetcherBootloader;
use Butschster\ContextGenerator\Application\Bootloader\VariableBootloader;
+use Butschster\ContextGenerator\Research\ResearchBootloader;
use Butschster\ContextGenerator\McpServer\McpServerBootloader;
use Butschster\ContextGenerator\Template\TemplateSystemBootloader;
use Butschster\ContextGenerator\Modifier\PhpContentFilter\PhpContentFilterBootloader;
@@ -74,6 +75,9 @@ protected function defineBootloaders(): array
// Template System
TemplateSystemBootloader::class,
+ // Research
+ ResearchBootloader::class,
+
// Sources
TextSourceBootloader::class,
FileSourceBootloader::class,
diff --git a/src/McpServer/Action/ToolResult.php b/src/McpServer/Action/ToolResult.php
new file mode 100644
index 00000000..516d86d6
--- /dev/null
+++ b/src/McpServer/Action/ToolResult.php
@@ -0,0 +1,69 @@
+ false,
+ 'error' => $error,
+ ]),
+ ),
+ ], isError: true);
+ }
+
+ /**
+ * Create an error tool result with validation details.
+ */
+ public static function validationError(array $validationErrors): CallToolResult
+ {
+ return new CallToolResult([
+ new TextContent(
+ text: \json_encode([
+ 'success' => false,
+ 'error' => 'Validation failed',
+ 'details' => $validationErrors,
+ ]),
+ ),
+ ], isError: true);
+ }
+
+ /**
+ * Create a simple text result (for cases where just text is returned).
+ */
+ public static function text(string $text): CallToolResult
+ {
+ return new CallToolResult([
+ new TextContent(
+ text: $text,
+ ),
+ ]);
+ }
+}
diff --git a/src/McpServer/Action/Tools/Context/ContextAction.php b/src/McpServer/Action/Tools/Context/ContextAction.php
index d42dd606..afa48cac 100644
--- a/src/McpServer/Action/Tools/Context/ContextAction.php
+++ b/src/McpServer/Action/Tools/Context/ContextAction.php
@@ -7,6 +7,7 @@
use Butschster\ContextGenerator\Config\Loader\ConfigLoaderInterface;
use Butschster\ContextGenerator\Config\Registry\ConfigRegistryAccessor;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
@@ -48,9 +49,7 @@ public function __invoke(ServerRequestInterface $request): CallToolResult
]);
// Return all documents in JSON format
- return new CallToolResult([
- new TextContent('Error: ' . $e->getMessage()),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Context/ContextGetAction.php b/src/McpServer/Action/Tools/Context/ContextGetAction.php
index f9c6985c..1762f617 100644
--- a/src/McpServer/Action/Tools/Context/ContextGetAction.php
+++ b/src/McpServer/Action/Tools/Context/ContextGetAction.php
@@ -11,9 +11,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Context\Dto\ContextGetRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -39,11 +39,7 @@ public function __invoke(ContextGetRequest $request): CallToolResult
$path = $request->path;
if (empty($path)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing path parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing path parameter');
}
try {
@@ -51,21 +47,15 @@ public function __invoke(ContextGetRequest $request): CallToolResult
foreach ($config->getDocuments() as $document) {
if ($document->outputPath === $path) {
- $content = new TextContent(
- text: (string) $this->documentCompiler->buildContent(new ErrorCollection(), $document)->content,
- );
+ $content = (string) $this->documentCompiler->buildContent(new ErrorCollection(), $document)->content;
// Return all documents in JSON format
- return new CallToolResult([$content]);
+ return ToolResult::text($content);
}
}
// Return all documents in JSON format
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Document with path '%s' not found", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Document with path '%s' not found", $path));
} catch (\Throwable $e) {
$this->logger->error('Error getting context', [
'path' => $path,
@@ -73,11 +63,7 @@ public function __invoke(ContextGetRequest $request): CallToolResult
]);
// Return all documents in JSON format
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Context/ContextRequestAction.php b/src/McpServer/Action/Tools/Context/ContextRequestAction.php
index 9635c837..04b1a55b 100644
--- a/src/McpServer/Action/Tools/Context/ContextRequestAction.php
+++ b/src/McpServer/Action/Tools/Context/ContextRequestAction.php
@@ -11,6 +11,7 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Context\Dto\ContextRequestRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
@@ -39,11 +40,7 @@ public function __invoke(ContextRequestRequest $request): CallToolResult
$json = $request->json;
if (empty($json)) {
- return new CallToolResult([
- new TextContent(
- text: 'Missing JSON parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing JSON parameter');
}
try {
@@ -64,11 +61,7 @@ public function __invoke(ContextRequestRequest $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: \sprintf('Error: %s', $e->getMessage()),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Docs/FetchLibraryDocsAction.php b/src/McpServer/Action/Tools/Docs/FetchLibraryDocsAction.php
index 68fda43c..5c2063f1 100644
--- a/src/McpServer/Action/Tools/Docs/FetchLibraryDocsAction.php
+++ b/src/McpServer/Action/Tools/Docs/FetchLibraryDocsAction.php
@@ -9,9 +9,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Docs\Dto\FetchLibraryDocsRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -38,11 +38,7 @@ public function __invoke(FetchLibraryDocsRequest $request): CallToolResult
$topic = $request->topic !== null ? \trim($request->topic) : null;
if (empty($libraryId)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing id parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing id parameter');
}
try {
@@ -52,13 +48,9 @@ public function __invoke(FetchLibraryDocsRequest $request): CallToolResult
topic: $topic,
);
- return new CallToolResult([
- new TextContent(text: $documentation),
- ]);
+ return ToolResult::text($documentation);
} catch (Context7ClientException $e) {
- return new CallToolResult([
- new TextContent(text: $e->getMessage()),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error in fetch-library-docs tool', [
'libraryId' => $libraryId,
@@ -66,11 +58,7 @@ public function __invoke(FetchLibraryDocsRequest $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error fetching library documentation. Please try again later. ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error('Error fetching library documentation. Please try again later. ' . $e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Docs/LibrarySearchAction.php b/src/McpServer/Action/Tools/Docs/LibrarySearchAction.php
index f848b26a..6d875466 100644
--- a/src/McpServer/Action/Tools/Docs/LibrarySearchAction.php
+++ b/src/McpServer/Action/Tools/Docs/LibrarySearchAction.php
@@ -9,9 +9,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Docs\Dto\LibrarySearchRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -37,11 +37,7 @@ public function __invoke(LibrarySearchRequest $request): CallToolResult
$maxResults = \min(10, \max(1, $request->maxResults ?? 5));
if (empty($query)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing query parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing query parameter');
}
try {
@@ -50,15 +46,9 @@ public function __invoke(LibrarySearchRequest $request): CallToolResult
maxResults: $maxResults,
);
- return new CallToolResult([
- new TextContent(
- text: \json_encode($searchResult),
- ),
- ]);
+ return ToolResult::success($searchResult);
} catch (Context7ClientException $e) {
- return new CallToolResult([
- new TextContent(text: $e->getMessage()),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error in library-search tool', [
'query' => $query,
@@ -66,11 +56,7 @@ public function __invoke(LibrarySearchRequest $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error searching libraries: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error('Error searching libraries: ' . $e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Filesystem/DirectoryListAction.php b/src/McpServer/Action/Tools/Filesystem/DirectoryListAction.php
index fe4518ac..e4fe45e5 100644
--- a/src/McpServer/Action/Tools/Filesystem/DirectoryListAction.php
+++ b/src/McpServer/Action/Tools/Filesystem/DirectoryListAction.php
@@ -9,9 +9,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\DirectoryListRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Finder;
@@ -39,22 +39,16 @@ public function __invoke(DirectoryListRequest $request): CallToolResult
$path = (string) $this->dirs->getRootPath()->join($relativePath);
if (empty($path)) {
- return new CallToolResult([
- new TextContent(text: 'Error: Missing path parameter'),
- ], isError: true);
+ return ToolResult::error('Missing path parameter');
}
try {
if (!\file_exists($path)) {
- return new CallToolResult([
- new TextContent(text: \sprintf("Error: Path '%s' does not exist", $relativePath)),
- ], isError: true);
+ return ToolResult::error(\sprintf("Path '%s' does not exist", $relativePath));
}
if (!\is_dir($path)) {
- return new CallToolResult([
- new TextContent(text: \sprintf("Error: Path '%s' is not a directory", $relativePath)),
- ], isError: true);
+ return ToolResult::error(\sprintf("Path '%s' is not a directory", $relativePath));
}
// Create and configure Symfony Finder
@@ -176,11 +170,7 @@ public function __invoke(DirectoryListRequest $request): CallToolResult
$responseData['files'] = $files;
}
- return new CallToolResult([
- new TextContent(
- text: \json_encode($responseData, JSON_PRETTY_PRINT),
- ),
- ]);
+ return ToolResult::success($responseData);
} catch (\Throwable $e) {
$this->logger->error('Error listing directory', [
'path' => $path,
@@ -188,11 +178,7 @@ public function __invoke(DirectoryListRequest $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Filesystem/FileApplyPatchAction.php b/src/McpServer/Action/Tools/Filesystem/FileApplyPatchAction.php
index df0efc14..19f8201f 100644
--- a/src/McpServer/Action/Tools/Filesystem/FileApplyPatchAction.php
+++ b/src/McpServer/Action/Tools/Filesystem/FileApplyPatchAction.php
@@ -9,9 +9,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\FileApplyPatchRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -37,37 +37,21 @@ public function __invoke(FileApplyPatchRequest $request): CallToolResult
// Validate patch format
if (!\str_starts_with($patch, 'diff --git a/')) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Invalid patch format. The patch must start with "diff --git a/".',
- ),
- ], isError: true);
+ return ToolResult::error('Invalid patch format. The patch must start with "diff --git a/".');
}
if (empty($path)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing path parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing path parameter');
}
if (empty($patch)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing patch parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing patch parameter');
}
try {
$result = $this->commandsExecutor->applyPatch($path, $patch);
- return new CallToolResult([
- new TextContent(
- text: $result,
- ),
- ]);
+ return ToolResult::text($result);
} catch (GitCommandException $e) {
$this->logger->error('Error applying git patch', [
'path' => $path,
@@ -75,22 +59,14 @@ public function __invoke(FileApplyPatchRequest $request): CallToolResult
'code' => $e->getCode(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error applying git patch', [
'path' => $path,
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Filesystem/FileMoveAction.php b/src/McpServer/Action/Tools/Filesystem/FileMoveAction.php
index c1b5448b..1ea27b3b 100644
--- a/src/McpServer/Action/Tools/Filesystem/FileMoveAction.php
+++ b/src/McpServer/Action/Tools/Filesystem/FileMoveAction.php
@@ -8,9 +8,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\FileMoveRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
use Spiral\Files\Exception\FilesException;
use Spiral\Files\FilesInterface;
@@ -40,28 +40,16 @@ public function __invoke(FileMoveRequest $request): CallToolResult
$createDirectory = $request->createDirectory;
if (empty($source)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing source parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing source parameter');
}
if (empty($destination)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing destination parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing destination parameter');
}
try {
if (!$this->files->exists($source)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Source file '%s' does not exist", $source),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Source file '%s' does not exist", $source));
}
// Ensure destination directory exists if requested
@@ -69,11 +57,7 @@ public function __invoke(FileMoveRequest $request): CallToolResult
$directory = \dirname($destination);
if (!$this->files->exists($directory)) {
if (!$this->files->ensureDirectory($directory)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not create directory '%s'", $directory),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not create directory '%s'", $directory));
}
}
}
@@ -82,43 +66,29 @@ public function __invoke(FileMoveRequest $request): CallToolResult
try {
$content = $this->files->read($source);
} catch (FilesException) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not read source file '%s'", $source),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not read source file '%s'", $source));
}
// Write to destination
$writeSuccess = $this->files->write($destination, $content);
if (!$writeSuccess) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not write to destination file '%s'", $destination),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not write to destination file '%s'", $destination));
}
// Delete source file
$deleteSuccess = $this->files->delete($source);
if (!$deleteSuccess) {
// Even if delete fails, the move operation is partially successful
- return new CallToolResult([
- new TextContent(
- text: \sprintf(
- "Warning: File copied to '%s' but could not delete source file '%s'",
- $destination,
- $source,
- ),
+ return ToolResult::text(
+ \sprintf(
+ "Warning: File copied to '%s' but could not delete source file '%s'",
+ $destination,
+ $source,
),
- ]);
+ );
}
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Successfully moved '%s' to '%s'", $source, $destination),
- ),
- ]);
+ return ToolResult::text(\sprintf("Successfully moved '%s' to '%s'", $source, $destination));
} catch (\Throwable $e) {
$this->logger->error('Error moving file', [
'source' => $source,
@@ -126,11 +96,7 @@ public function __invoke(FileMoveRequest $request): CallToolResult
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Filesystem/FileReadAction.php b/src/McpServer/Action/Tools/Filesystem/FileReadAction.php
index 3d89a769..501e2f8b 100644
--- a/src/McpServer/Action/Tools/Filesystem/FileReadAction.php
+++ b/src/McpServer/Action/Tools/Filesystem/FileReadAction.php
@@ -5,12 +5,12 @@
namespace Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem;
use Butschster\ContextGenerator\DirectoriesInterface;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\FileReadRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
use Spiral\Files\Exception\FilesException;
use Spiral\Files\FilesInterface;
@@ -38,56 +38,32 @@ public function __invoke(FileReadRequest $request): CallToolResult
$path = (string) $this->dirs->getRootPath()->join($request->path);
if (empty($path)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing path parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing path parameter');
}
try {
if (!$this->files->exists($path)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: File '%s' does not exist", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("File '%s' does not exist", $path));
}
if (\is_dir($path)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: '%s' is a directory", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("'%s' is a directory", $path));
}
try {
$content = $this->files->read($path);
} catch (FilesException) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not read file '%s'", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not read file '%s'", $path));
}
- return new CallToolResult([
- new TextContent(
- text: $content,
- ),
- ]);
+ return ToolResult::text($content);
} catch (\Throwable $e) {
$this->logger->error('Error reading file', [
'path' => $path,
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Filesystem/FileWriteAction.php b/src/McpServer/Action/Tools/Filesystem/FileWriteAction.php
index 894d92aa..3220926c 100644
--- a/src/McpServer/Action/Tools/Filesystem/FileWriteAction.php
+++ b/src/McpServer/Action/Tools/Filesystem/FileWriteAction.php
@@ -8,9 +8,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\FileWriteRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
use Spiral\Files\FilesInterface;
@@ -37,11 +37,7 @@ public function __invoke(FileWriteRequest $request): CallToolResult
$path = (string) $this->dirs->getRootPath()->join($request->path);
if (empty($path)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing path parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing path parameter');
}
try {
@@ -50,49 +46,29 @@ public function __invoke(FileWriteRequest $request): CallToolResult
$directory = \dirname($path);
if (!$this->files->exists($directory)) {
if (!$this->files->ensureDirectory($directory)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not create directory '%s'", $directory),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not create directory '%s'", $directory));
}
}
}
if (\is_dir($path)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: '%s' is a directory", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("'%s' is a directory", $path));
}
$success = $this->files->write($path, $request->content);
if (!$success) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Could not write to file '%s'", $path),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Could not write to file '%s'", $path));
}
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Successfully wrote %d bytes to file '%s'", \strlen($request->content), $path),
- ),
- ]);
+ return ToolResult::text(\sprintf("Successfully wrote %d bytes to file '%s'", \strlen($request->content), $path));
} catch (\Throwable $e) {
$this->logger->error('Error writing file', [
'path' => $path,
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Action/Tools/Git/GitAddAction.php b/src/McpServer/Action/Tools/Git/GitAddAction.php
index 26d9c97c..03678f4f 100644
--- a/src/McpServer/Action/Tools/Git/GitAddAction.php
+++ b/src/McpServer/Action/Tools/Git/GitAddAction.php
@@ -11,9 +11,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Git\Dto\GitAddRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -39,20 +39,12 @@ public function __invoke(GitAddRequest $request): CallToolResult
// Check if we're in a valid git repository
if (!$this->commandsExecutor->isValidRepository($repository)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Not a git repository (or any of the parent directories)',
- ),
- ], isError: true);
+ return ToolResult::error('Not a git repository (or any of the parent directories)');
}
// Validate that paths are provided
if (empty($request->paths)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: No paths specified for staging',
- ),
- ], isError: true);
+ return ToolResult::error('No paths specified for staging');
}
try {
@@ -78,11 +70,7 @@ public function __invoke(GitAddRequest $request): CallToolResult
$result = $stagedInfo ?: 'Files staged successfully';
}
- return new CallToolResult([
- new TextContent(
- text: $result,
- ),
- ]);
+ return ToolResult::text($result);
} catch (GitCommandException $e) {
$this->logger->error('Error executing git add', [
'repository' => $repository,
@@ -91,11 +79,7 @@ public function __invoke(GitAddRequest $request): CallToolResult
'code' => $e->getCode(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error during git add', [
'repository' => $repository,
@@ -103,11 +87,7 @@ public function __invoke(GitAddRequest $request): CallToolResult
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
diff --git a/src/McpServer/Action/Tools/Git/GitCommitAction.php b/src/McpServer/Action/Tools/Git/GitCommitAction.php
index cea0eb0b..f3e88b6b 100644
--- a/src/McpServer/Action/Tools/Git/GitCommitAction.php
+++ b/src/McpServer/Action/Tools/Git/GitCommitAction.php
@@ -11,9 +11,9 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Git\Dto\GitCommitRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -39,20 +39,12 @@ public function __invoke(GitCommitRequest $request): CallToolResult
// Check if we're in a valid git repository
if (!$this->commandsExecutor->isValidRepository($repository)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Not a git repository (or any of the parent directories)',
- ),
- ], isError: true);
+ return ToolResult::error('Not a git repository (or any of the parent directories)');
}
// Validate commit message
if (empty(\trim($request->message))) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Commit message cannot be empty',
- ),
- ], isError: true);
+ return ToolResult::error('Commit message cannot be empty');
}
try {
@@ -69,21 +61,13 @@ public function __invoke(GitCommitRequest $request): CallToolResult
// Check if there are changes to commit (unless allowEmpty is true)
if (!$this->hasChangesToCommit($repository, $request->stageAll)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: No changes to commit. Use git-add to stage files or enable stageAll option',
- ),
- ], isError: true);
+ return ToolResult::error('No changes to commit. Use git-add to stage files or enable stageAll option');
}
$command = new Command($repository, $commandParts);
$result = $this->commandsExecutor->executeString($command);
- return new CallToolResult([
- new TextContent(
- text: $result,
- ),
- ]);
+ return ToolResult::text($result);
} catch (GitCommandException $e) {
$this->logger->error('Error executing git commit', [
'repository' => $repository,
@@ -92,11 +76,7 @@ public function __invoke(GitCommitRequest $request): CallToolResult
'code' => $e->getCode(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error during git commit', [
'repository' => $repository,
@@ -104,11 +84,7 @@ public function __invoke(GitCommitRequest $request): CallToolResult
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
diff --git a/src/McpServer/Action/Tools/Git/GitStatusAction.php b/src/McpServer/Action/Tools/Git/GitStatusAction.php
index d2f7255c..099710d7 100644
--- a/src/McpServer/Action/Tools/Git/GitStatusAction.php
+++ b/src/McpServer/Action/Tools/Git/GitStatusAction.php
@@ -8,13 +8,13 @@
use Butschster\ContextGenerator\Lib\Git\Command;
use Butschster\ContextGenerator\Lib\Git\CommandsExecutorInterface;
use Butschster\ContextGenerator\Lib\Git\Exception\GitCommandException;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Action\Tools\Git\Dto\GitStatusFormat;
use Butschster\ContextGenerator\McpServer\Action\Tools\Git\Dto\GitStatusRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -40,11 +40,7 @@ public function __invoke(GitStatusRequest $request): CallToolResult
// Check if we're in a valid git repository
if (!$this->commandsExecutor->isValidRepository($repository)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Not a git repository (or any of the parent directories)',
- ),
- ], isError: true);
+ return ToolResult::error('Not a git repository (or any of the parent directories)');
}
try {
@@ -83,11 +79,7 @@ public function __invoke(GitStatusRequest $request): CallToolResult
);
}
- return new CallToolResult([
- new TextContent(
- text: $result,
- ),
- ]);
+ return ToolResult::text($result);
} catch (GitCommandException $e) {
$this->logger->error('Error executing git status', [
'repository' => $repository,
@@ -95,22 +87,14 @@ public function __invoke(GitStatusRequest $request): CallToolResult
'code' => $e->getCode(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
} catch (\Throwable $e) {
$this->logger->error('Unexpected error during git status', [
'repository' => $repository,
'error' => $e->getMessage(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
diff --git a/src/McpServer/Action/Tools/Prompts/GetPromptToolAction.php b/src/McpServer/Action/Tools/Prompts/GetPromptToolAction.php
index e3f9d67e..fcbbc500 100644
--- a/src/McpServer/Action/Tools/Prompts/GetPromptToolAction.php
+++ b/src/McpServer/Action/Tools/Prompts/GetPromptToolAction.php
@@ -9,6 +9,7 @@
use Butschster\ContextGenerator\McpServer\Action\Tools\Prompts\Dto\GetPromptRequest;
use Butschster\ContextGenerator\McpServer\Attribute\InputSchema;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
@@ -38,21 +39,13 @@ public function __invoke(GetPromptRequest $request, ServerRequestInterface $serv
$id = $request->id;
if (empty($id)) {
- return new CallToolResult([
- new TextContent(
- text: 'Error: Missing prompt ID parameter',
- ),
- ], isError: true);
+ return ToolResult::error('Missing prompt ID parameter');
}
try {
// Check if prompt exists
if (!$this->prompts->has($id)) {
- return new CallToolResult([
- new TextContent(
- text: \sprintf("Error: Prompt with ID '%s' not found", $id),
- ),
- ], isError: true);
+ return ToolResult::error(\sprintf("Prompt with ID '%s' not found", $id));
}
// Get prompt and process messages
@@ -69,14 +62,10 @@ public function __invoke(GetPromptRequest $request, ServerRequestInterface $serv
];
}
- return new CallToolResult([
- new TextContent(
- text: \json_encode([
- 'id' => $id,
- 'description' => $prompt->prompt->description,
- 'messages' => $formattedMessages,
- ], JSON_PRETTY_PRINT),
- ),
+ return ToolResult::success([
+ 'id' => $id,
+ 'description' => $prompt->prompt->description,
+ 'messages' => $formattedMessages,
]);
} catch (\Throwable $e) {
$this->logger->error('Error getting prompt', [
@@ -85,11 +74,7 @@ public function __invoke(GetPromptRequest $request, ServerRequestInterface $serv
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
diff --git a/src/McpServer/Action/Tools/Prompts/ListPromptsToolAction.php b/src/McpServer/Action/Tools/Prompts/ListPromptsToolAction.php
index 36d86420..bcf57dab 100644
--- a/src/McpServer/Action/Tools/Prompts/ListPromptsToolAction.php
+++ b/src/McpServer/Action/Tools/Prompts/ListPromptsToolAction.php
@@ -8,9 +8,9 @@
use Butschster\ContextGenerator\McpServer\Prompt\PromptProviderInterface;
use Butschster\ContextGenerator\McpServer\Registry\McpItemsRegistry;
use Butschster\ContextGenerator\McpServer\Attribute\Tool;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
@@ -54,13 +54,9 @@ public function __invoke(ServerRequestInterface $request): CallToolResult
];
}
- return new CallToolResult([
- new TextContent(
- text: \json_encode([
- 'count' => \count($promptsList),
- 'prompts' => $promptsList,
- ], JSON_PRETTY_PRINT),
- ),
+ return ToolResult::success([
+ 'count' => \count($promptsList),
+ 'prompts' => $promptsList,
]);
} catch (\Throwable $e) {
$this->logger->error('Error listing prompts', [
@@ -68,11 +64,7 @@ public function __invoke(ServerRequestInterface $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(
- text: 'Error: ' . $e->getMessage(),
- ),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/McpServerBootloader.php b/src/McpServer/McpServerBootloader.php
index 5d1e42a1..89546dc3 100644
--- a/src/McpServer/McpServerBootloader.php
+++ b/src/McpServer/McpServerBootloader.php
@@ -7,6 +7,15 @@
use Butschster\ContextGenerator\Application\Bootloader\ConsoleBootloader;
use Butschster\ContextGenerator\Application\Bootloader\HttpClientBootloader;
use Butschster\ContextGenerator\Config\Loader\ConfigLoaderInterface;
+use Butschster\ContextGenerator\Research\MCP\Tools\CreateEntryToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\CreateResearchToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\GetResearchToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\ListEntriesToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\ListResearchesToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\ListTemplatesToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\ReadEntryToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\UpdateEntryToolAction;
+use Butschster\ContextGenerator\Research\MCP\Tools\UpdateResearchToolAction;
use Butschster\ContextGenerator\McpServer\Action\Prompts\FilesystemOperationsAction;
use Butschster\ContextGenerator\McpServer\Action\Prompts\GetPromptAction;
use Butschster\ContextGenerator\McpServer\Action\Prompts\ListPromptsAction;
@@ -229,12 +238,26 @@ private function actions(McpConfig $config): array
];
}
+ $actions = [
+ ...$actions,
+ ListTemplatesToolAction::class,
+ CreateResearchToolAction::class,
+ ListResearchesToolAction::class,
+ GetResearchToolAction::class,
+ CreateEntryToolAction::class,
+ ListEntriesToolAction::class,
+ ReadEntryToolAction::class,
+ UpdateEntryToolAction::class,
+ UpdateResearchToolAction::class,
+ ];
+
if ($config->isGitOperationsEnabled()) {
$actions[] = GitStatusAction::class;
$actions[] = GitAddAction::class;
$actions[] = GitCommitAction::class;
}
+ // Should be last
if ($config->isCustomToolsEnabled()) {
$actions = [
...$actions,
@@ -242,6 +265,6 @@ private function actions(McpConfig $config): array
];
}
- return $actions;
+ return \array_unique($actions);
}
}
diff --git a/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php b/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php
index 5227ffd3..b8655a40 100644
--- a/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php
+++ b/src/McpServer/Projects/Actions/ProjectSwitchToolAction.php
@@ -12,9 +12,9 @@
use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectSwitchRequest;
use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectSwitchResponse;
use Butschster\ContextGenerator\McpServer\Projects\ProjectServiceInterface;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -41,9 +41,7 @@ public function __invoke(ProjectSwitchRequest $request): CallToolResult
$pathOrAlias = $request->alias;
if (empty($pathOrAlias)) {
- return new CallToolResult([
- new TextContent(text: 'Error: Missing pathOrAlias parameter'),
- ], isError: true);
+ return ToolResult::error('Missing pathOrAlias parameter');
}
// Handle using an alias as the path
@@ -67,15 +65,13 @@ public function __invoke(ProjectSwitchRequest $request): CallToolResult
$suggestions[] = 'Available aliases: ' . \implode(', ', $availableAliases);
}
- return new CallToolResult([
- new TextContent(
- text: \sprintf(
- "Error: Project '%s' is not registered.\n%s",
- $projectPath,
- \implode("\n", $suggestions),
- ),
+ return ToolResult::error(
+ \sprintf(
+ "Project '%s' is not registered.\n%s",
+ $projectPath,
+ \implode("\n", $suggestions),
),
- ], isError: true);
+ );
}
// Try to switch to this project
@@ -105,9 +101,7 @@ public function __invoke(ProjectSwitchRequest $request): CallToolResult
resolvedFromAlias: $aliasResolution,
);
- return new CallToolResult([
- new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)),
- ]);
+ return ToolResult::success($response);
}
$response = new ProjectSwitchResponse(
@@ -115,9 +109,7 @@ public function __invoke(ProjectSwitchRequest $request): CallToolResult
message: \sprintf("Failed to switch to project '%s'", $projectPath),
);
- return new CallToolResult([
- new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)),
- ], isError: true);
+ return ToolResult::error($response->message);
} catch (\Throwable $e) {
$this->logger->error('Error switching project', [
'pathOrAlias' => $request->alias,
@@ -125,9 +117,7 @@ public function __invoke(ProjectSwitchRequest $request): CallToolResult
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(text: 'Error: ' . $e->getMessage()),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
diff --git a/src/McpServer/Projects/Actions/ProjectsListToolAction.php b/src/McpServer/Projects/Actions/ProjectsListToolAction.php
index f0326bcf..32733a6d 100644
--- a/src/McpServer/Projects/Actions/ProjectsListToolAction.php
+++ b/src/McpServer/Projects/Actions/ProjectsListToolAction.php
@@ -11,9 +11,9 @@
use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectListRequest;
use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectsListResponse;
use Butschster\ContextGenerator\McpServer\Projects\ProjectServiceInterface;
+use Butschster\ContextGenerator\McpServer\Action\ToolResult;
use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post;
use Mcp\Types\CallToolResult;
-use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
#[Tool(
@@ -47,9 +47,7 @@ public function __invoke(ProjectListRequest $request): CallToolResult
message: 'No projects registered. Use project:add command to add projects.',
);
- return new CallToolResult([
- new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)),
- ]);
+ return ToolResult::success($response);
}
// Create inverse alias map for quick lookups
@@ -91,18 +89,14 @@ public function __invoke(ProjectListRequest $request): CallToolResult
totalProjects: \count($projects),
);
- return new CallToolResult([
- new TextContent(text: \json_encode($response, JSON_PRETTY_PRINT)),
- ]);
+ return ToolResult::success($response);
} catch (\Throwable $e) {
$this->logger->error('Error listing projects', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
- return new CallToolResult([
- new TextContent(text: 'Error: ' . $e->getMessage()),
- ], isError: true);
+ return ToolResult::error($e->getMessage());
}
}
}
diff --git a/src/McpServer/Routing/ActionCaller.php b/src/McpServer/Routing/ActionCaller.php
index 84622854..2da1cc5d 100644
--- a/src/McpServer/Routing/ActionCaller.php
+++ b/src/McpServer/Routing/ActionCaller.php
@@ -36,6 +36,7 @@ public function __invoke(ServerRequestInterface $request): mixed
json: (array) ($request->getParsedBody() ?? []),
class: $inputSchema->class,
);
+
$bindings[$inputSchema->class] = $input;
}
diff --git a/src/McpServer/Routing/Mcp2PsrRequestAdapter.php b/src/McpServer/Routing/Mcp2PsrRequestAdapter.php
index aa86584d..3fbf099e 100644
--- a/src/McpServer/Routing/Mcp2PsrRequestAdapter.php
+++ b/src/McpServer/Routing/Mcp2PsrRequestAdapter.php
@@ -34,7 +34,17 @@ public function createPsrRequest(string $method, array $mcpParams = []): ServerR
// For POST requests, also add parameters to parsed body
if ($httpMethod === 'POST' && !empty($mcpParams)) {
- $request = $request->withParsedBody($mcpParams);
+ $parsedBody = [];
+
+ foreach ($mcpParams as $key => $value) {
+ if (\is_string($value) && \json_validate($value)) {
+ $value = \json_decode($value, true);
+ }
+
+ $parsedBody[$key] = $value;
+ }
+
+ $request = $request->withParsedBody($parsedBody);
}
return $request;
diff --git a/src/McpServer/Server.php b/src/McpServer/Server.php
index 9bcfc5f9..8ec92b22 100644
--- a/src/McpServer/Server.php
+++ b/src/McpServer/Server.php
@@ -21,6 +21,7 @@
use Mcp\Types\ReadResourceResult;
use Mcp\Types\TextContent;
use Psr\Log\LoggerInterface;
+use Spiral\Exceptions\ExceptionReporterInterface;
final readonly class Server
{
@@ -28,6 +29,7 @@ public function __construct(
private Router $router,
private LoggerInterface $logger,
private ProjectServiceInterface $projectService,
+ private ExceptionReporterInterface $reporter,
private Mcp2PsrRequestAdapter $requestFactory = new Mcp2PsrRequestAdapter(),
) {}
@@ -101,6 +103,7 @@ private function handleRoute(string $method, string $class, array $params = []):
// Convert the response back to appropriate MCP type
return $this->projectService->processResponse($response->getPayload());
} catch (\Throwable $e) {
+ $this->reporter->report($e);
$this->logger->error('Route handling error', [
'method' => $method,
'error' => $e->getMessage(),
diff --git a/src/McpServer/ServerRunner.php b/src/McpServer/ServerRunner.php
index a82fd80f..98e7fb63 100644
--- a/src/McpServer/ServerRunner.php
+++ b/src/McpServer/ServerRunner.php
@@ -13,6 +13,7 @@
use Spiral\Core\Attribute\Singleton;
use Spiral\Core\Scope;
use Spiral\Core\ScopeInterface;
+use Spiral\Exceptions\ExceptionReporterInterface;
#[Singleton]
final class ServerRunner implements ServerRunnerInterface
@@ -47,6 +48,7 @@ public function run(string $name): void
RouteRegistrar $registrar,
McpItemsRegistry $registry,
HasPrefixLoggerInterface $logger,
+ ExceptionReporterInterface $reporter,
) use ($name): void {
// Register all classes with MCP item attributes. Should be before registering controllers!
$registry->registerMany($this->actions);
@@ -59,6 +61,7 @@ public function run(string $name): void
router: $registrar->router,
logger: $logger,
projectService: $this->projectService,
+ reporter: $reporter,
))->run($name);
},
);
diff --git a/src/Research/Config/ResearchConfig.php b/src/Research/Config/ResearchConfig.php
new file mode 100644
index 00000000..2bb482fa
--- /dev/null
+++ b/src/Research/Config/ResearchConfig.php
@@ -0,0 +1,46 @@
+ true,
+ 'templates_path' => '.templates',
+ 'researches_path' => '.researches',
+ 'storage_driver' => 'markdown',
+ 'default_entry_status' => 'draft',
+ 'env_config' => [],
+ ];
+
+ public function getTemplatesPath(): string
+ {
+ return (string) $this->config['templates_path'];
+ }
+
+ public function getResearchesPath(): string
+ {
+ return (string) $this->config['researches_path'];
+ }
+
+ public function getStorageDriver(): string
+ {
+ return (string) $this->config['storage_driver'];
+ }
+
+ public function getDefaultEntryStatus(): string
+ {
+ return (string) $this->config['default_entry_status'];
+ }
+
+ public function getEnvConfig(): array
+ {
+ return (array) $this->config['env_config'];
+ }
+}
diff --git a/src/Research/Config/ResearchConfigInterface.php b/src/Research/Config/ResearchConfigInterface.php
new file mode 100644
index 00000000..a25bd787
--- /dev/null
+++ b/src/Research/Config/ResearchConfigInterface.php
@@ -0,0 +1,33 @@
+researchId);
+
+ // Get research information
+ $research = $service->get($researchId);
+ if ($research === null) {
+ $this->output->error("Research not found: {$this->researchId}");
+ return Command::FAILURE;
+ }
+
+ // Get template information
+ $template = $templateService->getTemplate(new TemplateKey($research->template));
+
+ // Display research information
+ $this->displayInfo($research, $template);
+
+ // Show entries if requested
+ $this->displayEntries($entryService, $researchId);
+
+ // Show statistics if requested
+ $this->displayStatistics($entryService, $researchId);
+
+ return Command::SUCCESS;
+
+ } catch (\Throwable $e) {
+ $this->output->error('Failed to get research information: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ private function displayInfo(Research $research, ?Template $template): void
+ {
+ $this->output->title("Research Information");
+
+ $this->output->definitionList(
+ ['ID', Style::property($research->id)],
+ ['Name', $research->name],
+ ['Description', $research->description ?: 'None'],
+ ['Status', $research->status],
+ ['Template', $research->template . ($template ? " ({$template->name})" : ' (template not found)')],
+ ['Tags', empty($research->tags) ? 'None' : \implode(', ', $research->tags)],
+ ['Entry Directories', empty($research->entryDirs) ? 'None' : \implode(', ', $research->entryDirs)],
+ ['Research Path', $research->path ?? 'Not set'],
+ );
+
+ if ($template) {
+ $this->output->section('Template Information');
+ $this->output->definitionList(
+ ['Template Name', $template->name],
+ ['Template Description', $template->description ?: 'None'],
+ ['Template Tags', empty($template->tags) ? 'None' : \implode(', ', $template->tags)],
+ ['Categories', \count($template->categories)],
+ ['Entry Types', \count($template->entryTypes)],
+ );
+ }
+ }
+
+ private function displayEntries(EntryServiceInterface $entryService, ResearchId $researchId): void
+ {
+ $this->output->section('Entries');
+
+ try {
+ $entries = $entryService->findAll($researchId);
+
+ if (empty($entries)) {
+ $this->output->info('No entries found in this research.');
+ return;
+ }
+
+ $table = new Table($this->output);
+ $table->setHeaders(['ID', 'Title', 'Type', 'Category', 'Status', 'Created', 'Updated', 'Tags']);
+
+ foreach ($entries as $entry) {
+ $table->addRow([
+ Style::property(\substr($entry->entryId, 0, 8) . '...'),
+ $entry->title,
+ $entry->entryType,
+ $entry->category,
+ $entry->status,
+ $entry->createdAt->format('Y-m-d H:i'),
+ $entry->updatedAt->format('Y-m-d H:i'),
+ empty($entry->tags) ? '-' : \implode(', ', $entry->tags),
+ ]);
+ }
+
+ $table->render();
+
+ } catch (\Throwable $e) {
+ $this->output->error('Failed to load research entries: ' . $e->getMessage());
+ }
+ }
+
+ private function displayStatistics(EntryServiceInterface $entryService, ResearchId $researchId): void
+ {
+ $this->output->section('Statistics');
+
+ try {
+ $entries = $entryService->findAll($researchId);
+
+ // Calculate statistics
+ $totalEntries = \count($entries);
+ $entriesByType = [];
+ $entriesByCategory = [];
+ $entriesByStatus = [];
+ $totalContentLength = 0;
+
+ foreach ($entries as $entry) {
+ // Count by type
+ if (!isset($entriesByType[$entry->entryType])) {
+ $entriesByType[$entry->entryType] = 0;
+ }
+ $entriesByType[$entry->entryType]++;
+
+ // Count by category
+ if (!isset($entriesByCategory[$entry->category])) {
+ $entriesByCategory[$entry->category] = 0;
+ }
+ $entriesByCategory[$entry->category]++;
+
+ // Count by status
+ if (!isset($entriesByStatus[$entry->status])) {
+ $entriesByStatus[$entry->status] = 0;
+ }
+ $entriesByStatus[$entry->status]++;
+
+ // Content length
+ $totalContentLength += \strlen($entry->content);
+ }
+
+ $this->output->definitionList(
+ ['Total Entries', (string) $totalEntries],
+ ['Total Content Length', \number_format($totalContentLength) . ' characters'],
+ ['Average Content Length', $totalEntries > 0 ? \number_format($totalContentLength / $totalEntries) . ' characters' : '0'],
+ );
+
+ if (!empty($entriesByType)) {
+ $this->output->writeln("\n
Not an entry
'); + + $entries = $this->scanner->scanEntries($projectPath); + + // Should only find .md files + foreach ($entries as $entryPath) { + $this->assertStringEndsWith('.md', $entryPath); + } + + // Should still be only 2 entries (the original ones) + $this->assertCount(3, $entries); + } + + #[Test] + public function it_handles_empty_project_directory(): void + { + // Create completely empty project directory + $emptyProjectPath = $this->tempPath . '/projects/empty_project'; + \mkdir($emptyProjectPath, 0755, true); + + $entries = $this->scanner->scanEntries($emptyProjectPath); + $this->assertEmpty($entries); + + $directories = $this->scanner->getEntryDirectories($emptyProjectPath); + $this->assertEmpty($directories); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->testDataPath = \dirname(__DIR__, 5) . '/fixtures/Research/FileStorage'; + $this->tempPath = \sys_get_temp_dir() . '/scanner_test_' . \uniqid(); + + // Copy fixture data to temp directory + $this->copyFixturesToTemp(); + + $files = new Files(); + $reporter = new class implements ExceptionReporterInterface { + public function report(\Throwable $exception): void + { + // No-op for tests + } + }; + + $this->scanner = new DirectoryScanner($files, $reporter); + } + + protected function tearDown(): void + { + // Clean up temp directory + if (\is_dir($this->tempPath)) { + $this->removeDirectory($this->tempPath); + } + + parent::tearDown(); + } + + private function copyFixturesToTemp(): void + { + if (!\is_dir($this->tempPath)) { + \mkdir($this->tempPath, 0755, true); + } + + $this->copyDirectory($this->testDataPath, $this->tempPath); + } + + private function copyDirectory(string $source, string $destination): void + { + if (!\is_dir($destination)) { + \mkdir($destination, 0755, true); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + $destPath = $destination . DIRECTORY_SEPARATOR . $iterator->getSubPathName(); + + if ($item->isDir()) { + if (!\is_dir($destPath)) { + \mkdir($destPath, 0755, true); + } + } else { + \copy($item->getRealPath(), $destPath); + } + } + } + + private function removeDirectory(string $directory): void + { + if (!\is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + \rmdir($item->getRealPath()); + } else { + \unlink($item->getRealPath()); + } + } + + \rmdir($directory); + } +} diff --git a/tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php b/tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php new file mode 100644 index 00000000..302ec235 --- /dev/null +++ b/tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php @@ -0,0 +1,326 @@ +parser->parse($content); + + $this->assertArrayHasKey('frontmatter', $result); + $this->assertArrayHasKey('content', $result); + + $frontmatter = $result['frontmatter']; + $this->assertEquals('Test Entry', $frontmatter['title']); + $this->assertEquals('A test entry', $frontmatter['description']); + $this->assertEquals('draft', $frontmatter['status']); + $this->assertEquals(['test', 'example'], $frontmatter['tags']); + $this->assertEquals('2023-01-01T10:00:00Z', $frontmatter['created_at']); + + $expectedContent = "# Test Entry Content\n\nThis is the main content of the entry.\n\n## Section 1\n\nSome more content here."; + $this->assertEquals($expectedContent, $result['content']); + } + + #[Test] + public function it_parses_content_without_frontmatter(): void + { + $content = <<<'MARKDOWN' +# Regular Markdown + +This is just regular markdown content without frontmatter. + +## Section + +More content here. +MARKDOWN; + + $result = $this->parser->parse($content); + + $this->assertArrayHasKey('frontmatter', $result); + $this->assertArrayHasKey('content', $result); + + $this->assertEmpty($result['frontmatter']); + $this->assertEquals($content, $result['content']); + } + + #[Test] + public function it_handles_empty_frontmatter(): void + { + $content = <<<'MARKDOWN' +--- +--- + +# Content Only + +This has empty frontmatter. +MARKDOWN; + + $result = $this->parser->parse($content); + + $this->assertEmpty($result['frontmatter']); + $this->assertEquals("# Content Only\n\nThis has empty frontmatter.", $result['content']); + } + + #[Test] + public function it_handles_complex_yaml_frontmatter(): void + { + $content = <<<'MARKDOWN' +--- +entry_info: + id: "entry_123" + type: "user_story" +metadata: + author: "John Doe" + version: 1.2 + published: true +nested: + - item1: "value1" + - item2: "value2" +tags: + - "complex" + - "yaml" + - "nested" +--- + +# Complex Entry + +This entry has complex YAML frontmatter. +MARKDOWN; + + $result = $this->parser->parse($content); + + $frontmatter = $result['frontmatter']; + $this->assertEquals('entry_123', $frontmatter['entry_info']['id']); + $this->assertEquals('user_story', $frontmatter['entry_info']['type']); + $this->assertEquals('John Doe', $frontmatter['metadata']['author']); + $this->assertEquals(1.2, $frontmatter['metadata']['version']); + $this->assertTrue($frontmatter['metadata']['published']); + $this->assertCount(2, $frontmatter['nested']); + $this->assertEquals(['complex', 'yaml', 'nested'], $frontmatter['tags']); + } + + #[Test] + public function it_combines_frontmatter_and_content(): void + { + $frontmatter = [ + 'title' => 'Combined Entry', + 'description' => 'Testing combine functionality', + 'status' => 'draft', + 'tags' => ['test', 'combine'], + ]; + + $content = "# Combined Entry\n\nThis content was combined with frontmatter."; + + $result = $this->parser->combine($frontmatter, $content); + + $expectedOutput = <<<'MARKDOWN' +--- +title: 'Combined Entry' +description: 'Testing combine functionality' +status: draft +tags: + - test + - combine +--- +# Combined Entry + +This content was combined with frontmatter. +MARKDOWN; + + $this->assertEquals($expectedOutput, $result); + } + + #[Test] + public function it_combines_empty_frontmatter_with_content(): void + { + $frontmatter = []; + $content = "# Content Only\n\nJust content, no frontmatter."; + + $result = $this->parser->combine($frontmatter, $content); + + $this->assertEquals($content, $result); + } + + #[Test] + public function it_extracts_frontmatter_only(): void + { + $content = <<<'MARKDOWN' +--- +title: "Extract Test" +status: "published" +priority: 5 +--- + +# Content that should be ignored + +This content is not extracted. +MARKDOWN; + + $frontmatter = $this->parser->extractFrontmatter($content); + + $this->assertEquals('Extract Test', $frontmatter['title']); + $this->assertEquals('published', $frontmatter['status']); + $this->assertEquals(5, $frontmatter['priority']); + } + + #[Test] + public function it_throws_exception_for_invalid_yaml(): void + { + $content = <<<'MARKDOWN' +--- +title: "Invalid YAML" +invalid_yaml: [unclosed array +status: "draft" +--- + +# Content +MARKDOWN; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to parse YAML frontmatter'); + + $this->parser->parse($content); + } + + #[Test] + public function it_handles_frontmatter_with_special_characters(): void + { + $content = <<<'MARKDOWN' +--- +title: "Entry with 'quotes' and \"double quotes\"" +description: "Contains: colons, semicolons; and other punctuation!" +special_chars: "αβγ 中文 العربية 🚀" +--- + +# Special Characters Test + +Content with special characters. +MARKDOWN; + + $result = $this->parser->parse($content); + + $frontmatter = $result['frontmatter']; + $this->assertEquals("Entry with 'quotes' and \"double quotes\"", $frontmatter['title']); + $this->assertEquals("Contains: colons, semicolons; and other punctuation!", $frontmatter['description']); + $this->assertEquals("αβγ 中文 العربية 🚀", $frontmatter['special_chars']); + } + + #[Test] + public function it_handles_multiline_yaml_values(): void + { + $content = <<<'MARKDOWN' +--- +title: "Multiline Test" +description: | + This is a multiline description + that spans multiple lines + and preserves formatting. +notes: > + This is a folded + multiline string that + will be on one line. +--- + +# Multiline Content + +Test content. +MARKDOWN; + + $result = $this->parser->parse($content); + + $frontmatter = $result['frontmatter']; + $this->assertEquals('Multiline Test', $frontmatter['title']); + $this->assertStringContainsString("This is a multiline description\nthat spans multiple lines", $frontmatter['description']); + $this->assertStringContainsString('This is a folded multiline string', $frontmatter['notes']); + } + + #[Test] + public function it_preserves_content_formatting(): void + { + $content = <<<'MARKDOWN' +--- +title: "Formatting Test" +--- + +# Main Title + +This is a paragraph with **bold** and *italic* text. + +## Code Block + +```php + This is a blockquote +> with multiple lines. +MARKDOWN; + + $result = $this->parser->parse($content); + + $expectedContent = <<<'CONTENT' +# Main Title + +This is a paragraph with **bold** and *italic* text. + +## Code Block + +```php + This is a blockquote +> with multiple lines. +CONTENT; + + $this->assertEquals($expectedContent, $result['content']); + } + + protected function setUp(): void + { + parent::setUp(); + $this->parser = new FrontmatterParser(); + } +} diff --git a/tests/src/Unit/McpServer/Action/ToolResultTest.php b/tests/src/Unit/McpServer/Action/ToolResultTest.php new file mode 100644 index 00000000..1895c6b3 --- /dev/null +++ b/tests/src/Unit/McpServer/Action/ToolResultTest.php @@ -0,0 +1,267 @@ + true, + 'message' => 'Operation completed', + 'id' => 123, + ]; + + // Act + $result = ToolResult::success($data); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + $this->assertCount(1, $result->content); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + $decodedContent = \json_decode($content->text, true); + $this->assertEquals($data, $decodedContent); + } + + #[Test] + public function success_creates_successful_result_with_json_serializable_object(): void + { + // Arrange + $data = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return [ + 'id' => 456, + 'name' => 'Test Object', + 'active' => true, + ]; + } + }; + + // Act + $result = ToolResult::success($data); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + $this->assertCount(1, $result->content); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'id' => 456, + 'name' => 'Test Object', + 'active' => true, + ], $decodedContent); + } + + #[Test] + public function error_creates_error_result_with_message(): void + { + // Arrange + $errorMessage = 'Something went wrong'; + + // Act + $result = ToolResult::error($errorMessage); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'success' => false, + 'error' => $errorMessage, + ], $decodedContent); + } + + #[Test] + public function error_handles_empty_error_message(): void + { + // Act + $result = ToolResult::error(''); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'success' => false, + 'error' => '', + ], $decodedContent); + } + + #[Test] + public function validation_error_creates_error_result_with_details(): void + { + // Arrange + $validationErrors = [ + 'field1' => ['Field is required'], + 'field2' => ['Must be at least 3 characters', 'Contains invalid characters'], + ]; + + // Act + $result = ToolResult::validationError($validationErrors); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], $decodedContent); + } + + #[Test] + public function validation_error_handles_empty_validation_errors(): void + { + // Act + $result = ToolResult::validationError([]); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => [], + ], $decodedContent); + } + + #[Test] + public function text_creates_result_with_plain_text(): void + { + // Arrange + $textContent = 'This is plain text content\nwith multiple lines\nand special characters: !@#$%'; + + // Act + $result = ToolResult::text($textContent); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + $this->assertCount(1, $result->content); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + $this->assertEquals($textContent, $content->text); + } + + #[Test] + public function text_handles_empty_string(): void + { + // Act + $result = ToolResult::text(''); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + $this->assertEquals('', $content->text); + } + + #[Test] + public function text_handles_unicode_characters(): void + { + // Arrange + $unicodeText = 'Unicode: 🚀 αβγ 中文 العربية'; + + // Act + $result = ToolResult::text($unicodeText); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $this->assertEquals($unicodeText, $content->text); + } + + #[Test] + public function success_handles_nested_array_data(): void + { + // Arrange + $complexData = [ + 'user' => [ + 'id' => 1, + 'profile' => [ + 'name' => 'John Doe', + 'settings' => [ + 'theme' => 'dark', + 'notifications' => true, + ], + ], + ], + 'metadata' => [ + 'created_at' => '2023-01-01T00:00:00Z', + 'tags' => ['admin', 'user'], + ], + ]; + + // Act + $result = ToolResult::success($complexData); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $decodedContent = \json_decode($content->text, true); + $this->assertEquals($complexData, $decodedContent); + } + + #[Test] + public function error_handles_special_characters_in_message(): void + { + // Arrange + $errorWithSpecialChars = 'Error: "file.txt" not found at path /home/user\'s folder'; + + // Act + $result = ToolResult::error($errorWithSpecialChars); + + // Assert + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $decodedContent = \json_decode($content->text, true); + $this->assertEquals([ + 'success' => false, + 'error' => $errorWithSpecialChars, + ], $decodedContent); + } +} diff --git a/tests/src/Unit/McpServer/Action/Tools/Filesystem/FileWriteActionTest.php b/tests/src/Unit/McpServer/Action/Tools/Filesystem/FileWriteActionTest.php index 625a7b8a..f62c2d62 100644 --- a/tests/src/Unit/McpServer/Action/Tools/Filesystem/FileWriteActionTest.php +++ b/tests/src/Unit/McpServer/Action/Tools/Filesystem/FileWriteActionTest.php @@ -175,8 +175,10 @@ public function it_returns_error_when_directory_creation_fails(): void $content = $result->content[0]; $this->assertInstanceOf(TextContent::class, $content); - $this->assertStringContainsString('Could not create directory', $content->text); - $this->assertStringContainsString($directory, $content->text); + $decodedContent = \json_decode($content->text, true); + $this->assertEquals(false, $decodedContent['success']); + $this->assertStringContainsString('Could not create directory', $decodedContent['error']); + $this->assertStringContainsString($directory, $decodedContent['error']); } #[Test] @@ -261,8 +263,10 @@ public function it_returns_error_when_write_fails(): void $content = $result->content[0]; $this->assertInstanceOf(TextContent::class, $content); - $this->assertStringContainsString('Could not write to file', $content->text); - $this->assertStringContainsString($expectedFullPath, $content->text); + $decodedContent = \json_decode($content->text, true); + $this->assertEquals(false, $decodedContent['success']); + $this->assertStringContainsString('Could not write to file', $decodedContent['error']); + $this->assertStringContainsString($expectedFullPath, $decodedContent['error']); } #[Test] @@ -305,7 +309,9 @@ public function it_handles_exceptions_gracefully(): void $content = $result->content[0]; $this->assertInstanceOf(TextContent::class, $content); - $this->assertEquals('Error: File system error', $content->text); + $decodedContent = \json_decode($content->text, true); + $this->assertEquals(false, $decodedContent['success']); + $this->assertEquals('File system error', $decodedContent['error']); } #[Test] diff --git a/tests/src/Unit/Research/Domain/Model/ProjectTest.php b/tests/src/Unit/Research/Domain/Model/ProjectTest.php new file mode 100644 index 00000000..25bc7eb6 --- /dev/null +++ b/tests/src/Unit/Research/Domain/Model/ProjectTest.php @@ -0,0 +1,217 @@ +assertSame('proj_123', $project->id); + $this->assertSame('Test Project', $project->name); + $this->assertSame('A test project', $project->description); + $this->assertSame('blog', $project->template); + $this->assertSame('active', $project->status); + $this->assertSame(['web', 'blog'], $project->tags); + $this->assertSame(['posts', 'pages'], $project->entryDirs); + $this->assertSame(['Initial memory entry', 'Another memory'], $project->memory); + $this->assertSame('/path/to/project', $project->path); + } + + public function testProjectConstructionWithDefaults(): void + { + $project = new Research( + id: 'proj_456', + name: 'Minimal Project', + description: 'Basic project', + template: 'simple', + status: 'draft', + tags: [], + entryDirs: [], + ); + + $this->assertSame('proj_456', $project->id); + $this->assertSame('Minimal Project', $project->name); + $this->assertSame([], $project->memory); // Should default to empty array + $this->assertNull($project->path); // Should default to null + } + + public function testWithUpdates(): void + { + $original = new Research( + id: 'proj_789', + name: 'Original Name', + description: 'Original description', + template: 'blog', + status: 'draft', + tags: ['old'], + entryDirs: ['old-dir'], + memory: ['old memory'], + ); + + $updated = $original->withUpdates( + name: 'Updated Name', + description: 'Updated description', + status: 'active', + tags: ['new', 'updated'], + entryDirs: ['new-dir', 'another-dir'], + memory: ['new memory', 'updated memory'], + ); + + // Original should be unchanged + $this->assertSame('Original Name', $original->name); + $this->assertSame('Original description', $original->description); + $this->assertSame('draft', $original->status); + $this->assertSame(['old'], $original->tags); + $this->assertSame(['old-dir'], $original->entryDirs); + $this->assertSame(['old memory'], $original->memory); + + // New instance should have updated values + $this->assertSame('proj_789', $updated->id); // ID unchanged + $this->assertSame('Updated Name', $updated->name); + $this->assertSame('Updated description', $updated->description); + $this->assertSame('active', $updated->status); + $this->assertSame(['new', 'updated'], $updated->tags); + $this->assertSame(['new-dir', 'another-dir'], $updated->entryDirs); + $this->assertSame(['new memory', 'updated memory'], $updated->memory); + $this->assertSame('blog', $updated->template); // Template unchanged + } + + public function testWithUpdatesPartial(): void + { + $original = new Research( + id: 'proj_abc', + name: 'Test', + description: 'Description', + template: 'blog', + status: 'draft', + tags: ['tag1'], + entryDirs: ['dir1'], + memory: ['mem1'], + ); + + $updated = $original->withUpdates(name: 'New Name'); + + $this->assertSame('New Name', $updated->name); + $this->assertSame('Description', $updated->description); // Unchanged + $this->assertSame('draft', $updated->status); // Unchanged + $this->assertSame(['tag1'], $updated->tags); // Unchanged + $this->assertSame(['dir1'], $updated->entryDirs); // Unchanged + $this->assertSame(['mem1'], $updated->memory); // Unchanged + } + + public function testWithAddedMemory(): void + { + $project = new Research( + id: 'proj_mem', + name: 'Memory Test', + description: 'Testing memory', + template: 'test', + status: 'active', + tags: [], + entryDirs: [], + memory: ['First memory', 'Second memory'], + ); + + $updated = $project->withAddedMemory('Third memory'); + + // Original unchanged + $this->assertSame(['First memory', 'Second memory'], $project->memory); + + // New instance has added memory + $this->assertSame(['First memory', 'Second memory', 'Third memory'], $updated->memory); + + // Other properties unchanged + $this->assertSame('proj_mem', $updated->id); + $this->assertSame('Memory Test', $updated->name); + } + + public function testWithAddedMemoryToEmptyArray(): void + { + $project = new Research( + id: 'proj_empty', + name: 'Empty Memory', + description: 'No memory yet', + template: 'test', + status: 'active', + tags: [], + entryDirs: [], + memory: [], + ); + + $updated = $project->withAddedMemory('First memory entry'); + + $this->assertSame([], $project->memory); + $this->assertSame(['First memory entry'], $updated->memory); + } + + public function testJsonSerialize(): void + { + $project = new Research( + id: 'proj_json', + name: 'JSON Test', + description: 'Testing JSON serialization', + template: 'api', + status: 'published', + tags: ['api', 'json'], + entryDirs: ['endpoints'], + memory: ['json memory'], + ); + + $serialized = $project->jsonSerialize(); + + $this->assertSame('JSON Test', $serialized['title']); + $this->assertSame('published', $serialized['status']); + $this->assertSame('api', $serialized['research_type']); + + // Check metadata + $this->assertSame('Testing JSON serialization', $serialized['metadata']['description']); + $this->assertSame(['api', 'json'], $serialized['metadata']['tags']); + $this->assertSame(['json memory'], $serialized['metadata']['memory']); + } + + public function testProjectIsImmutable(): void + { + $project = new Research( + id: 'proj_immutable', + name: 'Immutable Test', + description: 'Testing immutability', + template: 'test', + status: 'active', + tags: ['immutable'], + entryDirs: ['test'], + memory: ['original'], + ); + + $updated = $project->withUpdates(name: 'Updated'); + $withMemory = $project->withAddedMemory('new memory'); + + // Original should be completely unchanged + $this->assertSame('Immutable Test', $project->name); + $this->assertSame(['original'], $project->memory); + + // Each method should return a new instance + $this->assertNotSame($project, $updated); + $this->assertNotSame($project, $withMemory); + $this->assertNotSame($updated, $withMemory); + } +} diff --git a/tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php b/tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php new file mode 100644 index 00000000..ea9f8927 --- /dev/null +++ b/tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php @@ -0,0 +1,67 @@ +assertSame('entry_789xyz', $entryId->value); + } + + public function testFromStringWithEmptyValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Entry ID cannot be empty'); + + EntryId::fromString(''); + } + + public function testFromStringWithWhitespaceOnly(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Entry ID cannot be empty'); + + EntryId::fromString(" \t\n "); + } + + public function testEquality(): void + { + $id1 = EntryId::fromString('entry_abc'); + $id2 = EntryId::fromString('entry_abc'); + $id3 = EntryId::fromString('entry_def'); + + $this->assertTrue($id1->equals($id2)); + $this->assertFalse($id1->equals($id3)); + } + + public function testToString(): void + { + $entryId = EntryId::fromString('entry_test_123'); + + $this->assertSame('entry_test_123', (string) $entryId); + } + + public function testValueObjectIsImmutable(): void + { + $entryId = EntryId::fromString('entry_readonly'); + + // Value should be read-only + $this->assertSame('entry_readonly', $entryId->value); + + // Creating from same string should be equal but different instances + $anotherEntryId = EntryId::fromString('entry_readonly'); + $this->assertTrue($entryId->equals($anotherEntryId)); + $this->assertNotSame($entryId, $anotherEntryId); + } +} diff --git a/tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php b/tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php new file mode 100644 index 00000000..2d2c64c5 --- /dev/null +++ b/tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php @@ -0,0 +1,67 @@ +assertSame('proj_123abc', $projectId->value); + } + + public function testFromStringWithEmptyValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Research ID cannot be empty'); + + ResearchId::fromString(''); + } + + public function testFromStringWithWhitespaceOnly(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Research ID cannot be empty'); + + ResearchId::fromString(" \t\n "); + } + + public function testEquality(): void + { + $id1 = ResearchId::fromString('proj_123'); + $id2 = ResearchId::fromString('proj_123'); + $id3 = ResearchId::fromString('proj_456'); + + $this->assertTrue($id1->equals($id2)); + $this->assertFalse($id1->equals($id3)); + } + + public function testToString(): void + { + $projectId = ResearchId::fromString('proj_test'); + + $this->assertSame('proj_test', (string) $projectId); + } + + public function testValueObjectIsImmutable(): void + { + $projectId = ResearchId::fromString('proj_immutable'); + + // Value should be read-only + $this->assertSame('proj_immutable', $projectId->value); + + // Creating from same string should be equal but different instances + $anotherProjectId = ResearchId::fromString('proj_immutable'); + $this->assertTrue($projectId->equals($anotherProjectId)); + $this->assertNotSame($projectId, $anotherProjectId); + } +} diff --git a/tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php b/tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php new file mode 100644 index 00000000..8cfdc692 --- /dev/null +++ b/tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php @@ -0,0 +1,75 @@ +assertSame('blog-template', $templateKey->value); + } + + public function testFromStringWithEmptyValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Template key cannot be empty'); + + TemplateKey::fromString(''); + } + + public function testFromStringWithWhitespaceOnly(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Template key cannot be empty'); + + TemplateKey::fromString(" \n\t "); + } + + public function testEquality(): void + { + $key1 = TemplateKey::fromString('blog'); + $key2 = TemplateKey::fromString('blog'); + $key3 = TemplateKey::fromString('portfolio'); + + $this->assertTrue($key1->equals($key2)); + $this->assertFalse($key1->equals($key3)); + } + + public function testToString(): void + { + $templateKey = TemplateKey::fromString('api-docs'); + + $this->assertSame('api-docs', (string) $templateKey); + } + + public function testValueObjectIsImmutable(): void + { + $templateKey = TemplateKey::fromString('immutable-template'); + + // Value should be read-only + $this->assertSame('immutable-template', $templateKey->value); + + // Creating from same string should be equal but different instances + $anotherTemplateKey = TemplateKey::fromString('immutable-template'); + $this->assertTrue($templateKey->equals($anotherTemplateKey)); + $this->assertNotSame($templateKey, $anotherTemplateKey); + } + + public function testKeyWithSpecialCharacters(): void + { + $templateKey = TemplateKey::fromString('my_template-v2.0'); + + $this->assertSame('my_template-v2.0', $templateKey->value); + $this->assertSame('my_template-v2.0', (string) $templateKey); + } +} diff --git a/tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php b/tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php new file mode 100644 index 00000000..4ae70e4d --- /dev/null +++ b/tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php @@ -0,0 +1,210 @@ +assertSame('blog-template', $request->templateId); + $this->assertSame('My Blog Project', $request->title); + $this->assertSame('A personal blog about tech', $request->description); + $this->assertSame(['blog', 'tech', 'personal'], $request->tags); + $this->assertSame(['posts', 'drafts', 'pages'], $request->entryDirs); + $this->assertSame(['Initial project memory', 'Setup notes'], $request->memory); + } + + public function testConstructionWithMinimalFields(): void + { + $request = new ResearchCreateRequest( + templateId: 'simple-template', + title: 'Simple Project', + ); + + $this->assertSame('simple-template', $request->templateId); + $this->assertSame('Simple Project', $request->title); + $this->assertSame('', $request->description); + $this->assertSame([], $request->tags); + $this->assertSame([], $request->entryDirs); + $this->assertSame([], $request->memory); + } + + public function testValidateWithValidRequest(): void + { + $request = new ResearchCreateRequest( + templateId: 'valid-template', + title: 'Valid Project', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithEmptyTemplateId(): void + { + $request = new ResearchCreateRequest( + templateId: '', + title: 'Some Project', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + } + + public function testValidateWithEmptyTitle(): void + { + $request = new ResearchCreateRequest( + templateId: 'valid-template', + title: '', + ); + + $errors = $request->validate(); + + $this->assertContains('Research title cannot be empty', $errors); + } + + public function testValidateWithMultipleErrors(): void + { + $request = new ResearchCreateRequest( + templateId: '', + title: '', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + $this->assertContains('Research title cannot be empty', $errors); + $this->assertCount(2, $errors); + } + + public function testValidateWithWhitespaceOnlyTitle(): void + { + $request = new ResearchCreateRequest( + templateId: 'valid-template', + title: " \t\n ", + ); + + $errors = $request->validate(); + + $this->assertContains('Research title cannot be empty', $errors); + } + + public function testValidateWithWhitespaceOnlyTemplateId(): void + { + $request = new ResearchCreateRequest( + templateId: " \n\t ", + title: 'Valid Title', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + } + + public function testValidateWithValidTitleContainingWhitespace(): void + { + $request = new ResearchCreateRequest( + templateId: 'valid-template', + title: ' Valid Title with Spaces ', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateAcceptsEmptyOptionalFields(): void + { + $request = new ResearchCreateRequest( + templateId: 'template', + title: 'Project', + description: '', + tags: [], + entryDirs: [], + memory: [], + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithLongDescription(): void + { + $longDescription = \str_repeat('This is a very long description. ', 100); + + $request = new ResearchCreateRequest( + templateId: 'template', + title: 'Project', + description: $longDescription, + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithSpecialCharactersInFields(): void + { + $request = new ResearchCreateRequest( + templateId: 'template-with-dashes_and_underscores', + title: 'Project with Special Chars: !@#$%', + description: 'Description with unicode: 🚀 café', + tags: ['tag-1', 'tag_2', 'tag with spaces'], + entryDirs: ['dir-1', 'dir_2', 'dir with spaces'], + memory: ['Memory with unicode: 世界', 'Memory with symbols: !@#$%'], + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testArrayFieldsAreEmptyByDefault(): void + { + $request = new ResearchCreateRequest( + templateId: 'template', + title: 'Project', + ); + + $this->assertIsArray($request->tags); + $this->assertEmpty($request->tags); + + $this->assertIsArray($request->entryDirs); + $this->assertEmpty($request->entryDirs); + + $this->assertIsArray($request->memory); + $this->assertEmpty($request->memory); + } + + public function testIsReadonlyDto(): void + { + $request = new ResearchCreateRequest( + templateId: 'readonly-template', + title: 'Readonly Project', + ); + + // Properties should be readonly (this is ensured by PHP at compile time) + $this->assertSame('readonly-template', $request->templateId); + $this->assertSame('Readonly Project', $request->title); + } +} diff --git a/tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php b/tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php new file mode 100644 index 00000000..3f74bd9a --- /dev/null +++ b/tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php @@ -0,0 +1,195 @@ +assertSame('proj_123', $request->researchId); + $this->assertSame('Updated Title', $request->title); + $this->assertSame('Updated description', $request->description); + $this->assertSame('published', $request->status); + $this->assertSame(['web', 'blog'], $request->tags); + $this->assertSame(['posts', 'pages'], $request->entryDirs); + $this->assertSame(['memory1', 'memory2'], $request->memory); + } + + public function testConstructionWithMinimalFields(): void + { + $request = new ResearchUpdateRequest(researchId: 'proj_456'); + + $this->assertSame('proj_456', $request->researchId); + $this->assertNull($request->title); + $this->assertNull($request->description); + $this->assertNull($request->status); + $this->assertNull($request->tags); + $this->assertNull($request->entryDirs); + $this->assertNull($request->memory); + } + + public function testHasUpdatesReturnsTrueWithTitle(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + title: 'New Title', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithDescription(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + description: 'New description', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithStatus(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + status: 'active', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithTags(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + tags: ['new-tag'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithEntryDirs(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + entryDirs: ['new-dir'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithMemory(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + memory: ['new memory'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsFalseWithNoUpdates(): void + { + $request = new ResearchUpdateRequest(researchId: 'proj_123'); + + $this->assertFalse($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithEmptyArrays(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + tags: [], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testValidateWithValidRequest(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + title: 'Valid Title', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithEmptyProjectId(): void + { + $request = new ResearchUpdateRequest( + researchId: '', + title: 'Some Title', + ); + + $errors = $request->validate(); + + $this->assertContains('Research ID cannot be empty', $errors); + } + + public function testValidateWithNoUpdates(): void + { + $request = new ResearchUpdateRequest(researchId: 'proj_123'); + + $errors = $request->validate(); + + $this->assertContains('At least one field must be provided for update', $errors); + } + + public function testValidateWithMultipleErrors(): void + { + $request = new ResearchUpdateRequest(researchId: ''); + + $errors = $request->validate(); + + $this->assertContains('Research ID cannot be empty', $errors); + $this->assertContains('At least one field must be provided for update', $errors); + $this->assertCount(2, $errors); + } + + public function testValidateWithEmptyArraysIsValid(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_valid', + tags: [], + entryDirs: [], + memory: [], + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testIsReadonlyDto(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_readonly', + title: 'Original Title', + ); + + // Properties should be readonly (this is ensured by PHP at compile time) + $this->assertSame('proj_readonly', $request->researchId); + $this->assertSame('Original Title', $request->title); + } +} diff --git a/tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php b/tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php new file mode 100644 index 00000000..668036bb --- /dev/null +++ b/tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php @@ -0,0 +1,313 @@ +projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertTrue($responseData['success']); + } + + public function testPartialProjectUpdate(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_456', + title: 'New Title Only', + ); + + $updatedProject = new Research( + id: 'proj_456', + name: 'New Title Only', + description: 'Original description', + template: 'simple-template', + status: 'draft', + tags: ['existing'], + entryDirs: ['existing-dir'], + memory: ['existing memory'], + ); + + $projectId = ResearchId::fromString('proj_456'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertTrue($responseData['success']); + } + + public function testValidationErrors(): void + { + $request = new ResearchUpdateRequest(researchId: ''); // Empty project ID + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertFalse($responseData['success']); + $this->assertSame('Validation failed', $responseData['error']); + $this->assertIsArray($responseData['details']); + } + + public function testProjectNotFound(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_nonexistent', + title: 'New Title', + ); + + $projectId = ResearchId::fromString('proj_nonexistent'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertFalse($responseData['success']); + $this->assertSame("Research 'proj_nonexistent' not found", $responseData['error']); + } + + public function testProjectNotFoundExceptionFromService(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_exception', + title: 'New Title', + ); + + $projectId = ResearchId::fromString('proj_exception'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willThrowException(new ResearchNotFoundException('Project not found in service')); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertFalse($responseData['success']); + $this->assertSame('Project not found in service', $responseData['error']); + } + + public function testDraflingException(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_error', + title: 'New Title', + ); + + $projectId = ResearchId::fromString('proj_error'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willThrowException(new ResearchException('Service error')); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertFalse($responseData['success']); + $this->assertSame('Service error', $responseData['error']); + } + + public function testUnexpectedException(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_unexpected', + title: 'New Title', + ); + + $projectId = ResearchId::fromString('proj_unexpected'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willThrowException(new \RuntimeException('Unexpected error')); + + $result = ($this->toolAction)($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertFalse($responseData['success']); + $this->assertSame('Failed to update research: Unexpected error', $responseData['error']); + } + + public function testEmptyArrayUpdatesAreTracked(): void + { + $request = new ResearchUpdateRequest( + researchId: 'proj_empty', + tags: [], + entryDirs: [], + memory: [], + ); + + $updatedProject = new Research( + id: 'proj_empty', + name: 'Test Project', + description: 'Description', + template: 'template', + status: 'draft', + tags: [], + entryDirs: [], + memory: [], + ); + + $projectId = ResearchId::fromString('proj_empty'); + + $this->projectService + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('update') + ->with($projectId, $request) + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + $this->assertTrue($responseData['success']); + } + + protected function setUp(): void + { + $this->logger = $this->createMock(LoggerInterface::class); + $this->projectService = $this->createMock(ResearchServiceInterface::class); + + $this->toolAction = new UpdateResearchToolAction( + $this->logger, + $this->projectService, + ); + } +} diff --git a/tests/src/Unit/Research/Service/ProjectServiceTest.php b/tests/src/Unit/Research/Service/ProjectServiceTest.php new file mode 100644 index 00000000..1dc7fc6f --- /dev/null +++ b/tests/src/Unit/Research/Service/ProjectServiceTest.php @@ -0,0 +1,509 @@ +templateService + ->expects($this->once()) + ->method('templateExists') + ->with($templateKey) + ->willReturn(true); + + // Storage driver creates project + $this->storageDriver + ->expects($this->once()) + ->method('createResearch') + ->with($request) + ->willReturn($createdProject); + + // Repository saves project + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->with($createdProject); + + $result = $this->projectService->create($request); + + $this->assertSame($createdProject, $result); + } + + public function testCreateProjectWithNonExistentTemplate(): void + { + $request = new ResearchCreateRequest( + templateId: 'non-existent-template', + title: 'Test Project', + ); + + $templateKey = TemplateKey::fromString('non-existent-template'); + + $this->templateService + ->expects($this->once()) + ->method('templateExists') + ->with($templateKey) + ->willReturn(false); + + $this->expectException(TemplateNotFoundException::class); + $this->expectExceptionMessage("Template 'non-existent-template' not found"); + + $this->projectService->create($request); + } + + public function testCreateProjectStorageFailure(): void + { + $request = new ResearchCreateRequest( + templateId: 'valid-template', + title: 'Test Project', + ); + + $templateKey = TemplateKey::fromString('valid-template'); + + $this->templateService + ->expects($this->once()) + ->method('templateExists') + ->with($templateKey) + ->willReturn(true); + + $this->storageDriver + ->expects($this->once()) + ->method('createResearch') + ->with($request) + ->willThrowException(new \RuntimeException('Storage error')); + + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to create research: Storage error'); + + $this->projectService->create($request); + } + + public function testUpdateProjectSuccess(): void + { + $projectId = ResearchId::fromString('proj_123'); + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + title: 'Updated Title', + ); + + $updatedProject = new Research( + id: 'proj_123', + name: 'Updated Title', + description: 'description', + template: 'blog', + status: 'draft', + tags: [], + entryDirs: [], + ); + + // Project exists + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + // Storage driver updates project + $this->storageDriver + ->expects($this->once()) + ->method('updateResearch') + ->with($projectId, $request) + ->willReturn($updatedProject); + + // Repository saves updated project + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->with($updatedProject); + + $result = $this->projectService->update($projectId, $request); + + $this->assertSame($updatedProject, $result); + } + + public function testUpdateProjectNotFound(): void + { + $projectId = ResearchId::fromString('proj_nonexistent'); + $request = new ResearchUpdateRequest( + researchId: 'proj_nonexistent', + title: 'Updated Title', + ); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $this->expectException(ResearchNotFoundException::class); + $this->expectExceptionMessage("Research 'proj_nonexistent' not found"); + + $this->projectService->update($projectId, $request); + } + + public function testUpdateProjectStorageFailure(): void + { + $projectId = ResearchId::fromString('proj_123'); + $request = new ResearchUpdateRequest( + researchId: 'proj_123', + title: 'Updated Title', + ); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->storageDriver + ->expects($this->once()) + ->method('updateResearch') + ->with($projectId, $request) + ->willThrowException(new \RuntimeException('Update failed')); + + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to update research: Update failed'); + + $this->projectService->update($projectId, $request); + } + + public function testProjectExists(): void + { + $projectId = ResearchId::fromString('proj_exists'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $result = $this->projectService->exists($projectId); + + $this->assertTrue($result); + } + + public function testProjectNotExists(): void + { + $projectId = ResearchId::fromString('proj_notexists'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $result = $this->projectService->exists($projectId); + + $this->assertFalse($result); + } + + public function testGetProject(): void + { + $projectId = ResearchId::fromString('proj_get'); + $project = new Research( + id: 'proj_get', + name: 'Test Project', + description: 'description', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + ); + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn($project); + + $result = $this->projectService->get($projectId); + + $this->assertSame($project, $result); + } + + public function testGetProjectNotFound(): void + { + $projectId = ResearchId::fromString('proj_notfound'); + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn(null); + + $result = $this->projectService->get($projectId); + + $this->assertNull($result); + } + + public function testListProjects(): void + { + $filters = ['status' => 'active']; + $projects = [ + new Research( + id: 'proj_1', + name: 'Project 1', + description: 'desc1', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + ), + new Research( + id: 'proj_2', + name: 'Project 2', + description: 'desc2', + template: 'portfolio', + status: 'active', + tags: [], + entryDirs: [], + ), + ]; + + $this->projectRepository + ->expects($this->once()) + ->method('findAll') + ->with($filters) + ->willReturn($projects); + + $result = $this->projectService->findAll($filters); + + $this->assertSame($projects, $result); + } + + public function testListProjectsFailure(): void + { + $filters = ['status' => 'active']; + + $this->projectRepository + ->expects($this->once()) + ->method('findAll') + ->with($filters) + ->willThrowException(new \RuntimeException('Database error')); + + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to list researches: Database error'); + + $this->projectService->findAll($filters); + } + + public function testDeleteProjectSuccess(): void + { + $projectId = ResearchId::fromString('proj_delete'); + + // Project exists + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + // Storage driver deletes project + $this->storageDriver + ->expects($this->once()) + ->method('deleteResearch') + ->with($projectId) + ->willReturn(true); + + // Repository removes project + $this->projectRepository + ->expects($this->once()) + ->method('delete') + ->with($projectId); + + $result = $this->projectService->delete($projectId); + + $this->assertTrue($result); + } + + public function testDeleteProjectNotFound(): void + { + $projectId = ResearchId::fromString('proj_notexist'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $result = $this->projectService->delete($projectId); + + $this->assertFalse($result); + } + + public function testDeleteProjectStorageFailure(): void + { + $projectId = ResearchId::fromString('proj_storage_fail'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->storageDriver + ->expects($this->once()) + ->method('deleteResearch') + ->with($projectId) + ->willThrowException(new \RuntimeException('Delete failed')); + + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to delete research: Delete failed'); + + $this->projectService->delete($projectId); + } + + public function testAddProjectMemorySuccess(): void + { + $projectId = ResearchId::fromString('proj_memory'); + $memory = 'New memory entry'; + + $originalProject = new Research( + id: 'proj_memory', + name: 'Memory Test', + description: 'desc', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + memory: ['existing memory'], + ); + + $updatedProject = new Research( + id: 'proj_memory', + name: 'Memory Test', + description: 'desc', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + memory: ['existing memory', 'New memory entry'], + ); + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn($originalProject); + + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->with($updatedProject); + + $result = $this->projectService->addMemory($projectId, $memory); + + $this->assertEquals($updatedProject->memory, $result->memory); + $this->assertContains('New memory entry', $result->memory); + $this->assertContains('existing memory', $result->memory); + } + + public function testAddProjectMemoryProjectNotFound(): void + { + $projectId = ResearchId::fromString('proj_notexist'); + $memory = 'Some memory'; + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn(null); + + $this->expectException(ResearchNotFoundException::class); + $this->expectExceptionMessage("Research 'proj_notexist' not found"); + + $this->projectService->addMemory($projectId, $memory); + } + + public function testAddProjectMemoryRepositoryFailure(): void + { + $projectId = ResearchId::fromString('proj_memory_fail'); + $memory = 'Memory content'; + + $project = new Research( + id: 'proj_memory_fail', + name: 'Test', + description: 'desc', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + memory: [], + ); + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn($project); + + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->willThrowException(new \RuntimeException('Save failed')); + + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to add memory to research: Save failed'); + + $this->projectService->addMemory($projectId, $memory); + } + + protected function setUp(): void + { + $this->projectRepository = $this->createMock(ResearchRepositoryInterface::class); + $this->templateService = $this->createMock(TemplateServiceInterface::class); + $this->storageDriver = $this->createMock(StorageDriverInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->projectService = new ResearchService( + $this->projectRepository, + $this->templateService, + $this->storageDriver, + $this->logger, + ); + } +}