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("\nEntries by Type:"); + foreach ($entriesByType as $type => $count) { + $this->output->writeln(" • {$type}: {$count}"); + } + } + + if (!empty($entriesByCategory)) { + $this->output->writeln("\nEntries by Category:"); + foreach ($entriesByCategory as $category => $count) { + $this->output->writeln(" • {$category}: {$count}"); + } + } + + if (!empty($entriesByStatus)) { + $this->output->writeln("\nEntries by Status:"); + foreach ($entriesByStatus as $status => $count) { + $this->output->writeln(" • {$status}: {$count}"); + } + } + + } catch (\Throwable $e) { + $this->output->error('Failed to calculate research statistics: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/Console/ResearchListCommand.php b/src/Research/Console/ResearchListCommand.php new file mode 100644 index 00000000..e3ba162b --- /dev/null +++ b/src/Research/Console/ResearchListCommand.php @@ -0,0 +1,73 @@ +status !== null) { + $filters['status'] = $this->status; + } + + if ($this->template !== null) { + $filters['template'] = $this->template; + } + + try { + $researches = $service->findAll($filters); + + if (empty($researches)) { + $this->output->info('No researches found.'); + return Command::SUCCESS; + } + + $this->output->title('Researches'); + + $table = new Table($this->output); + $table->setHeaders(['ID', 'Name', 'Status', 'Template', 'Description', 'Tags']); + + foreach ($researches as $research) { + $table->addRow([ + Style::property($research->id), + $research->name, + $research->status, + $research->template, + $research->description ?: '-', + \implode(', ', $research->tags), + ]); + } + + $table->render(); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->output->error('Failed to list researches: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/src/Research/Console/TemplateListCommand.php b/src/Research/Console/TemplateListCommand.php new file mode 100644 index 00000000..0868941c --- /dev/null +++ b/src/Research/Console/TemplateListCommand.php @@ -0,0 +1,88 @@ +findAll(); + + // Apply filters + if ($this->tag !== null) { + $templates = \array_filter( + $templates, + fn(Template $template) => + \in_array($this->tag, $template->tags, true), + ); + } + + if ($this->nameFilter !== null) { + $searchTerm = \strtolower(\trim($this->nameFilter)); + $templates = \array_filter( + $templates, + static fn($template) => + \str_contains(\strtolower($template->name), $searchTerm), + ); + } + + if (empty($templates)) { + $this->output->info('No templates found.'); + return Command::SUCCESS; + } + + $this->output->title('Templates'); + + foreach ($templates as $template) { + $this->displayDetails($template); + } + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->output->error('Failed to list templates: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + private function displayDetails(Template $template): void + { + $this->output->section($template->name); + $this->output->writeln("ID: " . Style::property($template->key)); + $this->output->writeln("Description: " . ($template->description ?: 'None')); + $this->output->writeln("Tags: " . \implode(', ', $template->tags)); + + if (!empty($template->categories)) { + $this->output->writeln("\nCategories:"); + foreach ($template->categories as $category) { + $this->output->writeln(" • {$category->displayName} ({$category->name})"); + if (!empty($category->entryTypes)) { + $this->output->writeln(" Entry types: " . \implode(', ', $category->entryTypes)); + } + } + } + + $this->output->newLine(); + } +} diff --git a/src/Research/Domain/Model/Category.php b/src/Research/Domain/Model/Category.php new file mode 100644 index 00000000..8382538a --- /dev/null +++ b/src/Research/Domain/Model/Category.php @@ -0,0 +1,30 @@ +entryTypes, true); + } +} diff --git a/src/Research/Domain/Model/Entry.php b/src/Research/Domain/Model/Entry.php new file mode 100644 index 00000000..29c8089c --- /dev/null +++ b/src/Research/Domain/Model/Entry.php @@ -0,0 +1,80 @@ +entryId, + title: $title ?? $this->title, + description: $description ?? $this->description, + entryType: $this->entryType, + category: $this->category, + status: $status ?? $this->status, + createdAt: $this->createdAt, + updatedAt: new \DateTime(), + tags: $tags ?? $this->tags, + content: $content ?? $this->content, + filePath: $this->filePath, + ); + } + + /** + * Specify data which should be serialized to JSON + */ + public function jsonSerialize(): array + { + return [ + 'entry_id' => $this->entryId, + 'title' => $this->title, + 'description' => $this->description, + 'entry_type' => $this->entryType, + 'category' => $this->category, + 'status' => $this->status, + 'tags' => $this->tags, + 'content' => $this->content, + ]; + } +} diff --git a/src/Research/Domain/Model/EntryType.php b/src/Research/Domain/Model/EntryType.php new file mode 100644 index 00000000..37ebbd0e --- /dev/null +++ b/src/Research/Domain/Model/EntryType.php @@ -0,0 +1,47 @@ +statuses as $status) { + if ($status->value === $value) { + return $status; + } + } + return null; + } + + /** + * Check if status is valid for this entry type + */ + public function hasStatus(string $value): bool + { + return $this->getStatus($value) !== null; + } +} diff --git a/src/Research/Domain/Model/Research.php b/src/Research/Domain/Model/Research.php new file mode 100644 index 00000000..de365b92 --- /dev/null +++ b/src/Research/Domain/Model/Research.php @@ -0,0 +1,91 @@ +id, + name: $name ?? $this->name, + description: $description ?? $this->description, + template: $this->template, + status: $status ?? $this->status, + tags: $tags ?? $this->tags, + entryDirs: $entryDirs ?? $this->entryDirs, + memory: $memory ?? $this->memory, + path: $this->path, + ); + } + + /** + * Create research with added memory entry + */ + public function withAddedMemory(string $memoryEntry): self + { + return new self( + id: $this->id, + name: $this->name, + description: $this->description, + template: $this->template, + status: $this->status, + tags: $this->tags, + entryDirs: $this->entryDirs, + memory: [...$this->memory, $memoryEntry], + path: $this->path, + ); + } + + /** + * Specify data which should be serialized to JSON + */ + public function jsonSerialize(): array + { + return [ + 'research_id' => $this->id, + 'title' => $this->name, + 'status' => $this->status, + 'research_type' => $this->template, + 'metadata' => [ + 'description' => $this->description, + 'tags' => $this->tags, + 'memory' => $this->memory, + ], + ]; + } +} diff --git a/src/Research/Domain/Model/Status.php b/src/Research/Domain/Model/Status.php new file mode 100644 index 00000000..ec993247 --- /dev/null +++ b/src/Research/Domain/Model/Status.php @@ -0,0 +1,16 @@ +categories as $category) { + if ($category->name === $name) { + return $category; + } + } + return null; + } + + /** + * Get entry type by key + */ + public function getEntryType(string $key): ?EntryType + { + foreach ($this->entryTypes as $entryType) { + if ($entryType->key === $key) { + return $entryType; + } + } + return null; + } + + /** + * Check if category exists in template + */ + public function hasCategory(string $name): bool + { + return $this->getCategory($name) !== null; + } + + /** + * Check if entry type exists in template + */ + public function hasEntryType(string $key): bool + { + return $this->getEntryType($key) !== null; + } + + /** + * Validate entry type is allowed in category + */ + public function validateEntryInCategory(string $categoryName, string $entryTypeKey): bool + { + $category = $this->getCategory($categoryName); + if ($category === null) { + return false; + } + + return $category->allowsEntryType($entryTypeKey); + } + + public function jsonSerialize(): array + { + $formatted = [ + 'template_id' => $this->key, + 'name' => $this->name, + 'description' => $this->description, + 'tags' => $this->tags, + ]; + + $formatted['categories'] = \array_map(static fn($category) => [ + 'name' => $category->name, + 'display_name' => $category->displayName, + 'allowed_entry_types' => $category->entryTypes, + ], $this->categories); + + $formatted['entry_types'] = \array_map(static fn($entryType) => [ + 'key' => $entryType->key, + 'display_name' => $entryType->displayName, + 'default_status' => $entryType->defaultStatus, + 'statuses' => \array_map(static fn($status) => $status->value, $entryType->statuses), + ], $this->entryTypes); + + if ($this->prompt !== null) { + $formatted['prompt'] = $this->prompt; + } + + return $formatted; + } +} diff --git a/src/Research/Domain/ValueObject/EntryId.php b/src/Research/Domain/ValueObject/EntryId.php new file mode 100644 index 00000000..efe6fceb --- /dev/null +++ b/src/Research/Domain/ValueObject/EntryId.php @@ -0,0 +1,48 @@ +value))) { + throw new \InvalidArgumentException('Entry ID cannot be empty'); + } + } + + /** + * Generate new UUID-based entry ID + */ + public static function generate(): self + { + return new self(\uniqid('entry_', true)); + } + + /** + * Create from string + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Check equality with another EntryId + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Research/Domain/ValueObject/ResearchId.php b/src/Research/Domain/ValueObject/ResearchId.php new file mode 100644 index 00000000..0530952c --- /dev/null +++ b/src/Research/Domain/ValueObject/ResearchId.php @@ -0,0 +1,36 @@ +value))) { + throw new \InvalidArgumentException('Research ID cannot be empty'); + } + } + + public static function generate(): self + { + return new self(\uniqid('research_', true)); + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Research/Domain/ValueObject/TemplateKey.php b/src/Research/Domain/ValueObject/TemplateKey.php new file mode 100644 index 00000000..77a74dc7 --- /dev/null +++ b/src/Research/Domain/ValueObject/TemplateKey.php @@ -0,0 +1,40 @@ +value))) { + throw new \InvalidArgumentException('Template key cannot be empty'); + } + } + + /** + * Create from string + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Check equality with another TemplateKey + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Research/Exception/EntryNotFoundException.php b/src/Research/Exception/EntryNotFoundException.php new file mode 100644 index 00000000..1ef9dcd7 --- /dev/null +++ b/src/Research/Exception/EntryNotFoundException.php @@ -0,0 +1,10 @@ +errors)) { + $errorMessage .= ': ' . \implode(', ', $this->errors); + } + + parent::__construct($errorMessage, $code, $previous); + } + + /** + * Create from array of errors + * + * @param string[] $errors + */ + public static function fromErrors(array $errors, string $message = 'Validation failed'): self + { + return new self($errors, $message); + } +} diff --git a/src/Research/MCP/DTO/EntryCreateRequest.php b/src/Research/MCP/DTO/EntryCreateRequest.php new file mode 100644 index 00000000..bfc82967 --- /dev/null +++ b/src/Research/MCP/DTO/EntryCreateRequest.php @@ -0,0 +1,170 @@ +title !== null && !empty(\trim($this->title))) { + return \trim($this->title); + } + + // Generate title from first line of content + $lines = \explode("\n", \trim($this->content)); + $firstLine = \trim($lines[0] ?? ''); + + if (empty($firstLine)) { + return 'Untitled Entry'; + } + + // Remove markdown heading markers + $title = \preg_replace('/^#+\s*/', '', $firstLine); + + // Limit title length + if (\strlen((string) $title) > 100) { + $title = \substr((string) $title, 0, 100) . '...'; + } + + return \trim((string) $title) ?: 'Untitled Entry'; + } + + /** + * Get the processed description for entry creation + * This should be called by the service layer to ensure consistent description handling + */ + public function getProcessedDescription(): string + { + if ($this->description !== null && !empty(\trim($this->description))) { + $desc = \trim($this->description); + // Limit to 200 characters + return \strlen($desc) > 200 ? \substr($desc, 0, 197) . '...' : $desc; + } + + // Generate description from content summary + $cleanContent = \strip_tags($this->content); + $lines = \explode("\n", \trim($cleanContent)); + + // Skip title line and get summary from content + $contentLines = \array_filter(\array_slice($lines, 1), static fn($line) => !empty(\trim($line))); + + if (empty($contentLines)) { + return 'Entry content'; + } + + $summary = \implode(' ', \array_slice($contentLines, 0, 3)); + $summary = \preg_replace('/\s+/', ' ', $summary) ?? $summary; + + return \strlen($summary) > 200 ? \substr($summary, 0, 197) . '...' : $summary; + } + + /** + * @deprecated Use getProcessedTitle() instead for consistency + */ + public function getTitle(): string + { + return $this->getProcessedTitle(); + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; + } + + if (empty($this->category)) { + $errors[] = 'Category cannot be empty'; + } + + if (empty($this->entryType)) { + $errors[] = 'Entry type cannot be empty'; + } + + if (empty(\trim($this->content))) { + $errors[] = 'Content cannot be empty'; + } + + // Validate tags if provided + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; + } + } + + // Validate description length if provided + if ($this->description !== null && \strlen(\trim($this->description)) > 200) { + $errors[] = 'Description must not exceed 200 characters'; + } + + return $errors; + } + + /** + * Create a copy with resolved internal keys (to be used by services after template lookup) + */ + public function withResolvedKeys( + string $resolvedCategory, + string $resolvedEntryType, + ?string $resolvedStatus = null, + ): self { + return new self( + researchId: $this->researchId, + category: $resolvedCategory, + entryType: $resolvedEntryType, + content: $this->content, + title: $this->title, + description: $this->description, + status: $resolvedStatus ?? $this->status, + tags: $this->tags, + ); + } +} diff --git a/src/Research/MCP/DTO/EntryFilters.php b/src/Research/MCP/DTO/EntryFilters.php new file mode 100644 index 00000000..543200a1 --- /dev/null +++ b/src/Research/MCP/DTO/EntryFilters.php @@ -0,0 +1,141 @@ +category !== null) { + $filters['category'] = $this->category; + } + + if ($this->entryType !== null) { + $filters['entry_type'] = $this->entryType; + } + + if ($this->status !== null) { + $filters['status'] = $this->status; + } + + if ($this->tags !== null && !empty($this->tags)) { + $filters['tags'] = $this->tags; + } + + if ($this->titleContains !== null) { + $filters['title_contains'] = $this->titleContains; + } + + if ($this->descriptionContains !== null) { + $filters['description_contains'] = $this->descriptionContains; + } + + if ($this->contentContains !== null) { + $filters['content_contains'] = $this->contentContains; + } + + return $filters; + } + + /** + * Check if any filters are applied + */ + public function hasFilters(): bool + { + return $this->category !== null + || $this->entryType !== null + || $this->status !== null + || ($this->tags !== null && !empty($this->tags)) + || $this->titleContains !== null + || $this->descriptionContains !== null + || $this->contentContains !== null; + } + + /** + * Validate the filters + */ + public function validate(): array + { + $errors = []; + + // Validate tags array if provided + if ($this->tags !== null) { + if (empty($this->tags)) { + $errors[] = 'Tags array cannot be empty when provided'; + } else { + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; + } + } + } + } + + // Validate text filters if provided + $textFilters = [ + 'titleContains' => $this->titleContains, + 'descriptionContains' => $this->descriptionContains, + 'contentContains' => $this->contentContains, + ]; + + foreach ($textFilters as $field => $value) { + if ($value !== null && empty(\trim($value))) { + $errors[] = "{$field} filter cannot be empty when provided"; + } + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/EntryUpdateRequest.php b/src/Research/MCP/DTO/EntryUpdateRequest.php new file mode 100644 index 00000000..e79e9807 --- /dev/null +++ b/src/Research/MCP/DTO/EntryUpdateRequest.php @@ -0,0 +1,191 @@ +title !== null + || $this->description !== null + || $this->content !== null + || $this->status !== null + || $this->contentType !== null + || $this->tags !== null + || $this->textReplace !== null; + } + + /** + * Get processed content applying text replacement if needed + * This method should be called by the service layer to ensure proper content handling + */ + public function getProcessedContent(?string $existingContent = null): ?string + { + $baseContent = $this->content ?? $existingContent; + + if ($baseContent === null || $this->textReplace === null) { + return $this->content; + } + + return \str_replace($this->textReplace->find, $this->textReplace->replace, $baseContent); + } + + /** + * Get the final content that should be saved + * Considers both direct content updates and text replacement operations + */ + public function getFinalContent(?string $existingContent = null): ?string + { + // If we have direct content update, use it as base + if ($this->content !== null) { + $baseContent = $this->content; + } else { + $baseContent = $existingContent; + } + + // Apply text replacement if specified + if ($this->textReplace !== null && $baseContent !== null) { + return \str_replace($this->textReplace->find, $this->textReplace->replace, $baseContent); + } + + return $this->content; // Return direct content update or null + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; + } + + if (empty($this->entryId)) { + $errors[] = 'Entry ID cannot be empty'; + } + + if (!$this->hasUpdates()) { + $errors[] = 'At least one field must be provided for update'; + } + + // Validate tags if provided + if ($this->tags !== null) { + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; + } + } + } + + // Validate description length if provided + if ($this->description !== null && \strlen(\trim($this->description)) > 200) { + $errors[] = 'Description must not exceed 200 characters'; + } + + // Validate text replace if provided + if ($this->textReplace !== null) { + $replaceErrors = $this->textReplace->validate(); + $errors = \array_merge($errors, $replaceErrors); + } + + return $errors; + } + + /** + * Create a copy with resolved internal keys (to be used by services after template lookup) + */ + public function withResolvedStatus(?string $resolvedStatus): self + { + return new self( + researchId: $this->researchId, + entryId: $this->entryId, + title: $this->title, + description: $this->description, + content: $this->content, + status: $resolvedStatus, + contentType: $this->contentType, + tags: $this->tags, + textReplace: $this->textReplace, + ); + } +} + +/** + * Nested DTO for text replace operations + */ +final readonly class TextReplaceRequest +{ + public function __construct( + #[Field(description: 'Text to find')] + public string $find, + #[Field(description: 'Replacement text')] + public string $replace, + ) {} + + /** + * Validate text replace request + */ + public function validate(): array + { + $errors = []; + + if (empty($this->find)) { + $errors[] = 'Find text cannot be empty for text replacement'; + } + + // Note: replace text can be empty (for deletion) + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/GetResearchRequest.php b/src/Research/MCP/DTO/GetResearchRequest.php new file mode 100644 index 00000000..801c0fa4 --- /dev/null +++ b/src/Research/MCP/DTO/GetResearchRequest.php @@ -0,0 +1,32 @@ +id)) { + $errors[] = 'Research ID cannot be empty'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ListEntriesRequest.php b/src/Research/MCP/DTO/ListEntriesRequest.php new file mode 100644 index 00000000..79a55043 --- /dev/null +++ b/src/Research/MCP/DTO/ListEntriesRequest.php @@ -0,0 +1,82 @@ +researchId))) { + $errors[] = 'Research ID is required'; + } + + + // Validate pagination parameters + if ($this->limit < 1 || $this->limit > 200) { + $errors[] = 'Limit must be between 1 and 200'; + } + + if ($this->offset < 0) { + $errors[] = 'Offset must be non-negative'; + } + + // Validate filters if provided + if ($this->filters !== null) { + $filterErrors = $this->filters->validate(); + $errors = \array_merge($errors, $filterErrors); + } + + return $errors; + } + + /** + * Check if filters are applied + */ + public function hasFilters(): bool + { + return $this->filters !== null && $this->filters->hasFilters(); + } + + /** + * Get filters as array + */ + public function getFilters(): array + { + return $this->filters?->toArray() ?? []; + } +} diff --git a/src/Research/MCP/DTO/ListResearchesRequest.php b/src/Research/MCP/DTO/ListResearchesRequest.php new file mode 100644 index 00000000..93f2ad4d --- /dev/null +++ b/src/Research/MCP/DTO/ListResearchesRequest.php @@ -0,0 +1,90 @@ +filters === null) { + return []; + } + + return $this->filters->toArray(); + } + + /** + * Get pagination options + */ + public function getPaginationOptions(): array + { + return [ + 'limit' => $this->limit, + 'offset' => $this->offset, + ]; + } + + /** + * Check if any filters are applied + */ + public function hasFilters(): bool + { + return $this->filters !== null && $this->filters->hasFilters(); + } + + /** + * Validate the request + */ + public function validate(): array + { + $errors = []; + + // Validate pagination + if ($this->limit < 1 || $this->limit > 100) { + $errors[] = 'Limit must be between 1 and 100'; + } + + if ($this->offset < 0) { + $errors[] = 'Offset must be non-negative'; + } + + // Validate filters if provided + if ($this->filters !== null) { + $filterErrors = $this->filters->validate(); + $errors = \array_merge($errors, $filterErrors); + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ListTemplatesRequest.php b/src/Research/MCP/DTO/ListTemplatesRequest.php new file mode 100644 index 00000000..b4ce3960 --- /dev/null +++ b/src/Research/MCP/DTO/ListTemplatesRequest.php @@ -0,0 +1,57 @@ +tag !== null || $this->nameContains !== null; + } + + /** + * Validate the request + */ + public function validate(): array + { + $errors = []; + + if ($this->tag !== null && empty(\trim($this->tag))) { + $errors[] = 'Tag filter cannot be empty when provided'; + } + + if ($this->nameContains !== null && empty(\trim($this->nameContains))) { + $errors[] = 'Name filter cannot be empty when provided'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ReadEntryRequest.php b/src/Research/MCP/DTO/ReadEntryRequest.php new file mode 100644 index 00000000..20169717 --- /dev/null +++ b/src/Research/MCP/DTO/ReadEntryRequest.php @@ -0,0 +1,46 @@ +researchId))) { + $errors[] = 'Research ID is required'; + } + + // Validate entry ID + if (empty(\trim($this->entryId))) { + $errors[] = 'Entry ID is required'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ResearchCreateRequest.php b/src/Research/MCP/DTO/ResearchCreateRequest.php new file mode 100644 index 00000000..7265e896 --- /dev/null +++ b/src/Research/MCP/DTO/ResearchCreateRequest.php @@ -0,0 +1,58 @@ +templateId))) { + $errors[] = 'Template ID cannot be empty'; + } + + if (empty(\trim($this->title))) { + $errors[] = 'Research title cannot be empty'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ResearchFilters.php b/src/Research/MCP/DTO/ResearchFilters.php new file mode 100644 index 00000000..32660b45 --- /dev/null +++ b/src/Research/MCP/DTO/ResearchFilters.php @@ -0,0 +1,102 @@ +status !== null) { + $filters['status'] = $this->status; + } + + if ($this->template !== null) { + $filters['template'] = $this->template; + } + + if ($this->tags !== null && !empty($this->tags)) { + $filters['tags'] = $this->tags; + } + + if ($this->nameContains !== null) { + $filters['name_contains'] = $this->nameContains; + } + + return $filters; + } + + /** + * Check if any filters are applied + */ + public function hasFilters(): bool + { + return $this->status !== null + || $this->template !== null + || (!empty($this->tags)) + || $this->nameContains !== null; + } + + /** + * Validate the filters + */ + public function validate(): array + { + $errors = []; + + // Validate tags array if provided + if ($this->tags !== null) { + if (empty($this->tags)) { + $errors[] = 'Tags array cannot be empty when provided'; + } else { + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; + } + } + } + } + + // Validate nameContains if provided + if ($this->nameContains !== null && empty(\trim($this->nameContains))) { + $errors[] = 'Name filter cannot be empty when provided'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/DTO/ResearchMemory.php b/src/Research/MCP/DTO/ResearchMemory.php new file mode 100644 index 00000000..d7d4a9ef --- /dev/null +++ b/src/Research/MCP/DTO/ResearchMemory.php @@ -0,0 +1,12 @@ +title !== null + || $this->description !== null + || $this->status !== null + || $this->tags !== null + || $this->entryDirs !== null + || $this->memory !== null; + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; + } + + if (!$this->hasUpdates()) { + $errors[] = 'At least one field must be provided for update'; + } + + return $errors; + } +} diff --git a/src/Research/MCP/Tools/CreateEntryToolAction.php b/src/Research/MCP/Tools/CreateEntryToolAction.php new file mode 100644 index 00000000..20c55f72 --- /dev/null +++ b/src/Research/MCP/Tools/CreateEntryToolAction.php @@ -0,0 +1,105 @@ +logger->info('Creating new entry', [ + 'research_id' => $request->researchId, + 'category' => $request->category, + 'entry_type' => $request->entryType, + 'has_description' => $request->description !== null, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Verify research exists + $researchId = ResearchId::fromString($request->researchId); + if (!$this->service->exists($researchId)) { + return ToolResult::error("Research '{$request->researchId}' not found"); + } + + // Create entry using domain service + $entry = $this->entryService->createEntry($researchId, $request); + + $this->logger->info('Entry created successfully', [ + 'research_id' => $request->researchId, + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + 'entry_type' => $entry->entryType, + 'category' => $entry->category, + 'status' => $entry->status, + 'content_type' => 'markdown', + 'created_at' => $entry->createdAt->format('c'), + ]; + + return ToolResult::success($response); + + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Research error during entry creation', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error creating entry', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to create entry: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/CreateResearchToolAction.php b/src/Research/MCP/Tools/CreateResearchToolAction.php new file mode 100644 index 00000000..85b6d450 --- /dev/null +++ b/src/Research/MCP/Tools/CreateResearchToolAction.php @@ -0,0 +1,100 @@ +logger->info('Creating new research', [ + 'template_id' => $request->templateId, + 'title' => $request->title, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Verify template exists + $templateKey = TemplateKey::fromString($request->templateId); + if (!$this->templateService->templateExists($templateKey)) { + return ToolResult::error("Template '{$request->templateId}' not found"); + } + + // Create research using domain service + $research = $this->service->create($request); + + $this->logger->info('Research created successfully', [ + 'research_id' => $research->id, + 'template' => $research->template, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'research_id' => $research->id, + 'title' => $research->name, + 'template_id' => $research->template, + 'status' => $research->status, + 'created_at' => (new \DateTime())->format('c'), + ]; + + return ToolResult::success($response); + + } catch (TemplateNotFoundException $e) { + $this->logger->error('Template not found', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error during research creation', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error creating research', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to create research: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/GetResearchToolAction.php b/src/Research/MCP/Tools/GetResearchToolAction.php new file mode 100644 index 00000000..e80bbdfd --- /dev/null +++ b/src/Research/MCP/Tools/GetResearchToolAction.php @@ -0,0 +1,105 @@ +logger->info('Getting research', [ + 'research_id' => $request->id, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Get research + $researchId = ResearchId::fromString($request->id); + $research = $this->service->get($researchId); + + if ($research === null) { + return ToolResult::error("Research '{$request->id}' not found"); + } + + $this->logger->info('Research retrieved successfully', [ + 'research_id' => $research->id, + 'template' => $research->template, + ]); + + $template = $this->templateService->getTemplate(TemplateKey::fromString($research->template)); + + // Format research for response + return ToolResult::success([ + 'success' => true, + 'research' => [ + 'id' => $research->id, + 'title' => $research->name, + 'status' => $research->status, + 'metadata' => [ + 'description' => $research->description, + 'tags' => $research->tags, + 'memory' => $research->memory, + ], + ], + 'template' => $template, + ]); + + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error getting research', [ + 'research_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error getting research', [ + 'research_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to get research: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/ListEntriesToolAction.php b/src/Research/MCP/Tools/ListEntriesToolAction.php new file mode 100644 index 00000000..3fbe6c3e --- /dev/null +++ b/src/Research/MCP/Tools/ListEntriesToolAction.php @@ -0,0 +1,125 @@ +logger->info('Listing entries', [ + 'research_id' => $request->researchId, + 'has_filters' => $request->hasFilters(), + 'filters' => $request->getFilters(), + 'limit' => $request->limit, + 'offset' => $request->offset, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Verify research exists + $researchId = ResearchId::fromString($request->researchId); + if (!$this->service->exists($researchId)) { + return ToolResult::error("Research '{$request->researchId}' not found"); + } + + // Get entries with filters + $allEntries = $this->entryService->findAll($researchId, $request->getFilters()); + + // Apply pagination + $paginatedEntries = \array_slice( + $allEntries, + $request->offset, + $request->limit, + ); + + // Format entries for response (using JsonSerializable) + $entryData = \array_map(static function (Entry $entry) { + $data = $entry->jsonSerialize(); + unset($data['content']); + + return $data; + + }, $paginatedEntries); + + $response = [ + 'success' => true, + 'entries' => $entryData, + 'count' => \count($paginatedEntries), + 'total_count' => \count($allEntries), + 'pagination' => [ + 'limit' => $request->limit, + 'offset' => $request->offset, + 'has_more' => ($request->offset + \count($paginatedEntries)) < \count($allEntries), + ], + 'filters_applied' => $request->hasFilters() ? $request->getFilters() : null, + ]; + + $this->logger->info('Entries listed successfully', [ + 'research_id' => $request->researchId, + 'returned_count' => \count($paginatedEntries), + 'total_available' => \count($allEntries), + 'filters_applied' => $request->hasFilters(), + ]); + + return ToolResult::success($response); + + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error listing research entries', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing entries', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to list entries: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/ListResearchesToolAction.php b/src/Research/MCP/Tools/ListResearchesToolAction.php new file mode 100644 index 00000000..cfb72c87 --- /dev/null +++ b/src/Research/MCP/Tools/ListResearchesToolAction.php @@ -0,0 +1,92 @@ +logger->info('Listing researches', [ + 'has_filters' => $request->hasFilters(), + 'filters' => $request->getFilters(), + 'limit' => $request->limit, + 'offset' => $request->offset, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Get researches with filters + $researches = $this->service->findAll($request->getFilters()); + + // Apply pagination + $paginatedResearches = \array_slice( + $researches, + $request->offset, + $request->limit, + ); + + $response = [ + 'success' => true, + 'researches' => $paginatedResearches, + 'count' => \count($paginatedResearches), + 'total_count' => \count($researches), + 'pagination' => [ + 'limit' => $request->limit, + 'offset' => $request->offset, + 'has_more' => ($request->offset + \count($paginatedResearches)) < \count($researches), + ], + ]; + + $this->logger->info('Researches listed successfully', [ + 'returned_count' => \count($paginatedResearches), + 'total_available' => \count($researches), + 'filters_applied' => $request->hasFilters(), + ]); + + return ToolResult::success($response); + + } catch (ResearchException $e) { + $this->logger->error('Error listing researches', [ + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing researches', [ + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to list researches: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/ListTemplatesToolAction.php b/src/Research/MCP/Tools/ListTemplatesToolAction.php new file mode 100644 index 00000000..1a0c3484 --- /dev/null +++ b/src/Research/MCP/Tools/ListTemplatesToolAction.php @@ -0,0 +1,111 @@ +logger->info('Listing templates', [ + 'has_filters' => $request->hasFilters(), + 'tag_filter' => $request->tag, + 'name_filter' => $request->nameContains, + 'include_details' => $request->includeDetails, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Get all templates + $allTemplates = $this->templateService->findAll(); + + // Apply filters + $filteredTemplates = $this->applyFilters($allTemplates, $request); + + $response = [ + 'success' => true, + 'templates' => $filteredTemplates, + ]; + + $this->logger->info('Templates listed successfully', [ + 'total_available' => \count($allTemplates), + 'filters_applied' => $request->hasFilters(), + ]); + + return ToolResult::success($response); + + } catch (ResearchException $e) { + $this->logger->error('Error listing research templates', [ + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing templates', [ + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to list templates: ' . $e->getMessage()); + } + } + + /** + * Apply filters to templates array + */ + private function applyFilters(array $templates, ListTemplatesRequest $request): array + { + if (!$request->hasFilters()) { + return $templates; + } + + return \array_filter($templates, static function ($template) use ($request) { + // Filter by tag + if ($request->tag !== null) { + if (!\in_array($request->tag, $template->tags, true)) { + return false; + } + } + + // Filter by name (partial match, case insensitive) + if ($request->nameContains !== null) { + $searchTerm = \strtolower(\trim($request->nameContains)); + $templateName = \strtolower((string) $template->name); + + if (!\str_contains($templateName, $searchTerm)) { + return false; + } + } + + return true; + }); + } +} diff --git a/src/Research/MCP/Tools/ReadEntryToolAction.php b/src/Research/MCP/Tools/ReadEntryToolAction.php new file mode 100644 index 00000000..c83f2603 --- /dev/null +++ b/src/Research/MCP/Tools/ReadEntryToolAction.php @@ -0,0 +1,109 @@ +logger->info('Reading entry', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + // Verify research exists + $researchId = ResearchId::fromString($request->researchId); + if (!$this->service->exists($researchId)) { + return ToolResult::error("Research '{$request->researchId}' not found"); + } + + // Get the entry + $entryId = EntryId::fromString($request->entryId); + $entry = $this->entryService->getEntry($researchId, $entryId); + + if ($entry === null) { + return ToolResult::error("Entry '{$request->entryId}' not found in research '{$request->researchId}'"); + } + + $this->logger->info('Entry read successfully', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'title' => $entry->title, + ]); + + return ToolResult::success($entry); + + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (EntryNotFoundException $e) { + $this->logger->error('Entry not found', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error reading research entry', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error reading entry', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to read entry: ' . $e->getMessage()); + } + } +} diff --git a/src/Research/MCP/Tools/UpdateEntryToolAction.php b/src/Research/MCP/Tools/UpdateEntryToolAction.php new file mode 100644 index 00000000..f6d14bbb --- /dev/null +++ b/src/Research/MCP/Tools/UpdateEntryToolAction.php @@ -0,0 +1,150 @@ +logger->info('Updating entry', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'has_title' => $request->title !== null, + 'has_description' => $request->description !== null, + 'has_content' => $request->content !== null, + 'has_status' => $request->status !== null, + 'has_tags' => $request->tags !== null, + 'has_text_replace' => $request->textReplace !== null, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + $researchId = ResearchId::fromString($request->researchId); + if (!$this->service->exists($researchId)) { + return ToolResult::error("Research '{$request->researchId}' not found"); + } + + // Verify entry exists + $entryId = EntryId::fromString($request->entryId); + if (!$this->entryService->entryExists($researchId, $entryId)) { + return ToolResult::error("Entry '{$request->entryId}' not found in research '{$request->researchId}'"); + } + + // Update entry using domain service + $updatedEntry = $this->entryService->updateEntry($researchId, $entryId, $request); + + $this->logger->info('Entry updated successfully', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'title' => $updatedEntry->title, + ]); + + return ToolResult::success([ + 'success' => true, + ]); + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (EntryNotFoundException $e) { + $this->logger->error('Entry not found', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error during research entry update', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error updating entry', [ + 'research_id' => $request->researchId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to update entry: ' . $e->getMessage()); + } + } + + /** + * Get list of changes applied based on the request + */ + private function getAppliedChanges(EntryUpdateRequest $request): array + { + $changes = []; + + if ($request->title !== null) { + $changes[] = 'title'; + } + + if ($request->description !== null) { + $changes[] = 'description'; + } + + if ($request->content !== null) { + $changes[] = 'content'; + } + + if ($request->status !== null) { + $changes[] = 'status'; + } + + if ($request->tags !== null) { + $changes[] = 'tags'; + } + + if ($request->textReplace !== null) { + $changes[] = 'text_replacement'; + } + + return $changes; + } +} diff --git a/src/Research/MCP/Tools/UpdateResearchToolAction.php b/src/Research/MCP/Tools/UpdateResearchToolAction.php new file mode 100644 index 00000000..940e054f --- /dev/null +++ b/src/Research/MCP/Tools/UpdateResearchToolAction.php @@ -0,0 +1,128 @@ +logger->info('Updating research', [ + 'research_id' => $request->researchId, + 'has_title' => $request->title !== null, + 'has_description' => $request->description !== null, + 'has_status' => $request->status !== null, + 'has_tags' => $request->tags !== null, + 'has_entry_dirs' => $request->entryDirs !== null, + 'has_memory' => $request->memory !== null, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return ToolResult::validationError($validationErrors); + } + + $researchId = ResearchId::fromString($request->researchId); + if (!$this->service->exists($researchId)) { + return ToolResult::error("Research '{$request->researchId}' not found"); + } + + $research = $this->service->update($researchId, $request); + + $this->logger->info('Research updated successfully', [ + 'research_id' => $request->researchId, + 'title' => $research->name, + 'status' => $research->status, + ]); + + return ToolResult::success([ + 'success' => true, + ]); + + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (ResearchException $e) { + $this->logger->error('Error during research update', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error($e->getMessage()); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error updating research', [ + 'research_id' => $request->researchId, + 'error' => $e->getMessage(), + ]); + + return ToolResult::error('Failed to update research: ' . $e->getMessage()); + } + } + + /** + * Get list of changes applied based on the request + */ + private function getAppliedChanges(ResearchUpdateRequest $request): array + { + $changes = []; + + if ($request->title !== null) { + $changes[] = 'title'; + } + + if ($request->description !== null) { + $changes[] = 'description'; + } + + if ($request->status !== null) { + $changes[] = 'status'; + } + + if ($request->tags !== null) { + $changes[] = 'tags'; + } + + if ($request->entryDirs !== null) { + $changes[] = 'entry_directories'; + } + + if ($request->memory !== null) { + $changes[] = 'memory'; + } + + return $changes; + } +} diff --git a/src/Research/Repository/EntryRepositoryInterface.php b/src/Research/Repository/EntryRepositoryInterface.php new file mode 100644 index 00000000..327ab4be --- /dev/null +++ b/src/Research/Repository/EntryRepositoryInterface.php @@ -0,0 +1,43 @@ + ResearchConfig::class, + TemplateServiceInterface::class => TemplateService::class, + ResearchServiceInterface::class => ResearchService::class, + EntryServiceInterface::class => EntryService::class, + ]; + } + + public function init(ConsoleBootloader $console, EnvironmentInterface $env): void + { + $console->addCommand( + ResearchListCommand::class, + TemplateListCommand::class, + ResearchInfoCommand::class, + ); + + // Initialize configuration from environment variables + $this->config->setDefaults( + ResearchConfig::CONFIG, + [ + 'templates_path' => $env->get('RESEARCH_TEMPLATES_PATH', '.templates'), + 'researches_path' => $env->get('RESEARCH_RESEARCHES_PATH', '.researches'), + 'storage_driver' => $env->get('RESEARCH_STORAGE_DRIVER', 'markdown'), + 'default_entry_status' => $env->get('RESEARCH_DEFAULT_STATUS', 'draft'), + 'env_config' => [], + ], + ); + } +} diff --git a/src/Research/Service/EntryService.php b/src/Research/Service/EntryService.php new file mode 100644 index 00000000..1b766987 --- /dev/null +++ b/src/Research/Service/EntryService.php @@ -0,0 +1,395 @@ +logger?->info('Creating new entry', [ + 'research_id' => $researchId->value, + 'category' => $request->category, + 'entry_type' => $request->entryType, + ]); + + // Verify research exists + $research = $this->researches->findById($researchId); + if ($research === null) { + $error = "Research '{$researchId->value}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + ]); + throw new ResearchNotFoundException($error); + } + + // Get and validate template + $templateKey = TemplateKey::fromString($research->template); + $template = $this->templateService->getTemplate($templateKey); + if ($template === null) { + $error = "Template '{$research->template}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'template' => $research->template, + ]); + throw new TemplateNotFoundException($error); + } + + // Resolve display names to internal keys + $resolvedCategory = $this->templateService->resolveCategoryKey($template, $request->category); + if ($resolvedCategory === null) { + $error = "Category '{$request->category}' not found in template '{$research->template}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'category' => $request->category, + 'template' => $research->template, + ]); + throw new ResearchException($error); + } + + $resolvedEntryType = $this->templateService->resolveEntryTypeKey($template, $request->entryType); + if ($resolvedEntryType === null) { + $error = "Entry type '{$request->entryType}' not found in template '{$research->template}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'entry_type' => $request->entryType, + 'template' => $research->template, + ]); + throw new ResearchException($error); + } + + // Validate entry type is allowed in category + if (!$template->validateEntryInCategory($resolvedCategory, $resolvedEntryType)) { + $error = "Entry type '{$request->entryType}' is not allowed in category '{$request->category}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'category' => $request->category, + 'entry_type' => $request->entryType, + ]); + throw new ResearchException($error); + } + + // Resolve status if provided, otherwise use entry type default + if ($request->status !== null) { + $resolvedStatus = $this->templateService->resolveStatusValue($template, $resolvedEntryType, $request->status); + if ($resolvedStatus === null) { + $error = "Status '{$request->status}' not found for entry type '{$request->entryType}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'status' => $request->status, + 'entry_type' => $request->entryType, + ]); + throw new ResearchException($error); + } + } else { + // Use default status from entry type + $entryType = $template->getEntryType($resolvedEntryType); + $resolvedStatus = $entryType?->defaultStatus; + } + + try { + // Create request with resolved keys + $resolvedRequest = $request->withResolvedKeys( + $resolvedCategory, + $resolvedEntryType, + $resolvedStatus, + ); + + // Use storage driver to create the entry + $entry = $this->storageDriver->createEntry($researchId, $resolvedRequest); + + // Save entry to repository + $this->entryRepository->save($researchId, $entry); + + $this->logger?->info('Entry created successfully', [ + 'research_id' => $researchId->value, + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + 'category' => $entry->category, + 'entry_type' => $entry->entryType, + ]); + + return $entry; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to create entry', [ + 'research_id' => $researchId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to create entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function updateEntry(ResearchId $researchId, EntryId $entryId, EntryUpdateRequest $request): Entry + { + $this->logger?->info('Updating entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'has_title' => $request->title !== null, + 'has_content' => $request->content !== null, + 'has_status' => $request->status !== null, + ]); + + // Verify research exists + $research = $this->researches->findById($researchId); + if ($research === null) { + $error = "Research '{$researchId->value}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + ]); + throw new ResearchNotFoundException($error); + } + + // Verify entry exists + $existingEntry = $this->entryRepository->findById($researchId, $entryId); + if ($existingEntry === null) { + $error = "Entry '{$entryId->value}' not found in research '{$researchId->value}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + throw new EntryNotFoundException($error); + } + + // Resolve status if provided + $resolvedStatus = $request->status; + if ($request->status !== null) { + $templateKey = TemplateKey::fromString($research->template); + $template = $this->templateService->getTemplate($templateKey); + + if ($template !== null) { + $resolvedStatusValue = $this->templateService->resolveStatusValue( + $template, + $existingEntry->entryType, + $request->status, + ); + + if ($resolvedStatusValue === null) { + $error = "Status '{$request->status}' not found for entry type '{$existingEntry->entryType}'"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'status' => $request->status, + 'entry_type' => $existingEntry->entryType, + ]); + throw new ResearchException($error); + } + + $resolvedStatus = $resolvedStatusValue; + } + } + + try { + // Create request with resolved status + $resolvedRequest = $request->withResolvedStatus($resolvedStatus); + + // Use storage driver to update the entry + $updatedEntry = $this->storageDriver->updateEntry($researchId, $entryId, $resolvedRequest); + + // Save updated entry to repository + $this->entryRepository->save($researchId, $updatedEntry); + + $this->logger?->info('Entry updated successfully', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'title' => $updatedEntry->title, + ]); + + return $updatedEntry; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to update entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to update entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function entryExists(ResearchId $researchId, EntryId $entryId): bool + { + $exists = $this->entryRepository->exists($researchId, $entryId); + + $this->logger?->debug('Checking entry existence', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'exists' => $exists, + ]); + + return $exists; + } + + #[\Override] + public function getEntry(ResearchId $researchId, EntryId $entryId): ?Entry + { + $this->logger?->info('Retrieving single entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + + // Verify research exists + if (!$this->researches->exists($researchId)) { + $error = "Research '{$researchId->value}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + ]); + throw new ResearchNotFoundException($error); + } + + try { + $entry = $this->entryRepository->findById($researchId, $entryId); + + $this->logger?->info('Entry retrieval completed', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'found' => $entry !== null, + ]); + + return $entry; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to retrieve entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to retrieve entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function findAll(ResearchId $researchId, array $filters = []): array + { + $this->logger?->info('Retrieving entries', [ + 'research_id' => $researchId->value, + 'filters' => $filters, + ]); + + // Verify research exists + if (!$this->researches->exists($researchId)) { + $this->logger?->warning('Attempted to get entries for non-existent research', [ + 'research_id' => $researchId->value, + ]); + return []; + } + + try { + $entries = $this->entryRepository->findByResearch($researchId, $filters); + + $this->logger?->info('Entries retrieved successfully', [ + 'research_id' => $researchId->value, + 'count' => \count($entries), + 'filters_applied' => !empty($filters), + ]); + + return $entries; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to retrieve entries', [ + 'research_id' => $researchId->value, + 'filters' => $filters, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to retrieve entries: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function deleteEntry(ResearchId $researchId, EntryId $entryId): bool + { + $this->logger?->info('Deleting entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + + // Verify entry exists + if (!$this->entryRepository->exists($researchId, $entryId)) { + $this->logger?->warning('Attempted to delete non-existent entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + return false; + } + + try { + // Use storage driver to delete the entry + $deleted = $this->storageDriver->deleteEntry($researchId, $entryId); + + if ($deleted) { + // Remove from repository + $this->entryRepository->delete($researchId, $entryId); + + $this->logger?->info('Entry deleted successfully', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + } else { + $this->logger?->warning('Storage driver failed to delete entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + } + + return $deleted; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to delete entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to delete entry: {$e->getMessage()}", + previous: $e, + ); + } + } +} diff --git a/src/Research/Service/EntryServiceInterface.php b/src/Research/Service/EntryServiceInterface.php new file mode 100644 index 00000000..ffd524db --- /dev/null +++ b/src/Research/Service/EntryServiceInterface.php @@ -0,0 +1,70 @@ +logger?->info('Creating new research', [ + 'template_id' => $request->templateId, + 'title' => $request->title, + ]); + + // Validate template exists + $templateKey = TemplateKey::fromString($request->templateId); + if (!$this->templateService->templateExists($templateKey)) { + $error = "Template '{$request->templateId}' not found"; + $this->logger?->error($error, [ + 'template_id' => $request->templateId, + ]); + throw new TemplateNotFoundException($error); + } + + try { + // Use storage driver to create the research + $research = $this->storageDriver->createResearch($request); + + // Save research to repository + $this->researches->save($research); + + $this->logger?->info('Research created successfully', [ + 'research_id' => $research->id, + 'template' => $research->template, + 'name' => $research->name, + ]); + + return $research; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to create research', [ + 'template_id' => $request->templateId, + 'title' => $request->title, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to create research: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function update(ResearchId $researchId, ResearchUpdateRequest $request): Research + { + $this->logger?->info('Updating research', [ + 'research_id' => $researchId->value, + 'updates' => [ + 'title' => $request->title !== null, + 'description' => $request->description !== null, + 'status' => $request->status !== null, + 'tags' => $request->tags !== null, + 'entry_dirs' => $request->entryDirs !== null, + 'memory' => $request->memory !== null, + ], + ]); + + // Verify research exists + if (!$this->researches->exists($researchId)) { + $error = "Research '{$researchId->value}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + ]); + throw new ResearchNotFoundException($error); + } + + try { + // Use storage driver to update the research + $updatedResearch = $this->storageDriver->updateResearch($researchId, $request); + + // Save updated research to repository + $this->researches->save($updatedResearch); + + $this->logger?->info('Research updated successfully', [ + 'research_id' => $researchId->value, + 'name' => $updatedResearch->name, + 'status' => $updatedResearch->status, + ]); + + return $updatedResearch; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to update research', [ + 'research_id' => $researchId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to update research: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function exists(ResearchId $researchId): bool + { + $exists = $this->researches->exists($researchId); + + $this->logger?->debug('Checking research existence', [ + 'research_id' => $researchId->value, + 'exists' => $exists, + ]); + + return $exists; + } + + #[\Override] + public function get(ResearchId $researchId): ?Research + { + $this->logger?->debug('Retrieving research', [ + 'research_id' => $researchId->value, + ]); + + $research = $this->researches->findById($researchId); + + if ($research === null) { + $this->logger?->warning('Research not found', [ + 'research_id' => $researchId->value, + ]); + } else { + $this->logger?->debug('Research retrieved successfully', [ + 'research_id' => $research->id, + 'name' => $research->name, + 'template' => $research->template, + ]); + } + + return $research; + } + + #[\Override] + public function findAll(array $filters = []): array + { + $this->logger?->info('Listing researches', [ + 'filters' => $filters, + ]); + + try { + $researches = $this->researches->findAll($filters); + + $this->logger?->info('Researches retrieved successfully', [ + 'count' => \count($researches), + 'filters_applied' => !empty($filters), + ]); + + return $researches; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to list researches', [ + 'filters' => $filters, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to list researches: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function delete(ResearchId $researchId): bool + { + $this->logger?->info('Deleting research', [ + 'research_id' => $researchId->value, + ]); + + // Verify research exists + if (!$this->researches->exists($researchId)) { + $this->logger?->warning('Attempted to delete non-existent research', [ + 'research_id' => $researchId->value, + ]); + return false; + } + + try { + // Use storage driver to delete the research and its entries + $deleted = $this->storageDriver->deleteResearch($researchId); + + if ($deleted) { + // Remove from repository + $this->researches->delete($researchId); + + $this->logger?->info('Research deleted successfully', [ + 'research_id' => $researchId->value, + ]); + } else { + $this->logger?->warning('Storage driver failed to delete research', [ + 'research_id' => $researchId->value, + ]); + } + + return $deleted; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to delete research', [ + 'research_id' => $researchId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to delete research: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function addMemory(ResearchId $researchId, string $memory): Research + { + $this->logger?->info('Adding memory to research', [ + 'research_id' => $researchId->value, + 'memory_length' => \strlen($memory), + ]); + + // Verify research exists + $research = $this->researches->findById($researchId); + if ($research === null) { + $error = "Research '{$researchId->value}' not found"; + $this->logger?->error($error, [ + 'research_id' => $researchId->value, + ]); + throw new ResearchNotFoundException($error); + } + + try { + // Create updated research with added memory + $updatedResearch = $research->withAddedMemory($memory); + + // Save updated research to repository + $this->researches->save($updatedResearch); + + $this->logger?->info('Memory added to research successfully', [ + 'research_id' => $researchId->value, + 'memory_count' => \count($updatedResearch->memory), + 'name' => $updatedResearch->name, + ]); + + return $updatedResearch; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to add memory to research', [ + 'research_id' => $researchId->value, + 'error' => $e->getMessage(), + ]); + + throw new ResearchException( + "Failed to add memory to research: {$e->getMessage()}", + previous: $e, + ); + } + } +} diff --git a/src/Research/Service/ResearchServiceInterface.php b/src/Research/Service/ResearchServiceInterface.php new file mode 100644 index 00000000..44804b61 --- /dev/null +++ b/src/Research/Service/ResearchServiceInterface.php @@ -0,0 +1,64 @@ +templateRepository->findAll(); + } + + #[\Override] + public function getTemplate(TemplateKey $key): ?Template + { + return $this->templateRepository->findByKey($key); + } + + #[\Override] + public function templateExists(TemplateKey $key): bool + { + return $this->templateRepository->exists($key); + } + + #[\Override] + public function resolveCategoryKey(Template $template, string $displayNameOrKey): ?string + { + foreach ($template->categories as $category) { + if ($category->name === $displayNameOrKey || $category->displayName === $displayNameOrKey) { + $this->logger?->debug('Resolved category key', [ + 'input' => $displayNameOrKey, + 'resolved' => $category->name, + 'template' => $template->key, + ]); + return $category->name; + } + } + + $this->logger?->warning('Could not resolve category key', [ + 'input' => $displayNameOrKey, + 'template' => $template->key, + 'available_categories' => \array_map(static fn($cat) => [ + 'name' => $cat->name, + 'display_name' => $cat->displayName, + ], $template->categories), + ]); + + return null; + } + + #[\Override] + public function resolveEntryTypeKey(Template $template, string $displayNameOrKey): ?string + { + foreach ($template->entryTypes as $entryType) { + if ($entryType->key === $displayNameOrKey || $entryType->displayName === $displayNameOrKey) { + $this->logger?->debug('Resolved entry type key', [ + 'input' => $displayNameOrKey, + 'resolved' => $entryType->key, + 'template' => $template->key, + ]); + return $entryType->key; + } + } + + $this->logger?->warning('Could not resolve entry type key', [ + 'input' => $displayNameOrKey, + 'template' => $template->key, + 'available_entry_types' => \array_map(static fn($type) => [ + 'key' => $type->key, + 'display_name' => $type->displayName, + ], $template->entryTypes), + ]); + + return null; + } + + #[\Override] + public function resolveStatusValue(Template $template, string $entryTypeKey, string $displayNameOrValue): ?string + { + $entryType = $this->getEntryTypeByKey($template, $entryTypeKey); + if ($entryType === null) { + $this->logger?->error('Entry type not found for status resolution', [ + 'entry_type_key' => $entryTypeKey, + 'template' => $template->key, + ]); + return null; + } + + foreach ($entryType->statuses as $status) { + if ($status->value === $displayNameOrValue || $status->displayName === $displayNameOrValue) { + $this->logger?->debug('Resolved status value', [ + 'input' => $displayNameOrValue, + 'resolved' => $status->value, + 'entry_type' => $entryTypeKey, + 'template' => $template->key, + ]); + return $status->value; + } + } + + $this->logger?->warning('Could not resolve status value', [ + 'input' => $displayNameOrValue, + 'entry_type' => $entryTypeKey, + 'template' => $template->key, + 'available_statuses' => \array_map(static fn($status) => [ + 'value' => $status->value, + 'display_name' => $status->displayName, + ], $entryType->statuses), + ]); + + return null; + } + + #[\Override] + public function getAvailableStatuses(Template $template, string $entryTypeKey): array + { + $entryType = $this->getEntryTypeByKey($template, $entryTypeKey); + if ($entryType === null) { + return []; + } + + return \array_map(static fn($status) => $status->value, $entryType->statuses); + } + + #[\Override] + public function refreshTemplates(): void + { + $this->templateRepository->refresh(); + $this->logger?->info('Templates refreshed from storage'); + } + + /** + * Get entry type from template by key + */ + private function getEntryTypeByKey(Template $template, string $key): ?\Butschster\ContextGenerator\Research\Domain\Model\EntryType + { + foreach ($template->entryTypes as $entryType) { + if ($entryType->key === $key) { + return $entryType; + } + } + return null; + } +} diff --git a/src/Research/Service/TemplateServiceInterface.php b/src/Research/Service/TemplateServiceInterface.php new file mode 100644 index 00000000..994aaf63 --- /dev/null +++ b/src/Research/Service/TemplateServiceInterface.php @@ -0,0 +1,70 @@ +files->isDirectory($path)) { + return []; + } + + $researches = []; + + try { + $finder = new Finder(); + $finder + ->directories() + ->in($path) + ->depth(0) // Only immediate subdirectories + ->filter(static function (\SplFileInfo $file): bool { + // Check if this directory contains a research.yaml file + $configPath = $file->getRealPath() . '/research.yaml'; + return \file_exists($configPath); + }); + + foreach ($finder as $directory) { + $researches[] = $directory->getRealPath(); + } + } catch (\Throwable $e) { + $this->reporter->report($e); + // Handle cases where directory is not accessible + // Return empty array - calling code can handle this gracefully + } + + return $researches; + } + + /** + * Scan research directory for entry files + * + * @param string $path Path to research directory + * @return array Array of entry file paths + */ + public function scanEntries(string $path): array + { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { + return []; + } + + $entryFiles = []; + + try { + $finder = new Finder(); + $finder + ->files() + ->in($path) + ->name('*.md'); + + foreach ($finder as $file) { + $entryFiles[] = $file->getRealPath(); + } + } catch (\Throwable) { + // Handle cases where directories are not accessible + // Return empty array - calling code can handle this gracefully + } + + return $entryFiles; + } + + /** + * Get all subdirectories in research that could contain entries + */ + public function getEntryDirectories(string $path): array + { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { + return []; + } + + $directories = []; + + try { + $finder = new Finder(); + $finder + ->directories() + ->in($path) + ->depth(0) // Only immediate subdirectories + ->filter(static function (\SplFileInfo $file): bool { + // Skip special directories + $name = $file->getFilename(); + return !\in_array($name, ['.research', 'resources', '.git', '.idea', 'node_modules'], true); + }); + + foreach ($finder as $directory) { + $directories[] = $directory->getFilename(); // Return relative directory name + } + } catch (\Throwable) { + // Handle cases where directory is not accessible + // Return empty array + } + + return $directories; + } +} diff --git a/src/Research/Storage/FileStorage/FileEntryRepository.php b/src/Research/Storage/FileStorage/FileEntryRepository.php new file mode 100644 index 00000000..4820b8f3 --- /dev/null +++ b/src/Research/Storage/FileStorage/FileEntryRepository.php @@ -0,0 +1,309 @@ +getResearchPath($researchId->value); + + if (!$this->files->exists($researchPath)) { + return []; + } + + $entries = []; + + try { + $entryFiles = $this->directoryScanner->scanEntries($researchPath); + + foreach ($entryFiles as $filePath) { + try { + $entry = $this->loadEntryFromFile($filePath); + if ($entry !== null && $this->matchesFilters($entry, $filters)) { + $entries[] = $entry; + } + } catch (\Throwable $e) { + $this->logError('Failed to load entry', ['file' => $filePath], $e); + } + } + + $this->logOperation('Loaded entries for research', [ + 'research_id' => $researchId->value, + 'count' => \count($entries), + 'total_scanned' => \count($entryFiles), + ]); + } catch (\Throwable $e) { + $this->logError('Failed to scan entries for research', ['research_id' => $researchId->value], $e); + } + + return $entries; + } + + #[\Override] + public function findById(ResearchId $researchId, EntryId $entryId): ?Entry + { + $researchPath = $this->getResearchPath($researchId->value); + $entryFile = $this->findEntryFile($researchPath, $entryId->value); + + if ($entryFile === null) { + return null; + } + + try { + return $this->loadEntryFromFile($entryFile); + } catch (\Throwable $e) { + $this->logError('Failed to load entry by ID', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ], $e); + return null; + } + } + + #[\Override] + public function save(ResearchId $researchId, Entry $entry): void + { + $researchPath = $this->getResearchPath($researchId->value); + + if (!$this->files->exists($researchPath)) { + throw new \RuntimeException("Research directory not found: {$researchPath}"); + } + + try { + $this->saveEntryToFile($researchPath, $entry); + + $this->logOperation('Saved entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + ]); + } catch (\Throwable $e) { + $this->logError('Failed to save entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entry->entryId, + ], $e); + throw $e; + } + } + + #[\Override] + public function delete(ResearchId $researchId, EntryId $entryId): bool + { + $researchPath = $this->getResearchPath($researchId->value); + $entryFile = $this->findEntryFile($researchPath, $entryId->value); + + if ($entryFile === null) { + return false; + } + + try { + $deleted = $this->files->delete($entryFile); + + if ($deleted) { + $this->logOperation('Deleted entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + 'file' => $entryFile, + ]); + } + + return $deleted; + } catch (\Throwable $e) { + $this->logError('Failed to delete entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ], $e); + return false; + } + } + + #[\Override] + public function exists(ResearchId $researchId, EntryId $entryId): bool + { + $researchPath = $this->getResearchPath($researchId->value); + return $this->findEntryFile($researchPath, $entryId->value) !== null; + } + + /** + * Get research directory path from ID + */ + private function getResearchPath(string $researchId): string + { + $basePath = $this->getBasePath(); + return $this->files->normalizePath($basePath . '/' . $researchId); + } + + /** + * Find entry file by entry ID + */ + private function findEntryFile(string $researchPath, string $entryId): ?string + { + $entryFiles = $this->directoryScanner->scanEntries($researchPath); + + foreach ($entryFiles as $filePath) { + try { + $frontmatter = $this->frontmatterParser->extractFrontmatter( + $this->files->read($filePath), + ); + + if (isset($frontmatter['entry_id']) && $frontmatter['entry_id'] === $entryId) { + return $filePath; + } + } catch (\Throwable $e) { + $this->logError('Failed to check entry file', ['file' => $filePath], $e); + } + } + + return null; + } + + /** + * Load entry from markdown file + */ + private function loadEntryFromFile(string $filePath): ?Entry + { + try { + $parsed = $this->readMarkdownFile($filePath); + $frontmatter = $parsed['frontmatter']; + $content = $parsed['content']; + + // Validate required frontmatter fields + $requiredFields = ['entry_id', 'title', 'entry_type', 'category', 'status']; + foreach ($requiredFields as $field) { + if (!isset($frontmatter[$field])) { + throw new \RuntimeException("Missing required frontmatter field: {$field}"); + } + } + + // Parse dates + $createdAt = isset($frontmatter['created_at']) + ? new \DateTime($frontmatter['created_at']) + : new \DateTime(); + + $updatedAt = isset($frontmatter['updated_at']) + ? new \DateTime($frontmatter['updated_at']) + : new \DateTime(); + + return new Entry( + entryId: $frontmatter['entry_id'], + title: $frontmatter['title'], + description: $frontmatter['description'] ?? '', // Default to empty if not present in file + entryType: $frontmatter['entry_type'], + category: $frontmatter['category'], + status: $frontmatter['status'], + createdAt: $createdAt, + updatedAt: $updatedAt, + tags: $frontmatter['tags'] ?? [], + content: $content, + filePath: $filePath, + ); + } catch (\Throwable $e) { + $this->logError("Failed to load entry from file: {$filePath}", [], $e); + return null; + } + } + + /** + * Save entry to markdown file + */ + private function saveEntryToFile(string $researchPath, Entry $entry): void + { + // Determine file path + $filePath = $entry->filePath; + + if ($filePath === null) { + // New entry - generate file path + $categoryPath = $this->files->normalizePath($researchPath . '/' . $entry->category . '/' . $entry->entryType); + $this->ensureDirectory($categoryPath); + + $filename = $this->generateFilename($entry->title); + $filePath = $categoryPath . '/' . $filename; + } + + // Prepare frontmatter + $frontmatter = [ + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + 'description' => $entry->description, + 'entry_type' => $entry->entryType, + 'category' => $entry->category, + 'status' => $entry->status, + 'created_at' => $entry->createdAt->format('c'), + 'updated_at' => $entry->updatedAt->format('c'), + 'tags' => $entry->tags, + ]; + + // Write markdown file + $this->writeMarkdownFile($filePath, $frontmatter, $entry->content); + } + + /** + * Check if entry matches the provided filters + */ + private function matchesFilters(Entry $entry, array $filters): bool + { + // Category filter + if (isset($filters['category']) && $entry->category !== $filters['category']) { + return false; + } + + // Status filter + if (isset($filters['status']) && $entry->status !== $filters['status']) { + return false; + } + + // Entry type filter + if (isset($filters['entry_type']) && $entry->entryType !== $filters['entry_type']) { + return false; + } + + // Tags filter (any of the provided tags should match) + if (isset($filters['tags']) && \is_array($filters['tags'])) { + $hasMatchingTag = false; + foreach ($filters['tags'] as $filterTag) { + if (\in_array($filterTag, $entry->tags, true)) { + $hasMatchingTag = true; + break; + } + } + if (!$hasMatchingTag) { + return false; + } + } + + // Title contains filter + if (isset($filters['title_contains']) && \is_string($filters['title_contains'])) { + if (\stripos($entry->title, $filters['title_contains']) === false) { + return false; + } + } + + // Description contains filter + if (isset($filters['description_contains']) && \is_string($filters['description_contains'])) { + if (\stripos($entry->description, $filters['description_contains']) === false) { + return false; + } + } + + // Content contains filter + if (isset($filters['content_contains']) && \is_string($filters['content_contains'])) { + if (\stripos($entry->content, $filters['content_contains']) === false) { + return false; + } + } + + return true; + } +} diff --git a/src/Research/Storage/FileStorage/FileResearchRepository.php b/src/Research/Storage/FileStorage/FileResearchRepository.php new file mode 100644 index 00000000..08b1ab91 --- /dev/null +++ b/src/Research/Storage/FileStorage/FileResearchRepository.php @@ -0,0 +1,211 @@ +directoryScanner->scanResearches($this->getBasePath()); + + foreach ($researchPaths as $researchPath) { + try { + $research = $this->loadResearchFromDirectory($researchPath); + if ($research !== null && $this->matchesFilters($research, $filters)) { + $researches[] = $research; + } + } catch (\Throwable $e) { + $this->logError('Failed to load research', ['path' => $researchPath], $e); + } + } + + $this->logOperation('Loaded researches', [ + 'count' => \count($researches), + 'total_scanned' => \count($researchPaths), + ]); + + return $researches; + } + + #[\Override] + public function findById(ResearchId $id): ?Research + { + $path = $this->getResearchPath($id->value); + + if (!$this->files->exists($path)) { + return null; + } + + try { + return $this->loadResearchFromDirectory($path); + } catch (\Throwable $e) { + $this->logError('Failed to load research by ID', ['id' => $id->value, 'path' => $path], $e); + return null; + } + } + + #[\Override] + public function save(Research $research): void + { + $path = $this->getResearchPath($research->id); + + try { + // Ensure research directory exists + $this->ensureDirectory($path); + + // Create entry directories if they don't exist + foreach ($research->entryDirs as $entryDir) { + $entryDirPath = $this->files->normalizePath($path . '/' . $entryDir); + $this->ensureDirectory($entryDirPath); + } + + // Save research configuration + $this->saveResearchConfig($path, $research); + + $this->logOperation('Saved research', [ + 'id' => $research->id, + 'name' => $research->name, + 'path' => $path, + ]); + } catch (\Throwable $e) { + $this->logError('Failed to save research', ['id' => $research->id], $e); + throw $e; + } + } + + #[\Override] + public function delete(ResearchId $id): bool + { + $path = $this->getResearchPath($id->value); + + if (!$this->files->exists($path)) { + return false; + } + + try { + $deleted = $this->files->deleteDirectory($path); + + if ($deleted) { + $this->logOperation('Deleted research', ['id' => $id->value, 'path' => $path]); + } + + return $deleted; + } catch (\Throwable $e) { + $this->logError('Failed to delete research', ['id' => $id->value], $e); + return false; + } + } + + #[\Override] + public function exists(ResearchId $id): bool + { + $path = $this->getResearchPath($id->value); + $configPath = $path . '/' . self::CONFIG_FILE; + + return $this->files->exists($configPath); + } + + /** + * Get research directory path from ID + */ + private function getResearchPath(string $researchId): string + { + $basePath = $this->getBasePath(); + + return $this->files->normalizePath($basePath . '/' . $researchId); + } + + /** + * Load research from directory path + */ + private function loadResearchFromDirectory(string $researchPath): ?Research + { + $configPath = $researchPath . '/' . self::CONFIG_FILE; + + if (!$this->files->exists($configPath)) { + throw new \RuntimeException("Research configuration not found: {$configPath}"); + } + + $config = $this->readYamlFile($configPath); + + // Extract research ID from directory name + $id = \basename($researchPath); + + return new Research( + id: $id, + name: $config['name'] ?? $id, + description: $config['description'] ?? '', + template: $config['template'] ?? '', + status: $config['status'] ?? 'draft', + tags: $config['tags'] ?? [], + entryDirs: $config['entries']['dirs'] ?? [], + memory: $config['memory'] ?? [], + path: $researchPath, + ); + } + + /** + * Save research configuration to YAML file + */ + private function saveResearchConfig(string $researchPath, Research $research): void + { + $configPath = $researchPath . '/' . self::CONFIG_FILE; + + $this->writeYamlFile($configPath, [ + 'name' => $research->name, + 'description' => $research->description, + 'template' => $research->template, + 'status' => $research->status, + 'tags' => $research->tags, + 'memory' => $research->memory, + 'entries' => [ + 'dirs' => $research->entryDirs, + ], + ]); + } + + /** + * Check if research matches the provided filters + */ + private function matchesFilters(Research $research, array $filters): bool + { + // Status filter + if (isset($filters['status']) && $research->status !== $filters['status']) { + return false; + } + + // Template filter + if (isset($filters['template']) && $research->template !== $filters['template']) { + return false; + } + + // Tags filter (any of the provided tags should match) + if (isset($filters['tags']) && \is_array($filters['tags'])) { + $hasMatchingTag = false; + foreach ($filters['tags'] as $filterTag) { + if (\in_array($filterTag, $research->tags, true)) { + $hasMatchingTag = true; + break; + } + } + if (!$hasMatchingTag) { + return false; + } + } + + return true; + } +} diff --git a/src/Research/Storage/FileStorage/FileStorageConfig.php b/src/Research/Storage/FileStorage/FileStorageConfig.php new file mode 100644 index 00000000..750e7bd4 --- /dev/null +++ b/src/Research/Storage/FileStorage/FileStorageConfig.php @@ -0,0 +1,84 @@ +basePath)) { + $errors[] = 'Base path cannot be empty'; + } + + if (empty($this->templatesPath)) { + $errors[] = 'Templates path cannot be empty'; + } + + if (empty($this->defaultEntryStatus)) { + $errors[] = 'Default entry status cannot be empty'; + } + + if ($this->maxFileSize <= 0) { + $errors[] = 'Max file size must be greater than 0'; + } + + if (empty($this->allowedExtensions)) { + $errors[] = 'At least one allowed extension must be specified'; + } + + return $errors; + } + + #[\Override] + public function jsonSerialize(): array + { + return [ + 'base_path' => $this->basePath, + 'templates_path' => $this->templatesPath, + 'default_entry_status' => $this->defaultEntryStatus, + 'create_directories_on_demand' => $this->createDirectoriesOnDemand, + 'validate_templates_on_boot' => $this->validateTemplatesOnBoot, + 'max_file_size' => $this->maxFileSize, + 'allowed_extensions' => $this->allowedExtensions, + 'file_encoding' => $this->fileEncoding, + ]; + } +} diff --git a/src/Research/Storage/FileStorage/FileStorageDriver.php b/src/Research/Storage/FileStorage/FileStorageDriver.php new file mode 100644 index 00000000..cc64165f --- /dev/null +++ b/src/Research/Storage/FileStorage/FileStorageDriver.php @@ -0,0 +1,383 @@ +templateId); + $template = $this->templateRepository->findByKey($templateKey); + + if ($template === null) { + throw new TemplateNotFoundException("Template '{$request->templateId}' not found"); + } + + $suffix = ''; + + do { + $researchId = $this->generateId($request->title . $suffix); + $suffix = '-' . \date('YmdHis'); + } while ($this->researchRepository->exists(ResearchId::fromString($researchId))); + + $research = new Research( + id: $researchId, + name: $request->title, + description: $request->description, + template: $request->templateId, + status: $this->driverConfig->defaultEntryStatus, + tags: $request->tags, + entryDirs: !empty($request->entryDirs) ? $request->entryDirs : $this->getDefaultEntryDirs($template), + memory: $request->memory, + ); + + $this->researchRepository->save($research); + $this->logger->debug('Created research', ['id' => $researchId, 'name' => $request->title]); + + return $research; + } + + public function updateResearch(ResearchId $researchId, ResearchUpdateRequest $request): Research + { + $research = $this->researchRepository->findById($researchId); + if ($research === null) { + throw new ResearchNotFoundException("Research '{$researchId->value}' not found"); + } + + if (!$request->hasUpdates()) { + return $research; + } + + $updated = $research->withUpdates( + name: $request->title, + description: $request->description, + status: $request->status, + tags: $request->tags, + entryDirs: $request->entryDirs, + memory: \array_map( + static fn(ResearchMemory $memory): string => $memory->record, + $request->memory, + ), + ); + + $this->researchRepository->save($updated); + $this->logger->debug('Updated research', ['id' => $researchId->value]); + + return $updated; + } + + public function deleteResearch(ResearchId $researchId): bool + { + if (!$this->researchRepository->exists($researchId)) { + return false; + } + + $deleted = $this->researchRepository->delete($researchId); + if ($deleted) { + $this->logger->debug('Deleted research', ['id' => $researchId->value]); + } + + return $deleted; + } + + public function createEntry(ResearchId $researchId, EntryCreateRequest $request): Entry + { + // Verify research exists + $research = $this->researchRepository->findById($researchId); + if ($research === null) { + throw new ResearchNotFoundException("Research '{$researchId->value}' not found"); + } + + // Get template for validation and key resolution + $templateKey = TemplateKey::fromString($research->template); + $template = $this->templateRepository->findByKey($templateKey); + if ($template === null) { + throw new TemplateNotFoundException("Template '{$research->template}' not found"); + } + + // Resolve display names to internal keys + $resolvedRequest = $this->resolveEntryCreateRequestKeys($request, $template); + + // Validate resolved request against template + $this->validateEntryAgainstTemplate($template, $resolvedRequest); + + // Generate entry ID and create entry + $entryId = $this->generateId('entry_'); + $now = new \DateTime(); + + $entry = new Entry( + entryId: $entryId, + title: $resolvedRequest->getProcessedTitle(), // Use processed title + description: $resolvedRequest->getProcessedDescription(), // Use processed description + entryType: $resolvedRequest->entryType, + category: $resolvedRequest->category, + status: $resolvedRequest->status ?? $this->driverConfig->defaultEntryStatus, + createdAt: $now, + updatedAt: $now, + tags: $resolvedRequest->tags, + content: $resolvedRequest->content, + ); + + $this->entryRepository->save($researchId, $entry); + $this->logger->debug('Created entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId, + 'title' => $entry->title, + ]); + + return $entry; + } + + public function updateEntry(ResearchId $researchId, EntryId $entryId, EntryUpdateRequest $request): Entry + { + $entry = $this->entryRepository->findById($researchId, $entryId); + if ($entry === null) { + throw new EntryNotFoundException("Entry '{$entryId->value}' not found in research '{$researchId->value}'"); + } + + if (!$request->hasUpdates()) { + return $entry; + } + + // Resolve status if provided + $resolvedRequest = $request; + if ($request->status !== null) { + $research = $this->researchRepository->findById($researchId); + if ($research !== null) { + $templateKey = TemplateKey::fromString($research->template); + $template = $this->templateRepository->findByKey($templateKey); + if ($template !== null) { + $resolvedStatus = $this->resolveStatusForEntryType($template, $entry->entryType, $request->status); + $resolvedRequest = $request->withResolvedStatus($resolvedStatus); + } + } + } + + // Get final content considering text replacement + $finalContent = $resolvedRequest->getFinalContent($entry->content); + + $updatedEntry = $entry->withUpdates( + title: $resolvedRequest->title, + description: $resolvedRequest->description, + status: $resolvedRequest->status, + tags: $resolvedRequest->tags, + content: $finalContent, // Use processed content with text replacement + ); + + $this->entryRepository->save($researchId, $updatedEntry); + $this->logger->debug('Updated entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + + return $updatedEntry; + } + + public function deleteEntry(ResearchId $researchId, EntryId $entryId): bool + { + if (!$this->entryRepository->exists($researchId, $entryId)) { + return false; + } + + $deleted = $this->entryRepository->delete($researchId, $entryId); + if ($deleted) { + $this->logger->debug('Deleted entry', [ + 'research_id' => $researchId->value, + 'entry_id' => $entryId->value, + ]); + } + + return $deleted; + } + + /** + * Generate unique ID for entities + */ + private function generateId(string $prefix = ''): string + { + return $this->slugify->slugify($prefix); + } + + /** + * Get default entry directories from template + */ + private function getDefaultEntryDirs(Template $template): array + { + $dirs = []; + foreach ($template->categories as $category) { + $dirs[] = $category->name; + } + return $dirs; + } + + /** + * Resolve display names in entry create request to internal keys + */ + private function resolveEntryCreateRequestKeys( + EntryCreateRequest $request, + Template $template, + ): EntryCreateRequest { + // Resolve category + $resolvedCategory = $this->resolveCategoryKey($template, $request->category); + if ($resolvedCategory === null) { + throw new \InvalidArgumentException( + "Category '{$request->category}' not found in template '{$template->key}'", + ); + } + + // Resolve entry type + $resolvedEntryType = $this->resolveEntryTypeKey($template, $request->entryType); + if ($resolvedEntryType === null) { + throw new \InvalidArgumentException( + "Entry type '{$request->entryType}' not found in template '{$template->key}'", + ); + } + + // Resolve status if provided + $resolvedStatus = null; + if ($request->status !== null) { + $resolvedStatus = $this->resolveStatusForEntryType($template, $resolvedEntryType, $request->status); + if ($resolvedStatus === null) { + throw new \InvalidArgumentException( + "Status '{$request->status}' not found for entry type '{$resolvedEntryType}' in template '{$template->key}'", + ); + } + } + + return $request->withResolvedKeys($resolvedCategory, $resolvedEntryType, $resolvedStatus); + } + + /** + * Validate entry request against research template + */ + private function validateEntryAgainstTemplate( + Template $template, + EntryCreateRequest $request, + ): void { + // Validate category exists + if (!$template->hasCategory($request->category)) { + throw new \InvalidArgumentException( + "Category '{$request->category}' not found in template '{$template->key}'", + ); + } + + // Validate entry type exists + if (!$template->hasEntryType($request->entryType)) { + throw new \InvalidArgumentException( + "Entry type '{$request->entryType}' not found in template '{$template->key}'", + ); + } + + // Validate entry type is allowed in category + if (!$template->validateEntryInCategory($request->category, $request->entryType)) { + throw new \InvalidArgumentException( + "Entry type '{$request->entryType}' is not allowed in category '{$request->category}'", + ); + } + + // Validate status if provided + if ($request->status !== null) { + $entryType = $template->getEntryType($request->entryType); + if ($entryType !== null && !$entryType->hasStatus($request->status)) { + throw new \InvalidArgumentException( + "Status '{$request->status}' is not valid for entry type '{$request->entryType}'", + ); + } + } + } + + /** + * Resolve category display name to internal key + */ + private function resolveCategoryKey( + Template $template, + string $displayNameOrKey, + ): ?string { + foreach ($template->categories as $category) { + if ($category->name === $displayNameOrKey || $category->displayName === $displayNameOrKey) { + return $category->name; + } + } + return null; + } + + /** + * Resolve entry type display name to internal key + */ + private function resolveEntryTypeKey( + Template $template, + string $displayNameOrKey, + ): ?string { + foreach ($template->entryTypes as $entryType) { + if ($entryType->key === $displayNameOrKey || $entryType->displayName === $displayNameOrKey) { + return $entryType->key; + } + } + return null; + } + + /** + * Resolve status display name to internal value for specific entry type + */ + private function resolveStatusForEntryType( + Template $template, + string $entryTypeKey, + string $displayNameOrValue, + ): ?string { + $entryType = $template->getEntryType($entryTypeKey); + if ($entryType === null) { + return null; + } + + foreach ($entryType->statuses as $status) { + if ($status->value === $displayNameOrValue || $status->displayName === $displayNameOrValue) { + return $status->value; + } + } + + return null; + } +} diff --git a/src/Research/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Research/Storage/FileStorage/FileStorageRepositoryBase.php new file mode 100644 index 00000000..0b0bc18e --- /dev/null +++ b/src/Research/Storage/FileStorage/FileStorageRepositoryBase.php @@ -0,0 +1,151 @@ +dirs->getRootPath()->join($this->config->getResearchesPath()); + } + + /** + * Get templates base path + */ + protected function getTemplatesPath(): string + { + return (string) $this->dirs->getRootPath()->join($this->config->getTemplatesPath()); + } + + /** + * Ensure directory exists + */ + protected function ensureDirectory(string $path): void + { + if (!$this->files->exists($path)) { + $this->files->ensureDirectory($path); + $this->logger?->debug('Created directory', ['path' => $path]); + } + } + + /** + * Read and parse YAML file + */ + protected function readYamlFile(string $filePath): array + { + if (!$this->files->exists($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + $content = $this->files->read($filePath); + + try { + return Yaml::parse($content) ?? []; + } catch (ParseException $e) { + $this->reporter->report($e); + throw new \RuntimeException("Failed to parse YAML file '{$filePath}': {$e->getMessage()}", 0, $e); + } + } + + /** + * Write array data as YAML file + */ + protected function writeYamlFile(string $filePath, array $data): void + { + $yamlContent = Yaml::dump( + $data, + 4, + 2, + Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, + ); + + $this->ensureDirectory(\dirname($filePath)); + $this->files->write($filePath, $yamlContent); + + $this->logger?->debug('Wrote YAML file', ['path' => $filePath]); + } + + /** + * Read markdown file with frontmatter + */ + protected function readMarkdownFile(string $filePath): array + { + if (!$this->files->exists($filePath)) { + throw new \RuntimeException("Markdown file not found: {$filePath}"); + } + + $content = $this->files->read($filePath); + return $this->frontmatterParser->parse($content); + } + + /** + * Write markdown file with frontmatter + */ + protected function writeMarkdownFile(string $filePath, array $frontmatter, string $content): void + { + $fileContent = $this->frontmatterParser->combine($frontmatter, $content); + + $this->ensureDirectory(\dirname($filePath)); + $this->files->write($filePath, $fileContent); + + $this->logger?->debug('Wrote markdown file', ['path' => $filePath]); + } + + /** + * Generate safe filename from title + */ + protected function generateFilename(string $title, string $extension = 'md'): string + { + $slug = \strtolower($title); + $slug = \preg_replace('/[^a-z0-9\s\-]/', '', $slug); + $slug = \preg_replace('/[\s\-]+/', '-', (string) $slug); + $slug = \trim((string) $slug, '-'); + + return $slug . '.' . $extension; + } + + /** + * Log operation with context + */ + protected function logOperation(string $operation, array $context = []): void + { + $this->logger?->info("File storage operation: {$operation}", $context); + } + + /** + * Log error with context + */ + protected function logError(string $message, array $context = [], ?\Throwable $exception = null): void + { + $this->logger?->error($message, [ + 'exception' => $exception?->getMessage(), + ...$context, + ]); + } +} diff --git a/src/Research/Storage/FileStorage/FileTemplateRepository.php b/src/Research/Storage/FileStorage/FileTemplateRepository.php new file mode 100644 index 00000000..f8ddbd1b --- /dev/null +++ b/src/Research/Storage/FileStorage/FileTemplateRepository.php @@ -0,0 +1,213 @@ +loadTemplatesFromFilesystem(); + } + + #[\Override] + public function findByKey(TemplateKey $key): ?Template + { + $templates = $this->loadTemplatesFromFilesystem(); + + foreach ($templates as $template) { + if ($template->key === $key->value) { + return $template; + } + } + + return null; + } + + #[\Override] + public function exists(TemplateKey $key): bool + { + return $this->findByKey($key) !== null; + } + + #[\Override] + public function refresh(): void + { + // No-op since we don't cache anymore + $this->logOperation('Template refresh requested (no caching)'); + } + + /** + * Load templates from YAML files in templates directory + */ + private function loadTemplatesFromFilesystem(): array + { + $templatesPath = $this->getTemplatesPath(); + + $templates = []; + + if (!$this->files->exists($templatesPath) || !$this->files->isDirectory($templatesPath)) { + $this->logger?->warning('Templates directory not found', ['path' => $templatesPath]); + return $templates; + } + + $finder = new Finder(); + $finder->files() + ->in($templatesPath) + ->name('*.yaml') + ->name('*.yml'); + + foreach ($finder as $file) { + try { + $template = $this->loadTemplateFromFile($file->getRealPath()); + if ($template !== null) { + $templates[] = $template; + } + } catch (\Throwable $e) { + $this->reporter->report($e); + $this->logError('Failed to load template', ['file' => $file->getRealPath()], $e); + } + } + + $this->logOperation('Loaded templates from filesystem', [ + 'count' => \count($templates), + 'path' => $templatesPath, + ]); + + return $templates; + } + + /** + * Load template from individual YAML file + */ + private function loadTemplateFromFile(string $filePath): ?Template + { + try { + $templateData = $this->readYamlFile($filePath); + return $this->createTemplateFromData($templateData); + } catch (\Throwable $e) { + $this->reporter->report($e); + $this->logError("Failed to load template from file: {$filePath}", [], $e); + return null; + } + } + + /** + * Create Template object from parsed YAML data + */ + private function createTemplateFromData(array $data): Template + { + // Validate required fields + $requiredFields = ['key', 'name', 'description']; + foreach ($requiredFields as $field) { + if (!isset($data[$field])) { + throw new \RuntimeException("Missing required template field: {$field}"); + } + } + + // Parse categories + $categories = []; + if (isset($data['categories']) && \is_array($data['categories'])) { + foreach ($data['categories'] as $categoryData) { + $categories[] = $this->createCategoryFromData($categoryData); + } + } + + // Parse entry types + $entryTypes = []; + if (isset($data['entry_types']) && \is_array($data['entry_types'])) { + foreach ($data['entry_types'] as $key => $entryTypeData) { + $entryTypes[] = $this->createEntryTypeFromData($key, $entryTypeData); + } + } + + return new Template( + key: $data['key'], + name: $data['name'], + description: $data['description'], + tags: $data['tags'] ?? [], + categories: $categories, + entryTypes: $entryTypes, + prompt: $data['prompt'] ?? null, + ); + } + + /** + * Create Category object from parsed data + */ + private function createCategoryFromData(array $data): Category + { + $requiredFields = ['name', 'display_name', 'entry_types']; + foreach ($requiredFields as $field) { + if (!isset($data[$field])) { + throw new \RuntimeException("Missing required category field: {$field}"); + } + } + + return new Category( + name: $data['name'], + displayName: $data['display_name'], + entryTypes: $data['entry_types'], + ); + } + + /** + * Create EntryType object from parsed data + */ + private function createEntryTypeFromData(string $key, array $data): EntryType + { + $requiredFields = ['display_name']; + foreach ($requiredFields as $field) { + if (!isset($data[$field])) { + throw new \RuntimeException("Missing required entry type field: {$field}"); + } + } + + // Parse statuses + $statuses = []; + if (isset($data['statuses']) && \is_array($data['statuses'])) { + foreach ($data['statuses'] as $statusData) { + $statuses[] = $this->createStatusFromData($statusData); + } + } + + return new EntryType( + key: $key, + displayName: $data['display_name'], + contentType: $data['content_type'] ?? 'markdown', + defaultStatus: $data['default_status'] ?? 'draft', + statuses: $statuses, + ); + } + + /** + * Create Status object from parsed data + */ + private function createStatusFromData(array $data): Status + { + $requiredFields = ['value', 'display_name']; + foreach ($requiredFields as $field) { + if (!isset($data[$field])) { + throw new \RuntimeException("Missing required status field: {$field}"); + } + } + + return new Status( + value: $data['value'], + displayName: $data['display_name'], + ); + } +} diff --git a/src/Research/Storage/FileStorage/FrontmatterParser.php b/src/Research/Storage/FileStorage/FrontmatterParser.php new file mode 100644 index 00000000..b9018598 --- /dev/null +++ b/src/Research/Storage/FileStorage/FrontmatterParser.php @@ -0,0 +1,104 @@ + [], + 'content' => $content, + ]; + } + + // Find the closing delimiter + $lines = \explode("\n", $content); + $frontmatterLines = []; + $contentLines = []; + $inFrontmatter = false; + $frontmatterClosed = false; + + foreach ($lines as $index => $line) { + if ($index === 0 && $line === self::FRONTMATTER_DELIMITER) { + $inFrontmatter = true; + continue; + } + + if ($inFrontmatter && $line === self::FRONTMATTER_DELIMITER) { + $inFrontmatter = false; + $frontmatterClosed = true; + continue; + } + + if ($inFrontmatter) { + $frontmatterLines[] = $line; + } elseif ($frontmatterClosed) { + $contentLines[] = $line; + } + } + + // Parse YAML frontmatter + $frontmatter = []; + if (!empty($frontmatterLines)) { + $yamlContent = \implode("\n", $frontmatterLines); + try { + $frontmatter = Yaml::parse($yamlContent) ?? []; + } catch (ParseException $e) { + throw new \RuntimeException("Failed to parse YAML frontmatter: {$e->getMessage()}", 0, $e); + } + } + + $content = \implode("\n", $contentLines); + + return [ + 'frontmatter' => $frontmatter, + 'content' => \trim($content), + ]; + } + + /** + * Combine frontmatter and content into markdown file format + */ + public function combine(array $frontmatter, string $content): string + { + $output = ''; + + if (!empty($frontmatter)) { + $output .= self::FRONTMATTER_DELIMITER . "\n"; + $output .= Yaml::dump($frontmatter, 2, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $output .= self::FRONTMATTER_DELIMITER . "\n"; + } + + $output .= $content; + + return $output; + } + + /** + * Extract only the frontmatter from file content + */ + public function extractFrontmatter(string $content): array + { + return $this->parse($content)['frontmatter']; + } +} diff --git a/src/Research/Storage/FileStorageBootloader.php b/src/Research/Storage/FileStorageBootloader.php new file mode 100644 index 00000000..4b5e38a1 --- /dev/null +++ b/src/Research/Storage/FileStorageBootloader.php @@ -0,0 +1,50 @@ + FrontmatterParser::class, + TemplateRepositoryInterface::class => FileTemplateRepository::class, + ResearchRepositoryInterface::class => FileResearchRepository::class, + EntryRepositoryInterface::class => FileEntryRepository::class, + + // Storage driver + StorageDriverInterface::class => static fn(ResearchConfigInterface $config, FilesInterface $files, LoggerInterface $logger, ExceptionReporterInterface $reporter, DirectoriesInterface $dirs, TemplateRepositoryInterface $templateRepository, ResearchRepositoryInterface $researchRepository, EntryRepositoryInterface $entryRepository): StorageDriverInterface => new FileStorageDriver( + driverConfig: FileStorageConfig::fromArray([ + 'base_path' => $config->getResearchesPath(), + 'templates_path' => $config->getTemplatesPath(), + 'default_entry_status' => $config->getDefaultEntryStatus(), + ]), + templateRepository: $templateRepository, + researchRepository: $researchRepository, + entryRepository: $entryRepository, + slugify: new Slugify(), + logger: $logger, + ), + ]; + } +} diff --git a/src/Research/Storage/StorageDriverInterface.php b/src/Research/Storage/StorageDriverInterface.php new file mode 100644 index 00000000..4ab796b7 --- /dev/null +++ b/src/Research/Storage/StorageDriverInterface.php @@ -0,0 +1,57 @@ +tempPath . '/projects'; + $projects = $this->scanner->scanResearches($projectsPath); + + $this->assertCount(2, $projects); + + // Verify project paths + $projectNames = \array_map('basename', $projects); + $this->assertContains('test_project_1', $projectNames); + $this->assertContains('test_project_2', $projectNames); + + // Verify each project has a project.yaml file + foreach ($projects as $projectPath) { + $this->assertFileExists($projectPath . '/research.yaml'); + } + } + + #[Test] + public function it_returns_empty_array_for_nonexistent_projects_path(): void + { + $nonexistentPath = $this->tempPath . '/nonexistent'; + $projects = $this->scanner->scanResearches($nonexistentPath); + + $this->assertEmpty($projects); + } + + #[Test] + public function it_ignores_directories_without_project_yaml(): void + { + // Create a directory without project.yaml + $invalidProjectPath = $this->tempPath . '/projects/invalid_project'; + \mkdir($invalidProjectPath, 0755, true); + \file_put_contents($invalidProjectPath . '/readme.txt', 'Not a project'); + + $projectsPath = $this->tempPath . '/projects'; + $projects = $this->scanner->scanResearches($projectsPath); + + // Should still only find the 2 valid projects + $this->assertCount(2, $projects); + + $projectNames = \array_map('basename', $projects); + $this->assertNotContains('invalid_project', $projectNames); + } + + #[Test] + public function it_scans_entries_in_project(): void + { + $projectPath = $this->tempPath . '/projects/test_project_1'; + + $entries = $this->scanner->scanEntries($projectPath); + + $this->assertCount(3, $entries); + + // Verify entry file paths + $entryFiles = \array_map('basename', $entries); + $this->assertContains('sample_story.md', $entryFiles); + $this->assertContains('api_design.md', $entryFiles); + } + + #[Test] + public function it_scans_all_markdown_files_when_no_entry_dirs_specified(): void + { + $projectPath = $this->tempPath . '/projects/test_project_1'; + + $entries = $this->scanner->scanEntries($projectPath); + + $this->assertCount(3, $entries); + + foreach ($entries as $entryPath) { + $this->assertStringEndsWith('.md', $entryPath); + } + } + + #[Test] + public function it_returns_empty_array_for_nonexistent_project_path(): void + { + $nonexistentPath = $this->tempPath . '/projects/nonexistent_project'; + $entries = $this->scanner->scanEntries($nonexistentPath); + + $this->assertEmpty($entries); + } + + #[Test] + public function it_handles_project_with_no_entries(): void + { + $projectPath = $this->tempPath . '/projects/test_project_2'; + $entries = $this->scanner->scanEntries($projectPath); + + $this->assertEmpty($entries); + } + + #[Test] + public function it_gets_entry_directories(): void + { + $projectPath = $this->tempPath . '/projects/test_project_1'; + $directories = $this->scanner->getEntryDirectories($projectPath); + + $this->assertGreaterThan(0, \count($directories)); + $this->assertContains('features', $directories); + $this->assertContains('docs', $directories); + } + + #[Test] + public function it_excludes_special_directories(): void + { + // Create special directories that should be ignored + $projectPath = $this->tempPath . '/projects/test_project_1'; + $specialDirs = ['.project', 'resources', '.git', '.idea', 'node_modules']; + + foreach ($specialDirs as $specialDir) { + $dirPath = $projectPath . '/' . $specialDir; + if (!\is_dir($dirPath)) { + \mkdir($dirPath, 0755, true); + } + } + + $directories = $this->scanner->getEntryDirectories($projectPath); + + foreach ($specialDirs as $specialDir) { + $this->assertNotContains($specialDir, $directories); + } + } + + #[Test] + public function it_returns_empty_array_for_nonexistent_directory(): void + { + $nonexistentPath = $this->tempPath . '/nonexistent_directory'; + $directories = $this->scanner->getEntryDirectories($nonexistentPath); + + $this->assertEmpty($directories); + } + + #[Test] + public function it_handles_deeply_nested_entry_files(): void + { + // Create nested entry structure + $projectPath = $this->tempPath . '/projects/test_project_1'; + $nestedPath = $projectPath . '/features/user_story/nested'; + \mkdir($nestedPath, 0755, true); + + $nestedEntryContent = <<<'MARKDOWN' +--- +entry_id: "nested_entry" +title: "Nested Entry" +entry_type: "user_story" +category: "features" +status: "draft" +--- + +# Nested Entry + +This is a nested entry for testing. +MARKDOWN; + + \file_put_contents($nestedPath . '/nested_entry.md', $nestedEntryContent); + + $entries = $this->scanner->scanEntries($projectPath); + + // Should include the nested entry + $this->assertCount(4, $entries); // Original 2 + new nested one + + $nestedEntryFound = false; + foreach ($entries as $entryPath) { + if (\str_contains((string) $entryPath, 'nested_entry.md')) { + $nestedEntryFound = true; + break; + } + } + + $this->assertTrue($nestedEntryFound, 'Nested entry should be found'); + } + + #[Test] + public function it_only_finds_markdown_files(): void + { + // Create non-markdown files + $projectPath = $this->tempPath . '/projects/test_project_1'; + \file_put_contents($projectPath . '/features/readme.txt', 'Not an entry'); + \file_put_contents($projectPath . '/docs/config.json', '{"not": "an entry"}'); + \file_put_contents($projectPath . '/notes.html', '

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, + ); + } +}