From 5f0b7be5ff3a1bb7547096a6f80860c23de31247 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 23 Sep 2025 15:42:32 +0400 Subject: [PATCH 01/16] feat: add Drafling domain models and service contracts Implement comprehensive contracts and immutable domain models for file-based project management system with template-driven structure, MCP integration, and storage abstraction. --- context.yaml | 9 + docs/config/readme.md | 501 ++++++++++++++++++ src/Drafling/Config/DraflingConfig.php | 54 ++ .../Config/DraflingConfigInterface.php | 41 ++ src/Drafling/Domain/Model/Category.php | 32 ++ src/Drafling/Domain/Model/Entry.php | 86 +++ src/Drafling/Domain/Model/EntryType.php | 51 ++ src/Drafling/Domain/Model/Project.php | 82 +++ src/Drafling/Domain/Model/Status.php | 17 + src/Drafling/Domain/Model/Template.php | 85 +++ src/Drafling/Domain/ValueObject/EntryId.php | 45 ++ src/Drafling/Domain/ValueObject/ProjectId.php | 45 ++ .../Domain/ValueObject/TemplateKey.php | 37 ++ src/Drafling/DraflingBootloader.php | 74 +++ src/Drafling/Exception/DraflingException.php | 19 + .../Exception/EntryNotFoundException.php | 16 + .../Exception/ProjectNotFoundException.php | 16 + .../Exception/TemplateNotFoundException.php | 16 + .../Exception/ValidationException.php | 38 ++ src/Drafling/MCP/DTO/EntryCreateRequest.php | 28 + src/Drafling/MCP/DTO/EntryUpdateRequest.php | 35 ++ src/Drafling/MCP/DTO/ProjectCreateRequest.php | 26 + src/Drafling/MCP/DTO/ProjectFilters.php | 56 ++ src/Drafling/MCP/DTO/ProjectUpdateRequest.php | 38 ++ .../Repository/EntryRepositoryInterface.php | 44 ++ .../Repository/ProjectRepositoryInterface.php | 42 ++ .../TemplateRepositoryInterface.php | 36 ++ .../Service/DraflingServiceInterface.php | 47 ++ .../Service/EntryServiceInterface.php | 51 ++ .../Service/ProjectServiceInterface.php | 55 ++ .../Service/TemplateServiceInterface.php | 39 ++ .../Storage/AbstractStorageDriver.php | 137 +++++ .../Storage/Config/FileStorageConfig.php | 84 +++ .../Storage/FileStorage/DirectoryScanner.php | 334 ++++++++++++ .../FileStorage/FileEntryRepository.php | 311 +++++++++++ .../FileStorage/FileProjectRepository.php | 217 ++++++++ .../Storage/FileStorage/FileStorageDriver.php | 335 ++++++++++++ .../FileStorage/FileStorageRepositoryBase.php | 161 ++++++ .../FileStorage/FileTemplateRepository.php | 220 ++++++++ .../Storage/FileStorage/FrontmatterParser.php | 200 +++++++ src/Drafling/Storage/StorageBootloader.php | 78 +++ .../Storage/StorageDriverInterface.php | 74 +++ 42 files changed, 3912 insertions(+) create mode 100644 docs/config/readme.md create mode 100644 src/Drafling/Config/DraflingConfig.php create mode 100644 src/Drafling/Config/DraflingConfigInterface.php create mode 100644 src/Drafling/Domain/Model/Category.php create mode 100644 src/Drafling/Domain/Model/Entry.php create mode 100644 src/Drafling/Domain/Model/EntryType.php create mode 100644 src/Drafling/Domain/Model/Project.php create mode 100644 src/Drafling/Domain/Model/Status.php create mode 100644 src/Drafling/Domain/Model/Template.php create mode 100644 src/Drafling/Domain/ValueObject/EntryId.php create mode 100644 src/Drafling/Domain/ValueObject/ProjectId.php create mode 100644 src/Drafling/Domain/ValueObject/TemplateKey.php create mode 100644 src/Drafling/DraflingBootloader.php create mode 100644 src/Drafling/Exception/DraflingException.php create mode 100644 src/Drafling/Exception/EntryNotFoundException.php create mode 100644 src/Drafling/Exception/ProjectNotFoundException.php create mode 100644 src/Drafling/Exception/TemplateNotFoundException.php create mode 100644 src/Drafling/Exception/ValidationException.php create mode 100644 src/Drafling/MCP/DTO/EntryCreateRequest.php create mode 100644 src/Drafling/MCP/DTO/EntryUpdateRequest.php create mode 100644 src/Drafling/MCP/DTO/ProjectCreateRequest.php create mode 100644 src/Drafling/MCP/DTO/ProjectFilters.php create mode 100644 src/Drafling/MCP/DTO/ProjectUpdateRequest.php create mode 100644 src/Drafling/Repository/EntryRepositoryInterface.php create mode 100644 src/Drafling/Repository/ProjectRepositoryInterface.php create mode 100644 src/Drafling/Repository/TemplateRepositoryInterface.php create mode 100644 src/Drafling/Service/DraflingServiceInterface.php create mode 100644 src/Drafling/Service/EntryServiceInterface.php create mode 100644 src/Drafling/Service/ProjectServiceInterface.php create mode 100644 src/Drafling/Service/TemplateServiceInterface.php create mode 100644 src/Drafling/Storage/AbstractStorageDriver.php create mode 100644 src/Drafling/Storage/Config/FileStorageConfig.php create mode 100644 src/Drafling/Storage/FileStorage/DirectoryScanner.php create mode 100644 src/Drafling/Storage/FileStorage/FileEntryRepository.php create mode 100644 src/Drafling/Storage/FileStorage/FileProjectRepository.php create mode 100644 src/Drafling/Storage/FileStorage/FileStorageDriver.php create mode 100644 src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php create mode 100644 src/Drafling/Storage/FileStorage/FileTemplateRepository.php create mode 100644 src/Drafling/Storage/FileStorage/FrontmatterParser.php create mode 100644 src/Drafling/Storage/StorageBootloader.php create mode 100644 src/Drafling/Storage/StorageDriverInterface.php diff --git a/context.yaml b/context.yaml index 489e36f4..c6fd03cd 100644 --- a/context.yaml +++ b/context.yaml @@ -35,6 +35,15 @@ documents: - vendor/spiral/files/src/FilesInterface.php showTreeView: true + - description: Drafling Templates + outputPath: core/drafling.md + sources: + - type: file + sourcePaths: + - src/Drafling + - vendor/spiral/files/src/FilesInterface.php + showTreeView: true + - 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/Drafling/Config/DraflingConfig.php b/src/Drafling/Config/DraflingConfig.php new file mode 100644 index 00000000..514f4f19 --- /dev/null +++ b/src/Drafling/Config/DraflingConfig.php @@ -0,0 +1,54 @@ + true, + 'templates_path' => '.templates', + 'projects_path' => '.projects', + 'storage_driver' => 'markdown', + 'default_entry_status' => 'draft', + 'env_config' => [], + ]; + + public function isEnabled(): bool + { + return (bool) $this->config['enabled']; + } + + public function getTemplatesPath(): string + { + return (string) $this->config['templates_path']; + } + + public function getProjectsPath(): string + { + return (string) $this->config['projects_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/Drafling/Config/DraflingConfigInterface.php b/src/Drafling/Config/DraflingConfigInterface.php new file mode 100644 index 00000000..673499d3 --- /dev/null +++ b/src/Drafling/Config/DraflingConfigInterface.php @@ -0,0 +1,41 @@ +entryTypes, true); + } +} diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Drafling/Domain/Model/Entry.php new file mode 100644 index 00000000..09daaf8a --- /dev/null +++ b/src/Drafling/Domain/Model/Entry.php @@ -0,0 +1,86 @@ +entryId, + title: $title ?? $this->title, + 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, + ); + } + + /** + * Generate filename for this entry + */ + public function generateFilename(): string + { + $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->title)); + $slug = \trim($slug, '-'); + return "{$slug}.md"; + } + + /** + * Get entry metadata as array + */ + public function getMetadata(): array + { + return [ + 'entry_id' => $this->entryId, + 'title' => $this->title, + 'entry_type' => $this->entryType, + 'category' => $this->category, + 'status' => $this->status, + 'created_at' => $this->createdAt->format('c'), + 'updated_at' => $this->updatedAt->format('c'), + 'tags' => $this->tags, + ]; + } +} diff --git a/src/Drafling/Domain/Model/EntryType.php b/src/Drafling/Domain/Model/EntryType.php new file mode 100644 index 00000000..c3af0702 --- /dev/null +++ b/src/Drafling/Domain/Model/EntryType.php @@ -0,0 +1,51 @@ +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/Drafling/Domain/Model/Project.php b/src/Drafling/Domain/Model/Project.php new file mode 100644 index 00000000..52201541 --- /dev/null +++ b/src/Drafling/Domain/Model/Project.php @@ -0,0 +1,82 @@ +id, + name: $name ?? $this->name, + description: $description ?? $this->description, + template: $this->template, + status: $status ?? $this->status, + tags: $tags ?? $this->tags, + entryDirs: $entryDirs ?? $this->entryDirs, + projectPath: $this->projectPath, + ); + } + + /** + * Generate directory name for this project + */ + public function generateDirectoryName(): string + { + $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->name)); + return \trim($slug, '-'); + } + + /** + * Get project configuration as array + */ + public function getConfiguration(): array + { + return [ + 'project' => [ + 'name' => $this->name, + 'description' => $this->description, + 'template' => $this->template, + 'status' => $this->status, + 'tags' => $this->tags, + 'entries' => [ + 'dirs' => $this->entryDirs, + ], + ], + ]; + } +} diff --git a/src/Drafling/Domain/Model/Status.php b/src/Drafling/Domain/Model/Status.php new file mode 100644 index 00000000..fb07cacf --- /dev/null +++ b/src/Drafling/Domain/Model/Status.php @@ -0,0 +1,17 @@ +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); + } +} diff --git a/src/Drafling/Domain/ValueObject/EntryId.php b/src/Drafling/Domain/ValueObject/EntryId.php new file mode 100644 index 00000000..5d609262 --- /dev/null +++ b/src/Drafling/Domain/ValueObject/EntryId.php @@ -0,0 +1,45 @@ +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); + } + + public function toString(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Drafling/Domain/ValueObject/ProjectId.php b/src/Drafling/Domain/ValueObject/ProjectId.php new file mode 100644 index 00000000..8243a444 --- /dev/null +++ b/src/Drafling/Domain/ValueObject/ProjectId.php @@ -0,0 +1,45 @@ +value)) { + throw new \InvalidArgumentException('Project ID cannot be empty'); + } + } + + /** + * Generate new UUID-based project ID + */ + public static function generate(): self + { + return new self(\uniqid('project_', true)); + } + + /** + * Create from string + */ + public static function fromString(string $value): self + { + return new self($value); + } + + public function toString(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Drafling/Domain/ValueObject/TemplateKey.php b/src/Drafling/Domain/ValueObject/TemplateKey.php new file mode 100644 index 00000000..aeadbea8 --- /dev/null +++ b/src/Drafling/Domain/ValueObject/TemplateKey.php @@ -0,0 +1,37 @@ +value)) { + throw new \InvalidArgumentException('Template key cannot be empty'); + } + } + + /** + * Create from string + */ + public static function fromString(string $value): self + { + return new self($value); + } + + public function toString(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Drafling/DraflingBootloader.php b/src/Drafling/DraflingBootloader.php new file mode 100644 index 00000000..8397c04d --- /dev/null +++ b/src/Drafling/DraflingBootloader.php @@ -0,0 +1,74 @@ +config->setDefaults( + DraflingConfig::CONFIG, + [ + 'enabled' => (bool) $env->get('DRAFLING_ENABLED', true), + 'templates_path' => $env->get('DRAFLING_TEMPLATES_PATH', '.templates'), + 'projects_path' => $env->get('DRAFLING_PROJECTS_PATH', '.projects'), + 'storage_driver' => $env->get('DRAFLING_STORAGE_DRIVER', 'markdown'), + 'default_entry_status' => $env->get('DRAFLING_DEFAULT_STATUS', 'draft'), + 'env_config' => [], + ], + ); + } + + public function defineSingletons(): array + { + return [ + // Configuration + DraflingConfigInterface::class => DraflingConfig::class, + ]; + } + + public function boot( + DraflingConfigInterface $config, + StorageDriverInterface $storageDriver, + ): void { + // Verify Drafling system is enabled + if (!$config->isEnabled()) { + return; + } + + // Initialize storage driver + $storageDriver->initialize([ + 'base_path' => $config->getProjectsPath(), + 'templates_path' => $config->getTemplatesPath(), + 'storage_driver' => $config->getStorageDriver(), + 'default_entry_status' => $config->getDefaultEntryStatus(), + ]); + } +} diff --git a/src/Drafling/Exception/DraflingException.php b/src/Drafling/Exception/DraflingException.php new file mode 100644 index 00000000..dc282ecf --- /dev/null +++ b/src/Drafling/Exception/DraflingException.php @@ -0,0 +1,19 @@ +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/Drafling/MCP/DTO/EntryCreateRequest.php b/src/Drafling/MCP/DTO/EntryCreateRequest.php new file mode 100644 index 00000000..92e39c19 --- /dev/null +++ b/src/Drafling/MCP/DTO/EntryCreateRequest.php @@ -0,0 +1,28 @@ +title !== null + || $this->status !== null + || $this->tags !== null + || $this->content !== null; + } +} diff --git a/src/Drafling/MCP/DTO/ProjectCreateRequest.php b/src/Drafling/MCP/DTO/ProjectCreateRequest.php new file mode 100644 index 00000000..8979a9d3 --- /dev/null +++ b/src/Drafling/MCP/DTO/ProjectCreateRequest.php @@ -0,0 +1,26 @@ +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->limit !== null) { + $filters['limit'] = $this->limit; + } + + if ($this->offset > 0) { + $filters['offset'] = $this->offset; + } + + return $filters; + } +} diff --git a/src/Drafling/MCP/DTO/ProjectUpdateRequest.php b/src/Drafling/MCP/DTO/ProjectUpdateRequest.php new file mode 100644 index 00000000..fb22aa74 --- /dev/null +++ b/src/Drafling/MCP/DTO/ProjectUpdateRequest.php @@ -0,0 +1,38 @@ +name !== null + || $this->description !== null + || $this->status !== null + || $this->tags !== null + || $this->entryDirs !== null; + } +} diff --git a/src/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php new file mode 100644 index 00000000..a721328a --- /dev/null +++ b/src/Drafling/Repository/EntryRepositoryInterface.php @@ -0,0 +1,44 @@ + + */ +abstract class AbstractStorageDriver implements StorageDriverInterface +{ + /** @var TConfig */ + protected object $config; + + public function __construct( + protected readonly DraflingConfigInterface $draflingConfig, + protected readonly ?LoggerInterface $logger = null, + ) {} + + #[\Override] + public function initialize(object $config): void + { + $this->config = $config; + $this->logger?->debug('Storage driver initialized', [ + 'driver' => $this->getName(), + 'config' => $config, + ]); + } + + #[\Override] + public function synchronize(): void + { + $this->logger?->debug('Synchronizing storage state', [ + 'driver' => $this->getName(), + ]); + + // Base implementation - override in concrete classes + $this->performSynchronization(); + } + + /** + * Perform driver-specific synchronization + */ + abstract protected function performSynchronization(): void; + + /** + * Validate project ID format + */ + protected function validateProjectId(string $projectId): void + { + if (empty($projectId)) { + throw new \InvalidArgumentException('Project ID cannot be empty'); + } + } + + /** + * Validate entry ID format + */ + protected function validateEntryId(string $entryId): void + { + if (empty($entryId)) { + throw new \InvalidArgumentException('Entry ID cannot be empty'); + } + } + + /** + * Generate unique ID for entities + */ + protected function generateId(string $prefix = ''): string + { + $id = \uniqid($prefix, true); + return \str_replace('.', '_', $id); + } + + /** + * Get current timestamp + */ + protected function getCurrentTimestamp(): \DateTime + { + return new \DateTime(); + } + + /** + * Sanitize filename for file system safety + */ + protected function sanitizeFilename(string $filename): string + { + // Remove or replace unsafe characters + $filename = \preg_replace('/[^a-zA-Z0-9\-_\.]/', '-', $filename); + + // Remove consecutive dashes + $filename = \preg_replace('/-+/', '-', $filename); + + // Trim dashes from ends + return \trim($filename, '-'); + } + + /** + * Create slug from title + */ + protected function createSlug(string $title): string + { + $slug = \strtolower($title); + $slug = \preg_replace('/[^a-z0-9\s\-]/', '', $slug); + $slug = \preg_replace('/[\s\-]+/', '-', $slug); + return \trim($slug, '-'); + } + + /** + * Log operation with context + */ + protected function logOperation(string $operation, array $context = []): void + { + $this->logger?->info("Storage operation: {$operation}", [ + 'driver' => $this->getName(), + ...$context, + ]); + } + + /** + * Log error with context + */ + protected function logError(string $message, array $context = [], ?\Throwable $exception = null): void + { + $this->logger?->error($message, [ + 'driver' => $this->getName(), + 'exception' => $exception?->getMessage(), + ...$context, + ]); + } +} diff --git a/src/Drafling/Storage/Config/FileStorageConfig.php b/src/Drafling/Storage/Config/FileStorageConfig.php new file mode 100644 index 00000000..75092939 --- /dev/null +++ b/src/Drafling/Storage/Config/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/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Drafling/Storage/FileStorage/DirectoryScanner.php new file mode 100644 index 00000000..d8432c14 --- /dev/null +++ b/src/Drafling/Storage/FileStorage/DirectoryScanner.php @@ -0,0 +1,334 @@ +files->isDirectory($projectsPath)) { + return []; + } + + $projects = []; + + try { + $finder = new Finder(); + $finder + ->directories() + ->in($projectsPath) + ->depth(0) // Only immediate subdirectories + ->filter(static function (\SplFileInfo $file): bool { + // Check if this directory contains a project.yaml file + $configPath = $file->getRealPath() . '/project.yaml'; + return \file_exists($configPath); + }); + + foreach ($finder as $directory) { + $projects[] = $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 $projects; + } + + /** + * Scan project directory for entry files + * + * @param string $projectPath Path to project directory + * @param array $entryDirs Entry directories to scan (relative to project) + * @return array Array of entry file paths + */ + public function scanEntries(string $projectPath, array $entryDirs = []): array + { + if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { + return []; + } + + $entryFiles = []; + + // If no specific directories provided, scan all subdirectories + if (empty($entryDirs)) { + $entryDirs = $this->getEntryDirectories($projectPath); + } + + $validDirs = []; + foreach ($entryDirs as $dir) { + $dirPath = $this->files->normalizePath($projectPath . '/' . $dir); + if ($this->files->exists($dirPath) && $this->files->isDirectory($dirPath)) { + $validDirs[] = $dirPath; + } + } + + if (empty($validDirs)) { + return []; + } + + try { + $finder = new Finder(); + $finder + ->files() + ->in($validDirs) + ->name('*.md') + ->depth(0); // Only files directly in entry directories + + foreach ($finder as $file) { + $entryFiles[] = $file->getRealPath(); + } + } catch (\Throwable $e) { + // Handle cases where directories are not accessible + // Return empty array - calling code can handle this gracefully + } + + return $entryFiles; + } + + /** + * Get all subdirectories in project that could contain entries + */ + public function getEntryDirectories(string $projectPath): array + { + if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { + return []; + } + + $directories = []; + + try { + $finder = new Finder(); + $finder + ->directories() + ->in($projectPath) + ->depth(0) // Only immediate subdirectories + ->filter(static function (\SplFileInfo $file): bool { + // Skip special directories + $name = $file->getFilename(); + return !\in_array($name, ['.project', 'resources', '.git', '.idea', 'node_modules'], true); + }); + + foreach ($finder as $directory) { + $directories[] = $directory->getFilename(); // Return relative directory name + } + } catch (\Throwable $e) { + // Handle cases where directory is not accessible + // Return empty array + } + + return $directories; + } + + /** + * Create project directory structure + */ + public function createProjectDirectory(string $projectPath, array $entryDirs = []): void + { + // Create main project directory + $this->files->ensureDirectory($projectPath); + + // Create .project subdirectory for metadata + $this->files->ensureDirectory($projectPath . '/.project'); + + // Create entry directories + foreach ($entryDirs as $dir) { + $dirPath = $this->files->normalizePath($projectPath . '/' . $dir); + $this->files->ensureDirectory($dirPath); + } + } + + /** + * Validate project directory structure + */ + public function validateProjectStructure(string $projectPath): array + { + $errors = []; + + if (!$this->files->exists($projectPath)) { + $errors[] = "Project directory does not exist: {$projectPath}"; + return $errors; + } + + if (!$this->files->isDirectory($projectPath)) { + $errors[] = "Project path is not a directory: {$projectPath}"; + return $errors; + } + + // Check for project configuration file + $configPath = $projectPath . '/project.yaml'; + if (!$this->files->exists($configPath)) { + $errors[] = "Missing project configuration file: {$configPath}"; + } + + // Check for .project metadata directory + $metadataDir = $projectPath . '/.project'; + if (!$this->files->exists($metadataDir)) { + $errors[] = "Missing project metadata directory: {$metadataDir}"; + } + + return $errors; + } + + /** + * Get project statistics + */ + public function getProjectStats(string $projectPath): array + { + $stats = [ + 'total_files' => 0, + 'entry_files' => 0, + 'directories' => 0, + 'size_bytes' => 0, + 'last_modified' => null, + ]; + + if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { + return $stats; + } + + try { + // Count all files + $fileFinder = new Finder(); + $fileFinder + ->files() + ->in($projectPath); + + foreach ($fileFinder as $file) { + $stats['total_files']++; + + // Check if it's an entry file + if ($file->getExtension() === 'md') { + $stats['entry_files']++; + } + + // Add to size + $stats['size_bytes'] += $file->getSize(); + + // Update last modified + $modified = $file->getMTime(); + if ($stats['last_modified'] === null || $modified > $stats['last_modified']) { + $stats['last_modified'] = $modified; + } + } + + // Count directories + $dirFinder = new Finder(); + $dirFinder + ->directories() + ->in($projectPath); + + foreach ($dirFinder as $directory) { + $stats['directories']++; + } + } catch (\Throwable $e) { + // Return basic stats if scanning fails + } + + return $stats; + } + + /** + * Find specific files by pattern + * + * @param string $path Base path to search in + * @param string $pattern File pattern (e.g., "*.yaml", "project.*") + * @param int $depth Maximum depth to search (0 = only immediate directory) + * @return array Array of file paths + */ + public function findFiles(string $path, string $pattern, int $depth = 0): array + { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { + return []; + } + + $files = []; + + try { + $finder = new Finder(); + $finder + ->files() + ->in($path) + ->name($pattern) + ->depth($depth); + + foreach ($finder as $file) { + $files[] = $file->getRealPath(); + } + } catch (\Throwable $e) { + // Return empty array if scanning fails + } + + return $files; + } + + /** + * Check if directory contains any markdown files + */ + public function hasMarkdownFiles(string $path): bool + { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { + return false; + } + + try { + $finder = new Finder(); + $finder + ->files() + ->in($path) + ->name('*.md') + ->depth(0); + + return $finder->hasResults(); + } catch (\Throwable $e) { + return false; + } + } + + /** + * Get directory size recursively + */ + public function getDirectorySize(string $path): int + { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { + return 0; + } + + $totalSize = 0; + + try { + $finder = new Finder(); + $finder + ->files() + ->in($path); + + foreach ($finder as $file) { + $totalSize += $file->getSize(); + } + } catch (\Throwable $e) { + // Return 0 if calculation fails + } + + return $totalSize; + } +} diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php new file mode 100644 index 00000000..6150261c --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -0,0 +1,311 @@ +getProjectPath($projectId->value); + + if (!$this->files->exists($projectPath)) { + return []; + } + + $entries = []; + + try { + // Get entry directories from project or scan all directories + $entryDirs = $this->getProjectEntryDirs($projectPath); + $entryFiles = $this->directoryScanner->scanEntries($projectPath, $entryDirs); + + 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 project', [ + 'project_id' => $projectId->value, + 'count' => \count($entries), + 'total_scanned' => \count($entryFiles), + ]); + } catch (\Throwable $e) { + $this->logError('Failed to scan entries for project', ['project_id' => $projectId->value], $e); + } + + return $entries; + } + + #[\Override] + public function findById(ProjectId $projectId, EntryId $entryId): ?Entry + { + $projectPath = $this->getProjectPath($projectId->value); + $entryFile = $this->findEntryFile($projectPath, $entryId->value); + + if ($entryFile === null) { + return null; + } + + try { + return $this->loadEntryFromFile($entryFile); + } catch (\Throwable $e) { + $this->logError('Failed to load entry by ID', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ], $e); + return null; + } + } + + #[\Override] + public function save(ProjectId $projectId, Entry $entry): void + { + $projectPath = $this->getProjectPath($projectId->value); + + if (!$this->files->exists($projectPath)) { + throw new \RuntimeException("Project directory not found: {$projectPath}"); + } + + try { + $this->saveEntryToFile($projectPath, $entry); + + $this->logOperation('Saved entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + ]); + } catch (\Throwable $e) { + $this->logError('Failed to save entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entry->entryId, + ], $e); + throw $e; + } + } + + #[\Override] + public function delete(ProjectId $projectId, EntryId $entryId): bool + { + $projectPath = $this->getProjectPath($projectId->value); + $entryFile = $this->findEntryFile($projectPath, $entryId->value); + + if ($entryFile === null) { + return false; + } + + try { + $deleted = $this->files->delete($entryFile); + + if ($deleted) { + $this->logOperation('Deleted entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'file' => $entryFile, + ]); + } + + return $deleted; + } catch (\Throwable $e) { + $this->logError('Failed to delete entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ], $e); + return false; + } + } + + #[\Override] + public function exists(ProjectId $projectId, EntryId $entryId): bool + { + $projectPath = $this->getProjectPath($projectId->value); + return $this->findEntryFile($projectPath, $entryId->value) !== null; + } + + /** + * Get project directory path from ID + */ + private function getProjectPath(string $projectId): string + { + $basePath = $this->getBasePath(); + return $this->files->normalizePath($basePath . '/' . $projectId); + } + + /** + * Get entry directories for a project + */ + private function getProjectEntryDirs(string $projectPath): array + { + $configPath = $projectPath . '/project.yaml'; + + if (!$this->files->exists($configPath)) { + // Fallback: scan all directories + return $this->directoryScanner->getEntryDirectories($projectPath); + } + + try { + $config = $this->readYamlFile($configPath); + return $config['project']['entries']['dirs'] ?? []; + } catch (\Throwable) { + // Fallback: scan all directories + return $this->directoryScanner->getEntryDirectories($projectPath); + } + } + + /** + * Find entry file by entry ID + */ + private function findEntryFile(string $projectPath, string $entryId): ?string + { + $entryDirs = $this->getProjectEntryDirs($projectPath); + $entryFiles = $this->directoryScanner->scanEntries($projectPath, $entryDirs); + + 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'], + 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 $projectPath, Entry $entry): void + { + // Determine file path + $filePath = $entry->filePath; + + if ($filePath === null) { + // New entry - generate file path + $categoryPath = $this->files->normalizePath($projectPath . '/' . $entry->category); + $this->ensureDirectory($categoryPath); + + $filename = $this->generateFilename($entry->title); + $filePath = $categoryPath . '/' . $filename; + } + + // Prepare frontmatter + $frontmatter = [ + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + '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; + } + } + + return true; + } +} diff --git a/src/Drafling/Storage/FileStorage/FileProjectRepository.php b/src/Drafling/Storage/FileStorage/FileProjectRepository.php new file mode 100644 index 00000000..224585d5 --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FileProjectRepository.php @@ -0,0 +1,217 @@ +directoryScanner->scanProjects($this->getBasePath()); + + foreach ($projectPaths as $projectPath) { + try { + $project = $this->loadProjectFromDirectory($projectPath); + if ($project !== null && $this->matchesFilters($project, $filters)) { + $projects[] = $project; + } + } catch (\Throwable $e) { + $this->logError('Failed to load project', ['path' => $projectPath], $e); + } + } + + $this->logOperation('Loaded projects', [ + 'count' => \count($projects), + 'total_scanned' => \count($projectPaths), + ]); + + return $projects; + } + + #[\Override] + public function findById(ProjectId $id): ?Project + { + $projectPath = $this->getProjectPath($id->value); + + if (!$this->files->exists($projectPath)) { + return null; + } + + try { + return $this->loadProjectFromDirectory($projectPath); + } catch (\Throwable $e) { + $this->logError('Failed to load project by ID', ['id' => $id->value, 'path' => $projectPath], $e); + return null; + } + } + + #[\Override] + public function save(Project $project): void + { + $projectPath = $this->getProjectPath($project->id); + + try { + // Ensure project directory exists + $this->ensureDirectory($projectPath); + + // Create entry directories if they don't exist + foreach ($project->entryDirs as $entryDir) { + $entryDirPath = $this->files->normalizePath($projectPath . '/' . $entryDir); + $this->ensureDirectory($entryDirPath); + } + + // Save project configuration + $this->saveProjectConfig($projectPath, $project); + + $this->logOperation('Saved project', [ + 'id' => $project->id, + 'name' => $project->name, + 'path' => $projectPath, + ]); + } catch (\Throwable $e) { + $this->logError('Failed to save project', ['id' => $project->id], $e); + throw $e; + } + } + + #[\Override] + public function delete(ProjectId $id): bool + { + $projectPath = $this->getProjectPath($id->value); + + if (!$this->files->exists($projectPath)) { + return false; + } + + try { + $deleted = $this->files->deleteDirectory($projectPath); + + if ($deleted) { + $this->logOperation('Deleted project', ['id' => $id->value, 'path' => $projectPath]); + } + + return $deleted; + } catch (\Throwable $e) { + $this->logError('Failed to delete project', ['id' => $id->value], $e); + return false; + } + } + + #[\Override] + public function exists(ProjectId $id): bool + { + $projectPath = $this->getProjectPath($id->value); + $configPath = $projectPath . '/project.yaml'; + + return $this->files->exists($configPath); + } + + /** + * Get project directory path from ID + */ + private function getProjectPath(string $projectId): string + { + $basePath = $this->getBasePath(); + return $this->files->normalizePath($basePath . '/' . $projectId); + } + + /** + * Load project from directory path + */ + private function loadProjectFromDirectory(string $projectPath): ?Project + { + $configPath = $projectPath . '/project.yaml'; + + if (!$this->files->exists($configPath)) { + throw new \RuntimeException("Project configuration not found: {$configPath}"); + } + + $config = $this->readYamlFile($configPath); + + if (!isset($config['project'])) { + throw new \RuntimeException("Invalid project configuration: missing 'project' section"); + } + + $projectData = $config['project']; + + // Extract project ID from directory name + $projectId = \basename($projectPath); + + return new Project( + id: $projectId, + name: $projectData['name'] ?? $projectId, + description: $projectData['description'] ?? '', + template: $projectData['template'] ?? '', + status: $projectData['status'] ?? 'draft', + tags: $projectData['tags'] ?? [], + entryDirs: $projectData['entries']['dirs'] ?? [], + projectPath: $projectPath, + ); + } + + /** + * Save project configuration to YAML file + */ + private function saveProjectConfig(string $projectPath, Project $project): void + { + $configPath = $projectPath . '/project.yaml'; + + $config = [ + 'project' => [ + 'name' => $project->name, + 'description' => $project->description, + 'template' => $project->template, + 'status' => $project->status, + 'tags' => $project->tags, + 'entries' => [ + 'dirs' => $project->entryDirs, + ], + ], + ]; + + $this->writeYamlFile($configPath, $config); + } + + /** + * Check if project matches the provided filters + */ + private function matchesFilters(Project $project, array $filters): bool + { + // Status filter + if (isset($filters['status']) && $project->status !== $filters['status']) { + return false; + } + + // Template filter + if (isset($filters['template']) && $project->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, $project->tags, true)) { + $hasMatchingTag = true; + break; + } + } + if (!$hasMatchingTag) { + return false; + } + } + + return true; + } +} diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php new file mode 100644 index 00000000..5cee89e7 --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -0,0 +1,335 @@ + + */ +final class FileStorageDriver extends AbstractStorageDriver +{ + private readonly ProjectRepositoryInterface $projectRepository; + private readonly EntryRepositoryInterface $entryRepository; + private readonly TemplateRepositoryInterface $templateRepository; + + public function __construct( + DraflingConfigInterface $draflingConfig, + FilesInterface $files, + ?\Psr\Log\LoggerInterface $logger = null, + ) { + parent::__construct($draflingConfig, $logger); + + // Initialize repositories + $frontmatterParser = new FrontmatterParser(); + $directoryScanner = new DirectoryScanner($files); + + $this->templateRepository = new FileTemplateRepository( + $files, + $draflingConfig, + $frontmatterParser, + $directoryScanner, + $logger, + ); + + $this->projectRepository = new FileProjectRepository( + $files, + $draflingConfig, + $frontmatterParser, + $directoryScanner, + $logger, + ); + + $this->entryRepository = new FileEntryRepository( + $files, + $draflingConfig, + $frontmatterParser, + $directoryScanner, + $logger, + ); + } + + #[\Override] + public function supports(string $type): bool + { + return $type === 'markdown' || $type === 'file'; + } + + #[\Override] + public function getName(): string + { + return 'file_storage'; + } + + #[\Override] + public function initialize(object $config): void + { + if (!$config instanceof FileStorageConfig) { + throw new \InvalidArgumentException('FileStorageDriver requires FileStorageConfig'); + } + + // Validate configuration + $errors = $config->validate(); + if (!empty($errors)) { + throw new \InvalidArgumentException('Invalid FileStorageConfig: ' . \implode(', ', $errors)); + } + + parent::initialize($config); + } + + #[\Override] + public function createProject(ProjectCreateRequest $request): Project + { + // Validate template exists + $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($request->templateId); + $template = $this->templateRepository->findByKey($templateKey); + + if ($template === null) { + throw TemplateNotFoundException::withKey($request->templateId); + } + + // Generate project ID and create project + $projectId = $this->generateId('proj_'); + $project = new Project( + id: $projectId, + name: $request->name, + description: $request->description, + template: $request->templateId, + status: $this->config->defaultEntryStatus, + tags: $request->tags, + entryDirs: !empty($request->entryDirs) ? $request->entryDirs : $this->getDefaultEntryDirs($template), + ); + + $this->projectRepository->save($project); + $this->logOperation('Created project', ['id' => $projectId, 'name' => $request->name]); + + return $project; + } + + #[\Override] + public function updateProject(ProjectId $projectId, ProjectUpdateRequest $request): Project + { + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + throw ProjectNotFoundException::withId($projectId->value); + } + + if (!$request->hasUpdates()) { + return $project; + } + + $updatedProject = $project->withUpdates( + name: $request->name, + description: $request->description, + status: $request->status, + tags: $request->tags, + entryDirs: $request->entryDirs, + ); + + $this->projectRepository->save($updatedProject); + $this->logOperation('Updated project', ['id' => $projectId->value]); + + return $updatedProject; + } + + #[\Override] + public function deleteProject(ProjectId $projectId): bool + { + if (!$this->projectRepository->exists($projectId)) { + return false; + } + + $deleted = $this->projectRepository->delete($projectId); + if ($deleted) { + $this->logOperation('Deleted project', ['id' => $projectId->value]); + } + + return $deleted; + } + + #[\Override] + public function createEntry(ProjectId $projectId, EntryCreateRequest $request): Entry + { + // Verify project exists + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + throw ProjectNotFoundException::withId($projectId->value); + } + + // Validate against template + $this->validateEntryAgainstTemplate($project, $request); + + // Generate entry ID and create entry + $entryId = $this->generateId('entry_'); + $now = $this->getCurrentTimestamp(); + + $entry = new Entry( + entryId: $entryId, + title: $request->title, + entryType: $request->entryType, + category: $request->category, + status: $request->status ?? $this->config->defaultEntryStatus, + createdAt: $now, + updatedAt: $now, + tags: $request->tags, + content: $request->content, + ); + + $this->entryRepository->save($projectId, $entry); + $this->logOperation('Created entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId, + 'title' => $request->title, + ]); + + return $entry; + } + + #[\Override] + public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry + { + $entry = $this->entryRepository->findById($projectId, $entryId); + if ($entry === null) { + throw EntryNotFoundException::withId($projectId->value, $entryId->value); + } + + if (!$request->hasUpdates()) { + return $entry; + } + + $updatedEntry = $entry->withUpdates( + title: $request->title, + status: $request->status, + tags: $request->tags, + content: $request->content, + ); + + $this->entryRepository->save($projectId, $updatedEntry); + $this->logOperation('Updated entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + + return $updatedEntry; + } + + #[\Override] + public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool + { + if (!$this->entryRepository->exists($projectId, $entryId)) { + return false; + } + + $deleted = $this->entryRepository->delete($projectId, $entryId); + if ($deleted) { + $this->logOperation('Deleted entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + } + + return $deleted; + } + + #[\Override] + protected function performSynchronization(): void + { + // Refresh template cache + $this->templateRepository->refresh(); + + $this->logOperation('Synchronized file storage'); + } + + /** + * Get default entry directories from template + */ + private function getDefaultEntryDirs(\Butschster\ContextGenerator\Drafling\Domain\Model\Template $template): array + { + $dirs = []; + foreach ($template->categories as $category) { + $dirs[] = $category->name; + } + return $dirs; + } + + /** + * Validate entry request against project template + */ + private function validateEntryAgainstTemplate(Project $project, EntryCreateRequest $request): void + { + $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); + $template = $this->templateRepository->findByKey($templateKey); + + if ($template === null) { + throw TemplateNotFoundException::withKey($project->template); + } + + // 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}'"); + } + } + } + + /** + * Get project repository + */ + public function getProjectRepository(): ProjectRepositoryInterface + { + return $this->projectRepository; + } + + /** + * Get entry repository + */ + public function getEntryRepository(): EntryRepositoryInterface + { + return $this->entryRepository; + } + + /** + * Get template repository + */ + public function getTemplateRepository(): TemplateRepositoryInterface + { + return $this->templateRepository; + } +} diff --git a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php new file mode 100644 index 00000000..a44c790b --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php @@ -0,0 +1,161 @@ +config->getProjectsPath(); + } + + /** + * Get templates base path + */ + protected function getTemplatesPath(): string + { + return $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 \Symfony\Component\Yaml\Yaml::parse($content) ?? []; + } catch (\Symfony\Component\Yaml\Exception\ParseException $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 = \Symfony\Component\Yaml\Yaml::dump( + $data, + 4, + 2, + \Symfony\Component\Yaml\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\-]+/', '-', $slug); + $slug = \trim($slug, '-'); + + return $slug . '.' . $extension; + } + + /** + * Generate unique ID + */ + protected function generateId(string $prefix = ''): string + { + $id = \uniqid($prefix, true); + return \str_replace('.', '_', $id); + } + + /** + * Get current ISO 8601 timestamp + */ + protected function getCurrentTimestamp(): string + { + return (new \DateTime())->format('c'); + } + + /** + * 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/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php new file mode 100644 index 00000000..e68ba57f --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php @@ -0,0 +1,220 @@ +ensureCacheLoaded(); + return \array_values($this->templateCache); + } + + #[\Override] + public function findByKey(TemplateKey $key): ?Template + { + $this->ensureCacheLoaded(); + return $this->templateCache[$key->value] ?? null; + } + + #[\Override] + public function exists(TemplateKey $key): bool + { + return $this->findByKey($key) !== null; + } + + #[\Override] + public function refresh(): void + { + $this->templateCache = []; + $this->cacheLoaded = false; + $this->logOperation('Template cache refreshed'); + } + + /** + * Load all templates from file system if not already cached + */ + private function ensureCacheLoaded(): void + { + if ($this->cacheLoaded) { + return; + } + + $this->loadTemplatesFromFilesystem(); + $this->cacheLoaded = true; + } + + /** + * Load templates from YAML files in templates directory + */ + private function loadTemplatesFromFilesystem(): void + { + $templatesPath = $this->getTemplatesPath(); + + if (!$this->files->exists($templatesPath) || !$this->files->isDirectory($templatesPath)) { + $this->logger?->warning('Templates directory not found', ['path' => $templatesPath]); + return; + } + + $templateFiles = $this->files->getFiles($templatesPath, '*.yaml'); + + foreach ($templateFiles as $templateFile) { + $filePath = $this->files->normalizePath($templatesPath . '/' . $templateFile); + + try { + $template = $this->loadTemplateFromFile($filePath); + if ($template !== null) { + $this->templateCache[$template->key] = $template; + } + } catch (\Throwable $e) { + $this->logError('Failed to load template', ['file' => $filePath], $e); + } + } + + $this->logOperation('Loaded templates from filesystem', [ + 'count' => \count($this->templateCache), + 'path' => $templatesPath, + ]); + } + + /** + * 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->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', 'icon', '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'], + icon: $data['icon'], + entryTypes: $data['entry_types'], + ); + } + + /** + * Create EntryType object from parsed data + */ + private function createEntryTypeFromData(string $key, array $data): EntryType + { + $requiredFields = ['display_name', 'icon', 'content_type', 'color', 'default_status']; + 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'], + icon: $data['icon'], + contentType: $data['content_type'], + color: $data['color'], + defaultStatus: $data['default_status'], + statuses: $statuses, + ); + } + + /** + * Create Status object from parsed data + */ + private function createStatusFromData(array $data): Status + { + $requiredFields = ['value', 'display_name', 'color']; + 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'], + color: $data['color'], + ); + } +} diff --git a/src/Drafling/Storage/FileStorage/FrontmatterParser.php b/src/Drafling/Storage/FileStorage/FrontmatterParser.php new file mode 100644 index 00000000..8bbd81c3 --- /dev/null +++ b/src/Drafling/Storage/FileStorage/FrontmatterParser.php @@ -0,0 +1,200 @@ + [], + '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']; + } + + /** + * Extract only the markdown content from file content + */ + public function extractContent(string $content): string + { + return $this->parse($content)['content']; + } + + /** + * Update frontmatter while preserving content + */ + public function updateFrontmatter(string $originalContent, array $newFrontmatter): string + { + $parsed = $this->parse($originalContent); + return $this->combine($newFrontmatter, $parsed['content']); + } + + /** + * Update content while preserving frontmatter + */ + public function updateContent(string $originalContent, string $newContent): string + { + $parsed = $this->parse($originalContent); + return $this->combine($parsed['frontmatter'], $newContent); + } + + /** + * Merge frontmatter updates with existing frontmatter + */ + public function mergeFrontmatter(string $originalContent, array $updates): string + { + $parsed = $this->parse($originalContent); + $mergedFrontmatter = \array_merge($parsed['frontmatter'], $updates); + return $this->combine($mergedFrontmatter, $parsed['content']); + } + + /** + * Validate YAML frontmatter structure + */ + public function validateFrontmatter(array $frontmatter): array + { + $errors = []; + + // Validate required fields + $requiredFields = ['entry_id', 'title', 'entry_type', 'category', 'status']; + foreach ($requiredFields as $field) { + if (!isset($frontmatter[$field]) || empty($frontmatter[$field])) { + $errors[] = "Missing required field: {$field}"; + } + } + + // Validate date fields + $dateFields = ['created_at', 'updated_at']; + foreach ($dateFields as $field) { + if (isset($frontmatter[$field]) && !\is_string($frontmatter[$field])) { + $errors[] = "Field '{$field}' must be a valid ISO 8601 date string"; + } + } + + // Validate array fields + $arrayFields = ['tags']; + foreach ($arrayFields as $field) { + if (isset($frontmatter[$field]) && !\is_array($frontmatter[$field])) { + $errors[] = "Field '{$field}' must be an array"; + } + } + + return $errors; + } + + /** + * Normalize frontmatter data types + */ + public function normalizeFrontmatter(array $frontmatter): array + { + // Ensure tags is an array + if (isset($frontmatter['tags']) && !\is_array($frontmatter['tags'])) { + $frontmatter['tags'] = []; + } + + // Ensure dates are properly formatted + $dateFields = ['created_at', 'updated_at']; + foreach ($dateFields as $field) { + if (isset($frontmatter[$field]) && \is_string($frontmatter[$field])) { + try { + $date = new \DateTime($frontmatter[$field]); + $frontmatter[$field] = $date->format('c'); // ISO 8601 format + } catch (\Exception) { + // Invalid date - leave as is, validation will catch it + } + } + } + + return $frontmatter; + } +} diff --git a/src/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php new file mode 100644 index 00000000..feb7ef63 --- /dev/null +++ b/src/Drafling/Storage/StorageBootloader.php @@ -0,0 +1,78 @@ + static function ( + DraflingConfigInterface $config, + FilesInterface $files, + LoggerInterface $logger, + ): StorageDriverInterface { + $driver = new FileStorageDriver($config, $files, $logger); + + // Initialize with typed configuration + $storageConfig = FileStorageConfig::fromArray([ + 'base_path' => $config->getProjectsPath(), + 'templates_path' => $config->getTemplatesPath(), + 'default_entry_status' => $config->getDefaultEntryStatus(), + ]); + + $driver->initialize($storageConfig); + + return $driver; + }, + + // Repositories - bind to storage driver repositories + TemplateRepositoryInterface::class => static function (StorageDriverInterface $driver): TemplateRepositoryInterface { + if ($driver instanceof FileStorageDriver) { + return $driver->getTemplateRepository(); + } + + throw new \RuntimeException('Storage driver does not support template repository'); + }, + + ProjectRepositoryInterface::class => static function (StorageDriverInterface $driver): ProjectRepositoryInterface { + if ($driver instanceof FileStorageDriver) { + return $driver->getProjectRepository(); + } + + throw new \RuntimeException('Storage driver does not support project repository'); + }, + + EntryRepositoryInterface::class => static function (StorageDriverInterface $driver): EntryRepositoryInterface { + if ($driver instanceof FileStorageDriver) { + return $driver->getEntryRepository(); + } + + throw new \RuntimeException('Storage driver does not support entry repository'); + }, + ]; + } + + public function boot(StorageDriverInterface $storageDriver): void + { + // Synchronize storage on boot + $storageDriver->synchronize(); + } +} diff --git a/src/Drafling/Storage/StorageDriverInterface.php b/src/Drafling/Storage/StorageDriverInterface.php new file mode 100644 index 00000000..45aa5bfc --- /dev/null +++ b/src/Drafling/Storage/StorageDriverInterface.php @@ -0,0 +1,74 @@ + Date: Tue, 23 Sep 2025 11:46:14 +0000 Subject: [PATCH 02/16] style(php-cs-fixer): fix coding standards --- .../Exception/ValidationException.php | 6 +- src/Drafling/MCP/DTO/ProjectFilters.php | 12 ++-- .../Repository/EntryRepositoryInterface.php | 2 +- .../Repository/ProjectRepositoryInterface.php | 2 +- .../TemplateRepositoryInterface.php | 2 +- .../Service/EntryServiceInterface.php | 2 +- .../Storage/AbstractStorageDriver.php | 8 +-- .../FileStorage/FileEntryRepository.php | 29 ++++---- .../FileStorage/FileProjectRepository.php | 21 +++--- .../Storage/FileStorage/FileStorageDriver.php | 66 +++++++++---------- .../FileStorage/FileTemplateRepository.php | 7 +- .../Storage/FileStorage/FrontmatterParser.php | 2 +- src/Drafling/Storage/StorageBootloader.php | 14 ++-- .../Storage/StorageDriverInterface.php | 4 +- 14 files changed, 87 insertions(+), 90 deletions(-) diff --git a/src/Drafling/Exception/ValidationException.php b/src/Drafling/Exception/ValidationException.php index e03cb7ec..d913939b 100644 --- a/src/Drafling/Exception/ValidationException.php +++ b/src/Drafling/Exception/ValidationException.php @@ -20,15 +20,15 @@ public function __construct( ) { $errorMessage = $message; if (!empty($this->errors)) { - $errorMessage .= ': ' . implode(', ', $this->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 diff --git a/src/Drafling/MCP/DTO/ProjectFilters.php b/src/Drafling/MCP/DTO/ProjectFilters.php index d7c4a826..f2023777 100644 --- a/src/Drafling/MCP/DTO/ProjectFilters.php +++ b/src/Drafling/MCP/DTO/ProjectFilters.php @@ -30,27 +30,27 @@ public function __construct( public function toArray(): array { $filters = []; - + if ($this->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->limit !== null) { $filters['limit'] = $this->limit; } - + if ($this->offset > 0) { $filters['offset'] = $this->offset; } - + return $filters; } } diff --git a/src/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php index a721328a..58c1bbc5 100644 --- a/src/Drafling/Repository/EntryRepositoryInterface.php +++ b/src/Drafling/Repository/EntryRepositoryInterface.php @@ -15,7 +15,7 @@ interface EntryRepositoryInterface { /** * Find entries for a project with optional filters - * + * * @param ProjectId $projectId Project identifier * @param array $filters Optional filters (category, status, tags, etc.) * @return Entry[] diff --git a/src/Drafling/Repository/ProjectRepositoryInterface.php b/src/Drafling/Repository/ProjectRepositoryInterface.php index 8245f35f..aca41592 100644 --- a/src/Drafling/Repository/ProjectRepositoryInterface.php +++ b/src/Drafling/Repository/ProjectRepositoryInterface.php @@ -14,7 +14,7 @@ interface ProjectRepositoryInterface { /** * Find all projects with optional filters - * + * * @param array $filters Optional filters (status, template, tags, etc.) * @return Project[] */ diff --git a/src/Drafling/Repository/TemplateRepositoryInterface.php b/src/Drafling/Repository/TemplateRepositoryInterface.php index 152afa26..34ea7af8 100644 --- a/src/Drafling/Repository/TemplateRepositoryInterface.php +++ b/src/Drafling/Repository/TemplateRepositoryInterface.php @@ -14,7 +14,7 @@ interface TemplateRepositoryInterface { /** * Find all templates - * + * * @return Template[] */ public function findAll(): array; diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index 1a8137aa..6a1e6396 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -17,7 +17,7 @@ interface EntryServiceInterface { /** * Get entries for a project with optional filtering - * + * * @param ProjectId $projectId Project identifier * @param array $filters Optional filters (category, status, tags, etc.) * @return Entry[] diff --git a/src/Drafling/Storage/AbstractStorageDriver.php b/src/Drafling/Storage/AbstractStorageDriver.php index a4862254..e8da8ec9 100644 --- a/src/Drafling/Storage/AbstractStorageDriver.php +++ b/src/Drafling/Storage/AbstractStorageDriver.php @@ -9,7 +9,7 @@ /** * Abstract base class for storage drivers with common functionality - * + * * @template TConfig of object * @implements StorageDriverInterface */ @@ -39,7 +39,7 @@ public function synchronize(): void $this->logger?->debug('Synchronizing storage state', [ 'driver' => $this->getName(), ]); - + // Base implementation - override in concrete classes $this->performSynchronization(); } @@ -93,10 +93,10 @@ protected function sanitizeFilename(string $filename): string { // Remove or replace unsafe characters $filename = \preg_replace('/[^a-zA-Z0-9\-_\.]/', '-', $filename); - + // Remove consecutive dashes $filename = \preg_replace('/-+/', '-', $filename); - + // Trim dashes from ends return \trim($filename, '-'); } diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php index 6150261c..eb1f3f6b 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -8,7 +8,6 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; /** * File-based entry repository implementation @@ -19,13 +18,13 @@ final class FileEntryRepository extends FileStorageRepositoryBase implements Ent public function findByProject(ProjectId $projectId, array $filters = []): array { $projectPath = $this->getProjectPath($projectId->value); - + if (!$this->files->exists($projectPath)) { return []; } $entries = []; - + try { // Get entry directories from project or scan all directories $entryDirs = $this->getProjectEntryDirs($projectPath); @@ -59,7 +58,7 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry { $projectPath = $this->getProjectPath($projectId->value); $entryFile = $this->findEntryFile($projectPath, $entryId->value); - + if ($entryFile === null) { return null; } @@ -79,14 +78,14 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry public function save(ProjectId $projectId, Entry $entry): void { $projectPath = $this->getProjectPath($projectId->value); - + if (!$this->files->exists($projectPath)) { throw new \RuntimeException("Project directory not found: {$projectPath}"); } try { $this->saveEntryToFile($projectPath, $entry); - + $this->logOperation('Saved entry', [ 'project_id' => $projectId->value, 'entry_id' => $entry->entryId, @@ -106,14 +105,14 @@ public function delete(ProjectId $projectId, EntryId $entryId): bool { $projectPath = $this->getProjectPath($projectId->value); $entryFile = $this->findEntryFile($projectPath, $entryId->value); - + if ($entryFile === null) { return false; } try { $deleted = $this->files->delete($entryFile); - + if ($deleted) { $this->logOperation('Deleted entry', [ 'project_id' => $projectId->value, @@ -154,7 +153,7 @@ private function getProjectPath(string $projectId): string private function getProjectEntryDirs(string $projectPath): array { $configPath = $projectPath . '/project.yaml'; - + if (!$this->files->exists($configPath)) { // Fallback: scan all directories return $this->directoryScanner->getEntryDirectories($projectPath); @@ -180,7 +179,7 @@ private function findEntryFile(string $projectPath, string $entryId): ?string foreach ($entryFiles as $filePath) { try { $frontmatter = $this->frontmatterParser->extractFrontmatter( - $this->files->read($filePath) + $this->files->read($filePath), ); if (isset($frontmatter['entry_id']) && $frontmatter['entry_id'] === $entryId) { @@ -213,11 +212,11 @@ private function loadEntryFromFile(string $filePath): ?Entry } // Parse dates - $createdAt = isset($frontmatter['created_at']) + $createdAt = isset($frontmatter['created_at']) ? new \DateTime($frontmatter['created_at']) : new \DateTime(); - - $updatedAt = isset($frontmatter['updated_at']) + + $updatedAt = isset($frontmatter['updated_at']) ? new \DateTime($frontmatter['updated_at']) : new \DateTime(); @@ -246,12 +245,12 @@ private function saveEntryToFile(string $projectPath, Entry $entry): void { // Determine file path $filePath = $entry->filePath; - + if ($filePath === null) { // New entry - generate file path $categoryPath = $this->files->normalizePath($projectPath . '/' . $entry->category); $this->ensureDirectory($categoryPath); - + $filename = $this->generateFilename($entry->title); $filePath = $categoryPath . '/' . $filename; } diff --git a/src/Drafling/Storage/FileStorage/FileProjectRepository.php b/src/Drafling/Storage/FileStorage/FileProjectRepository.php index 224585d5..ce834f6a 100644 --- a/src/Drafling/Storage/FileStorage/FileProjectRepository.php +++ b/src/Drafling/Storage/FileStorage/FileProjectRepository.php @@ -7,7 +7,6 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Project; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; /** * File-based project repository implementation @@ -43,7 +42,7 @@ public function findAll(array $filters = []): array public function findById(ProjectId $id): ?Project { $projectPath = $this->getProjectPath($id->value); - + if (!$this->files->exists($projectPath)) { return null; } @@ -60,7 +59,7 @@ public function findById(ProjectId $id): ?Project public function save(Project $project): void { $projectPath = $this->getProjectPath($project->id); - + try { // Ensure project directory exists $this->ensureDirectory($projectPath); @@ -89,14 +88,14 @@ public function save(Project $project): void public function delete(ProjectId $id): bool { $projectPath = $this->getProjectPath($id->value); - + if (!$this->files->exists($projectPath)) { return false; } try { $deleted = $this->files->deleteDirectory($projectPath); - + if ($deleted) { $this->logOperation('Deleted project', ['id' => $id->value, 'path' => $projectPath]); } @@ -113,7 +112,7 @@ public function exists(ProjectId $id): bool { $projectPath = $this->getProjectPath($id->value); $configPath = $projectPath . '/project.yaml'; - + return $this->files->exists($configPath); } @@ -132,22 +131,22 @@ private function getProjectPath(string $projectId): string private function loadProjectFromDirectory(string $projectPath): ?Project { $configPath = $projectPath . '/project.yaml'; - + if (!$this->files->exists($configPath)) { throw new \RuntimeException("Project configuration not found: {$configPath}"); } $config = $this->readYamlFile($configPath); - + if (!isset($config['project'])) { throw new \RuntimeException("Invalid project configuration: missing 'project' section"); } $projectData = $config['project']; - + // Extract project ID from directory name $projectId = \basename($projectPath); - + return new Project( id: $projectId, name: $projectData['name'] ?? $projectId, @@ -166,7 +165,7 @@ private function loadProjectFromDirectory(string $projectPath): ?Project private function saveProjectConfig(string $projectPath, Project $project): void { $configPath = $projectPath . '/project.yaml'; - + $config = [ 'project' => [ 'name' => $project->name, diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index 5cee89e7..5aa170ba 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -25,7 +25,7 @@ /** * File-based storage driver implementation using Markdown files with YAML frontmatter - * + * * @extends AbstractStorageDriver */ final class FileStorageDriver extends AbstractStorageDriver @@ -40,11 +40,11 @@ public function __construct( ?\Psr\Log\LoggerInterface $logger = null, ) { parent::__construct($draflingConfig, $logger); - + // Initialize repositories $frontmatterParser = new FrontmatterParser(); $directoryScanner = new DirectoryScanner($files); - + $this->templateRepository = new FileTemplateRepository( $files, $draflingConfig, @@ -52,7 +52,7 @@ public function __construct( $directoryScanner, $logger, ); - + $this->projectRepository = new FileProjectRepository( $files, $draflingConfig, @@ -60,7 +60,7 @@ public function __construct( $directoryScanner, $logger, ); - + $this->entryRepository = new FileEntryRepository( $files, $draflingConfig, @@ -104,7 +104,7 @@ public function createProject(ProjectCreateRequest $request): Project // Validate template exists $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($request->templateId); $template = $this->templateRepository->findByKey($templateKey); - + if ($template === null) { throw TemplateNotFoundException::withKey($request->templateId); } @@ -183,7 +183,7 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): // Generate entry ID and create entry $entryId = $this->generateId('entry_'); $now = $this->getCurrentTimestamp(); - + $entry = new Entry( entryId: $entryId, title: $request->title, @@ -252,12 +252,36 @@ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool return $deleted; } + /** + * Get project repository + */ + public function getProjectRepository(): ProjectRepositoryInterface + { + return $this->projectRepository; + } + + /** + * Get entry repository + */ + public function getEntryRepository(): EntryRepositoryInterface + { + return $this->entryRepository; + } + + /** + * Get template repository + */ + public function getTemplateRepository(): TemplateRepositoryInterface + { + return $this->templateRepository; + } + #[\Override] protected function performSynchronization(): void { // Refresh template cache $this->templateRepository->refresh(); - + $this->logOperation('Synchronized file storage'); } @@ -280,7 +304,7 @@ private function validateEntryAgainstTemplate(Project $project, EntryCreateReque { $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); $template = $this->templateRepository->findByKey($templateKey); - + if ($template === null) { throw TemplateNotFoundException::withKey($project->template); } @@ -308,28 +332,4 @@ private function validateEntryAgainstTemplate(Project $project, EntryCreateReque } } } - - /** - * Get project repository - */ - public function getProjectRepository(): ProjectRepositoryInterface - { - return $this->projectRepository; - } - - /** - * Get entry repository - */ - public function getEntryRepository(): EntryRepositoryInterface - { - return $this->entryRepository; - } - - /** - * Get template repository - */ - public function getTemplateRepository(): TemplateRepositoryInterface - { - return $this->templateRepository; - } } diff --git a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php index e68ba57f..d83e589d 100644 --- a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php +++ b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php @@ -10,7 +10,6 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Template; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\Repository\TemplateRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; /** * File-based template repository implementation @@ -67,17 +66,17 @@ private function ensureCacheLoaded(): void private function loadTemplatesFromFilesystem(): void { $templatesPath = $this->getTemplatesPath(); - + if (!$this->files->exists($templatesPath) || !$this->files->isDirectory($templatesPath)) { $this->logger?->warning('Templates directory not found', ['path' => $templatesPath]); return; } $templateFiles = $this->files->getFiles($templatesPath, '*.yaml'); - + foreach ($templateFiles as $templateFile) { $filePath = $this->files->normalizePath($templatesPath . '/' . $templateFile); - + try { $template = $this->loadTemplateFromFile($filePath); if ($template !== null) { diff --git a/src/Drafling/Storage/FileStorage/FrontmatterParser.php b/src/Drafling/Storage/FileStorage/FrontmatterParser.php index 8bbd81c3..cd2c4a37 100644 --- a/src/Drafling/Storage/FileStorage/FrontmatterParser.php +++ b/src/Drafling/Storage/FileStorage/FrontmatterParser.php @@ -22,7 +22,7 @@ public function parse(string $content): array { $content = \trim($content); - + // Check if file starts with frontmatter delimiter if (!\str_starts_with($content, self::FRONTMATTER_DELIMITER)) { return [ diff --git a/src/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php index feb7ef63..f2c9439a 100644 --- a/src/Drafling/Storage/StorageBootloader.php +++ b/src/Drafling/Storage/StorageBootloader.php @@ -30,25 +30,25 @@ public function defineSingletons(): array LoggerInterface $logger, ): StorageDriverInterface { $driver = new FileStorageDriver($config, $files, $logger); - + // Initialize with typed configuration $storageConfig = FileStorageConfig::fromArray([ 'base_path' => $config->getProjectsPath(), 'templates_path' => $config->getTemplatesPath(), 'default_entry_status' => $config->getDefaultEntryStatus(), ]); - + $driver->initialize($storageConfig); - + return $driver; }, - // Repositories - bind to storage driver repositories + // Repositories - bind to storage driver repositories TemplateRepositoryInterface::class => static function (StorageDriverInterface $driver): TemplateRepositoryInterface { if ($driver instanceof FileStorageDriver) { return $driver->getTemplateRepository(); } - + throw new \RuntimeException('Storage driver does not support template repository'); }, @@ -56,7 +56,7 @@ public function defineSingletons(): array if ($driver instanceof FileStorageDriver) { return $driver->getProjectRepository(); } - + throw new \RuntimeException('Storage driver does not support project repository'); }, @@ -64,7 +64,7 @@ public function defineSingletons(): array if ($driver instanceof FileStorageDriver) { return $driver->getEntryRepository(); } - + throw new \RuntimeException('Storage driver does not support entry repository'); }, ]; diff --git a/src/Drafling/Storage/StorageDriverInterface.php b/src/Drafling/Storage/StorageDriverInterface.php index 45aa5bfc..3ba77c85 100644 --- a/src/Drafling/Storage/StorageDriverInterface.php +++ b/src/Drafling/Storage/StorageDriverInterface.php @@ -15,7 +15,7 @@ /** * Storage driver contract for different persistence mechanisms - * + * * @template TConfig of object */ interface StorageDriverInterface @@ -27,7 +27,7 @@ public function supports(string $type): bool; /** * Initialize storage driver with configuration - * + * * @param TConfig $config */ public function initialize(object $config): void; From 5524f2832381cd8f7d0475d61692797f4d6d38dd Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 23 Sep 2025 18:08:15 +0400 Subject: [PATCH 03/16] feat: add MCP tools --- context.yaml | 20 +- src/Drafling/Config/DraflingConfig.php | 2 +- src/Drafling/Domain/Model/Entry.php | 2 +- src/Drafling/Domain/Model/Project.php | 2 +- src/Drafling/DraflingBootloader.php | 2 + .../Exception/ValidationException.php | 6 +- src/Drafling/MCP/DTO/EntryCreateRequest.php | 88 +++++++- src/Drafling/MCP/DTO/EntryFilters.php | 132 +++++++++++ src/Drafling/MCP/DTO/EntryUpdateRequest.php | 113 +++++++++- src/Drafling/MCP/DTO/GetProjectRequest.php | 32 +++ src/Drafling/MCP/DTO/ListProjectsRequest.php | 122 ++++++++++ src/Drafling/MCP/DTO/ListTemplatesRequest.php | 57 +++++ src/Drafling/MCP/DTO/ProjectCreateRequest.php | 57 ++++- src/Drafling/MCP/DTO/ProjectFilters.php | 95 ++++++-- src/Drafling/MCP/DTO/ProjectUpdateRequest.php | 64 +++++- .../MCP/Tools/CreateEntryToolAction.php | 144 ++++++++++++ .../MCP/Tools/CreateProjectToolAction.php | 140 ++++++++++++ .../MCP/Tools/GetProjectToolAction.php | 144 ++++++++++++ .../MCP/Tools/ListProjectsToolAction.php | 188 ++++++++++++++++ .../MCP/Tools/ListTemplatesToolAction.php | 192 ++++++++++++++++ .../MCP/Tools/UpdateEntryToolAction.php | 213 ++++++++++++++++++ .../MCP/Tools/UpdateProjectToolAction.php | 179 +++++++++++++++ .../Repository/EntryRepositoryInterface.php | 2 +- .../Repository/ProjectRepositoryInterface.php | 2 +- .../TemplateRepositoryInterface.php | 2 +- .../Service/EntryServiceInterface.php | 2 +- .../Storage/AbstractStorageDriver.php | 16 +- .../Storage/FileStorage/DirectoryScanner.php | 12 +- .../FileStorage/FileEntryRepository.php | 29 ++- .../FileStorage/FileProjectRepository.php | 21 +- .../Storage/FileStorage/FileStorageDriver.php | 70 +++--- .../FileStorage/FileStorageRepositoryBase.php | 6 +- .../FileStorage/FileTemplateRepository.php | 7 +- .../Storage/FileStorage/FrontmatterParser.php | 4 +- src/Drafling/Storage/StorageBootloader.php | 14 +- .../Storage/StorageDriverInterface.php | 4 +- src/McpServer/McpServerBootloader.php | 18 ++ 37 files changed, 2036 insertions(+), 167 deletions(-) create mode 100644 src/Drafling/MCP/DTO/EntryFilters.php create mode 100644 src/Drafling/MCP/DTO/GetProjectRequest.php create mode 100644 src/Drafling/MCP/DTO/ListProjectsRequest.php create mode 100644 src/Drafling/MCP/DTO/ListTemplatesRequest.php create mode 100644 src/Drafling/MCP/Tools/CreateEntryToolAction.php create mode 100644 src/Drafling/MCP/Tools/CreateProjectToolAction.php create mode 100644 src/Drafling/MCP/Tools/GetProjectToolAction.php create mode 100644 src/Drafling/MCP/Tools/ListProjectsToolAction.php create mode 100644 src/Drafling/MCP/Tools/ListTemplatesToolAction.php create mode 100644 src/Drafling/MCP/Tools/UpdateEntryToolAction.php create mode 100644 src/Drafling/MCP/Tools/UpdateProjectToolAction.php diff --git a/context.yaml b/context.yaml index c6fd03cd..ae349f11 100644 --- a/context.yaml +++ b/context.yaml @@ -42,7 +42,25 @@ documents: sourcePaths: - src/Drafling - vendor/spiral/files/src/FilesInterface.php - showTreeView: true + notPath: + - src/Drafling/Storage + - src/Drafling/MCP + + - description: Drafling FileStorage + outputPath: drafling/file-storage.md + sources: + - type: file + sourcePaths: + - src/Drafling/Storage + + - description: Drafling MCP + outputPath: drafling/mcp.md + sources: + - type: file + sourcePaths: + - src/Drafling/MCP + - src/McpServer/McpServerBootloader.php + - description: "Changes in the Project" outputPath: "changes.md" diff --git a/src/Drafling/Config/DraflingConfig.php b/src/Drafling/Config/DraflingConfig.php index 514f4f19..4caa2e5a 100644 --- a/src/Drafling/Config/DraflingConfig.php +++ b/src/Drafling/Config/DraflingConfig.php @@ -11,7 +11,7 @@ */ final class DraflingConfig extends InjectableConfig implements DraflingConfigInterface { - public const CONFIG = 'drafling'; + public const string CONFIG = 'drafling'; protected array $config = [ 'enabled' => true, diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Drafling/Domain/Model/Entry.php index 09daaf8a..7613e712 100644 --- a/src/Drafling/Domain/Model/Entry.php +++ b/src/Drafling/Domain/Model/Entry.php @@ -63,7 +63,7 @@ public function withUpdates( public function generateFilename(): string { $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->title)); - $slug = \trim($slug, '-'); + $slug = \trim((string) $slug, '-'); return "{$slug}.md"; } diff --git a/src/Drafling/Domain/Model/Project.php b/src/Drafling/Domain/Model/Project.php index 52201541..1f9ab4ae 100644 --- a/src/Drafling/Domain/Model/Project.php +++ b/src/Drafling/Domain/Model/Project.php @@ -58,7 +58,7 @@ public function withUpdates( public function generateDirectoryName(): string { $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->name)); - return \trim($slug, '-'); + return \trim((string) $slug, '-'); } /** diff --git a/src/Drafling/DraflingBootloader.php b/src/Drafling/DraflingBootloader.php index 8397c04d..6847c294 100644 --- a/src/Drafling/DraflingBootloader.php +++ b/src/Drafling/DraflingBootloader.php @@ -22,6 +22,7 @@ public function __construct( private readonly ConfiguratorInterface $config, ) {} + #[\Override] public function defineDependencies(): array { return [ @@ -46,6 +47,7 @@ public function init(EnvironmentInterface $env): void ); } + #[\Override] public function defineSingletons(): array { return [ diff --git a/src/Drafling/Exception/ValidationException.php b/src/Drafling/Exception/ValidationException.php index e03cb7ec..d913939b 100644 --- a/src/Drafling/Exception/ValidationException.php +++ b/src/Drafling/Exception/ValidationException.php @@ -20,15 +20,15 @@ public function __construct( ) { $errorMessage = $message; if (!empty($this->errors)) { - $errorMessage .= ': ' . implode(', ', $this->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 diff --git a/src/Drafling/MCP/DTO/EntryCreateRequest.php b/src/Drafling/MCP/DTO/EntryCreateRequest.php index 92e39c19..b0e8691c 100644 --- a/src/Drafling/MCP/DTO/EntryCreateRequest.php +++ b/src/Drafling/MCP/DTO/EntryCreateRequest.php @@ -4,25 +4,91 @@ namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +use Spiral\JsonSchemaGenerator\Attribute\Field; + /** - * Request DTO for creating a new entry + * Data Transfer Object for entry creation requests */ final readonly class EntryCreateRequest { - /** - * @param string $category Category name (must match template) - * @param string $entryType Entry type key (must match template) - * @param string $title Entry title - * @param string $content Markdown content - * @param string[] $tags Entry tags - * @param string|null $status Override default status - */ public function __construct( + #[Field(description: 'Project ID')] + public string $projectId, + #[Field(description: 'Category name (accepts display names)')] public string $category, + #[Field(description: 'Entry type (accepts display names)')] public string $entryType, - public string $title, + #[Field(description: 'Entry content')] public string $content, - public array $tags = [], + #[Field( + description: 'Entry title (optional)', + default: null, + )] + public ?string $title = null, + #[Field( + description: 'Entry status (optional, accepts display names)', + default: null, + )] public ?string $status = null, + #[Field( + description: 'Entry tags (optional)', + default: [], + )] + /** @var string[] */ + public array $tags = [], ) {} + + /** + * Get the entry title, generating from content if not provided + */ + public function getTitle(): string + { + if ($this->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'; + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->projectId)) { + $errors[] = 'Project 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'; + } + + return $errors; + } } diff --git a/src/Drafling/MCP/DTO/EntryFilters.php b/src/Drafling/MCP/DTO/EntryFilters.php new file mode 100644 index 00000000..e81ed0eb --- /dev/null +++ b/src/Drafling/MCP/DTO/EntryFilters.php @@ -0,0 +1,132 @@ +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->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->contentContains !== null; + } + + /** + * Validate the filters + */ + public function validate(): array + { + $errors = []; + + // Validate tags array if provided + if ($this->tags !== null) { + if (!\is_array($this->tags)) { + $errors[] = 'Tags must be an array'; + } elseif (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, + '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/Drafling/MCP/DTO/EntryUpdateRequest.php b/src/Drafling/MCP/DTO/EntryUpdateRequest.php index f583325b..3ec64bd0 100644 --- a/src/Drafling/MCP/DTO/EntryUpdateRequest.php +++ b/src/Drafling/MCP/DTO/EntryUpdateRequest.php @@ -4,32 +4,125 @@ namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +use Spiral\JsonSchemaGenerator\Attribute\Field; + /** - * Request DTO for updating an existing entry + * Data Transfer Object for entry update requests */ final readonly class EntryUpdateRequest { - /** - * @param string|null $title Updated entry title - * @param string|null $status Updated entry status - * @param string[]|null $tags Updated entry tags - * @param string|null $content Updated markdown content - */ public function __construct( + #[Field(description: 'Project ID')] + public string $projectId, + #[Field(description: 'Entry ID to update')] + public string $entryId, + #[Field( + description: 'New title (optional)', + )] public ?string $title = null, + #[Field( + description: 'New content (optional)', + )] + public ?string $content = null, + #[Field( + description: 'New status (optional, accepts display names)', + )] public ?string $status = null, + #[Field( + description: 'New content type (optional)', + )] + public ?string $contentType = null, + #[Field( + description: 'New tags (optional)', + default: null, + )] + /** @var string[]|null */ public ?array $tags = null, - public ?string $content = null, + #[Field( + description: 'Find and replace in content (optional)', + default: null, + )] + public ?TextReplaceRequest $textReplace = null, ) {} /** - * Check if request has any updates + * Check if there are any updates to apply */ public function hasUpdates(): bool { return $this->title !== null + || $this->content !== null || $this->status !== null + || $this->contentType !== null || $this->tags !== null - || $this->content !== null; + || $this->textReplace !== null; + } + + /** + * Get processed content applying text replacement if needed + */ + public function getProcessedContent(?string $existingContent = null): ?string + { + $content = $this->content ?? $existingContent; + + if ($content === null || $this->textReplace === null) { + return $this->content; + } + + return \str_replace($this->textReplace->find, $this->textReplace->replace, $content); + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->projectId)) { + $errors[] = 'Project 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'; + } + + if ($this->textReplace !== null) { + $replaceErrors = $this->textReplace->validate(); + $errors = \array_merge($errors, $replaceErrors); + } + + return $errors; + } +} + +/** + * 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'; + } + + return $errors; } } diff --git a/src/Drafling/MCP/DTO/GetProjectRequest.php b/src/Drafling/MCP/DTO/GetProjectRequest.php new file mode 100644 index 00000000..b87e6373 --- /dev/null +++ b/src/Drafling/MCP/DTO/GetProjectRequest.php @@ -0,0 +1,32 @@ +id)) { + $errors[] = 'Project ID cannot be empty'; + } + + return $errors; + } +} diff --git a/src/Drafling/MCP/DTO/ListProjectsRequest.php b/src/Drafling/MCP/DTO/ListProjectsRequest.php new file mode 100644 index 00000000..0ca1b39e --- /dev/null +++ b/src/Drafling/MCP/DTO/ListProjectsRequest.php @@ -0,0 +1,122 @@ +filters === null) { + return []; + } + + return $this->filters->toArray(); + } + + /** + * Get pagination options + */ + public function getPaginationOptions(): array + { + return [ + 'limit' => $this->limit, + 'offset' => $this->offset, + ]; + } + + /** + * Get sorting options + */ + public function getSortingOptions(): array + { + return [ + 'sort_by' => $this->sortBy, + 'sort_direction' => $this->sortDirection, + ]; + } + + /** + * 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 sorting + $validSortFields = ['name', 'status', 'created_at', 'updated_at', 'template']; + if (!\in_array($this->sortBy, $validSortFields, true)) { + $errors[] = 'Sort field must be one of: ' . \implode(', ', $validSortFields); + } + + $validSortDirections = ['asc', 'desc']; + if (!\in_array(\strtolower($this->sortDirection), $validSortDirections, true)) { + $errors[] = 'Sort direction must be either "asc" or "desc"'; + } + + // Validate filters if provided + if ($this->filters !== null) { + $filterErrors = $this->filters->validate(); + $errors = \array_merge($errors, $filterErrors); + } + + return $errors; + } +} diff --git a/src/Drafling/MCP/DTO/ListTemplatesRequest.php b/src/Drafling/MCP/DTO/ListTemplatesRequest.php new file mode 100644 index 00000000..22c4aff6 --- /dev/null +++ b/src/Drafling/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/Drafling/MCP/DTO/ProjectCreateRequest.php b/src/Drafling/MCP/DTO/ProjectCreateRequest.php index 8979a9d3..ea1bb1a9 100644 --- a/src/Drafling/MCP/DTO/ProjectCreateRequest.php +++ b/src/Drafling/MCP/DTO/ProjectCreateRequest.php @@ -4,23 +4,60 @@ namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +use Spiral\JsonSchemaGenerator\Attribute\Field; + /** - * Request DTO for creating a new project + * Data Transfer Object for project creation requests */ final readonly class ProjectCreateRequest { - /** - * @param string $templateId Template key to base project on - * @param string $name Project name - * @param string $description Project description - * @param string[] $tags Project tags - * @param string[] $entryDirs Directories to create for entries - */ public function __construct( + #[Field(description: 'Template ID to use for the project')] public string $templateId, - public string $name, - public string $description, + #[Field(description: 'Project title')] + public string $title, + #[Field( + description: 'Project description (optional)', + default: '', + )] + public string $description = '', + #[Field( + description: 'Project tags for organization (optional)', + default: [], + )] + /** @var string[] */ public array $tags = [], + #[Field( + description: 'Entry directories to create (optional)', + default: [], + )] + /** @var string[] */ public array $entryDirs = [], ) {} + + /** + * Get project name (using title field) + */ + public function getName(): string + { + return $this->title; + } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->templateId)) { + $errors[] = 'Template ID cannot be empty'; + } + + if (empty($this->title)) { + $errors[] = 'Project title cannot be empty'; + } + + return $errors; + } } diff --git a/src/Drafling/MCP/DTO/ProjectFilters.php b/src/Drafling/MCP/DTO/ProjectFilters.php index d7c4a826..8c140572 100644 --- a/src/Drafling/MCP/DTO/ProjectFilters.php +++ b/src/Drafling/MCP/DTO/ProjectFilters.php @@ -4,53 +4,104 @@ namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +use Spiral\JsonSchemaGenerator\Attribute\Field; +use Spiral\JsonSchemaGenerator\Attribute\Constraint\Enum; + /** - * Filters for project listing operations + * DTO for project filtering criteria */ final readonly class ProjectFilters { - /** - * @param string|null $status Filter by project status - * @param string|null $template Filter by template key - * @param string[]|null $tags Filter by tags (any of these tags) - * @param int|null $limit Maximum number of results - * @param int $offset Offset for pagination - */ public function __construct( + #[Field( + description: 'Filter by project status', + default: null, + )] + #[Enum(values: ['draft', 'active', 'published', 'archived'])] public ?string $status = null, + #[Field( + description: 'Filter by template/project type', + default: null, + )] public ?string $template = null, + #[Field( + description: 'Filter by project tags (projects must have any of these tags)', + default: null, + )] + /** @var string[]|null */ public ?array $tags = null, - public ?int $limit = null, - public int $offset = 0, + #[Field( + description: 'Filter by project name (partial match)', + default: null, + )] + public ?string $nameContains = null, ) {} /** - * Convert to array for repository filtering + * Convert to array format expected by domain services */ public function toArray(): array { $filters = []; - + if ($this->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->limit !== null) { - $filters['limit'] = $this->limit; - } - - if ($this->offset > 0) { - $filters['offset'] = $this->offset; + + 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 + || ($this->tags !== 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 (!\is_array($this->tags)) { + $errors[] = 'Tags must be an array'; + } elseif (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/Drafling/MCP/DTO/ProjectUpdateRequest.php b/src/Drafling/MCP/DTO/ProjectUpdateRequest.php index fb22aa74..fa0779fe 100644 --- a/src/Drafling/MCP/DTO/ProjectUpdateRequest.php +++ b/src/Drafling/MCP/DTO/ProjectUpdateRequest.php @@ -4,35 +4,77 @@ namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +use Spiral\JsonSchemaGenerator\Attribute\Field; + /** - * Request DTO for updating an existing project + * Data Transfer Object for project update requests */ final readonly class ProjectUpdateRequest { - /** - * @param string|null $name Updated project name - * @param string|null $description Updated project description - * @param string|null $status Updated project status - * @param string[]|null $tags Updated project tags - * @param string[]|null $entryDirs Updated entry directories - */ public function __construct( - public ?string $name = null, + #[Field(description: 'Project ID to update')] + public string $projectId, + #[Field( + description: 'New project title (optional)', + )] + public ?string $title = null, + #[Field( + description: 'New project description (optional)', + )] public ?string $description = null, + #[Field( + description: 'New project status (optional)', + )] public ?string $status = null, + #[Field( + description: 'New project tags (optional)', + default: null, + )] + /** @var string[]|null */ public ?array $tags = null, + #[Field( + description: 'New entry directories (optional)', + default: null, + )] + /** @var string[]|null */ public ?array $entryDirs = null, ) {} /** - * Check if request has any updates + * Get project name (alias for title) + */ + public function getName(): ?string + { + return $this->title; + } + + /** + * Check if there are any updates to apply */ public function hasUpdates(): bool { - return $this->name !== null + return $this->title !== null || $this->description !== null || $this->status !== null || $this->tags !== null || $this->entryDirs !== null; } + + /** + * Validate the request data + */ + public function validate(): array + { + $errors = []; + + if (empty($this->projectId)) { + $errors[] = 'Project ID cannot be empty'; + } + + if (!$this->hasUpdates()) { + $errors[] = 'At least one field must be provided for update'; + } + + return $errors; + } } diff --git a/src/Drafling/MCP/Tools/CreateEntryToolAction.php b/src/Drafling/MCP/Tools/CreateEntryToolAction.php new file mode 100644 index 00000000..5dfaa108 --- /dev/null +++ b/src/Drafling/MCP/Tools/CreateEntryToolAction.php @@ -0,0 +1,144 @@ +logger->info('Creating new entry', [ + 'project_id' => $request->projectId, + 'category' => $request->category, + 'entry_type' => $request->entryType, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Create entry using domain service + $entry = $this->entryService->createEntry($projectId, $request); + + $this->logger->info('Entry created successfully', [ + 'project_id' => $request->projectId, + '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', // Default content type for Drafling + 'created_at' => $entry->createdAt->format('c'), + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error during entry creation', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error creating entry', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to create entry: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/MCP/Tools/CreateProjectToolAction.php b/src/Drafling/MCP/Tools/CreateProjectToolAction.php new file mode 100644 index 00000000..b4c09160 --- /dev/null +++ b/src/Drafling/MCP/Tools/CreateProjectToolAction.php @@ -0,0 +1,140 @@ +logger->info('Creating new project', [ + 'template_id' => $request->templateId, + 'title' => $request->title, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify template exists + $templateKey = TemplateKey::fromString($request->templateId); + if (!$this->templateService->templateExists($templateKey)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Template '{$request->templateId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Create project using domain service + $project = $this->projectService->createProject($request); + + $this->logger->info('Project created successfully', [ + 'project_id' => $project->id, + 'template' => $project->template, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'project_id' => $project->id, + 'title' => $project->name, + 'template_id' => $project->template, + 'status' => $project->status, + 'created_at' => (new \DateTime())->format('c'), + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (TemplateNotFoundException $e) { + $this->logger->error('Template not found', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error during project creation', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error creating project', [ + 'template_id' => $request->templateId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to create project: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/MCP/Tools/GetProjectToolAction.php b/src/Drafling/MCP/Tools/GetProjectToolAction.php new file mode 100644 index 00000000..8277baf3 --- /dev/null +++ b/src/Drafling/MCP/Tools/GetProjectToolAction.php @@ -0,0 +1,144 @@ +logger->info('Getting project', [ + 'project_id' => $request->id, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Get project + $projectId = ProjectId::fromString($request->id); + $project = $this->projectService->getProject($projectId); + + if ($project === null) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->id}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + $this->logger->info('Project retrieved successfully', [ + 'project_id' => $project->id, + 'template' => $project->template, + ]); + + // Format project for response + $response = [ + 'success' => true, + 'project' => [ + 'project_id' => $project->id, + 'title' => $project->name, + 'status' => $project->status, + 'project_type' => $project->template, + 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date + 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date + 'metadata' => [ + 'description' => $project->description, + 'tags' => $project->tags, + 'entry_dirs' => $project->entryDirs, + ], + ], + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error getting project', [ + 'project_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error getting project', [ + 'project_id' => $request->id, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to get project: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/MCP/Tools/ListProjectsToolAction.php b/src/Drafling/MCP/Tools/ListProjectsToolAction.php new file mode 100644 index 00000000..abcf5e88 --- /dev/null +++ b/src/Drafling/MCP/Tools/ListProjectsToolAction.php @@ -0,0 +1,188 @@ +logger->info('Listing projects', [ + 'has_filters' => $request->hasFilters(), + 'filters' => $request->getFilters(), + 'limit' => $request->limit, + 'offset' => $request->offset, + 'sort_by' => $request->sortBy, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Get projects with filters + $allProjects = $this->projectService->listProjects($request->getFilters()); + + // Apply sorting + $sortedProjects = $this->applySorting($allProjects, $request->getSortingOptions()); + + // Apply pagination + $paginatedProjects = \array_slice( + $sortedProjects, + $request->offset, + $request->limit, + ); + + // Format projects for response + $projectData = \array_map(static fn($project) => [ + 'project_id' => $project->id, + 'title' => $project->name, + 'status' => $project->status, + 'project_type' => $project->template, + 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date from domain + 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date from domain + 'metadata' => [ + 'description' => $project->description, + 'tags' => $project->tags, + 'entry_dirs' => $project->entryDirs, + ], + ], $paginatedProjects); + + $response = [ + 'success' => true, + 'projects' => $projectData, + 'count' => \count($paginatedProjects), + 'total_count' => \count($allProjects), + 'pagination' => [ + 'limit' => $request->limit, + 'offset' => $request->offset, + 'has_more' => ($request->offset + \count($paginatedProjects)) < \count($allProjects), + ], + 'filters_applied' => $request->hasFilters() ? $request->getFilters() : null, + ]; + + $this->logger->info('Projects listed successfully', [ + 'returned_count' => \count($paginatedProjects), + 'total_available' => \count($allProjects), + 'filters_applied' => $request->hasFilters(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error listing projects', [ + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing projects', [ + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to list projects: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } + + /** + * Apply sorting to projects array + */ + private function applySorting(array $projects, array $sortingOptions): array + { + $sortBy = $sortingOptions['sort_by']; + $sortDirection = \strtolower((string) $sortingOptions['sort_direction']); + + \usort($projects, function ($a, $b) use ($sortBy, $sortDirection) { + $valueA = $this->getProjectFieldValue($a, $sortBy); + $valueB = $this->getProjectFieldValue($b, $sortBy); + + // Handle null values + if ($valueA === $valueB) { + return 0; + } + + if ($valueA === null) { + return 1; + } + + if ($valueB === null) { + return -1; + } + + // Compare values + $result = $valueA <=> $valueB; + + return $sortDirection === 'desc' ? -$result : $result; + }); + + return $projects; + } + + /** + * Get field value from project for sorting + * @param mixed $project + */ + private function getProjectFieldValue($project, string $field): mixed + { + return match ($field) { + 'name' => $project->name, + 'status' => $project->status, + 'template' => $project->template, + 'created_at' => null, // Would need actual timestamps from domain + 'updated_at' => null, // Would need actual timestamps from domain + default => $project->name, + }; + } +} diff --git a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php new file mode 100644 index 00000000..a227e3fa --- /dev/null +++ b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php @@ -0,0 +1,192 @@ +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 new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Get all templates + $allTemplates = $this->templateService->getAllTemplates(); + + // Apply filters + $filteredTemplates = $this->applyFilters($allTemplates, $request); + + // Format templates for response + $templateData = \array_map( + fn($template) => $this->formatTemplate($template, $request->includeDetails), + $filteredTemplates, + ); + + $response = [ + 'success' => true, + 'templates' => $templateData, + 'count' => \count($templateData), + 'filters_applied' => $request->hasFilters() ? [ + 'tag' => $request->tag, + 'name_contains' => $request->nameContains, + ] : null, + ]; + + $this->logger->info('Templates listed successfully', [ + 'returned_count' => \count($templateData), + 'total_available' => \count($allTemplates), + 'filters_applied' => $request->hasFilters(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error listing templates', [ + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing templates', [ + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to list templates: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } + + /** + * 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; + }); + } + + /** + * Format template for response + * @param mixed $template + */ + private function formatTemplate($template, bool $includeDetails): array + { + $formatted = [ + 'template_id' => $template->key, + 'name' => $template->name, + 'description' => $template->description, + 'tags' => $template->tags, + ]; + + if ($includeDetails) { + $formatted['categories'] = \array_map(static fn($category) => [ + 'name' => $category->name, + 'display_name' => $category->displayName, + 'icon' => $category->icon, + 'allowed_entry_types' => $category->entryTypes, + ], $template->categories); + + $formatted['entry_types'] = \array_map(static fn($entryType) => [ + 'key' => $entryType->key, + 'display_name' => $entryType->displayName, + 'icon' => $entryType->icon, + 'content_type' => $entryType->contentType, + 'color' => $entryType->color, + 'default_status' => $entryType->defaultStatus, + 'statuses' => \array_map(static fn($status) => [ + 'value' => $status->value, + 'display_name' => $status->displayName, + 'color' => $status->color, + ], $entryType->statuses), + ], $template->entryTypes); + + if ($template->prompt !== null) { + $formatted['prompt'] = $template->prompt; + } + } + + return $formatted; + } +} diff --git a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php new file mode 100644 index 00000000..a2d3856e --- /dev/null +++ b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php @@ -0,0 +1,213 @@ +logger->info('Updating entry', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'has_title' => $request->title !== 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 new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify entry exists + $entryId = EntryId::fromString($request->entryId); + if (!$this->entryService->entryExists($projectId, $entryId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Entry '{$request->entryId}' not found in project '{$request->projectId}'", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Update entry using domain service + $updatedEntry = $this->entryService->updateEntry($projectId, $entryId, $request); + + $this->logger->info('Entry updated successfully', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'title' => $updatedEntry->title, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'entry_id' => $updatedEntry->entryId, + 'title' => $updatedEntry->title, + 'entry_type' => $updatedEntry->entryType, + 'category' => $updatedEntry->category, + 'status' => $updatedEntry->status, + 'content_type' => 'markdown', // Default content type for Drafling + 'updated_at' => $updatedEntry->updatedAt->format('c'), + 'tags' => $updatedEntry->tags, + 'changes_applied' => $this->getAppliedChanges($request), + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (EntryNotFoundException $e) { + $this->logger->error('Entry not found', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error during entry update', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error updating entry', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to update entry: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } + + /** + * Get list of changes applied based on the request + */ + private function getAppliedChanges(EntryUpdateRequest $request): array + { + $changes = []; + + if ($request->title !== null) { + $changes[] = 'title'; + } + + 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/Drafling/MCP/Tools/UpdateProjectToolAction.php b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php new file mode 100644 index 00000000..fec4b563 --- /dev/null +++ b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php @@ -0,0 +1,179 @@ +logger->info('Updating project', [ + 'project_id' => $request->projectId, + '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, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Update project using domain service + $updatedProject = $this->projectService->updateProject($projectId, $request); + + $this->logger->info('Project updated successfully', [ + 'project_id' => $request->projectId, + 'title' => $updatedProject->name, + 'status' => $updatedProject->status, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'project_id' => $updatedProject->id, + 'title' => $updatedProject->name, + 'status' => $updatedProject->status, + 'project_type' => $updatedProject->template, + 'updated_at' => (new \DateTime())->format('c'), // Would need actual update timestamp from domain + 'metadata' => [ + 'description' => $updatedProject->description, + 'tags' => $updatedProject->tags, + 'entry_dirs' => $updatedProject->entryDirs, + ], + 'changes_applied' => $this->getAppliedChanges($request), + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error during project update', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error updating project', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to update project: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } + + /** + * Get list of changes applied based on the request + */ + private function getAppliedChanges(ProjectUpdateRequest $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'; + } + + return $changes; + } +} diff --git a/src/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php index a721328a..58c1bbc5 100644 --- a/src/Drafling/Repository/EntryRepositoryInterface.php +++ b/src/Drafling/Repository/EntryRepositoryInterface.php @@ -15,7 +15,7 @@ interface EntryRepositoryInterface { /** * Find entries for a project with optional filters - * + * * @param ProjectId $projectId Project identifier * @param array $filters Optional filters (category, status, tags, etc.) * @return Entry[] diff --git a/src/Drafling/Repository/ProjectRepositoryInterface.php b/src/Drafling/Repository/ProjectRepositoryInterface.php index 8245f35f..aca41592 100644 --- a/src/Drafling/Repository/ProjectRepositoryInterface.php +++ b/src/Drafling/Repository/ProjectRepositoryInterface.php @@ -14,7 +14,7 @@ interface ProjectRepositoryInterface { /** * Find all projects with optional filters - * + * * @param array $filters Optional filters (status, template, tags, etc.) * @return Project[] */ diff --git a/src/Drafling/Repository/TemplateRepositoryInterface.php b/src/Drafling/Repository/TemplateRepositoryInterface.php index 152afa26..34ea7af8 100644 --- a/src/Drafling/Repository/TemplateRepositoryInterface.php +++ b/src/Drafling/Repository/TemplateRepositoryInterface.php @@ -14,7 +14,7 @@ interface TemplateRepositoryInterface { /** * Find all templates - * + * * @return Template[] */ public function findAll(): array; diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index 1a8137aa..6a1e6396 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -17,7 +17,7 @@ interface EntryServiceInterface { /** * Get entries for a project with optional filtering - * + * * @param ProjectId $projectId Project identifier * @param array $filters Optional filters (category, status, tags, etc.) * @return Entry[] diff --git a/src/Drafling/Storage/AbstractStorageDriver.php b/src/Drafling/Storage/AbstractStorageDriver.php index a4862254..4a0548e4 100644 --- a/src/Drafling/Storage/AbstractStorageDriver.php +++ b/src/Drafling/Storage/AbstractStorageDriver.php @@ -9,7 +9,7 @@ /** * Abstract base class for storage drivers with common functionality - * + * * @template TConfig of object * @implements StorageDriverInterface */ @@ -39,7 +39,7 @@ public function synchronize(): void $this->logger?->debug('Synchronizing storage state', [ 'driver' => $this->getName(), ]); - + // Base implementation - override in concrete classes $this->performSynchronization(); } @@ -93,12 +93,12 @@ protected function sanitizeFilename(string $filename): string { // Remove or replace unsafe characters $filename = \preg_replace('/[^a-zA-Z0-9\-_\.]/', '-', $filename); - + // Remove consecutive dashes - $filename = \preg_replace('/-+/', '-', $filename); - + $filename = \preg_replace('/-+/', '-', (string) $filename); + // Trim dashes from ends - return \trim($filename, '-'); + return \trim((string) $filename, '-'); } /** @@ -108,8 +108,8 @@ protected function createSlug(string $title): string { $slug = \strtolower($title); $slug = \preg_replace('/[^a-z0-9\s\-]/', '', $slug); - $slug = \preg_replace('/[\s\-]+/', '-', $slug); - return \trim($slug, '-'); + $slug = \preg_replace('/[\s\-]+/', '-', (string) $slug); + return \trim((string) $slug, '-'); } /** diff --git a/src/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Drafling/Storage/FileStorage/DirectoryScanner.php index d8432c14..f7da5dff 100644 --- a/src/Drafling/Storage/FileStorage/DirectoryScanner.php +++ b/src/Drafling/Storage/FileStorage/DirectoryScanner.php @@ -98,7 +98,7 @@ public function scanEntries(string $projectPath, array $entryDirs = []): array foreach ($finder as $file) { $entryFiles[] = $file->getRealPath(); } - } catch (\Throwable $e) { + } catch (\Throwable) { // Handle cases where directories are not accessible // Return empty array - calling code can handle this gracefully } @@ -132,7 +132,7 @@ public function getEntryDirectories(string $projectPath): array foreach ($finder as $directory) { $directories[] = $directory->getFilename(); // Return relative directory name } - } catch (\Throwable $e) { + } catch (\Throwable) { // Handle cases where directory is not accessible // Return empty array } @@ -241,7 +241,7 @@ public function getProjectStats(string $projectPath): array foreach ($dirFinder as $directory) { $stats['directories']++; } - } catch (\Throwable $e) { + } catch (\Throwable) { // Return basic stats if scanning fails } @@ -275,7 +275,7 @@ public function findFiles(string $path, string $pattern, int $depth = 0): array foreach ($finder as $file) { $files[] = $file->getRealPath(); } - } catch (\Throwable $e) { + } catch (\Throwable) { // Return empty array if scanning fails } @@ -300,7 +300,7 @@ public function hasMarkdownFiles(string $path): bool ->depth(0); return $finder->hasResults(); - } catch (\Throwable $e) { + } catch (\Throwable) { return false; } } @@ -325,7 +325,7 @@ public function getDirectorySize(string $path): int foreach ($finder as $file) { $totalSize += $file->getSize(); } - } catch (\Throwable $e) { + } catch (\Throwable) { // Return 0 if calculation fails } diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php index 6150261c..eb1f3f6b 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -8,7 +8,6 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; /** * File-based entry repository implementation @@ -19,13 +18,13 @@ final class FileEntryRepository extends FileStorageRepositoryBase implements Ent public function findByProject(ProjectId $projectId, array $filters = []): array { $projectPath = $this->getProjectPath($projectId->value); - + if (!$this->files->exists($projectPath)) { return []; } $entries = []; - + try { // Get entry directories from project or scan all directories $entryDirs = $this->getProjectEntryDirs($projectPath); @@ -59,7 +58,7 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry { $projectPath = $this->getProjectPath($projectId->value); $entryFile = $this->findEntryFile($projectPath, $entryId->value); - + if ($entryFile === null) { return null; } @@ -79,14 +78,14 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry public function save(ProjectId $projectId, Entry $entry): void { $projectPath = $this->getProjectPath($projectId->value); - + if (!$this->files->exists($projectPath)) { throw new \RuntimeException("Project directory not found: {$projectPath}"); } try { $this->saveEntryToFile($projectPath, $entry); - + $this->logOperation('Saved entry', [ 'project_id' => $projectId->value, 'entry_id' => $entry->entryId, @@ -106,14 +105,14 @@ public function delete(ProjectId $projectId, EntryId $entryId): bool { $projectPath = $this->getProjectPath($projectId->value); $entryFile = $this->findEntryFile($projectPath, $entryId->value); - + if ($entryFile === null) { return false; } try { $deleted = $this->files->delete($entryFile); - + if ($deleted) { $this->logOperation('Deleted entry', [ 'project_id' => $projectId->value, @@ -154,7 +153,7 @@ private function getProjectPath(string $projectId): string private function getProjectEntryDirs(string $projectPath): array { $configPath = $projectPath . '/project.yaml'; - + if (!$this->files->exists($configPath)) { // Fallback: scan all directories return $this->directoryScanner->getEntryDirectories($projectPath); @@ -180,7 +179,7 @@ private function findEntryFile(string $projectPath, string $entryId): ?string foreach ($entryFiles as $filePath) { try { $frontmatter = $this->frontmatterParser->extractFrontmatter( - $this->files->read($filePath) + $this->files->read($filePath), ); if (isset($frontmatter['entry_id']) && $frontmatter['entry_id'] === $entryId) { @@ -213,11 +212,11 @@ private function loadEntryFromFile(string $filePath): ?Entry } // Parse dates - $createdAt = isset($frontmatter['created_at']) + $createdAt = isset($frontmatter['created_at']) ? new \DateTime($frontmatter['created_at']) : new \DateTime(); - - $updatedAt = isset($frontmatter['updated_at']) + + $updatedAt = isset($frontmatter['updated_at']) ? new \DateTime($frontmatter['updated_at']) : new \DateTime(); @@ -246,12 +245,12 @@ private function saveEntryToFile(string $projectPath, Entry $entry): void { // Determine file path $filePath = $entry->filePath; - + if ($filePath === null) { // New entry - generate file path $categoryPath = $this->files->normalizePath($projectPath . '/' . $entry->category); $this->ensureDirectory($categoryPath); - + $filename = $this->generateFilename($entry->title); $filePath = $categoryPath . '/' . $filename; } diff --git a/src/Drafling/Storage/FileStorage/FileProjectRepository.php b/src/Drafling/Storage/FileStorage/FileProjectRepository.php index 224585d5..ce834f6a 100644 --- a/src/Drafling/Storage/FileStorage/FileProjectRepository.php +++ b/src/Drafling/Storage/FileStorage/FileProjectRepository.php @@ -7,7 +7,6 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Project; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; /** * File-based project repository implementation @@ -43,7 +42,7 @@ public function findAll(array $filters = []): array public function findById(ProjectId $id): ?Project { $projectPath = $this->getProjectPath($id->value); - + if (!$this->files->exists($projectPath)) { return null; } @@ -60,7 +59,7 @@ public function findById(ProjectId $id): ?Project public function save(Project $project): void { $projectPath = $this->getProjectPath($project->id); - + try { // Ensure project directory exists $this->ensureDirectory($projectPath); @@ -89,14 +88,14 @@ public function save(Project $project): void public function delete(ProjectId $id): bool { $projectPath = $this->getProjectPath($id->value); - + if (!$this->files->exists($projectPath)) { return false; } try { $deleted = $this->files->deleteDirectory($projectPath); - + if ($deleted) { $this->logOperation('Deleted project', ['id' => $id->value, 'path' => $projectPath]); } @@ -113,7 +112,7 @@ public function exists(ProjectId $id): bool { $projectPath = $this->getProjectPath($id->value); $configPath = $projectPath . '/project.yaml'; - + return $this->files->exists($configPath); } @@ -132,22 +131,22 @@ private function getProjectPath(string $projectId): string private function loadProjectFromDirectory(string $projectPath): ?Project { $configPath = $projectPath . '/project.yaml'; - + if (!$this->files->exists($configPath)) { throw new \RuntimeException("Project configuration not found: {$configPath}"); } $config = $this->readYamlFile($configPath); - + if (!isset($config['project'])) { throw new \RuntimeException("Invalid project configuration: missing 'project' section"); } $projectData = $config['project']; - + // Extract project ID from directory name $projectId = \basename($projectPath); - + return new Project( id: $projectId, name: $projectData['name'] ?? $projectId, @@ -166,7 +165,7 @@ private function loadProjectFromDirectory(string $projectPath): ?Project private function saveProjectConfig(string $projectPath, Project $project): void { $configPath = $projectPath . '/project.yaml'; - + $config = [ 'project' => [ 'name' => $project->name, diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index 5cee89e7..0d7e7bed 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -21,11 +21,12 @@ use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; +use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Files\FilesInterface; /** * File-based storage driver implementation using Markdown files with YAML frontmatter - * + * * @extends AbstractStorageDriver */ final class FileStorageDriver extends AbstractStorageDriver @@ -37,14 +38,15 @@ final class FileStorageDriver extends AbstractStorageDriver public function __construct( DraflingConfigInterface $draflingConfig, FilesInterface $files, + ExceptionReporterInterface $reporter, ?\Psr\Log\LoggerInterface $logger = null, ) { parent::__construct($draflingConfig, $logger); - + // Initialize repositories $frontmatterParser = new FrontmatterParser(); - $directoryScanner = new DirectoryScanner($files); - + $directoryScanner = new DirectoryScanner($files, $reporter); + $this->templateRepository = new FileTemplateRepository( $files, $draflingConfig, @@ -52,7 +54,7 @@ public function __construct( $directoryScanner, $logger, ); - + $this->projectRepository = new FileProjectRepository( $files, $draflingConfig, @@ -60,7 +62,7 @@ public function __construct( $directoryScanner, $logger, ); - + $this->entryRepository = new FileEntryRepository( $files, $draflingConfig, @@ -104,7 +106,7 @@ public function createProject(ProjectCreateRequest $request): Project // Validate template exists $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($request->templateId); $template = $this->templateRepository->findByKey($templateKey); - + if ($template === null) { throw TemplateNotFoundException::withKey($request->templateId); } @@ -183,7 +185,7 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): // Generate entry ID and create entry $entryId = $this->generateId('entry_'); $now = $this->getCurrentTimestamp(); - + $entry = new Entry( entryId: $entryId, title: $request->title, @@ -252,12 +254,36 @@ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool return $deleted; } + /** + * Get project repository + */ + public function getProjectRepository(): ProjectRepositoryInterface + { + return $this->projectRepository; + } + + /** + * Get entry repository + */ + public function getEntryRepository(): EntryRepositoryInterface + { + return $this->entryRepository; + } + + /** + * Get template repository + */ + public function getTemplateRepository(): TemplateRepositoryInterface + { + return $this->templateRepository; + } + #[\Override] protected function performSynchronization(): void { // Refresh template cache $this->templateRepository->refresh(); - + $this->logOperation('Synchronized file storage'); } @@ -280,7 +306,7 @@ private function validateEntryAgainstTemplate(Project $project, EntryCreateReque { $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); $template = $this->templateRepository->findByKey($templateKey); - + if ($template === null) { throw TemplateNotFoundException::withKey($project->template); } @@ -308,28 +334,4 @@ private function validateEntryAgainstTemplate(Project $project, EntryCreateReque } } } - - /** - * Get project repository - */ - public function getProjectRepository(): ProjectRepositoryInterface - { - return $this->projectRepository; - } - - /** - * Get entry repository - */ - public function getEntryRepository(): EntryRepositoryInterface - { - return $this->entryRepository; - } - - /** - * Get template repository - */ - public function getTemplateRepository(): TemplateRepositoryInterface - { - return $this->templateRepository; - } } diff --git a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php index a44c790b..1ecc0b00 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php +++ b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php @@ -17,7 +17,7 @@ public function __construct( protected readonly FilesInterface $files, protected readonly DraflingConfigInterface $config, protected readonly FrontmatterParser $frontmatterParser = new FrontmatterParser(), - protected readonly DirectoryScanner $directoryScanner, + protected readonly ?DirectoryScanner $directoryScanner = null, protected readonly ?LoggerInterface $logger = null, ) {} @@ -117,8 +117,8 @@ protected function generateFilename(string $title, string $extension = 'md'): st { $slug = \strtolower($title); $slug = \preg_replace('/[^a-z0-9\s\-]/', '', $slug); - $slug = \preg_replace('/[\s\-]+/', '-', $slug); - $slug = \trim($slug, '-'); + $slug = \preg_replace('/[\s\-]+/', '-', (string) $slug); + $slug = \trim((string) $slug, '-'); return $slug . '.' . $extension; } diff --git a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php index e68ba57f..d83e589d 100644 --- a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php +++ b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php @@ -10,7 +10,6 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Template; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\Repository\TemplateRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; /** * File-based template repository implementation @@ -67,17 +66,17 @@ private function ensureCacheLoaded(): void private function loadTemplatesFromFilesystem(): void { $templatesPath = $this->getTemplatesPath(); - + if (!$this->files->exists($templatesPath) || !$this->files->isDirectory($templatesPath)) { $this->logger?->warning('Templates directory not found', ['path' => $templatesPath]); return; } $templateFiles = $this->files->getFiles($templatesPath, '*.yaml'); - + foreach ($templateFiles as $templateFile) { $filePath = $this->files->normalizePath($templatesPath . '/' . $templateFile); - + try { $template = $this->loadTemplateFromFile($filePath); if ($template !== null) { diff --git a/src/Drafling/Storage/FileStorage/FrontmatterParser.php b/src/Drafling/Storage/FileStorage/FrontmatterParser.php index 8bbd81c3..ddac1c70 100644 --- a/src/Drafling/Storage/FileStorage/FrontmatterParser.php +++ b/src/Drafling/Storage/FileStorage/FrontmatterParser.php @@ -12,7 +12,7 @@ */ final readonly class FrontmatterParser { - private const FRONTMATTER_DELIMITER = '---'; + private const string FRONTMATTER_DELIMITER = '---'; /** * Parse frontmatter and content from markdown file content @@ -22,7 +22,7 @@ public function parse(string $content): array { $content = \trim($content); - + // Check if file starts with frontmatter delimiter if (!\str_starts_with($content, self::FRONTMATTER_DELIMITER)) { return [ diff --git a/src/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php index feb7ef63..f2c9439a 100644 --- a/src/Drafling/Storage/StorageBootloader.php +++ b/src/Drafling/Storage/StorageBootloader.php @@ -30,25 +30,25 @@ public function defineSingletons(): array LoggerInterface $logger, ): StorageDriverInterface { $driver = new FileStorageDriver($config, $files, $logger); - + // Initialize with typed configuration $storageConfig = FileStorageConfig::fromArray([ 'base_path' => $config->getProjectsPath(), 'templates_path' => $config->getTemplatesPath(), 'default_entry_status' => $config->getDefaultEntryStatus(), ]); - + $driver->initialize($storageConfig); - + return $driver; }, - // Repositories - bind to storage driver repositories + // Repositories - bind to storage driver repositories TemplateRepositoryInterface::class => static function (StorageDriverInterface $driver): TemplateRepositoryInterface { if ($driver instanceof FileStorageDriver) { return $driver->getTemplateRepository(); } - + throw new \RuntimeException('Storage driver does not support template repository'); }, @@ -56,7 +56,7 @@ public function defineSingletons(): array if ($driver instanceof FileStorageDriver) { return $driver->getProjectRepository(); } - + throw new \RuntimeException('Storage driver does not support project repository'); }, @@ -64,7 +64,7 @@ public function defineSingletons(): array if ($driver instanceof FileStorageDriver) { return $driver->getEntryRepository(); } - + throw new \RuntimeException('Storage driver does not support entry repository'); }, ]; diff --git a/src/Drafling/Storage/StorageDriverInterface.php b/src/Drafling/Storage/StorageDriverInterface.php index 45aa5bfc..3ba77c85 100644 --- a/src/Drafling/Storage/StorageDriverInterface.php +++ b/src/Drafling/Storage/StorageDriverInterface.php @@ -15,7 +15,7 @@ /** * Storage driver contract for different persistence mechanisms - * + * * @template TConfig of object */ interface StorageDriverInterface @@ -27,7 +27,7 @@ public function supports(string $type): bool; /** * Initialize storage driver with configuration - * + * * @param TConfig $config */ public function initialize(object $config): void; diff --git a/src/McpServer/McpServerBootloader.php b/src/McpServer/McpServerBootloader.php index 5d1e42a1..a480b928 100644 --- a/src/McpServer/McpServerBootloader.php +++ b/src/McpServer/McpServerBootloader.php @@ -7,6 +7,13 @@ use Butschster\ContextGenerator\Application\Bootloader\ConsoleBootloader; use Butschster\ContextGenerator\Application\Bootloader\HttpClientBootloader; use Butschster\ContextGenerator\Config\Loader\ConfigLoaderInterface; +use Butschster\ContextGenerator\Drafling\MCP\Tools\CreateEntryToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\CreateProjectToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\GetProjectToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\ListProjectsToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\ListTemplatesToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\UpdateEntryToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\UpdateProjectToolAction; use Butschster\ContextGenerator\McpServer\Action\Prompts\FilesystemOperationsAction; use Butschster\ContextGenerator\McpServer\Action\Prompts\GetPromptAction; use Butschster\ContextGenerator\McpServer\Action\Prompts\ListPromptsAction; @@ -242,6 +249,17 @@ private function actions(McpConfig $config): array ]; } + $actions = [ + ...$actions, + ListTemplatesToolAction::class, + CreateProjectToolAction::class, + ListProjectsToolAction::class, + GetProjectToolAction::class, + CreateEntryToolAction::class, + UpdateEntryToolAction::class, + UpdateProjectToolAction::class, + ]; + return $actions; } } From 1ff42f9c5fedb87008240e634fb87bdad4f0a7a2 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 23 Sep 2025 18:50:14 +0400 Subject: [PATCH 04/16] feat(drafling): Fix DTO validation and storage integration issues --- context.yaml | 10 +- src/Drafling/MCP/DTO/EntryCreateRequest.php | 40 ++++- src/Drafling/MCP/DTO/EntryUpdateRequest.php | 62 ++++++- .../Repository/EntryRepositoryInterface.php | 14 +- .../Repository/ProjectRepositoryInterface.php | 10 +- .../TemplateRepositoryInterface.php | 6 +- .../Service/EntryServiceInterface.php | 50 ++++-- .../Service/ProjectServiceInterface.php | 53 +++--- src/Drafling/Service/TemplateService.php | 159 ++++++++++++++++++ .../Service/TemplateServiceInterface.php | 55 +++++- .../Storage/FileStorage/FileStorageDriver.php | 154 ++++++++++++++--- src/Drafling/Storage/StorageBootloader.php | 4 +- 12 files changed, 522 insertions(+), 95 deletions(-) create mode 100644 src/Drafling/Service/TemplateService.php diff --git a/context.yaml b/context.yaml index ae349f11..88459123 100644 --- a/context.yaml +++ b/context.yaml @@ -40,11 +40,11 @@ documents: sources: - type: file sourcePaths: - - src/Drafling - - vendor/spiral/files/src/FilesInterface.php - notPath: - - src/Drafling/Storage - - src/Drafling/MCP + - src/Drafling/Config + - src/Drafling/Domain + - src/Drafling/Repository + - src/Drafling/Service + - src/Drafling/Storage/StorageDriverInterface.php - description: Drafling FileStorage outputPath: drafling/file-storage.md diff --git a/src/Drafling/MCP/DTO/EntryCreateRequest.php b/src/Drafling/MCP/DTO/EntryCreateRequest.php index b0e8691c..e8c2c317 100644 --- a/src/Drafling/MCP/DTO/EntryCreateRequest.php +++ b/src/Drafling/MCP/DTO/EntryCreateRequest.php @@ -39,9 +39,10 @@ public function __construct( ) {} /** - * Get the entry title, generating from content if not provided + * Get the processed title for entry creation + * This should be called by the service layer to ensure consistent title handling */ - public function getTitle(): string + public function getProcessedTitle(): string { if ($this->title !== null && !empty(\trim($this->title))) { return \trim($this->title); @@ -66,6 +67,14 @@ public function getTitle(): string return \trim((string) $title) ?: 'Untitled Entry'; } + /** + * @deprecated Use getProcessedTitle() instead for consistency + */ + public function getTitle(): string + { + return $this->getProcessedTitle(); + } + /** * Validate the request data */ @@ -89,6 +98,33 @@ public function validate(): array $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; + } + } + 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( + projectId: $this->projectId, + category: $resolvedCategory, + entryType: $resolvedEntryType, + content: $this->content, + title: $this->title, + status: $resolvedStatus ?? $this->status, + tags: $this->tags, + ); + } } diff --git a/src/Drafling/MCP/DTO/EntryUpdateRequest.php b/src/Drafling/MCP/DTO/EntryUpdateRequest.php index 3ec64bd0..9135aad5 100644 --- a/src/Drafling/MCP/DTO/EntryUpdateRequest.php +++ b/src/Drafling/MCP/DTO/EntryUpdateRequest.php @@ -60,16 +60,38 @@ public function hasUpdates(): bool /** * 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 { - $content = $this->content ?? $existingContent; + $baseContent = $this->content ?? $existingContent; - if ($content === null || $this->textReplace === null) { + if ($baseContent === null || $this->textReplace === null) { return $this->content; } - return \str_replace($this->textReplace->find, $this->textReplace->replace, $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 } /** @@ -91,6 +113,21 @@ public function validate(): array $errors[] = 'At least one field must be provided for update'; } + // Validate tags if provided + if ($this->tags !== null) { + if (!\is_array($this->tags)) { + $errors[] = 'Tags must be an array'; + } else { + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; + } + } + } + } + + // Validate text replace if provided if ($this->textReplace !== null) { $replaceErrors = $this->textReplace->validate(); $errors = \array_merge($errors, $replaceErrors); @@ -98,6 +135,23 @@ public function validate(): array 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( + projectId: $this->projectId, + entryId: $this->entryId, + title: $this->title, + content: $this->content, + status: $resolvedStatus, + contentType: $this->contentType, + tags: $this->tags, + textReplace: $this->textReplace, + ); + } } /** @@ -123,6 +177,8 @@ public function validate(): array $errors[] = 'Find text cannot be empty for text replacement'; } + // Note: replace text can be empty (for deletion) + return $errors; } } diff --git a/src/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php index 58c1bbc5..a9edd935 100644 --- a/src/Drafling/Repository/EntryRepositoryInterface.php +++ b/src/Drafling/Repository/EntryRepositoryInterface.php @@ -9,31 +9,31 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; /** - * Entry repository contract for persisting entry data + * Repository interface for managing entries */ interface EntryRepositoryInterface { /** - * Find entries for a project with optional filters + * Find all entries for a project with optional filtering * - * @param ProjectId $projectId Project identifier - * @param array $filters Optional filters (category, status, tags, etc.) + * @param ProjectId $projectId + * @param array $filters Associative array of filters (category, status, entry_type, tags, etc.) * @return Entry[] */ public function findByProject(ProjectId $projectId, array $filters = []): array; /** - * Find specific entry + * Find entry by project and entry ID */ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry; /** - * Save entry (create or update) + * Save entry to storage */ public function save(ProjectId $projectId, Entry $entry): void; /** - * Delete entry + * Delete entry from storage */ public function delete(ProjectId $projectId, EntryId $entryId): bool; diff --git a/src/Drafling/Repository/ProjectRepositoryInterface.php b/src/Drafling/Repository/ProjectRepositoryInterface.php index aca41592..9a4a430b 100644 --- a/src/Drafling/Repository/ProjectRepositoryInterface.php +++ b/src/Drafling/Repository/ProjectRepositoryInterface.php @@ -8,14 +8,14 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; /** - * Project repository contract for persisting project data + * Repository interface for managing projects */ interface ProjectRepositoryInterface { /** - * Find all projects with optional filters + * Find all projects with optional filtering * - * @param array $filters Optional filters (status, template, tags, etc.) + * @param array $filters Associative array of filters (status, template, tags, name_contains, etc.) * @return Project[] */ public function findAll(array $filters = []): array; @@ -26,12 +26,12 @@ public function findAll(array $filters = []): array; public function findById(ProjectId $id): ?Project; /** - * Save project (create or update) + * Save project to storage */ public function save(Project $project): void; /** - * Delete project + * Delete project from storage */ public function delete(ProjectId $id): bool; diff --git a/src/Drafling/Repository/TemplateRepositoryInterface.php b/src/Drafling/Repository/TemplateRepositoryInterface.php index 34ea7af8..100c046e 100644 --- a/src/Drafling/Repository/TemplateRepositoryInterface.php +++ b/src/Drafling/Repository/TemplateRepositoryInterface.php @@ -8,12 +8,12 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; /** - * Template repository contract for accessing template data + * Repository interface for managing templates */ interface TemplateRepositoryInterface { /** - * Find all templates + * Find all available templates * * @return Template[] */ @@ -30,7 +30,7 @@ public function findByKey(TemplateKey $key): ?Template; public function exists(TemplateKey $key): bool; /** - * Refresh template cache/data from source + * Refresh template cache/data */ public function refresh(): void; } diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index 6a1e6396..c0fd191f 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -11,41 +11,55 @@ use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryUpdateRequest; /** - * Entry service contract for managing entry operations + * Service interface for entry operations */ interface EntryServiceInterface { /** - * Get entries for a project with optional filtering + * Create a new entry in the specified project * - * @param ProjectId $projectId Project identifier - * @param array $filters Optional filters (category, status, tags, etc.) - * @return Entry[] + * @param ProjectId $projectId + * @param EntryCreateRequest $request + * @return Entry + * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ - public function getEntries(ProjectId $projectId, array $filters = []): array; + public function createEntry(ProjectId $projectId, EntryCreateRequest $request): Entry; /** - * Get specific entry by ID + * Update an existing entry + * + * @param ProjectId $projectId + * @param EntryId $entryId + * @param EntryUpdateRequest $request + * @return Entry + * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ - public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry; + public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry; /** - * Create new entry + * Check if an entry exists */ - public function createEntry(ProjectId $projectId, EntryCreateRequest $request): Entry; + public function entryExists(ProjectId $projectId, EntryId $entryId): bool; /** - * Update existing entry + * Get entries for a project with optional filtering + * + * @param ProjectId $projectId + * @param array $filters + * @return Entry[] */ - public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry; + public function getEntries(ProjectId $projectId, array $filters = []): array; /** - * Delete entry + * Delete an entry + * + * @param ProjectId $projectId + * @param EntryId $entryId + * @return bool */ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool; - - /** - * Check if entry exists - */ - public function entryExists(ProjectId $projectId, EntryId $entryId): bool; } diff --git a/src/Drafling/Service/ProjectServiceInterface.php b/src/Drafling/Service/ProjectServiceInterface.php index 7044f768..85f88bf5 100644 --- a/src/Drafling/Service/ProjectServiceInterface.php +++ b/src/Drafling/Service/ProjectServiceInterface.php @@ -6,50 +6,61 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Project; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectUpdateRequest; /** - * Project service contract for managing project lifecycle + * Service interface for project operations */ interface ProjectServiceInterface { /** - * Get all projects with optional filtering + * Create a new project from template * - * @param array $filters Optional filters (status, tags, template, etc.) - * @return Project[] + * @param ProjectCreateRequest $request + * @return Project + * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ - public function listProjects(array $filters = []): array; + public function createProject(ProjectCreateRequest $request): Project; /** - * Get project by ID + * Update an existing project + * + * @param ProjectId $projectId + * @param ProjectUpdateRequest $request + * @return Project + * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ - public function getProject(ProjectId $id): ?Project; + public function updateProject(ProjectId $projectId, ProjectUpdateRequest $request): Project; /** - * Create new project + * Check if a project exists */ - public function createProject(ProjectCreateRequest $request): Project; + public function projectExists(ProjectId $projectId): bool; /** - * Update existing project - */ - public function updateProject(ProjectId $id, ProjectUpdateRequest $request): Project; - - /** - * Delete project + * Get a single project by ID + * + * @param ProjectId $projectId + * @return Project|null */ - public function deleteProject(ProjectId $id): bool; + public function getProject(ProjectId $projectId): ?Project; /** - * Check if project exists + * List projects with optional filtering + * + * @param array $filters + * @return Project[] */ - public function projectExists(ProjectId $id): bool; + public function listProjects(array $filters = []): array; /** - * Get project template + * Delete a project + * + * @param ProjectId $projectId + * @return bool */ - public function getProjectTemplate(ProjectId $id): ?TemplateKey; + public function deleteProject(ProjectId $projectId): bool; } diff --git a/src/Drafling/Service/TemplateService.php b/src/Drafling/Service/TemplateService.php new file mode 100644 index 00000000..0a2448bc --- /dev/null +++ b/src/Drafling/Service/TemplateService.php @@ -0,0 +1,159 @@ +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\Drafling\Domain\Model\EntryType + { + foreach ($template->entryTypes as $entryType) { + if ($entryType->key === $key) { + return $entryType; + } + } + return null; + } +} diff --git a/src/Drafling/Service/TemplateServiceInterface.php b/src/Drafling/Service/TemplateServiceInterface.php index 4eef6b05..152e3278 100644 --- a/src/Drafling/Service/TemplateServiceInterface.php +++ b/src/Drafling/Service/TemplateServiceInterface.php @@ -8,7 +8,7 @@ use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; /** - * Template service contract for managing template operations + * Service interface for template operations */ interface TemplateServiceInterface { @@ -20,20 +20,63 @@ interface TemplateServiceInterface public function getAllTemplates(): array; /** - * Get template by key + * Get a template by key + * + * @param TemplateKey $key + * @return Template|null */ public function getTemplate(TemplateKey $key): ?Template; /** * Check if template exists + * + * @param TemplateKey $key + * @return bool */ public function templateExists(TemplateKey $key): bool; /** - * Validate template configuration + * Resolve display name to internal category key + * Checks both internal key and display name for matches * - * @param array $templateData Raw template data - * @return array Array of validation errors (empty if valid) + * @param Template $template + * @param string $displayNameOrKey + * @return string|null Internal category key or null if not found + */ + public function resolveCategoryKey(Template $template, string $displayNameOrKey): ?string; + + /** + * Resolve display name to internal entry type key + * Checks both internal key and display name for matches + * + * @param Template $template + * @param string $displayNameOrKey + * @return string|null Internal entry type key or null if not found + */ + public function resolveEntryTypeKey(Template $template, string $displayNameOrKey): ?string; + + /** + * Resolve display name to internal status value + * Checks both internal value and display name for matches + * + * @param Template $template + * @param string $entryTypeKey Internal entry type key + * @param string $displayNameOrValue + * @return string|null Internal status value or null if not found + */ + public function resolveStatusValue(Template $template, string $entryTypeKey, string $displayNameOrValue): ?string; + + /** + * Get available statuses for an entry type + * + * @param Template $template + * @param string $entryTypeKey + * @return array Array of status values + */ + public function getAvailableStatuses(Template $template, string $entryTypeKey): array; + + /** + * Refresh templates from storage */ - public function validateTemplate(array $templateData): array; + public function refreshTemplates(): void; } diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index 0d7e7bed..26a7ae1e 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -9,6 +9,7 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Project; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; +use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryCreateRequest; use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryUpdateRequest; use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; @@ -104,7 +105,7 @@ public function initialize(object $config): void public function createProject(ProjectCreateRequest $request): Project { // Validate template exists - $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($request->templateId); + $templateKey = TemplateKey::fromString($request->templateId); $template = $this->templateRepository->findByKey($templateKey); if ($template === null) { @@ -115,7 +116,7 @@ public function createProject(ProjectCreateRequest $request): Project $projectId = $this->generateId('proj_'); $project = new Project( id: $projectId, - name: $request->name, + name: $request->getName(), // Use getName() method for consistency description: $request->description, template: $request->templateId, status: $this->config->defaultEntryStatus, @@ -124,7 +125,7 @@ public function createProject(ProjectCreateRequest $request): Project ); $this->projectRepository->save($project); - $this->logOperation('Created project', ['id' => $projectId, 'name' => $request->name]); + $this->logOperation('Created project', ['id' => $projectId, 'name' => $request->getName()]); return $project; } @@ -142,7 +143,7 @@ public function updateProject(ProjectId $projectId, ProjectUpdateRequest $reques } $updatedProject = $project->withUpdates( - name: $request->name, + name: $request->getName(), // Use getName() method for consistency description: $request->description, status: $request->status, tags: $request->tags, @@ -179,8 +180,18 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): throw ProjectNotFoundException::withId($projectId->value); } - // Validate against template - $this->validateEntryAgainstTemplate($project, $request); + // Get template for validation and key resolution + $templateKey = TemplateKey::fromString($project->template); + $template = $this->templateRepository->findByKey($templateKey); + if ($template === null) { + throw TemplateNotFoundException::withKey($project->template); + } + + // 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_'); @@ -188,21 +199,21 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): $entry = new Entry( entryId: $entryId, - title: $request->title, - entryType: $request->entryType, - category: $request->category, - status: $request->status ?? $this->config->defaultEntryStatus, + title: $resolvedRequest->getProcessedTitle(), // Use processed title + entryType: $resolvedRequest->entryType, + category: $resolvedRequest->category, + status: $resolvedRequest->status ?? $this->config->defaultEntryStatus, createdAt: $now, updatedAt: $now, - tags: $request->tags, - content: $request->content, + tags: $resolvedRequest->tags, + content: $resolvedRequest->content, ); $this->entryRepository->save($projectId, $entry); $this->logOperation('Created entry', [ 'project_id' => $projectId->value, 'entry_id' => $entryId, - 'title' => $request->title, + 'title' => $entry->title, ]); return $entry; @@ -220,11 +231,28 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR return $entry; } + // Resolve status if provided + $resolvedRequest = $request; + if ($request->status !== null) { + $project = $this->projectRepository->findById($projectId); + if ($project !== null) { + $templateKey = TemplateKey::fromString($project->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: $request->title, - status: $request->status, - tags: $request->tags, - content: $request->content, + title: $resolvedRequest->title, + status: $resolvedRequest->status, + tags: $resolvedRequest->tags, + content: $finalContent, // Use processed content with text replacement ); $this->entryRepository->save($projectId, $updatedEntry); @@ -300,17 +328,43 @@ private function getDefaultEntryDirs(\Butschster\ContextGenerator\Drafling\Domai } /** - * Validate entry request against project template + * Resolve display names in entry create request to internal keys */ - private function validateEntryAgainstTemplate(Project $project, EntryCreateRequest $request): void - { - $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); - $template = $this->templateRepository->findByKey($templateKey); + private function resolveEntryCreateRequestKeys( + EntryCreateRequest $request, + \Butschster\ContextGenerator\Drafling\Domain\Model\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}'"); + } - if ($template === null) { - throw TemplateNotFoundException::withKey($project->template); + // 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 project template + */ + private function validateEntryAgainstTemplate( + \Butschster\ContextGenerator\Drafling\Domain\Model\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}'"); @@ -334,4 +388,56 @@ private function validateEntryAgainstTemplate(Project $project, EntryCreateReque } } } + + /** + * Resolve category display name to internal key + */ + private function resolveCategoryKey( + \Butschster\ContextGenerator\Drafling\Domain\Model\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( + \Butschster\ContextGenerator\Drafling\Domain\Model\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( + \Butschster\ContextGenerator\Drafling\Domain\Model\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/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php index f2c9439a..941c7d49 100644 --- a/src/Drafling/Storage/StorageBootloader.php +++ b/src/Drafling/Storage/StorageBootloader.php @@ -11,6 +11,7 @@ use Butschster\ContextGenerator\Drafling\Storage\Config\FileStorageConfig; use Butschster\ContextGenerator\Drafling\Storage\FileStorage\FileStorageDriver; use Spiral\Boot\Bootloader\Bootloader; +use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Files\FilesInterface; use Psr\Log\LoggerInterface; @@ -28,8 +29,9 @@ public function defineSingletons(): array DraflingConfigInterface $config, FilesInterface $files, LoggerInterface $logger, + ExceptionReporterInterface $reporter, ): StorageDriverInterface { - $driver = new FileStorageDriver($config, $files, $logger); + $driver = new FileStorageDriver($config, $files, $reporter, $logger); // Initialize with typed configuration $storageConfig = FileStorageConfig::fromArray([ From f5d9cfd47b51a2d7d892d108fd66efdad6706965 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 23 Sep 2025 14:50:58 +0000 Subject: [PATCH 05/16] style(php-cs-fixer): fix coding standards --- src/Drafling/MCP/Tools/ListTemplatesToolAction.php | 2 +- src/Drafling/Repository/EntryRepositoryInterface.php | 1 - src/Drafling/Service/EntryServiceInterface.php | 12 ------------ src/Drafling/Service/ProjectServiceInterface.php | 10 ---------- src/Drafling/Service/TemplateServiceInterface.php | 12 ------------ 5 files changed, 1 insertion(+), 36 deletions(-) diff --git a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php index a227e3fa..c3a505cb 100644 --- a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php +++ b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php @@ -138,7 +138,7 @@ private function applyFilters(array $templates, ListTemplatesRequest $request): $searchTerm = \strtolower(\trim($request->nameContains)); $templateName = \strtolower((string) $template->name); - if (!str_contains($templateName, $searchTerm)) { + if (!\str_contains($templateName, $searchTerm)) { return false; } } diff --git a/src/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php index a9edd935..ee5e57ba 100644 --- a/src/Drafling/Repository/EntryRepositoryInterface.php +++ b/src/Drafling/Repository/EntryRepositoryInterface.php @@ -16,7 +16,6 @@ interface EntryRepositoryInterface /** * Find all entries for a project with optional filtering * - * @param ProjectId $projectId * @param array $filters Associative array of filters (category, status, entry_type, tags, etc.) * @return Entry[] */ diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index c0fd191f..cb7d1c49 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -18,9 +18,6 @@ interface EntryServiceInterface /** * Create a new entry in the specified project * - * @param ProjectId $projectId - * @param EntryCreateRequest $request - * @return Entry * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException @@ -30,10 +27,6 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): /** * Update an existing entry * - * @param ProjectId $projectId - * @param EntryId $entryId - * @param EntryUpdateRequest $request - * @return Entry * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException @@ -48,8 +41,6 @@ public function entryExists(ProjectId $projectId, EntryId $entryId): bool; /** * Get entries for a project with optional filtering * - * @param ProjectId $projectId - * @param array $filters * @return Entry[] */ public function getEntries(ProjectId $projectId, array $filters = []): array; @@ -57,9 +48,6 @@ public function getEntries(ProjectId $projectId, array $filters = []): array; /** * Delete an entry * - * @param ProjectId $projectId - * @param EntryId $entryId - * @return bool */ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool; } diff --git a/src/Drafling/Service/ProjectServiceInterface.php b/src/Drafling/Service/ProjectServiceInterface.php index 85f88bf5..5c28552d 100644 --- a/src/Drafling/Service/ProjectServiceInterface.php +++ b/src/Drafling/Service/ProjectServiceInterface.php @@ -17,8 +17,6 @@ interface ProjectServiceInterface /** * Create a new project from template * - * @param ProjectCreateRequest $request - * @return Project * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ @@ -27,9 +25,6 @@ public function createProject(ProjectCreateRequest $request): Project; /** * Update an existing project * - * @param ProjectId $projectId - * @param ProjectUpdateRequest $request - * @return Project * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ @@ -43,15 +38,12 @@ public function projectExists(ProjectId $projectId): bool; /** * Get a single project by ID * - * @param ProjectId $projectId - * @return Project|null */ public function getProject(ProjectId $projectId): ?Project; /** * List projects with optional filtering * - * @param array $filters * @return Project[] */ public function listProjects(array $filters = []): array; @@ -59,8 +51,6 @@ public function listProjects(array $filters = []): array; /** * Delete a project * - * @param ProjectId $projectId - * @return bool */ public function deleteProject(ProjectId $projectId): bool; } diff --git a/src/Drafling/Service/TemplateServiceInterface.php b/src/Drafling/Service/TemplateServiceInterface.php index 152e3278..9d6ba230 100644 --- a/src/Drafling/Service/TemplateServiceInterface.php +++ b/src/Drafling/Service/TemplateServiceInterface.php @@ -22,16 +22,12 @@ public function getAllTemplates(): array; /** * Get a template by key * - * @param TemplateKey $key - * @return Template|null */ public function getTemplate(TemplateKey $key): ?Template; /** * Check if template exists * - * @param TemplateKey $key - * @return bool */ public function templateExists(TemplateKey $key): bool; @@ -39,8 +35,6 @@ public function templateExists(TemplateKey $key): bool; * Resolve display name to internal category key * Checks both internal key and display name for matches * - * @param Template $template - * @param string $displayNameOrKey * @return string|null Internal category key or null if not found */ public function resolveCategoryKey(Template $template, string $displayNameOrKey): ?string; @@ -49,8 +43,6 @@ public function resolveCategoryKey(Template $template, string $displayNameOrKey) * Resolve display name to internal entry type key * Checks both internal key and display name for matches * - * @param Template $template - * @param string $displayNameOrKey * @return string|null Internal entry type key or null if not found */ public function resolveEntryTypeKey(Template $template, string $displayNameOrKey): ?string; @@ -59,9 +51,7 @@ public function resolveEntryTypeKey(Template $template, string $displayNameOrKey * Resolve display name to internal status value * Checks both internal value and display name for matches * - * @param Template $template * @param string $entryTypeKey Internal entry type key - * @param string $displayNameOrValue * @return string|null Internal status value or null if not found */ public function resolveStatusValue(Template $template, string $entryTypeKey, string $displayNameOrValue): ?string; @@ -69,8 +59,6 @@ public function resolveStatusValue(Template $template, string $entryTypeKey, str /** * Get available statuses for an entry type * - * @param Template $template - * @param string $entryTypeKey * @return array Array of status values */ public function getAvailableStatuses(Template $template, string $entryTypeKey): array; From cfca22a6f000a2693b2b06fcd9d31110bafd8fcd Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 23 Sep 2025 20:45:56 +0400 Subject: [PATCH 06/16] feat(drafling): integrate services, commands, and bootloaders for improved project management --- .gitignore | 2 + src/Application/Kernel.php | 4 + src/Drafling/Console/ProjectInfoCommand.php | 216 ++++++++++ src/Drafling/Console/ProjectListCommand.php | 79 ++++ src/Drafling/Console/TemplateListCommand.php | 133 ++++++ src/Drafling/Domain/Model/Entry.php | 20 +- src/Drafling/Domain/Model/Project.php | 22 +- src/Drafling/DraflingBootloader.php | 55 ++- src/Drafling/Exception/DraflingException.php | 9 +- .../Exception/EntryNotFoundException.php | 8 +- .../Exception/ProjectNotFoundException.php | 8 +- .../Exception/TemplateNotFoundException.php | 8 +- src/Drafling/MCP/DTO/ListEntriesRequest.php | 84 ++++ src/Drafling/MCP/DTO/ReadEntryRequest.php | 56 +++ .../MCP/Tools/CreateEntryToolAction.php | 2 +- .../MCP/Tools/CreateProjectToolAction.php | 2 +- .../MCP/Tools/GetProjectToolAction.php | 2 +- .../MCP/Tools/ListEntriesToolAction.php | 158 +++++++ .../MCP/Tools/ListProjectsToolAction.php | 18 +- .../MCP/Tools/ListTemplatesToolAction.php | 4 +- .../MCP/Tools/ReadEntryToolAction.php | 193 +++++++++ .../MCP/Tools/UpdateEntryToolAction.php | 2 +- .../MCP/Tools/UpdateProjectToolAction.php | 2 +- src/Drafling/Service/DraflingService.php | 342 +++++++++++++++ src/Drafling/Service/EntryService.php | 396 ++++++++++++++++++ .../Service/EntryServiceInterface.php | 11 + src/Drafling/Service/ProjectService.php | 242 +++++++++++ .../Service/ProjectServiceInterface.php | 10 - .../Storage/FileStorage/FileStorageDriver.php | 21 +- .../FileStorage/FileStorageRepositoryBase.php | 6 +- .../FileStorage/FileTemplateRepository.php | 62 ++- src/Drafling/Storage/StorageBootloader.php | 4 +- src/McpServer/McpServerBootloader.php | 29 +- src/McpServer/Server.php | 3 + src/McpServer/ServerRunner.php | 3 + 35 files changed, 2083 insertions(+), 133 deletions(-) create mode 100644 src/Drafling/Console/ProjectInfoCommand.php create mode 100644 src/Drafling/Console/ProjectListCommand.php create mode 100644 src/Drafling/Console/TemplateListCommand.php create mode 100644 src/Drafling/MCP/DTO/ListEntriesRequest.php create mode 100644 src/Drafling/MCP/DTO/ReadEntryRequest.php create mode 100644 src/Drafling/MCP/Tools/ListEntriesToolAction.php create mode 100644 src/Drafling/MCP/Tools/ReadEntryToolAction.php create mode 100644 src/Drafling/Service/DraflingService.php create mode 100644 src/Drafling/Service/EntryService.php create mode 100644 src/Drafling/Service/ProjectService.php diff --git a/.gitignore b/.gitignore index a9fe37fd..03cfcf73 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ vendor node_modules .php-cs-fixer.cache .context +.templates +.projects runtime mcp-*.log .env diff --git a/src/Application/Kernel.php b/src/Application/Kernel.php index 96b28294..13438abe 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\Drafling\DraflingBootloader; 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, + // Drafling + DraflingBootloader::class, + // Sources TextSourceBootloader::class, FileSourceBootloader::class, diff --git a/src/Drafling/Console/ProjectInfoCommand.php b/src/Drafling/Console/ProjectInfoCommand.php new file mode 100644 index 00000000..88ae4acb --- /dev/null +++ b/src/Drafling/Console/ProjectInfoCommand.php @@ -0,0 +1,216 @@ +projectId); + + // Get project information + $project = $projectService->getProject($projectId); + if ($project === null) { + $this->output->error("Project not found: {$this->projectId}"); + return Command::FAILURE; + } + + // Get template information + $template = $templateService->getTemplate(new TemplateKey($project->template)); + + // Display project information + $this->displayProjectInfo($project, $template); + + // Show entries if requested + if ($this->showEntries) { + $this->displayProjectEntries($entryService, $projectId); + } + + // Show statistics if requested + if ($this->showStats) { + $this->displayProjectStatistics($entryService, $projectId); + } + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->output->error('Failed to get project information: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + private function displayProjectInfo($project, $template): void + { + $this->output->title("Project Information"); + + $this->output->definitionList( + ['ID', Style::property($project->id)], + ['Name', $project->name], + ['Description', $project->description ?: 'None'], + ['Status', $project->status], + ['Template', $project->template . ($template ? " ({$template->name})" : ' (template not found)')], + ['Tags', empty($project->tags) ? 'None' : \implode(', ', $project->tags)], + ['Entry Directories', empty($project->entryDirs) ? 'None' : \implode(', ', $project->entryDirs)], + ['Project Path', $project->projectPath ?? '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 displayProjectEntries(EntryServiceInterface $entryService, ProjectId $projectId): void + { + $this->output->section('Project Entries'); + + try { + $entries = $entryService->getEntries($projectId); + + if (empty($entries)) { + $this->output->info('No entries found in this project.'); + 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 project entries: ' . $e->getMessage()); + } + } + + private function displayProjectStatistics(EntryServiceInterface $entryService, ProjectId $projectId): void + { + $this->output->section('Project Statistics'); + + try { + $entries = $entryService->getEntries($projectId); + + // 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 project statistics: ' . $e->getMessage()); + } + } +} diff --git a/src/Drafling/Console/ProjectListCommand.php b/src/Drafling/Console/ProjectListCommand.php new file mode 100644 index 00000000..f3967f56 --- /dev/null +++ b/src/Drafling/Console/ProjectListCommand.php @@ -0,0 +1,79 @@ +status !== null) { + $filters['status'] = $this->status; + } + + if ($this->template !== null) { + $filters['template'] = $this->template; + } + + try { + $projects = $projectService->listProjects($filters); + + if (empty($projects)) { + $this->output->info('No Drafling projects found.'); + return Command::SUCCESS; + } + + $this->output->title('Drafling Projects'); + + $table = new Table($this->output); + $table->setHeaders(['ID', 'Name', 'Status', 'Template', 'Description', 'Tags']); + + foreach ($projects as $project) { + $table->addRow([ + Style::property($project->id), + $project->name, + $project->status, + $project->template, + $project->description ?: '-', + \implode(', ', $project->tags), + ]); + } + + $table->render(); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->output->error('Failed to list projects: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/src/Drafling/Console/TemplateListCommand.php b/src/Drafling/Console/TemplateListCommand.php new file mode 100644 index 00000000..7b0be761 --- /dev/null +++ b/src/Drafling/Console/TemplateListCommand.php @@ -0,0 +1,133 @@ +getAllTemplates(); + + // Apply filters + if ($this->tag !== null) { + $templates = \array_filter( + $templates, + fn($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((string) $template->name), $searchTerm), + ); + } + + if (empty($templates)) { + $this->output->info('No Drafling templates found.'); + return Command::SUCCESS; + } + + $this->output->title('Drafling Templates'); + + if ($this->details) { + foreach ($templates as $template) { + $this->displayTemplateDetails($template); + } + } else { + $table = new Table($this->output); + $table->setHeaders(['ID', 'Name', 'Description', 'Tags']); + + foreach ($templates as $template) { + $table->addRow([ + Style::property($template->key), + $template->name, + $template->description ?: '-', + \implode(', ', $template->tags), + ]); + } + + $table->render(); + } + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->output->error('Failed to list templates: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + private function displayTemplateDetails($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)); + } + } + } + + if (!empty($template->entryTypes)) { + $this->output->writeln("\nEntry Types:"); + foreach ($template->entryTypes as $entryType) { + $this->output->writeln(" • {$entryType->displayName} ({$entryType->key})"); + $this->output->writeln(" Content type: {$entryType->contentType}"); + if (!empty($entryType->statuses)) { + $statuses = \array_map(static fn($status) => $status->displayName, $entryType->statuses); + $this->output->writeln(" Statuses: " . \implode(', ', $statuses)); + } + } + } + + if ($template->prompt !== null) { + $this->output->writeln("\nPrompt: {$template->prompt}"); + } + + $this->output->newLine(); + } +} diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Drafling/Domain/Model/Entry.php index 7613e712..625aff5d 100644 --- a/src/Drafling/Domain/Model/Entry.php +++ b/src/Drafling/Domain/Model/Entry.php @@ -7,7 +7,7 @@ /** * Entry represents an individual markdown document with structured metadata */ -final readonly class Entry +final readonly class Entry implements \JsonSerializable { /** * @param string $entryId Unique entry identifier (UUID) @@ -83,4 +83,22 @@ public function getMetadata(): array 'tags' => $this->tags, ]; } + + /** + * Specify data which should be serialized to JSON + */ + public function jsonSerialize(): array + { + return [ + 'entry_id' => $this->entryId, + 'title' => $this->title, + 'entry_type' => $this->entryType, + 'category' => $this->category, + 'status' => $this->status, + 'content_type' => 'markdown', + 'created_at' => $this->createdAt->format('c'), + 'updated_at' => $this->updatedAt->format('c'), + 'tags' => $this->tags, + ]; + } } diff --git a/src/Drafling/Domain/Model/Project.php b/src/Drafling/Domain/Model/Project.php index 1f9ab4ae..d6026118 100644 --- a/src/Drafling/Domain/Model/Project.php +++ b/src/Drafling/Domain/Model/Project.php @@ -7,7 +7,7 @@ /** * Project represents an instance of a template with its own configuration and entries */ -final readonly class Project +final readonly class Project implements \JsonSerializable { /** * @param string $id Unique project identifier @@ -79,4 +79,24 @@ public function getConfiguration(): array ], ]; } + + /** + * Specify data which should be serialized to JSON + */ + public function jsonSerialize(): array + { + return [ + 'project_id' => $this->id, + 'title' => $this->name, + 'status' => $this->status, + 'project_type' => $this->template, + 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date from domain + 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date from domain + 'metadata' => [ + 'description' => $this->description, + 'tags' => $this->tags, + 'entry_dirs' => $this->entryDirs, + ], + ]; + } } diff --git a/src/Drafling/DraflingBootloader.php b/src/Drafling/DraflingBootloader.php index 6847c294..cd1ddeb0 100644 --- a/src/Drafling/DraflingBootloader.php +++ b/src/Drafling/DraflingBootloader.php @@ -4,9 +4,21 @@ namespace Butschster\ContextGenerator\Drafling; -use Butschster\ContextGenerator\Application\Bootloader\ConfigurationBootloader; +use Butschster\ContextGenerator\Application\Bootloader\ConsoleBootloader; use Butschster\ContextGenerator\Drafling\Config\DraflingConfig; use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; +use Butschster\ContextGenerator\Drafling\Console\ProjectInfoCommand; +use Butschster\ContextGenerator\Drafling\Console\ProjectListCommand; +use Butschster\ContextGenerator\Drafling\Console\TemplateListCommand; +use Butschster\ContextGenerator\Drafling\Service\DraflingService; +use Butschster\ContextGenerator\Drafling\Service\DraflingServiceInterface; +use Butschster\ContextGenerator\Drafling\Service\EntryService; +use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Drafling\Service\ProjectService; +use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +use Butschster\ContextGenerator\Drafling\Service\TemplateService; +use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; +use Butschster\ContextGenerator\Drafling\Storage\Config\FileStorageConfig; use Butschster\ContextGenerator\Drafling\Storage\StorageBootloader; use Butschster\ContextGenerator\Drafling\Storage\StorageDriverInterface; use Spiral\Boot\Bootloader\Bootloader; @@ -26,13 +38,30 @@ public function __construct( public function defineDependencies(): array { return [ - ConfigurationBootloader::class, StorageBootloader::class, ]; } - public function init(EnvironmentInterface $env): void + public function defineSingletons(): array + { + return [ + // Configuration + DraflingConfigInterface::class => DraflingConfig::class, + TemplateServiceInterface::class => TemplateService::class, + DraflingServiceInterface::class => DraflingService::class, + ProjectServiceInterface::class => ProjectService::class, + EntryServiceInterface::class => EntryService::class, + ]; + } + + public function init(ConsoleBootloader $console, EnvironmentInterface $env): void { + $console->addCommand( + ProjectListCommand::class, + TemplateListCommand::class, + ProjectInfoCommand::class, + ); + // Initialize configuration from environment variables $this->config->setDefaults( DraflingConfig::CONFIG, @@ -47,15 +76,6 @@ public function init(EnvironmentInterface $env): void ); } - #[\Override] - public function defineSingletons(): array - { - return [ - // Configuration - DraflingConfigInterface::class => DraflingConfig::class, - ]; - } - public function boot( DraflingConfigInterface $config, StorageDriverInterface $storageDriver, @@ -66,11 +86,10 @@ public function boot( } // Initialize storage driver - $storageDriver->initialize([ - 'base_path' => $config->getProjectsPath(), - 'templates_path' => $config->getTemplatesPath(), - 'storage_driver' => $config->getStorageDriver(), - 'default_entry_status' => $config->getDefaultEntryStatus(), - ]); + $storageDriver->initialize(new FileStorageConfig( + basePath: $config->getProjectsPath(), + templatesPath: $config->getTemplatesPath(), + defaultEntryStatus: $config->getDefaultEntryStatus(), + )); } } diff --git a/src/Drafling/Exception/DraflingException.php b/src/Drafling/Exception/DraflingException.php index dc282ecf..f48552eb 100644 --- a/src/Drafling/Exception/DraflingException.php +++ b/src/Drafling/Exception/DraflingException.php @@ -5,15 +5,8 @@ namespace Butschster\ContextGenerator\Drafling\Exception; /** - * Base exception for Drafling system + * Base exception for all Drafling-related errors */ class DraflingException extends \Exception { - public function __construct( - string $message = '', - int $code = 0, - ?\Throwable $previous = null, - ) { - parent::__construct($message, $code, $previous); - } } diff --git a/src/Drafling/Exception/EntryNotFoundException.php b/src/Drafling/Exception/EntryNotFoundException.php index 60e111cd..d11a2898 100644 --- a/src/Drafling/Exception/EntryNotFoundException.php +++ b/src/Drafling/Exception/EntryNotFoundException.php @@ -5,12 +5,8 @@ namespace Butschster\ContextGenerator\Drafling\Exception; /** - * Exception thrown when entry is not found + * Exception thrown when an entry cannot be found */ -final class EntryNotFoundException extends DraflingException +class EntryNotFoundException extends DraflingException { - public static function withId(string $projectId, string $entryId): self - { - return new self("Entry '{$entryId}' not found in project '{$projectId}'"); - } } diff --git a/src/Drafling/Exception/ProjectNotFoundException.php b/src/Drafling/Exception/ProjectNotFoundException.php index 86db0487..8ae3334f 100644 --- a/src/Drafling/Exception/ProjectNotFoundException.php +++ b/src/Drafling/Exception/ProjectNotFoundException.php @@ -5,12 +5,8 @@ namespace Butschster\ContextGenerator\Drafling\Exception; /** - * Exception thrown when project is not found + * Exception thrown when a project cannot be found */ -final class ProjectNotFoundException extends DraflingException +class ProjectNotFoundException extends DraflingException { - public static function withId(string $id): self - { - return new self("Project with ID '{$id}' not found"); - } } diff --git a/src/Drafling/Exception/TemplateNotFoundException.php b/src/Drafling/Exception/TemplateNotFoundException.php index 0798d4e2..49351c7d 100644 --- a/src/Drafling/Exception/TemplateNotFoundException.php +++ b/src/Drafling/Exception/TemplateNotFoundException.php @@ -5,12 +5,8 @@ namespace Butschster\ContextGenerator\Drafling\Exception; /** - * Exception thrown when template is not found + * Exception thrown when a template cannot be found */ -final class TemplateNotFoundException extends DraflingException +class TemplateNotFoundException extends DraflingException { - public static function withKey(string $key): self - { - return new self("Template with key '{$key}' not found"); - } } diff --git a/src/Drafling/MCP/DTO/ListEntriesRequest.php b/src/Drafling/MCP/DTO/ListEntriesRequest.php new file mode 100644 index 00000000..a407d069 --- /dev/null +++ b/src/Drafling/MCP/DTO/ListEntriesRequest.php @@ -0,0 +1,84 @@ +projectId))) { + $errors[] = 'Project 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/Drafling/MCP/DTO/ReadEntryRequest.php b/src/Drafling/MCP/DTO/ReadEntryRequest.php new file mode 100644 index 00000000..d258b78c --- /dev/null +++ b/src/Drafling/MCP/DTO/ReadEntryRequest.php @@ -0,0 +1,56 @@ +projectId))) { + $errors[] = 'Project ID is required'; + } + + // Validate entry ID + if (empty(trim($this->entryId))) { + $errors[] = 'Entry ID is required'; + } + + return $errors; + } +} \ No newline at end of file diff --git a/src/Drafling/MCP/Tools/CreateEntryToolAction.php b/src/Drafling/MCP/Tools/CreateEntryToolAction.php index 5dfaa108..700b5603 100644 --- a/src/Drafling/MCP/Tools/CreateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/CreateEntryToolAction.php @@ -31,7 +31,7 @@ public function __construct( private ProjectServiceInterface $projectService, ) {} - #[Post(path: '/tools/call/drafling_create_entry', name: 'tools.drafling.drafling_create_entry')] + #[Post(path: '/tools/call/drafling_create_entry', name: 'tools.drafling_create_entry')] public function __invoke(EntryCreateRequest $request): CallToolResult { $this->logger->info('Creating new entry', [ diff --git a/src/Drafling/MCP/Tools/CreateProjectToolAction.php b/src/Drafling/MCP/Tools/CreateProjectToolAction.php index b4c09160..641ddbac 100644 --- a/src/Drafling/MCP/Tools/CreateProjectToolAction.php +++ b/src/Drafling/MCP/Tools/CreateProjectToolAction.php @@ -31,7 +31,7 @@ public function __construct( private TemplateServiceInterface $templateService, ) {} - #[Post(path: '/tools/call/drafling_create_project', name: 'tools.drafling.drafling_create_project')] + #[Post(path: '/tools/call/drafling_create_project', name: 'tools.drafling_create_project')] public function __invoke(ProjectCreateRequest $request): CallToolResult { $this->logger->info('Creating new project', [ diff --git a/src/Drafling/MCP/Tools/GetProjectToolAction.php b/src/Drafling/MCP/Tools/GetProjectToolAction.php index 8277baf3..efce625f 100644 --- a/src/Drafling/MCP/Tools/GetProjectToolAction.php +++ b/src/Drafling/MCP/Tools/GetProjectToolAction.php @@ -29,7 +29,7 @@ public function __construct( private ProjectServiceInterface $projectService, ) {} - #[Post(path: '/tools/call/drafling_get_project', name: 'tools.drafling.drafling_get_project')] + #[Post(path: '/tools/call/drafling_get_project', name: 'tools.drafling_get_project')] public function __invoke(GetProjectRequest $request): CallToolResult { $this->logger->info('Getting project', [ diff --git a/src/Drafling/MCP/Tools/ListEntriesToolAction.php b/src/Drafling/MCP/Tools/ListEntriesToolAction.php new file mode 100644 index 00000000..eed9516d --- /dev/null +++ b/src/Drafling/MCP/Tools/ListEntriesToolAction.php @@ -0,0 +1,158 @@ +logger->info('Listing entries', [ + 'project_id' => $request->projectId, + 'has_filters' => $request->hasFilters(), + 'filters' => $request->getFilters(), + 'limit' => $request->limit, + 'offset' => $request->offset, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Get entries with filters + $allEntries = $this->entryService->getEntries($projectId, $request->getFilters()); + + // Apply pagination + $paginatedEntries = \array_slice( + $allEntries, + $request->offset, + $request->limit, + ); + + // Format entries for response (using JsonSerializable) + $entryData = $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', [ + 'project_id' => $request->projectId, + 'returned_count' => \count($paginatedEntries), + 'total_available' => \count($allEntries), + 'filters_applied' => $request->hasFilters(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error listing entries', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error listing entries', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to list entries: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/MCP/Tools/ListProjectsToolAction.php b/src/Drafling/MCP/Tools/ListProjectsToolAction.php index abcf5e88..d395cb15 100644 --- a/src/Drafling/MCP/Tools/ListProjectsToolAction.php +++ b/src/Drafling/MCP/Tools/ListProjectsToolAction.php @@ -27,7 +27,7 @@ public function __construct( private ProjectServiceInterface $projectService, ) {} - #[Post(path: '/tools/call/drafling_list_projects', name: 'tools.drafling.drafling_list_projects')] + #[Post(path: '/tools/call/drafling_list_projects', name: 'tools.drafling_list_projects')] public function __invoke(ListProjectsRequest $request): CallToolResult { $this->logger->info('Listing projects', [ @@ -66,20 +66,8 @@ public function __invoke(ListProjectsRequest $request): CallToolResult $request->limit, ); - // Format projects for response - $projectData = \array_map(static fn($project) => [ - 'project_id' => $project->id, - 'title' => $project->name, - 'status' => $project->status, - 'project_type' => $project->template, - 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date from domain - 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date from domain - 'metadata' => [ - 'description' => $project->description, - 'tags' => $project->tags, - 'entry_dirs' => $project->entryDirs, - ], - ], $paginatedProjects); + // Format projects for response (using JsonSerializable) + $projectData = $paginatedProjects; $response = [ 'success' => true, diff --git a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php index a227e3fa..2744a517 100644 --- a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php +++ b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php @@ -27,7 +27,7 @@ public function __construct( private TemplateServiceInterface $templateService, ) {} - #[Post(path: '/tools/call/drafling_list_templates', name: 'tools.drafling.drafling_list_templates')] + #[Post(path: '/tools/call/drafling_list_templates', name: 'tools.drafling_list_templates')] public function __invoke(ListTemplatesRequest $request): CallToolResult { $this->logger->info('Listing templates', [ @@ -138,7 +138,7 @@ private function applyFilters(array $templates, ListTemplatesRequest $request): $searchTerm = \strtolower(\trim($request->nameContains)); $templateName = \strtolower((string) $template->name); - if (!str_contains($templateName, $searchTerm)) { + if (!\str_contains($templateName, $searchTerm)) { return false; } } diff --git a/src/Drafling/MCP/Tools/ReadEntryToolAction.php b/src/Drafling/MCP/Tools/ReadEntryToolAction.php new file mode 100644 index 00000000..e7242c3e --- /dev/null +++ b/src/Drafling/MCP/Tools/ReadEntryToolAction.php @@ -0,0 +1,193 @@ +logger->info('Reading entry', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'include_content' => $request->includeContent, + 'include_metadata' => $request->includeMetadata, + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Get the entry + $entryId = EntryId::fromString($request->entryId); + $entry = $this->entryService->getEntry($projectId, $entryId); + + if ($entry === null) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Entry '{$request->entryId}' not found in project '{$request->projectId}'", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Build response based on inclusion flags + $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'), + 'updated_at' => $entry->updatedAt->format('c'), + ]; + + // Include content if requested + if ($request->includeContent) { + $response['content'] = $entry->content ?? ''; + } + + // Include metadata if requested + if ($request->includeMetadata) { + $response['metadata'] = [ + 'tags' => $entry->tags, + 'file_path' => $entry->filePath ?? null, + ]; + } + + $this->logger->info('Entry read successfully', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'title' => $entry->title, + 'included_content' => $request->includeContent, + 'included_metadata' => $request->includeMetadata, + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (EntryNotFoundException $e) { + $this->logger->error('Entry not found', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error reading entry', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error reading entry', [ + 'project_id' => $request->projectId, + 'entry_id' => $request->entryId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to read entry: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php index a2d3856e..38412a03 100644 --- a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php @@ -33,7 +33,7 @@ public function __construct( private ProjectServiceInterface $projectService, ) {} - #[Post(path: '/tools/call/drafling_update_entry', name: 'tools.drafling.drafling_update_entry')] + #[Post(path: '/tools/call/drafling_update_entry', name: 'tools.drafling_update_entry')] public function __invoke(EntryUpdateRequest $request): CallToolResult { $this->logger->info('Updating entry', [ diff --git a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php index fec4b563..20145946 100644 --- a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php @@ -29,7 +29,7 @@ public function __construct( private ProjectServiceInterface $projectService, ) {} - #[Post(path: '/tools/call/drafling_update_project', name: 'tools.drafling.drafling_update_project')] + #[Post(path: '/tools/call/drafling_update_project', name: 'tools.drafling_update_project')] public function __invoke(ProjectUpdateRequest $request): CallToolResult { $this->logger->info('Updating project', [ diff --git a/src/Drafling/Service/DraflingService.php b/src/Drafling/Service/DraflingService.php new file mode 100644 index 00000000..524d9e94 --- /dev/null +++ b/src/Drafling/Service/DraflingService.php @@ -0,0 +1,342 @@ +projectRepository->findById($projectId); + if ($project === null) { + $this->logger?->error('Project not found for content retrieval', [ + 'project_id' => $projectId->value, + ]); + return []; + } + + $this->logger?->info('Retrieving project content', [ + 'project_id' => $projectId->value, + 'options' => $options, + ]); + + // Get all entries for the project + $entries = $this->entryRepository->findByProject($projectId); + + // Group by category if requested + $groupByCategory = $options['categories'] ?? false; + $includeMetadata = $options['include_metadata'] ?? true; + $format = $options['format'] ?? 'full'; + + if ($groupByCategory) { + return $this->groupEntriesByCategory($entries, $format, $includeMetadata); + } + + return $this->formatEntries($entries, $format, $includeMetadata); + } + + #[\Override] + public function getProjectStructure(ProjectId $projectId): array + { + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + $this->logger?->error('Project not found for structure retrieval', [ + 'project_id' => $projectId->value, + ]); + return []; + } + + $this->logger?->info('Retrieving project structure', [ + 'project_id' => $projectId->value, + ]); + + $entries = $this->entryRepository->findByProject($projectId); + + // Get template for structure information + $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); + $template = $this->templateService->getTemplate($templateKey); + + $structure = [ + 'project' => [ + 'id' => $project->id, + 'name' => $project->name, + 'template' => $project->template, + 'status' => $project->status, + 'entry_dirs' => $project->entryDirs, + ], + 'template_info' => $template ? [ + 'categories' => \array_map(static fn($cat) => [ + 'name' => $cat->name, + 'display_name' => $cat->displayName, + 'allowed_types' => $cat->entryTypes, + ], $template->categories), + 'entry_types' => \array_map(static fn($type) => [ + 'key' => $type->key, + 'display_name' => $type->displayName, + 'statuses' => \array_map(static fn($status) => $status->value, $type->statuses), + ], $template->entryTypes), + ] : null, + 'content_summary' => [ + 'total_entries' => \count($entries), + 'by_category' => $this->countEntriesByCategory($entries), + 'by_type' => $this->countEntriesByType($entries), + 'by_status' => $this->countEntriesByStatus($entries), + ], + ]; + + return $structure; + } + + #[\Override] + public function getProjectHistory(ProjectId $projectId): array + { + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + $this->logger?->error('Project not found for history retrieval', [ + 'project_id' => $projectId->value, + ]); + return []; + } + + $this->logger?->info('Retrieving project history', [ + 'project_id' => $projectId->value, + ]); + + $entries = $this->entryRepository->findByProject($projectId); + + // Sort entries by creation and update dates to build timeline + $timeline = []; + + foreach ($entries as $entry) { + $timeline[] = [ + 'type' => 'entry_created', + 'timestamp' => $entry->createdAt, + 'entry_id' => $entry->entryId, + 'entry_title' => $entry->title, + 'category' => $entry->category, + 'entry_type' => $entry->entryType, + ]; + + // Add update event if entry was modified after creation + if ($entry->updatedAt > $entry->createdAt) { + $timeline[] = [ + 'type' => 'entry_updated', + 'timestamp' => $entry->updatedAt, + 'entry_id' => $entry->entryId, + 'entry_title' => $entry->title, + ]; + } + } + + // Sort timeline by timestamp (newest first) + \usort($timeline, static fn($a, $b) => $b['timestamp'] <=> $a['timestamp']); + + return [ + 'project_id' => $project->id, + 'timeline' => $timeline, + 'summary' => [ + 'total_events' => \count($timeline), + 'latest_activity' => $timeline[0]['timestamp'] ?? null, + ], + ]; + } + + #[\Override] + public function validateProject(Project $project): array + { + $errors = []; + + $this->logger?->debug('Validating project against template', [ + 'project_id' => $project->id, + 'template' => $project->template, + ]); + + // Check if template exists + $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); + $template = $this->templateService->getTemplate($templateKey); + + if ($template === null) { + $errors[] = "Template '{$project->template}' not found"; + return $errors; // Can't validate further without template + } + + // Validate project structure against template requirements + if (empty($project->entryDirs)) { + $errors[] = "Project must have at least one entry directory configured"; + } + + $this->logger?->info('Project validation completed', [ + 'project_id' => $project->id, + 'errors_count' => \count($errors), + ]); + + return $errors; + } + + #[\Override] + public function validateEntry(Entry $entry, Project $project): array + { + $errors = []; + + $this->logger?->debug('Validating entry against project template', [ + 'entry_id' => $entry->entryId, + 'project_id' => $project->id, + 'template' => $project->template, + ]); + + // Get template + $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); + $template = $this->templateService->getTemplate($templateKey); + + if ($template === null) { + $errors[] = "Project template '{$project->template}' not found"; + return $errors; + } + + // Validate category exists in template + if (!$template->hasCategory($entry->category)) { + $errors[] = "Category '{$entry->category}' is not valid for template '{$project->template}'"; + } + + // Validate entry type exists in template + if (!$template->hasEntryType($entry->entryType)) { + $errors[] = "Entry type '{$entry->entryType}' is not valid for template '{$project->template}'"; + } + + // Validate entry type is allowed in category + if (!$template->validateEntryInCategory($entry->category, $entry->entryType)) { + $errors[] = "Entry type '{$entry->entryType}' is not allowed in category '{$entry->category}'"; + } + + // Validate status is valid for entry type + $entryType = $template->getEntryType($entry->entryType); + if ($entryType !== null && !$entryType->hasStatus($entry->status)) { + $errors[] = "Status '{$entry->status}' is not valid for entry type '{$entry->entryType}'"; + } + + $this->logger?->info('Entry validation completed', [ + 'entry_id' => $entry->entryId, + 'errors_count' => \count($errors), + ]); + + return $errors; + } + + /** + * Group entries by category + */ + private function groupEntriesByCategory(array $entries, string $format, bool $includeMetadata): array + { + $grouped = []; + + foreach ($entries as $entry) { + $category = $entry->category; + if (!isset($grouped[$category])) { + $grouped[$category] = []; + } + $grouped[$category][] = $this->formatEntry($entry, $format, $includeMetadata); + } + + return $grouped; + } + + /** + * Format entries for output + */ + private function formatEntries(array $entries, string $format, bool $includeMetadata): array + { + return \array_map( + fn($entry) => $this->formatEntry($entry, $format, $includeMetadata), + $entries, + ); + } + + /** + * Format single entry based on requested format + */ + private function formatEntry(Entry $entry, string $format, bool $includeMetadata): array + { + $formatted = [ + 'entry_id' => $entry->entryId, + 'title' => $entry->title, + ]; + + if ($format === 'full') { + $formatted['content'] = $entry->content; + } elseif ($format === 'summary') { + // Truncate content for summary + $formatted['content_preview'] = \substr($entry->content, 0, 200) . '...'; + } + + if ($includeMetadata) { + $formatted['metadata'] = [ + 'category' => $entry->category, + 'entry_type' => $entry->entryType, + 'status' => $entry->status, + 'tags' => $entry->tags, + 'created_at' => $entry->createdAt->format('c'), + 'updated_at' => $entry->updatedAt->format('c'), + ]; + } + + return $formatted; + } + + /** + * Count entries by category + */ + private function countEntriesByCategory(array $entries): array + { + $counts = []; + foreach ($entries as $entry) { + $category = $entry->category; + $counts[$category] = ($counts[$category] ?? 0) + 1; + } + return $counts; + } + + /** + * Count entries by type + */ + private function countEntriesByType(array $entries): array + { + $counts = []; + foreach ($entries as $entry) { + $type = $entry->entryType; + $counts[$type] = ($counts[$type] ?? 0) + 1; + } + return $counts; + } + + /** + * Count entries by status + */ + private function countEntriesByStatus(array $entries): array + { + $counts = []; + foreach ($entries as $entry) { + $status = $entry->status; + $counts[$status] = ($counts[$status] ?? 0) + 1; + } + return $counts; + } +} diff --git a/src/Drafling/Service/EntryService.php b/src/Drafling/Service/EntryService.php new file mode 100644 index 00000000..71d9c926 --- /dev/null +++ b/src/Drafling/Service/EntryService.php @@ -0,0 +1,396 @@ +logger?->info('Creating new entry', [ + 'project_id' => $projectId->value, + 'category' => $request->category, + 'entry_type' => $request->entryType, + ]); + + // Verify project exists + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + $error = "Project '{$projectId->value}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + ]); + throw new ProjectNotFoundException($error); + } + + // Get and validate template + $templateKey = TemplateKey::fromString($project->template); + $template = $this->templateService->getTemplate($templateKey); + if ($template === null) { + $error = "Template '{$project->template}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + 'template' => $project->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 '{$project->template}'"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + 'category' => $request->category, + 'template' => $project->template, + ]); + throw new DraflingException($error); + } + + $resolvedEntryType = $this->templateService->resolveEntryTypeKey($template, $request->entryType); + if ($resolvedEntryType === null) { + $error = "Entry type '{$request->entryType}' not found in template '{$project->template}'"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + 'entry_type' => $request->entryType, + 'template' => $project->template, + ]); + throw new DraflingException($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, [ + 'project_id' => $projectId->value, + 'category' => $request->category, + 'entry_type' => $request->entryType, + ]); + throw new DraflingException($error); + } + + // Resolve status if provided, otherwise use entry type default + $resolvedStatus = null; + 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, [ + 'project_id' => $projectId->value, + 'status' => $request->status, + 'entry_type' => $request->entryType, + ]); + throw new DraflingException($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($projectId, $resolvedRequest); + + // Save entry to repository + $this->entryRepository->save($projectId, $entry); + + $this->logger?->info('Entry created successfully', [ + 'project_id' => $projectId->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', [ + 'project_id' => $projectId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to create entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry + { + $this->logger?->info('Updating entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'has_title' => $request->title !== null, + 'has_content' => $request->content !== null, + 'has_status' => $request->status !== null, + ]); + + // Verify project exists + $project = $this->projectRepository->findById($projectId); + if ($project === null) { + $error = "Project '{$projectId->value}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + ]); + throw new ProjectNotFoundException($error); + } + + // Verify entry exists + $existingEntry = $this->entryRepository->findById($projectId, $entryId); + if ($existingEntry === null) { + $error = "Entry '{$entryId->value}' not found in project '{$projectId->value}'"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + throw new EntryNotFoundException($error); + } + + // Resolve status if provided + $resolvedStatus = $request->status; + if ($request->status !== null) { + $templateKey = TemplateKey::fromString($project->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, [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'status' => $request->status, + 'entry_type' => $existingEntry->entryType, + ]); + throw new DraflingException($error); + } + + $resolvedStatus = $resolvedStatusValue; + } + } + + try { + // Create request with resolved status + $resolvedRequest = $request->withResolvedStatus($resolvedStatus); + + // Use storage driver to update the entry + $updatedEntry = $this->storageDriver->updateEntry($projectId, $entryId, $resolvedRequest); + + // Save updated entry to repository + $this->entryRepository->save($projectId, $updatedEntry); + + $this->logger?->info('Entry updated successfully', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'title' => $updatedEntry->title, + ]); + + return $updatedEntry; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to update entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to update entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function entryExists(ProjectId $projectId, EntryId $entryId): bool + { + $exists = $this->entryRepository->exists($projectId, $entryId); + + $this->logger?->debug('Checking entry existence', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'exists' => $exists, + ]); + + return $exists; + } + + #[\Override] + public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry + { + $this->logger?->info('Retrieving single entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + + // Verify project exists + if (!$this->projectRepository->exists($projectId)) { + $error = "Project '{$projectId->value}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + ]); + throw new ProjectNotFoundException($error); + } + + try { + $entry = $this->entryRepository->findById($projectId, $entryId); + + $this->logger?->info('Entry retrieval completed', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'found' => $entry !== null, + ]); + + return $entry; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to retrieve entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to retrieve entry: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function getEntries(ProjectId $projectId, array $filters = []): array + { + $this->logger?->info('Retrieving entries', [ + 'project_id' => $projectId->value, + 'filters' => $filters, + ]); + + // Verify project exists + if (!$this->projectRepository->exists($projectId)) { + $this->logger?->warning('Attempted to get entries for non-existent project', [ + 'project_id' => $projectId->value, + ]); + return []; + } + + try { + $entries = $this->entryRepository->findByProject($projectId, $filters); + + $this->logger?->info('Entries retrieved successfully', [ + 'project_id' => $projectId->value, + 'count' => \count($entries), + 'filters_applied' => !empty($filters), + ]); + + return $entries; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to retrieve entries', [ + 'project_id' => $projectId->value, + 'filters' => $filters, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to retrieve entries: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool + { + $this->logger?->info('Deleting entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + + // Verify entry exists + if (!$this->entryRepository->exists($projectId, $entryId)) { + $this->logger?->warning('Attempted to delete non-existent entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + return false; + } + + try { + // Use storage driver to delete the entry + $deleted = $this->storageDriver->deleteEntry($projectId, $entryId); + + if ($deleted) { + // Remove from repository + $this->entryRepository->delete($projectId, $entryId); + + $this->logger?->info('Entry deleted successfully', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + } else { + $this->logger?->warning('Storage driver failed to delete entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + ]); + } + + return $deleted; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to delete entry', [ + 'project_id' => $projectId->value, + 'entry_id' => $entryId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to delete entry: {$e->getMessage()}", + previous: $e, + ); + } + } +} diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index c0fd191f..585233b7 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -45,6 +45,17 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR */ public function entryExists(ProjectId $projectId, EntryId $entryId): bool; + /** + * Get a specific entry by ID + * + * @param ProjectId $projectId + * @param EntryId $entryId + * @return Entry|null + * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException + */ + public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry; + /** * Get entries for a project with optional filtering * diff --git a/src/Drafling/Service/ProjectService.php b/src/Drafling/Service/ProjectService.php new file mode 100644 index 00000000..9e34d9ba --- /dev/null +++ b/src/Drafling/Service/ProjectService.php @@ -0,0 +1,242 @@ +logger?->info('Creating new project', [ + '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 project + $project = $this->storageDriver->createProject($request); + + // Save project to repository + $this->projectRepository->save($project); + + $this->logger?->info('Project created successfully', [ + 'project_id' => $project->id, + 'template' => $project->template, + 'name' => $project->name, + ]); + + return $project; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to create project', [ + 'template_id' => $request->templateId, + 'title' => $request->title, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to create project: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function updateProject(ProjectId $projectId, ProjectUpdateRequest $request): Project + { + $this->logger?->info('Updating project', [ + 'project_id' => $projectId->value, + 'updates' => [ + 'title' => $request->title !== null, + 'description' => $request->description !== null, + 'status' => $request->status !== null, + 'tags' => $request->tags !== null, + 'entry_dirs' => $request->entryDirs !== null, + ], + ]); + + // Verify project exists + if (!$this->projectRepository->exists($projectId)) { + $error = "Project '{$projectId->value}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + ]); + throw new ProjectNotFoundException($error); + } + + try { + // Use storage driver to update the project + $updatedProject = $this->storageDriver->updateProject($projectId, $request); + + // Save updated project to repository + $this->projectRepository->save($updatedProject); + + $this->logger?->info('Project updated successfully', [ + 'project_id' => $projectId->value, + 'name' => $updatedProject->name, + 'status' => $updatedProject->status, + ]); + + return $updatedProject; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to update project', [ + 'project_id' => $projectId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to update project: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function projectExists(ProjectId $projectId): bool + { + $exists = $this->projectRepository->exists($projectId); + + $this->logger?->debug('Checking project existence', [ + 'project_id' => $projectId->value, + 'exists' => $exists, + ]); + + return $exists; + } + + #[\Override] + public function getProject(ProjectId $projectId): ?Project + { + $this->logger?->debug('Retrieving project', [ + 'project_id' => $projectId->value, + ]); + + $project = $this->projectRepository->findById($projectId); + + if ($project === null) { + $this->logger?->warning('Project not found', [ + 'project_id' => $projectId->value, + ]); + } else { + $this->logger?->debug('Project retrieved successfully', [ + 'project_id' => $project->id, + 'name' => $project->name, + 'template' => $project->template, + ]); + } + + return $project; + } + + #[\Override] + public function listProjects(array $filters = []): array + { + $this->logger?->info('Listing projects', [ + 'filters' => $filters, + ]); + + try { + $projects = $this->projectRepository->findAll($filters); + + $this->logger?->info('Projects retrieved successfully', [ + 'count' => \count($projects), + 'filters_applied' => !empty($filters), + ]); + + return $projects; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to list projects', [ + 'filters' => $filters, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to list projects: {$e->getMessage()}", + previous: $e, + ); + } + } + + #[\Override] + public function deleteProject(ProjectId $projectId): bool + { + $this->logger?->info('Deleting project', [ + 'project_id' => $projectId->value, + ]); + + // Verify project exists + if (!$this->projectRepository->exists($projectId)) { + $this->logger?->warning('Attempted to delete non-existent project', [ + 'project_id' => $projectId->value, + ]); + return false; + } + + try { + // Use storage driver to delete the project and its entries + $deleted = $this->storageDriver->deleteProject($projectId); + + if ($deleted) { + // Remove from repository + $this->projectRepository->delete($projectId); + + $this->logger?->info('Project deleted successfully', [ + 'project_id' => $projectId->value, + ]); + } else { + $this->logger?->warning('Storage driver failed to delete project', [ + 'project_id' => $projectId->value, + ]); + } + + return $deleted; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to delete project', [ + 'project_id' => $projectId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to delete project: {$e->getMessage()}", + previous: $e, + ); + } + } +} diff --git a/src/Drafling/Service/ProjectServiceInterface.php b/src/Drafling/Service/ProjectServiceInterface.php index 85f88bf5..5c28552d 100644 --- a/src/Drafling/Service/ProjectServiceInterface.php +++ b/src/Drafling/Service/ProjectServiceInterface.php @@ -17,8 +17,6 @@ interface ProjectServiceInterface /** * Create a new project from template * - * @param ProjectCreateRequest $request - * @return Project * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ @@ -27,9 +25,6 @@ public function createProject(ProjectCreateRequest $request): Project; /** * Update an existing project * - * @param ProjectId $projectId - * @param ProjectUpdateRequest $request - * @return Project * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ @@ -43,15 +38,12 @@ public function projectExists(ProjectId $projectId): bool; /** * Get a single project by ID * - * @param ProjectId $projectId - * @return Project|null */ public function getProject(ProjectId $projectId): ?Project; /** * List projects with optional filtering * - * @param array $filters * @return Project[] */ public function listProjects(array $filters = []): array; @@ -59,8 +51,6 @@ public function listProjects(array $filters = []): array; /** * Delete a project * - * @param ProjectId $projectId - * @return bool */ public function deleteProject(ProjectId $projectId): bool; } diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index 26a7ae1e..bc2969cc 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -4,9 +4,11 @@ namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +use Butschster\ContextGenerator\DirectoriesInterface; use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; use Butschster\ContextGenerator\Drafling\Domain\Model\Entry; use Butschster\ContextGenerator\Drafling\Domain\Model\Project; +use Butschster\ContextGenerator\Drafling\Domain\Model\Template; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; @@ -22,6 +24,7 @@ use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; +use Psr\Log\LoggerInterface; use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Files\FilesInterface; @@ -39,8 +42,9 @@ final class FileStorageDriver extends AbstractStorageDriver public function __construct( DraflingConfigInterface $draflingConfig, FilesInterface $files, + DirectoriesInterface $dirs, ExceptionReporterInterface $reporter, - ?\Psr\Log\LoggerInterface $logger = null, + ?LoggerInterface $logger = null, ) { parent::__construct($draflingConfig, $logger); @@ -51,6 +55,7 @@ public function __construct( $this->templateRepository = new FileTemplateRepository( $files, $draflingConfig, + $dirs, $frontmatterParser, $directoryScanner, $logger, @@ -59,6 +64,7 @@ public function __construct( $this->projectRepository = new FileProjectRepository( $files, $draflingConfig, + $dirs, $frontmatterParser, $directoryScanner, $logger, @@ -67,6 +73,7 @@ public function __construct( $this->entryRepository = new FileEntryRepository( $files, $draflingConfig, + $dirs, $frontmatterParser, $directoryScanner, $logger, @@ -318,7 +325,7 @@ protected function performSynchronization(): void /** * Get default entry directories from template */ - private function getDefaultEntryDirs(\Butschster\ContextGenerator\Drafling\Domain\Model\Template $template): array + private function getDefaultEntryDirs(Template $template): array { $dirs = []; foreach ($template->categories as $category) { @@ -332,7 +339,7 @@ private function getDefaultEntryDirs(\Butschster\ContextGenerator\Drafling\Domai */ private function resolveEntryCreateRequestKeys( EntryCreateRequest $request, - \Butschster\ContextGenerator\Drafling\Domain\Model\Template $template, + Template $template, ): EntryCreateRequest { // Resolve category $resolvedCategory = $this->resolveCategoryKey($template, $request->category); @@ -362,7 +369,7 @@ private function resolveEntryCreateRequestKeys( * Validate entry request against project template */ private function validateEntryAgainstTemplate( - \Butschster\ContextGenerator\Drafling\Domain\Model\Template $template, + Template $template, EntryCreateRequest $request, ): void { // Validate category exists @@ -393,7 +400,7 @@ private function validateEntryAgainstTemplate( * Resolve category display name to internal key */ private function resolveCategoryKey( - \Butschster\ContextGenerator\Drafling\Domain\Model\Template $template, + Template $template, string $displayNameOrKey, ): ?string { foreach ($template->categories as $category) { @@ -408,7 +415,7 @@ private function resolveCategoryKey( * Resolve entry type display name to internal key */ private function resolveEntryTypeKey( - \Butschster\ContextGenerator\Drafling\Domain\Model\Template $template, + Template $template, string $displayNameOrKey, ): ?string { foreach ($template->entryTypes as $entryType) { @@ -423,7 +430,7 @@ private function resolveEntryTypeKey( * Resolve status display name to internal value for specific entry type */ private function resolveStatusForEntryType( - \Butschster\ContextGenerator\Drafling\Domain\Model\Template $template, + Template $template, string $entryTypeKey, string $displayNameOrValue, ): ?string { diff --git a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php index 1ecc0b00..5b8d0c16 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php +++ b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +use Butschster\ContextGenerator\DirectoriesInterface; use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; use Spiral\Files\FilesInterface; use Psr\Log\LoggerInterface; @@ -16,6 +17,7 @@ abstract class FileStorageRepositoryBase public function __construct( protected readonly FilesInterface $files, protected readonly DraflingConfigInterface $config, + private readonly DirectoriesInterface $dirs, protected readonly FrontmatterParser $frontmatterParser = new FrontmatterParser(), protected readonly ?DirectoryScanner $directoryScanner = null, protected readonly ?LoggerInterface $logger = null, @@ -26,7 +28,7 @@ public function __construct( */ protected function getBasePath(): string { - return $this->config->getProjectsPath(); + return (string) $this->dirs->getRootPath()->join($this->config->getProjectsPath()); } /** @@ -34,7 +36,7 @@ protected function getBasePath(): string */ protected function getTemplatesPath(): string { - return $this->config->getTemplatesPath(); + return (string) $this->dirs->getRootPath()->join($this->config->getTemplatesPath()); } /** diff --git a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php index d83e589d..865f9de4 100644 --- a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php +++ b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php @@ -10,27 +10,31 @@ use Butschster\ContextGenerator\Drafling\Domain\Model\Template; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\Repository\TemplateRepositoryInterface; +use Symfony\Component\Finder\Finder; /** * File-based template repository implementation */ final class FileTemplateRepository extends FileStorageRepositoryBase implements TemplateRepositoryInterface { - private array $templateCache = []; - private bool $cacheLoaded = false; - #[\Override] public function findAll(): array { - $this->ensureCacheLoaded(); - return \array_values($this->templateCache); + return $this->loadTemplatesFromFilesystem(); } #[\Override] public function findByKey(TemplateKey $key): ?Template { - $this->ensureCacheLoaded(); - return $this->templateCache[$key->value] ?? null; + $templates = $this->loadTemplatesFromFilesystem(); + + foreach ($templates as $template) { + if ($template->key === $key->value) { + return $template; + } + } + + return null; } #[\Override] @@ -42,55 +46,47 @@ public function exists(TemplateKey $key): bool #[\Override] public function refresh(): void { - $this->templateCache = []; - $this->cacheLoaded = false; - $this->logOperation('Template cache refreshed'); - } - - /** - * Load all templates from file system if not already cached - */ - private function ensureCacheLoaded(): void - { - if ($this->cacheLoaded) { - return; - } - - $this->loadTemplatesFromFilesystem(); - $this->cacheLoaded = true; + // 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(): void + 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; + return $templates; } - $templateFiles = $this->files->getFiles($templatesPath, '*.yaml'); - - foreach ($templateFiles as $templateFile) { - $filePath = $this->files->normalizePath($templatesPath . '/' . $templateFile); + $finder = new Finder(); + $finder->files() + ->in($templatesPath) + ->name('*.yaml') + ->name('*.yml'); + foreach ($finder as $file) { try { - $template = $this->loadTemplateFromFile($filePath); + $template = $this->loadTemplateFromFile($file->getRealPath()); if ($template !== null) { - $this->templateCache[$template->key] = $template; + $templates[] = $template; } } catch (\Throwable $e) { - $this->logError('Failed to load template', ['file' => $filePath], $e); + $this->logError('Failed to load template', ['file' => $file->getRealPath()], $e); } } $this->logOperation('Loaded templates from filesystem', [ - 'count' => \count($this->templateCache), + 'count' => \count($templates), 'path' => $templatesPath, ]); + + return $templates; } /** diff --git a/src/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php index 941c7d49..a039234c 100644 --- a/src/Drafling/Storage/StorageBootloader.php +++ b/src/Drafling/Storage/StorageBootloader.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Drafling\Storage; +use Butschster\ContextGenerator\DirectoriesInterface; use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; @@ -30,8 +31,9 @@ public function defineSingletons(): array FilesInterface $files, LoggerInterface $logger, ExceptionReporterInterface $reporter, + DirectoriesInterface $dirs, ): StorageDriverInterface { - $driver = new FileStorageDriver($config, $files, $reporter, $logger); + $driver = new FileStorageDriver($config, $files, $dirs, $reporter, $logger); // Initialize with typed configuration $storageConfig = FileStorageConfig::fromArray([ diff --git a/src/McpServer/McpServerBootloader.php b/src/McpServer/McpServerBootloader.php index a480b928..d1ea435b 100644 --- a/src/McpServer/McpServerBootloader.php +++ b/src/McpServer/McpServerBootloader.php @@ -10,8 +10,10 @@ use Butschster\ContextGenerator\Drafling\MCP\Tools\CreateEntryToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\CreateProjectToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\GetProjectToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\ListEntriesToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\ListProjectsToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\ListTemplatesToolAction; +use Butschster\ContextGenerator\Drafling\MCP\Tools\ReadEntryToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\UpdateEntryToolAction; use Butschster\ContextGenerator\Drafling\MCP\Tools\UpdateProjectToolAction; use Butschster\ContextGenerator\McpServer\Action\Prompts\FilesystemOperationsAction; @@ -236,12 +238,26 @@ private function actions(McpConfig $config): array ]; } + $actions = [ + ...$actions, + ListTemplatesToolAction::class, + CreateProjectToolAction::class, + ListProjectsToolAction::class, + GetProjectToolAction::class, + CreateEntryToolAction::class, + ListEntriesToolAction::class, + ReadEntryToolAction::class, + UpdateEntryToolAction::class, + UpdateProjectToolAction::class, + ]; + if ($config->isGitOperationsEnabled()) { $actions[] = GitStatusAction::class; $actions[] = GitAddAction::class; $actions[] = GitCommitAction::class; } + // Should be last if ($config->isCustomToolsEnabled()) { $actions = [ ...$actions, @@ -249,17 +265,6 @@ private function actions(McpConfig $config): array ]; } - $actions = [ - ...$actions, - ListTemplatesToolAction::class, - CreateProjectToolAction::class, - ListProjectsToolAction::class, - GetProjectToolAction::class, - CreateEntryToolAction::class, - UpdateEntryToolAction::class, - UpdateProjectToolAction::class, - ]; - - return $actions; + return \array_unique($actions); } } 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); }, ); From 6aff0bcdb8cfebb0d0401da40a6a5092036781e1 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 23 Sep 2025 16:46:43 +0000 Subject: [PATCH 07/16] style(php-cs-fixer): fix coding standards --- src/Drafling/Exception/DraflingException.php | 4 +--- src/Drafling/Exception/EntryNotFoundException.php | 4 +--- src/Drafling/Exception/ProjectNotFoundException.php | 4 +--- src/Drafling/Exception/TemplateNotFoundException.php | 4 +--- src/Drafling/MCP/DTO/ListEntriesRequest.php | 1 - src/Drafling/MCP/DTO/ReadEntryRequest.php | 6 +++--- src/Drafling/Service/EntryService.php | 8 ++++---- src/Drafling/Service/EntryServiceInterface.php | 3 --- 8 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/Drafling/Exception/DraflingException.php b/src/Drafling/Exception/DraflingException.php index f48552eb..3038d5f8 100644 --- a/src/Drafling/Exception/DraflingException.php +++ b/src/Drafling/Exception/DraflingException.php @@ -7,6 +7,4 @@ /** * Base exception for all Drafling-related errors */ -class DraflingException extends \Exception -{ -} +class DraflingException extends \Exception {} diff --git a/src/Drafling/Exception/EntryNotFoundException.php b/src/Drafling/Exception/EntryNotFoundException.php index d11a2898..8ecb55c8 100644 --- a/src/Drafling/Exception/EntryNotFoundException.php +++ b/src/Drafling/Exception/EntryNotFoundException.php @@ -7,6 +7,4 @@ /** * Exception thrown when an entry cannot be found */ -class EntryNotFoundException extends DraflingException -{ -} +class EntryNotFoundException extends DraflingException {} diff --git a/src/Drafling/Exception/ProjectNotFoundException.php b/src/Drafling/Exception/ProjectNotFoundException.php index 8ae3334f..2a8a1472 100644 --- a/src/Drafling/Exception/ProjectNotFoundException.php +++ b/src/Drafling/Exception/ProjectNotFoundException.php @@ -7,6 +7,4 @@ /** * Exception thrown when a project cannot be found */ -class ProjectNotFoundException extends DraflingException -{ -} +class ProjectNotFoundException extends DraflingException {} diff --git a/src/Drafling/Exception/TemplateNotFoundException.php b/src/Drafling/Exception/TemplateNotFoundException.php index 49351c7d..5e0a0c45 100644 --- a/src/Drafling/Exception/TemplateNotFoundException.php +++ b/src/Drafling/Exception/TemplateNotFoundException.php @@ -7,6 +7,4 @@ /** * Exception thrown when a template cannot be found */ -class TemplateNotFoundException extends DraflingException -{ -} +class TemplateNotFoundException extends DraflingException {} diff --git a/src/Drafling/MCP/DTO/ListEntriesRequest.php b/src/Drafling/MCP/DTO/ListEntriesRequest.php index a407d069..c068514c 100644 --- a/src/Drafling/MCP/DTO/ListEntriesRequest.php +++ b/src/Drafling/MCP/DTO/ListEntriesRequest.php @@ -80,5 +80,4 @@ public function getFilters(): array { return $this->filters?->toArray() ?? []; } - } diff --git a/src/Drafling/MCP/DTO/ReadEntryRequest.php b/src/Drafling/MCP/DTO/ReadEntryRequest.php index d258b78c..c2250987 100644 --- a/src/Drafling/MCP/DTO/ReadEntryRequest.php +++ b/src/Drafling/MCP/DTO/ReadEntryRequest.php @@ -42,15 +42,15 @@ public function validate(): array $errors = []; // Validate project ID - if (empty(trim($this->projectId))) { + if (empty(\trim($this->projectId))) { $errors[] = 'Project ID is required'; } // Validate entry ID - if (empty(trim($this->entryId))) { + if (empty(\trim($this->entryId))) { $errors[] = 'Entry ID is required'; } return $errors; } -} \ No newline at end of file +} diff --git a/src/Drafling/Service/EntryService.php b/src/Drafling/Service/EntryService.php index 71d9c926..86615b23 100644 --- a/src/Drafling/Service/EntryService.php +++ b/src/Drafling/Service/EntryService.php @@ -190,14 +190,14 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR if ($request->status !== null) { $templateKey = TemplateKey::fromString($project->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, [ @@ -208,7 +208,7 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR ]); throw new DraflingException($error); } - + $resolvedStatus = $resolvedStatusValue; } } @@ -249,7 +249,7 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR public function entryExists(ProjectId $projectId, EntryId $entryId): bool { $exists = $this->entryRepository->exists($projectId, $entryId); - + $this->logger?->debug('Checking entry existence', [ 'project_id' => $projectId->value, 'entry_id' => $entryId->value, diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index 2ff74e85..c1296093 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -41,9 +41,6 @@ public function entryExists(ProjectId $projectId, EntryId $entryId): bool; /** * Get a specific entry by ID * - * @param ProjectId $projectId - * @param EntryId $entryId - * @return Entry|null * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException */ From d9c2c47a6932269df7d1ce981d72de6536018025 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 23 Sep 2025 21:27:39 +0400 Subject: [PATCH 08/16] feat(drafling): add support for entry descriptions and enhance filtering functionality --- src/Drafling/Domain/Model/Entry.php | 6 +++ src/Drafling/DraflingBootloader.php | 1 + src/Drafling/MCP/DTO/EntryCreateRequest.php | 40 +++++++++++++++++++ src/Drafling/MCP/DTO/EntryFilters.php | 11 +++++ src/Drafling/MCP/DTO/EntryUpdateRequest.php | 11 +++++ .../MCP/Tools/CreateEntryToolAction.php | 1 + .../MCP/Tools/UpdateEntryToolAction.php | 5 +++ .../Service/EntryServiceInterface.php | 9 +++++ .../FileStorage/FileEntryRepository.php | 23 +++++++++++ .../Storage/FileStorage/FileStorageDriver.php | 2 + 10 files changed, 109 insertions(+) diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Drafling/Domain/Model/Entry.php index 625aff5d..ff33a024 100644 --- a/src/Drafling/Domain/Model/Entry.php +++ b/src/Drafling/Domain/Model/Entry.php @@ -12,6 +12,7 @@ /** * @param string $entryId Unique entry identifier (UUID) * @param string $title Entry title + * @param string $description Short description for LLM understanding (max 200 chars) * @param string $entryType Entry type key (must match template) * @param string $category Category name (must match template) * @param string $status Current entry status @@ -24,6 +25,7 @@ public function __construct( public string $entryId, public string $title, + public string $description, public string $entryType, public string $category, public string $status, @@ -39,6 +41,7 @@ public function __construct( */ public function withUpdates( ?string $title = null, + ?string $description = null, ?string $status = null, ?array $tags = null, ?string $content = null, @@ -46,6 +49,7 @@ public function withUpdates( return new self( entryId: $this->entryId, title: $title ?? $this->title, + description: $description ?? $this->description, entryType: $this->entryType, category: $this->category, status: $status ?? $this->status, @@ -75,6 +79,7 @@ public function getMetadata(): array return [ 'entry_id' => $this->entryId, 'title' => $this->title, + 'description' => $this->description, 'entry_type' => $this->entryType, 'category' => $this->category, 'status' => $this->status, @@ -92,6 +97,7 @@ 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, diff --git a/src/Drafling/DraflingBootloader.php b/src/Drafling/DraflingBootloader.php index cd1ddeb0..4662642e 100644 --- a/src/Drafling/DraflingBootloader.php +++ b/src/Drafling/DraflingBootloader.php @@ -42,6 +42,7 @@ public function defineDependencies(): array ]; } + #[\Override] public function defineSingletons(): array { return [ diff --git a/src/Drafling/MCP/DTO/EntryCreateRequest.php b/src/Drafling/MCP/DTO/EntryCreateRequest.php index e8c2c317..c69b1a55 100644 --- a/src/Drafling/MCP/DTO/EntryCreateRequest.php +++ b/src/Drafling/MCP/DTO/EntryCreateRequest.php @@ -25,6 +25,11 @@ public function __construct( default: null, )] public ?string $title = null, + #[Field( + description: 'Short description for LLM understanding (optional, max 200 chars)', + default: null, + )] + public ?string $description = null, #[Field( description: 'Entry status (optional, accepts display names)', default: null, @@ -67,6 +72,35 @@ public function getProcessedTitle(): string 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); + + return \strlen((string) $summary) > 200 ? \substr((string) $summary, 0, 197) . '...' : $summary; + } + /** * @deprecated Use getProcessedTitle() instead for consistency */ @@ -106,6 +140,11 @@ public function validate(): array } } + // Validate description length if provided + if ($this->description !== null && \strlen(\trim($this->description)) > 200) { + $errors[] = 'Description must not exceed 200 characters'; + } + return $errors; } @@ -123,6 +162,7 @@ public function withResolvedKeys( entryType: $resolvedEntryType, content: $this->content, title: $this->title, + description: $this->description, status: $resolvedStatus ?? $this->status, tags: $this->tags, ); diff --git a/src/Drafling/MCP/DTO/EntryFilters.php b/src/Drafling/MCP/DTO/EntryFilters.php index e81ed0eb..bd451e03 100644 --- a/src/Drafling/MCP/DTO/EntryFilters.php +++ b/src/Drafling/MCP/DTO/EntryFilters.php @@ -38,6 +38,11 @@ public function __construct( default: null, )] public ?string $titleContains = null, + #[Field( + description: 'Filter by entry description (partial match)', + default: null, + )] + public ?string $descriptionContains = null, #[Field( description: 'Filter by entry content (partial match)', default: null, @@ -72,6 +77,10 @@ public function toArray(): array $filters['title_contains'] = $this->titleContains; } + if ($this->descriptionContains !== null) { + $filters['description_contains'] = $this->descriptionContains; + } + if ($this->contentContains !== null) { $filters['content_contains'] = $this->contentContains; } @@ -89,6 +98,7 @@ public function hasFilters(): bool || $this->status !== null || ($this->tags !== null && !empty($this->tags)) || $this->titleContains !== null + || $this->descriptionContains !== null || $this->contentContains !== null; } @@ -118,6 +128,7 @@ public function validate(): array // Validate text filters if provided $textFilters = [ 'titleContains' => $this->titleContains, + 'descriptionContains' => $this->descriptionContains, 'contentContains' => $this->contentContains, ]; diff --git a/src/Drafling/MCP/DTO/EntryUpdateRequest.php b/src/Drafling/MCP/DTO/EntryUpdateRequest.php index 9135aad5..fa4df3cf 100644 --- a/src/Drafling/MCP/DTO/EntryUpdateRequest.php +++ b/src/Drafling/MCP/DTO/EntryUpdateRequest.php @@ -20,6 +20,10 @@ public function __construct( description: 'New title (optional)', )] public ?string $title = null, + #[Field( + description: 'New description (optional, max 200 chars)', + )] + public ?string $description = null, #[Field( description: 'New content (optional)', )] @@ -51,6 +55,7 @@ public function __construct( public function hasUpdates(): bool { return $this->title !== null + || $this->description !== null || $this->content !== null || $this->status !== null || $this->contentType !== null @@ -127,6 +132,11 @@ public function validate(): array } } + // 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(); @@ -145,6 +155,7 @@ public function withResolvedStatus(?string $resolvedStatus): self projectId: $this->projectId, entryId: $this->entryId, title: $this->title, + description: $this->description, content: $this->content, status: $resolvedStatus, contentType: $this->contentType, diff --git a/src/Drafling/MCP/Tools/CreateEntryToolAction.php b/src/Drafling/MCP/Tools/CreateEntryToolAction.php index 700b5603..73e9131c 100644 --- a/src/Drafling/MCP/Tools/CreateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/CreateEntryToolAction.php @@ -38,6 +38,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'project_id' => $request->projectId, 'category' => $request->category, 'entry_type' => $request->entryType, + 'has_description' => $request->description !== null, ]); try { diff --git a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php index 38412a03..1e963d65 100644 --- a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php @@ -40,6 +40,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'project_id' => $request->projectId, '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, @@ -192,6 +193,10 @@ private function getAppliedChanges(EntryUpdateRequest $request): array $changes[] = 'title'; } + if ($request->description !== null) { + $changes[] = 'description'; + } + if ($request->content !== null) { $changes[] = 'content'; } diff --git a/src/Drafling/Service/EntryServiceInterface.php b/src/Drafling/Service/EntryServiceInterface.php index c1296093..0e6856c1 100644 --- a/src/Drafling/Service/EntryServiceInterface.php +++ b/src/Drafling/Service/EntryServiceInterface.php @@ -18,6 +18,9 @@ interface EntryServiceInterface /** * Create a new entry in the specified project * + * Creates an entry with title, description, content, and metadata. + * Description is auto-generated from content if not provided. + * * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException @@ -27,6 +30,9 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): /** * Update an existing entry * + * Updates entry fields including title, description, content, status, and tags. + * Supports partial updates - only provided fields are modified. + * * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException @@ -49,6 +55,9 @@ public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry; /** * Get entries for a project with optional filtering * + * Supports filtering by title, description, category, type, status, tags, and content. + * Returns entries with full metadata including description for LLM understanding. + * * @return Entry[] */ public function getEntries(ProjectId $projectId, array $filters = []): array; diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php index eb1f3f6b..7c4a096b 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -223,6 +223,7 @@ private function loadEntryFromFile(string $filePath): ?Entry 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'], @@ -259,6 +260,7 @@ private function saveEntryToFile(string $projectPath, Entry $entry): void $frontmatter = [ 'entry_id' => $entry->entryId, 'title' => $entry->title, + 'description' => $entry->description, 'entry_type' => $entry->entryType, 'category' => $entry->category, 'status' => $entry->status, @@ -305,6 +307,27 @@ private function matchesFilters(Entry $entry, array $filters): bool } } + // 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/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index bc2969cc..3c8b895e 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -207,6 +207,7 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): $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->config->defaultEntryStatus, @@ -257,6 +258,7 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR $updatedEntry = $entry->withUpdates( title: $resolvedRequest->title, + description: $resolvedRequest->description, status: $resolvedRequest->status, tags: $resolvedRequest->tags, content: $finalContent, // Use processed content with text replacement From 0e353f6a1d68dd81259ffcfb334824c2fb285c8f Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 24 Sep 2025 00:07:59 +0400 Subject: [PATCH 09/16] feat(drafling): introduce project memory management and simplify directory scanning --- src/Drafling/Console/TemplateListCommand.php | 2 +- src/Drafling/Domain/Model/Category.php | 2 - src/Drafling/Domain/Model/Entry.php | 23 +------- src/Drafling/Domain/Model/EntryType.php | 4 -- src/Drafling/Domain/Model/Project.php | 24 ++++++++ src/Drafling/Domain/Model/Status.php | 1 - src/Drafling/Domain/Model/Template.php | 31 +++++++++- src/Drafling/MCP/DTO/EntryCreateRequest.php | 4 +- src/Drafling/MCP/DTO/EntryFilters.php | 4 +- src/Drafling/MCP/DTO/EntryUpdateRequest.php | 12 ++-- src/Drafling/MCP/DTO/ProjectCreateRequest.php | 6 ++ src/Drafling/MCP/DTO/ProjectFilters.php | 4 +- src/Drafling/MCP/DTO/ProjectMemory.php | 12 ++++ src/Drafling/MCP/DTO/ProjectUpdateRequest.php | 9 ++- src/Drafling/MCP/DTO/ReadEntryRequest.php | 10 ---- .../MCP/Tools/GetProjectToolAction.php | 11 ++-- .../MCP/Tools/ListEntriesToolAction.php | 9 ++- .../MCP/Tools/ListTemplatesToolAction.php | 57 +------------------ .../MCP/Tools/ReadEntryToolAction.php | 32 +---------- .../MCP/Tools/UpdateProjectToolAction.php | 8 ++- src/Drafling/Service/EntryService.php | 1 - src/Drafling/Service/ProjectService.php | 47 +++++++++++++++ .../Service/ProjectServiceInterface.php | 8 +++ .../Storage/FileStorage/DirectoryScanner.php | 24 +------- .../FileStorage/FileEntryRepository.php | 2 +- .../FileStorage/FileProjectRepository.php | 2 + .../Storage/FileStorage/FileStorageDriver.php | 16 ++++-- .../FileStorage/FileTemplateRepository.php | 10 +--- src/McpServer/Routing/ActionCaller.php | 1 + .../Routing/Mcp2PsrRequestAdapter.php | 12 +++- 30 files changed, 202 insertions(+), 186 deletions(-) create mode 100644 src/Drafling/MCP/DTO/ProjectMemory.php diff --git a/src/Drafling/Console/TemplateListCommand.php b/src/Drafling/Console/TemplateListCommand.php index 7b0be761..bb57dd08 100644 --- a/src/Drafling/Console/TemplateListCommand.php +++ b/src/Drafling/Console/TemplateListCommand.php @@ -56,7 +56,7 @@ public function __invoke(TemplateServiceInterface $templateService): int $templates = \array_filter( $templates, static fn($template) => - \str_contains(\strtolower((string) $template->name), $searchTerm), + \str_contains(\strtolower($template->name), $searchTerm), ); } diff --git a/src/Drafling/Domain/Model/Category.php b/src/Drafling/Domain/Model/Category.php index 574d9275..54f40293 100644 --- a/src/Drafling/Domain/Model/Category.php +++ b/src/Drafling/Domain/Model/Category.php @@ -12,13 +12,11 @@ /** * @param string $name Category identifier * @param string $displayName Human-readable name - * @param string $icon Tabler icon identifier * @param string[] $entryTypes Array of entry type keys allowed in this category */ public function __construct( public string $name, public string $displayName, - public string $icon, public array $entryTypes, ) {} diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Drafling/Domain/Model/Entry.php index ff33a024..0ee4710d 100644 --- a/src/Drafling/Domain/Model/Entry.php +++ b/src/Drafling/Domain/Model/Entry.php @@ -68,25 +68,8 @@ public function generateFilename(): string { $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->title)); $slug = \trim((string) $slug, '-'); - return "{$slug}.md"; - } - /** - * Get entry metadata as array - */ - public function getMetadata(): array - { - return [ - 'entry_id' => $this->entryId, - 'title' => $this->title, - 'description' => $this->description, - 'entry_type' => $this->entryType, - 'category' => $this->category, - 'status' => $this->status, - 'created_at' => $this->createdAt->format('c'), - 'updated_at' => $this->updatedAt->format('c'), - 'tags' => $this->tags, - ]; + return "{$slug}.md"; } /** @@ -101,10 +84,8 @@ public function jsonSerialize(): array 'entry_type' => $this->entryType, 'category' => $this->category, 'status' => $this->status, - 'content_type' => 'markdown', - 'created_at' => $this->createdAt->format('c'), - 'updated_at' => $this->updatedAt->format('c'), 'tags' => $this->tags, + 'content' => $this->content, ]; } } diff --git a/src/Drafling/Domain/Model/EntryType.php b/src/Drafling/Domain/Model/EntryType.php index c3af0702..a956824a 100644 --- a/src/Drafling/Domain/Model/EntryType.php +++ b/src/Drafling/Domain/Model/EntryType.php @@ -12,18 +12,14 @@ /** * @param string $key Unique identifier for this entry type * @param string $displayName Human-readable name - * @param string $icon Tabler icon identifier * @param string $contentType MIME type for content - * @param string $color Theme color * @param string $defaultStatus Default status value for new entries * @param Status[] $statuses Available statuses for this entry type */ public function __construct( public string $key, public string $displayName, - public string $icon, public string $contentType, - public string $color, public string $defaultStatus, public array $statuses, ) {} diff --git a/src/Drafling/Domain/Model/Project.php b/src/Drafling/Domain/Model/Project.php index d6026118..bb1df691 100644 --- a/src/Drafling/Domain/Model/Project.php +++ b/src/Drafling/Domain/Model/Project.php @@ -17,6 +17,7 @@ * @param string $status Project status * @param string[] $tags Project tags for organization * @param string[] $entryDirs Directories to scan for entries + * @param string[] $memory LLM memory entries for project context * @param string|null $projectPath Optional file path for storage reference */ public function __construct( @@ -27,6 +28,7 @@ public function __construct( public string $status, public array $tags, public array $entryDirs, + public array $memory = [], public ?string $projectPath = null, ) {} @@ -39,6 +41,7 @@ public function withUpdates( ?string $status = null, ?array $tags = null, ?array $entryDirs = null, + ?array $memory = null, ): self { return new self( id: $this->id, @@ -48,6 +51,25 @@ public function withUpdates( status: $status ?? $this->status, tags: $tags ?? $this->tags, entryDirs: $entryDirs ?? $this->entryDirs, + memory: $memory ?? $this->memory, + projectPath: $this->projectPath, + ); + } + + /** + * Create project 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], projectPath: $this->projectPath, ); } @@ -73,6 +95,7 @@ public function getConfiguration(): array 'template' => $this->template, 'status' => $this->status, 'tags' => $this->tags, + 'memory' => $this->memory, 'entries' => [ 'dirs' => $this->entryDirs, ], @@ -96,6 +119,7 @@ public function jsonSerialize(): array 'description' => $this->description, 'tags' => $this->tags, 'entry_dirs' => $this->entryDirs, + 'memory' => $this->memory, ], ]; } diff --git a/src/Drafling/Domain/Model/Status.php b/src/Drafling/Domain/Model/Status.php index fb07cacf..9cce294d 100644 --- a/src/Drafling/Domain/Model/Status.php +++ b/src/Drafling/Domain/Model/Status.php @@ -12,6 +12,5 @@ public function __construct( public string $value, public string $displayName, - public string $color, ) {} } diff --git a/src/Drafling/Domain/Model/Template.php b/src/Drafling/Domain/Model/Template.php index 0d66d814..54771bdf 100644 --- a/src/Drafling/Domain/Model/Template.php +++ b/src/Drafling/Domain/Model/Template.php @@ -7,7 +7,7 @@ /** * Template definition with categories, entry types, and metadata */ -final readonly class Template +final readonly class Template implements \JsonSerializable { /** * @param string $key Unique template identifier @@ -82,4 +82,33 @@ public function validateEntryInCategory(string $categoryName, string $entryTypeK 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/Drafling/MCP/DTO/EntryCreateRequest.php b/src/Drafling/MCP/DTO/EntryCreateRequest.php index c69b1a55..8648e44e 100644 --- a/src/Drafling/MCP/DTO/EntryCreateRequest.php +++ b/src/Drafling/MCP/DTO/EntryCreateRequest.php @@ -96,9 +96,9 @@ public function getProcessedDescription(): string } $summary = \implode(' ', \array_slice($contentLines, 0, 3)); - $summary = \preg_replace('/\s+/', ' ', $summary); + $summary = \preg_replace('/\s+/', ' ', $summary) ?? $summary; - return \strlen((string) $summary) > 200 ? \substr((string) $summary, 0, 197) . '...' : $summary; + return \strlen($summary) > 200 ? \substr($summary, 0, 197) . '...' : $summary; } /** diff --git a/src/Drafling/MCP/DTO/EntryFilters.php b/src/Drafling/MCP/DTO/EntryFilters.php index bd451e03..05432240 100644 --- a/src/Drafling/MCP/DTO/EntryFilters.php +++ b/src/Drafling/MCP/DTO/EntryFilters.php @@ -111,9 +111,7 @@ public function validate(): array // Validate tags array if provided if ($this->tags !== null) { - if (!\is_array($this->tags)) { - $errors[] = 'Tags must be an array'; - } elseif (empty($this->tags)) { + if (empty($this->tags)) { $errors[] = 'Tags array cannot be empty when provided'; } else { foreach ($this->tags as $tag) { diff --git a/src/Drafling/MCP/DTO/EntryUpdateRequest.php b/src/Drafling/MCP/DTO/EntryUpdateRequest.php index fa4df3cf..a6a3bffd 100644 --- a/src/Drafling/MCP/DTO/EntryUpdateRequest.php +++ b/src/Drafling/MCP/DTO/EntryUpdateRequest.php @@ -120,14 +120,10 @@ public function validate(): array // Validate tags if provided if ($this->tags !== null) { - if (!\is_array($this->tags)) { - $errors[] = 'Tags must be an array'; - } else { - foreach ($this->tags as $tag) { - if (!\is_string($tag) || empty(\trim($tag))) { - $errors[] = 'All tags must be non-empty strings'; - break; - } + foreach ($this->tags as $tag) { + if (!\is_string($tag) || empty(\trim($tag))) { + $errors[] = 'All tags must be non-empty strings'; + break; } } } diff --git a/src/Drafling/MCP/DTO/ProjectCreateRequest.php b/src/Drafling/MCP/DTO/ProjectCreateRequest.php index ea1bb1a9..f562a60b 100644 --- a/src/Drafling/MCP/DTO/ProjectCreateRequest.php +++ b/src/Drafling/MCP/DTO/ProjectCreateRequest.php @@ -33,6 +33,12 @@ public function __construct( )] /** @var string[] */ public array $entryDirs = [], + #[Field( + description: 'Initial memory entries (optional)', + default: [], + )] + /** @var string[] */ + public array $memory = [], ) {} /** diff --git a/src/Drafling/MCP/DTO/ProjectFilters.php b/src/Drafling/MCP/DTO/ProjectFilters.php index 8c140572..a1a91f4e 100644 --- a/src/Drafling/MCP/DTO/ProjectFilters.php +++ b/src/Drafling/MCP/DTO/ProjectFilters.php @@ -83,9 +83,7 @@ public function validate(): array // Validate tags array if provided if ($this->tags !== null) { - if (!\is_array($this->tags)) { - $errors[] = 'Tags must be an array'; - } elseif (empty($this->tags)) { + if (empty($this->tags)) { $errors[] = 'Tags array cannot be empty when provided'; } else { foreach ($this->tags as $tag) { diff --git a/src/Drafling/MCP/DTO/ProjectMemory.php b/src/Drafling/MCP/DTO/ProjectMemory.php new file mode 100644 index 00000000..eaf60b4c --- /dev/null +++ b/src/Drafling/MCP/DTO/ProjectMemory.php @@ -0,0 +1,12 @@ +description !== null || $this->status !== null || $this->tags !== null - || $this->entryDirs !== null; + || $this->entryDirs !== null + || $this->memory !== null; } /** diff --git a/src/Drafling/MCP/DTO/ReadEntryRequest.php b/src/Drafling/MCP/DTO/ReadEntryRequest.php index c2250987..8376eca3 100644 --- a/src/Drafling/MCP/DTO/ReadEntryRequest.php +++ b/src/Drafling/MCP/DTO/ReadEntryRequest.php @@ -22,16 +22,6 @@ public function __construct( default: null, )] public string $entryId, - #[Field( - description: 'Include entry content in response (default: true)', - default: true, - )] - public bool $includeContent = true, - #[Field( - description: 'Include entry metadata in response (default: true)', - default: true, - )] - public bool $includeMetadata = true, ) {} /** diff --git a/src/Drafling/MCP/Tools/GetProjectToolAction.php b/src/Drafling/MCP/Tools/GetProjectToolAction.php index efce625f..95a6d7ac 100644 --- a/src/Drafling/MCP/Tools/GetProjectToolAction.php +++ b/src/Drafling/MCP/Tools/GetProjectToolAction.php @@ -5,10 +5,12 @@ namespace Butschster\ContextGenerator\Drafling\MCP\Tools; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; +use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; use Butschster\ContextGenerator\Drafling\Exception\DraflingException; use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; use Butschster\ContextGenerator\Drafling\MCP\DTO\GetProjectRequest; use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post; @@ -27,6 +29,7 @@ public function __construct( private LoggerInterface $logger, private ProjectServiceInterface $projectService, + private TemplateServiceInterface $templateService, ) {} #[Post(path: '/tools/call/drafling_get_project', name: 'tools.drafling_get_project')] @@ -71,6 +74,8 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'template' => $project->template, ]); + $template = $this->templateService->getTemplate(TemplateKey::fromString($project->template)); + // Format project for response $response = [ 'success' => true, @@ -78,15 +83,13 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'project_id' => $project->id, 'title' => $project->name, 'status' => $project->status, - 'project_type' => $project->template, - 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date - 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date 'metadata' => [ 'description' => $project->description, 'tags' => $project->tags, - 'entry_dirs' => $project->entryDirs, + 'memory' => $project->memory, ], ], + 'template' => $template, ]; return new CallToolResult([ diff --git a/src/Drafling/MCP/Tools/ListEntriesToolAction.php b/src/Drafling/MCP/Tools/ListEntriesToolAction.php index eed9516d..64051b0f 100644 --- a/src/Drafling/MCP/Tools/ListEntriesToolAction.php +++ b/src/Drafling/MCP/Tools/ListEntriesToolAction.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\Drafling\MCP\Tools; +use Butschster\ContextGenerator\Drafling\Domain\Model\Entry; use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; use Butschster\ContextGenerator\Drafling\Exception\DraflingException; use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; @@ -81,7 +82,13 @@ public function __invoke(ListEntriesRequest $request): CallToolResult ); // Format entries for response (using JsonSerializable) - $entryData = $paginatedEntries; + $entryData = \array_map(static function (Entry $entry) { + $data = $entry->jsonSerialize(); + unset($data['content']); + + return $data; + + }, $paginatedEntries); $response = [ 'success' => true, diff --git a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php index 2744a517..b9143a9f 100644 --- a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php +++ b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php @@ -58,24 +58,12 @@ public function __invoke(ListTemplatesRequest $request): CallToolResult // Apply filters $filteredTemplates = $this->applyFilters($allTemplates, $request); - // Format templates for response - $templateData = \array_map( - fn($template) => $this->formatTemplate($template, $request->includeDetails), - $filteredTemplates, - ); - $response = [ 'success' => true, - 'templates' => $templateData, - 'count' => \count($templateData), - 'filters_applied' => $request->hasFilters() ? [ - 'tag' => $request->tag, - 'name_contains' => $request->nameContains, - ] : null, + 'templates' => $filteredTemplates, ]; $this->logger->info('Templates listed successfully', [ - 'returned_count' => \count($templateData), 'total_available' => \count($allTemplates), 'filters_applied' => $request->hasFilters(), ]); @@ -146,47 +134,4 @@ private function applyFilters(array $templates, ListTemplatesRequest $request): return true; }); } - - /** - * Format template for response - * @param mixed $template - */ - private function formatTemplate($template, bool $includeDetails): array - { - $formatted = [ - 'template_id' => $template->key, - 'name' => $template->name, - 'description' => $template->description, - 'tags' => $template->tags, - ]; - - if ($includeDetails) { - $formatted['categories'] = \array_map(static fn($category) => [ - 'name' => $category->name, - 'display_name' => $category->displayName, - 'icon' => $category->icon, - 'allowed_entry_types' => $category->entryTypes, - ], $template->categories); - - $formatted['entry_types'] = \array_map(static fn($entryType) => [ - 'key' => $entryType->key, - 'display_name' => $entryType->displayName, - 'icon' => $entryType->icon, - 'content_type' => $entryType->contentType, - 'color' => $entryType->color, - 'default_status' => $entryType->defaultStatus, - 'statuses' => \array_map(static fn($status) => [ - 'value' => $status->value, - 'display_name' => $status->displayName, - 'color' => $status->color, - ], $entryType->statuses), - ], $template->entryTypes); - - if ($template->prompt !== null) { - $formatted['prompt'] = $template->prompt; - } - } - - return $formatted; - } } diff --git a/src/Drafling/MCP/Tools/ReadEntryToolAction.php b/src/Drafling/MCP/Tools/ReadEntryToolAction.php index e7242c3e..c4215ffb 100644 --- a/src/Drafling/MCP/Tools/ReadEntryToolAction.php +++ b/src/Drafling/MCP/Tools/ReadEntryToolAction.php @@ -39,8 +39,6 @@ public function __invoke(ReadEntryRequest $request): CallToolResult $this->logger->info('Reading entry', [ 'project_id' => $request->projectId, 'entry_id' => $request->entryId, - 'include_content' => $request->includeContent, - 'include_metadata' => $request->includeMetadata, ]); try { @@ -86,43 +84,15 @@ public function __invoke(ReadEntryRequest $request): CallToolResult ], isError: true); } - // Build response based on inclusion flags - $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'), - 'updated_at' => $entry->updatedAt->format('c'), - ]; - - // Include content if requested - if ($request->includeContent) { - $response['content'] = $entry->content ?? ''; - } - - // Include metadata if requested - if ($request->includeMetadata) { - $response['metadata'] = [ - 'tags' => $entry->tags, - 'file_path' => $entry->filePath ?? null, - ]; - } - $this->logger->info('Entry read successfully', [ 'project_id' => $request->projectId, 'entry_id' => $request->entryId, 'title' => $entry->title, - 'included_content' => $request->includeContent, - 'included_metadata' => $request->includeMetadata, ]); return new CallToolResult([ new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), + text: \json_encode($entry, JSON_PRETTY_PRINT), ), ]); diff --git a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php index 20145946..b9bf54d7 100644 --- a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php @@ -18,7 +18,7 @@ #[Tool( name: 'drafling_update_project', - description: 'Update existing project properties including title, description, status, tags, and entry directories', + description: 'Update existing project properties including title, description, status, tags, entry directories, and memory entries', title: 'Update Project', )] #[InputSchema(class: ProjectUpdateRequest::class)] @@ -39,6 +39,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'has_status' => $request->status !== null, 'has_tags' => $request->tags !== null, 'has_entry_dirs' => $request->entryDirs !== null, + 'has_memory' => $request->memory !== null, ]); try { @@ -90,6 +91,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'description' => $updatedProject->description, 'tags' => $updatedProject->tags, 'entry_dirs' => $updatedProject->entryDirs, + 'memory' => $updatedProject->memory, ], 'changes_applied' => $this->getAppliedChanges($request), ]; @@ -174,6 +176,10 @@ private function getAppliedChanges(ProjectUpdateRequest $request): array $changes[] = 'entry_directories'; } + if ($request->memory !== null) { + $changes[] = 'memory'; + } + return $changes; } } diff --git a/src/Drafling/Service/EntryService.php b/src/Drafling/Service/EntryService.php index 86615b23..1a4c239e 100644 --- a/src/Drafling/Service/EntryService.php +++ b/src/Drafling/Service/EntryService.php @@ -98,7 +98,6 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): } // Resolve status if provided, otherwise use entry type default - $resolvedStatus = null; if ($request->status !== null) { $resolvedStatus = $this->templateService->resolveStatusValue($template, $resolvedEntryType, $request->status); if ($resolvedStatus === null) { diff --git a/src/Drafling/Service/ProjectService.php b/src/Drafling/Service/ProjectService.php index 9e34d9ba..50cfd3fc 100644 --- a/src/Drafling/Service/ProjectService.php +++ b/src/Drafling/Service/ProjectService.php @@ -86,6 +86,7 @@ public function updateProject(ProjectId $projectId, ProjectUpdateRequest $reques 'status' => $request->status !== null, 'tags' => $request->tags !== null, 'entry_dirs' => $request->entryDirs !== null, + 'memory' => $request->memory !== null, ], ]); @@ -239,4 +240,50 @@ public function deleteProject(ProjectId $projectId): bool ); } } + + #[\Override] + public function addProjectMemory(ProjectId $projectId, string $memory): Project + { + $this->logger?->info('Adding memory to project', [ + 'project_id' => $projectId->value, + 'memory_length' => \strlen($memory), + ]); + + // Verify project exists and get current project + $currentProject = $this->projectRepository->findById($projectId); + if ($currentProject === null) { + $error = "Project '{$projectId->value}' not found"; + $this->logger?->error($error, [ + 'project_id' => $projectId->value, + ]); + throw new ProjectNotFoundException($error); + } + + try { + // Create updated project with added memory + $updatedProject = $currentProject->withAddedMemory($memory); + + // Save updated project to repository + $this->projectRepository->save($updatedProject); + + $this->logger?->info('Memory added to project successfully', [ + 'project_id' => $projectId->value, + 'memory_count' => \count($updatedProject->memory), + 'name' => $updatedProject->name, + ]); + + return $updatedProject; + + } catch (\Throwable $e) { + $this->logger?->error('Failed to add memory to project', [ + 'project_id' => $projectId->value, + 'error' => $e->getMessage(), + ]); + + throw new DraflingException( + "Failed to add memory to project: {$e->getMessage()}", + previous: $e, + ); + } + } } diff --git a/src/Drafling/Service/ProjectServiceInterface.php b/src/Drafling/Service/ProjectServiceInterface.php index 5c28552d..4e6dba40 100644 --- a/src/Drafling/Service/ProjectServiceInterface.php +++ b/src/Drafling/Service/ProjectServiceInterface.php @@ -53,4 +53,12 @@ public function listProjects(array $filters = []): array; * */ public function deleteProject(ProjectId $projectId): bool; + + /** + * Add a memory entry to project + * + * @throws \Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException + * @throws \Butschster\ContextGenerator\Drafling\Exception\DraflingException + */ + public function addProjectMemory(ProjectId $projectId, string $memory): Project; } diff --git a/src/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Drafling/Storage/FileStorage/DirectoryScanner.php index f7da5dff..104a8d51 100644 --- a/src/Drafling/Storage/FileStorage/DirectoryScanner.php +++ b/src/Drafling/Storage/FileStorage/DirectoryScanner.php @@ -70,30 +70,12 @@ public function scanEntries(string $projectPath, array $entryDirs = []): array $entryFiles = []; - // If no specific directories provided, scan all subdirectories - if (empty($entryDirs)) { - $entryDirs = $this->getEntryDirectories($projectPath); - } - - $validDirs = []; - foreach ($entryDirs as $dir) { - $dirPath = $this->files->normalizePath($projectPath . '/' . $dir); - if ($this->files->exists($dirPath) && $this->files->isDirectory($dirPath)) { - $validDirs[] = $dirPath; - } - } - - if (empty($validDirs)) { - return []; - } - try { $finder = new Finder(); $finder ->files() - ->in($validDirs) - ->name('*.md') - ->depth(0); // Only files directly in entry directories + ->in($projectPath) + ->name('*.md'); foreach ($finder as $file) { $entryFiles[] = $file->getRealPath(); @@ -238,7 +220,7 @@ public function getProjectStats(string $projectPath): array ->directories() ->in($projectPath); - foreach ($dirFinder as $directory) { + foreach ($dirFinder as $_) { $stats['directories']++; } } catch (\Throwable) { diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php index 7c4a096b..1c9797d5 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -249,7 +249,7 @@ private function saveEntryToFile(string $projectPath, Entry $entry): void if ($filePath === null) { // New entry - generate file path - $categoryPath = $this->files->normalizePath($projectPath . '/' . $entry->category); + $categoryPath = $this->files->normalizePath($projectPath . '/' . $entry->category . '/' . $entry->entryType); $this->ensureDirectory($categoryPath); $filename = $this->generateFilename($entry->title); diff --git a/src/Drafling/Storage/FileStorage/FileProjectRepository.php b/src/Drafling/Storage/FileStorage/FileProjectRepository.php index ce834f6a..8b8e319c 100644 --- a/src/Drafling/Storage/FileStorage/FileProjectRepository.php +++ b/src/Drafling/Storage/FileStorage/FileProjectRepository.php @@ -155,6 +155,7 @@ private function loadProjectFromDirectory(string $projectPath): ?Project status: $projectData['status'] ?? 'draft', tags: $projectData['tags'] ?? [], entryDirs: $projectData['entries']['dirs'] ?? [], + memory: $projectData['memory'] ?? [], projectPath: $projectPath, ); } @@ -173,6 +174,7 @@ private function saveProjectConfig(string $projectPath, Project $project): void 'template' => $project->template, 'status' => $project->status, 'tags' => $project->tags, + 'memory' => $project->memory, 'entries' => [ 'dirs' => $project->entryDirs, ], diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php index 3c8b895e..637f522b 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ b/src/Drafling/Storage/FileStorage/FileStorageDriver.php @@ -15,6 +15,7 @@ use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryCreateRequest; use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryUpdateRequest; use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; +use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectMemory; use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectUpdateRequest; use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; @@ -116,7 +117,7 @@ public function createProject(ProjectCreateRequest $request): Project $template = $this->templateRepository->findByKey($templateKey); if ($template === null) { - throw TemplateNotFoundException::withKey($request->templateId); + throw new TemplateNotFoundException("Template '{$request->templateId}' not found"); } // Generate project ID and create project @@ -129,6 +130,7 @@ public function createProject(ProjectCreateRequest $request): Project status: $this->config->defaultEntryStatus, tags: $request->tags, entryDirs: !empty($request->entryDirs) ? $request->entryDirs : $this->getDefaultEntryDirs($template), + memory: $request->memory, ); $this->projectRepository->save($project); @@ -142,7 +144,7 @@ public function updateProject(ProjectId $projectId, ProjectUpdateRequest $reques { $project = $this->projectRepository->findById($projectId); if ($project === null) { - throw ProjectNotFoundException::withId($projectId->value); + throw new ProjectNotFoundException("Project '{$projectId->value}' not found"); } if (!$request->hasUpdates()) { @@ -155,6 +157,10 @@ public function updateProject(ProjectId $projectId, ProjectUpdateRequest $reques status: $request->status, tags: $request->tags, entryDirs: $request->entryDirs, + memory: \array_map( + static fn(ProjectMemory $memory): string => $memory->record, + $request->memory, + ), ); $this->projectRepository->save($updatedProject); @@ -184,14 +190,14 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): // Verify project exists $project = $this->projectRepository->findById($projectId); if ($project === null) { - throw ProjectNotFoundException::withId($projectId->value); + throw new ProjectNotFoundException("Project '{$projectId->value}' not found"); } // Get template for validation and key resolution $templateKey = TemplateKey::fromString($project->template); $template = $this->templateRepository->findByKey($templateKey); if ($template === null) { - throw TemplateNotFoundException::withKey($project->template); + throw new TemplateNotFoundException("Template '{$project->template}' not found"); } // Resolve display names to internal keys @@ -232,7 +238,7 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR { $entry = $this->entryRepository->findById($projectId, $entryId); if ($entry === null) { - throw EntryNotFoundException::withId($projectId->value, $entryId->value); + throw new EntryNotFoundException("Entry '{$entryId->value}' not found in project '{$projectId->value}'"); } if (!$request->hasUpdates()) { diff --git a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php index 865f9de4..5f2639db 100644 --- a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php +++ b/src/Drafling/Storage/FileStorage/FileTemplateRepository.php @@ -148,7 +148,7 @@ private function createTemplateFromData(array $data): Template */ private function createCategoryFromData(array $data): Category { - $requiredFields = ['name', 'display_name', 'icon', 'entry_types']; + $requiredFields = ['name', 'display_name', 'entry_types']; foreach ($requiredFields as $field) { if (!isset($data[$field])) { throw new \RuntimeException("Missing required category field: {$field}"); @@ -158,7 +158,6 @@ private function createCategoryFromData(array $data): Category return new Category( name: $data['name'], displayName: $data['display_name'], - icon: $data['icon'], entryTypes: $data['entry_types'], ); } @@ -168,7 +167,7 @@ private function createCategoryFromData(array $data): Category */ private function createEntryTypeFromData(string $key, array $data): EntryType { - $requiredFields = ['display_name', 'icon', 'content_type', 'color', 'default_status']; + $requiredFields = ['display_name', 'content_type', 'default_status']; foreach ($requiredFields as $field) { if (!isset($data[$field])) { throw new \RuntimeException("Missing required entry type field: {$field}"); @@ -186,9 +185,7 @@ private function createEntryTypeFromData(string $key, array $data): EntryType return new EntryType( key: $key, displayName: $data['display_name'], - icon: $data['icon'], contentType: $data['content_type'], - color: $data['color'], defaultStatus: $data['default_status'], statuses: $statuses, ); @@ -199,7 +196,7 @@ private function createEntryTypeFromData(string $key, array $data): EntryType */ private function createStatusFromData(array $data): Status { - $requiredFields = ['value', 'display_name', 'color']; + $requiredFields = ['value', 'display_name']; foreach ($requiredFields as $field) { if (!isset($data[$field])) { throw new \RuntimeException("Missing required status field: {$field}"); @@ -209,7 +206,6 @@ private function createStatusFromData(array $data): Status return new Status( value: $data['value'], displayName: $data['display_name'], - color: $data['color'], ); } } 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; From 21a04ef7ad9200603395bf32c8960118f63ea526 Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 24 Sep 2025 00:33:50 +0400 Subject: [PATCH 10/16] test(drafling): add unit tests for project services, DTOs, and tool actions --- src/Drafling/Domain/ValueObject/EntryId.php | 10 +- src/Drafling/Domain/ValueObject/ProjectId.php | 10 +- .../Domain/ValueObject/TemplateKey.php | 10 +- .../MCP/DTO/AddProjectMemoryRequest.php | 38 ++ src/Drafling/MCP/DTO/ProjectCreateRequest.php | 4 +- .../MCP/Tools/AddProjectMemoryToolAction.php | 147 +++++ .../Storage/FileStorage/DirectoryScanner.php | 192 ------- .../Drafling/Domain/Model/ProjectTest.php | 289 ++++++++++ .../Domain/ValueObject/EntryIdTest.php | 67 +++ .../Domain/ValueObject/ProjectIdTest.php | 67 +++ .../Domain/ValueObject/TemplateKeyTest.php | 75 +++ .../MCP/DTO/AddProjectMemoryRequestTest.php | 161 ++++++ .../MCP/DTO/ProjectCreateRequestTest.php | 220 ++++++++ .../MCP/DTO/ProjectUpdateRequestTest.php | 212 ++++++++ .../Tools/AddProjectMemoryToolActionTest.php | 412 ++++++++++++++ .../MCP/Tools/UpdateProjectToolActionTest.php | 373 +++++++++++++ .../Drafling/Service/ProjectServiceTest.php | 509 ++++++++++++++++++ 17 files changed, 2599 insertions(+), 197 deletions(-) create mode 100644 src/Drafling/MCP/DTO/AddProjectMemoryRequest.php create mode 100644 src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php create mode 100644 tests/src/Unit/Drafling/Domain/Model/ProjectTest.php create mode 100644 tests/src/Unit/Drafling/Domain/ValueObject/EntryIdTest.php create mode 100644 tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php create mode 100644 tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php create mode 100644 tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php create mode 100644 tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php create mode 100644 tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php create mode 100644 tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php create mode 100644 tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php create mode 100644 tests/src/Unit/Drafling/Service/ProjectServiceTest.php diff --git a/src/Drafling/Domain/ValueObject/EntryId.php b/src/Drafling/Domain/ValueObject/EntryId.php index 5d609262..36e4b77d 100644 --- a/src/Drafling/Domain/ValueObject/EntryId.php +++ b/src/Drafling/Domain/ValueObject/EntryId.php @@ -12,7 +12,7 @@ public function __construct( public string $value, ) { - if (empty($this->value)) { + if (empty(\trim($this->value))) { throw new \InvalidArgumentException('Entry ID cannot be empty'); } } @@ -33,6 +33,14 @@ 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/Drafling/Domain/ValueObject/ProjectId.php b/src/Drafling/Domain/ValueObject/ProjectId.php index 8243a444..0692e240 100644 --- a/src/Drafling/Domain/ValueObject/ProjectId.php +++ b/src/Drafling/Domain/ValueObject/ProjectId.php @@ -12,7 +12,7 @@ public function __construct( public string $value, ) { - if (empty($this->value)) { + if (empty(\trim($this->value))) { throw new \InvalidArgumentException('Project ID cannot be empty'); } } @@ -33,6 +33,14 @@ public static function fromString(string $value): self return new self($value); } + /** + * Check equality with another ProjectId + */ + public function equals(self $other): bool + { + return $this->value === $other->value; + } + public function toString(): string { return $this->value; diff --git a/src/Drafling/Domain/ValueObject/TemplateKey.php b/src/Drafling/Domain/ValueObject/TemplateKey.php index aeadbea8..bcd13856 100644 --- a/src/Drafling/Domain/ValueObject/TemplateKey.php +++ b/src/Drafling/Domain/ValueObject/TemplateKey.php @@ -12,7 +12,7 @@ public function __construct( public string $value, ) { - if (empty($this->value)) { + if (empty(\trim($this->value))) { throw new \InvalidArgumentException('Template key cannot be empty'); } } @@ -25,6 +25,14 @@ 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/Drafling/MCP/DTO/AddProjectMemoryRequest.php b/src/Drafling/MCP/DTO/AddProjectMemoryRequest.php new file mode 100644 index 00000000..3b3382d5 --- /dev/null +++ b/src/Drafling/MCP/DTO/AddProjectMemoryRequest.php @@ -0,0 +1,38 @@ +projectId)) { + $errors[] = 'Project ID cannot be empty'; + } + + if (empty(\trim($this->memory))) { + $errors[] = 'Memory entry cannot be empty'; + } + + return $errors; + } +} diff --git a/src/Drafling/MCP/DTO/ProjectCreateRequest.php b/src/Drafling/MCP/DTO/ProjectCreateRequest.php index f562a60b..95f9f1c3 100644 --- a/src/Drafling/MCP/DTO/ProjectCreateRequest.php +++ b/src/Drafling/MCP/DTO/ProjectCreateRequest.php @@ -56,11 +56,11 @@ public function validate(): array { $errors = []; - if (empty($this->templateId)) { + if (empty(\trim($this->templateId))) { $errors[] = 'Template ID cannot be empty'; } - if (empty($this->title)) { + if (empty(\trim($this->title))) { $errors[] = 'Project title cannot be empty'; } diff --git a/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php b/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php new file mode 100644 index 00000000..d640b338 --- /dev/null +++ b/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php @@ -0,0 +1,147 @@ +logger->info('Adding memory to project', [ + 'project_id' => $request->projectId, + 'memory_length' => \strlen($request->memory), + ]); + + try { + // Validate request + $validationErrors = $request->validate(); + if (!empty($validationErrors)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Validation failed', + 'details' => $validationErrors, + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Verify project exists + $projectId = ProjectId::fromString($request->projectId); + if (!$this->projectService->projectExists($projectId)) { + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => "Project '{$request->projectId}' not found", + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + + // Add memory to project using domain service + $updatedProject = $this->projectService->addProjectMemory($projectId, $request->memory); + + $this->logger->info('Memory added to project successfully', [ + 'project_id' => $request->projectId, + 'memory_count' => \count($updatedProject->memory), + 'title' => $updatedProject->name, + ]); + + // Format successful response according to MCP specification + $response = [ + 'success' => true, + 'project_id' => $updatedProject->id, + 'title' => $updatedProject->name, + 'status' => $updatedProject->status, + 'project_type' => $updatedProject->template, + 'updated_at' => (new \DateTime())->format('c'), + 'memory_count' => \count($updatedProject->memory), + 'memory_added' => $request->memory, + 'metadata' => [ + 'description' => $updatedProject->description, + 'tags' => $updatedProject->tags, + 'entry_dirs' => $updatedProject->entryDirs, + 'memory' => $updatedProject->memory, + ], + ]; + + return new CallToolResult([ + new TextContent( + text: \json_encode($response, JSON_PRETTY_PRINT), + ), + ]); + + } catch (ProjectNotFoundException $e) { + $this->logger->error('Project not found', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (DraflingException $e) { + $this->logger->error('Drafling error during memory addition', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + + } catch (\Throwable $e) { + $this->logger->error('Unexpected error adding memory to project', [ + 'project_id' => $request->projectId, + 'error' => $e->getMessage(), + ]); + + return new CallToolResult([ + new TextContent( + text: \json_encode([ + 'success' => false, + 'error' => 'Failed to add memory to project: ' . $e->getMessage(), + ], JSON_PRETTY_PRINT), + ), + ], isError: true); + } + } +} diff --git a/src/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Drafling/Storage/FileStorage/DirectoryScanner.php index 104a8d51..7a186082 100644 --- a/src/Drafling/Storage/FileStorage/DirectoryScanner.php +++ b/src/Drafling/Storage/FileStorage/DirectoryScanner.php @@ -121,196 +121,4 @@ public function getEntryDirectories(string $projectPath): array return $directories; } - - /** - * Create project directory structure - */ - public function createProjectDirectory(string $projectPath, array $entryDirs = []): void - { - // Create main project directory - $this->files->ensureDirectory($projectPath); - - // Create .project subdirectory for metadata - $this->files->ensureDirectory($projectPath . '/.project'); - - // Create entry directories - foreach ($entryDirs as $dir) { - $dirPath = $this->files->normalizePath($projectPath . '/' . $dir); - $this->files->ensureDirectory($dirPath); - } - } - - /** - * Validate project directory structure - */ - public function validateProjectStructure(string $projectPath): array - { - $errors = []; - - if (!$this->files->exists($projectPath)) { - $errors[] = "Project directory does not exist: {$projectPath}"; - return $errors; - } - - if (!$this->files->isDirectory($projectPath)) { - $errors[] = "Project path is not a directory: {$projectPath}"; - return $errors; - } - - // Check for project configuration file - $configPath = $projectPath . '/project.yaml'; - if (!$this->files->exists($configPath)) { - $errors[] = "Missing project configuration file: {$configPath}"; - } - - // Check for .project metadata directory - $metadataDir = $projectPath . '/.project'; - if (!$this->files->exists($metadataDir)) { - $errors[] = "Missing project metadata directory: {$metadataDir}"; - } - - return $errors; - } - - /** - * Get project statistics - */ - public function getProjectStats(string $projectPath): array - { - $stats = [ - 'total_files' => 0, - 'entry_files' => 0, - 'directories' => 0, - 'size_bytes' => 0, - 'last_modified' => null, - ]; - - if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { - return $stats; - } - - try { - // Count all files - $fileFinder = new Finder(); - $fileFinder - ->files() - ->in($projectPath); - - foreach ($fileFinder as $file) { - $stats['total_files']++; - - // Check if it's an entry file - if ($file->getExtension() === 'md') { - $stats['entry_files']++; - } - - // Add to size - $stats['size_bytes'] += $file->getSize(); - - // Update last modified - $modified = $file->getMTime(); - if ($stats['last_modified'] === null || $modified > $stats['last_modified']) { - $stats['last_modified'] = $modified; - } - } - - // Count directories - $dirFinder = new Finder(); - $dirFinder - ->directories() - ->in($projectPath); - - foreach ($dirFinder as $_) { - $stats['directories']++; - } - } catch (\Throwable) { - // Return basic stats if scanning fails - } - - return $stats; - } - - /** - * Find specific files by pattern - * - * @param string $path Base path to search in - * @param string $pattern File pattern (e.g., "*.yaml", "project.*") - * @param int $depth Maximum depth to search (0 = only immediate directory) - * @return array Array of file paths - */ - public function findFiles(string $path, string $pattern, int $depth = 0): array - { - if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { - return []; - } - - $files = []; - - try { - $finder = new Finder(); - $finder - ->files() - ->in($path) - ->name($pattern) - ->depth($depth); - - foreach ($finder as $file) { - $files[] = $file->getRealPath(); - } - } catch (\Throwable) { - // Return empty array if scanning fails - } - - return $files; - } - - /** - * Check if directory contains any markdown files - */ - public function hasMarkdownFiles(string $path): bool - { - if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { - return false; - } - - try { - $finder = new Finder(); - $finder - ->files() - ->in($path) - ->name('*.md') - ->depth(0); - - return $finder->hasResults(); - } catch (\Throwable) { - return false; - } - } - - /** - * Get directory size recursively - */ - public function getDirectorySize(string $path): int - { - if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { - return 0; - } - - $totalSize = 0; - - try { - $finder = new Finder(); - $finder - ->files() - ->in($path); - - foreach ($finder as $file) { - $totalSize += $file->getSize(); - } - } catch (\Throwable) { - // Return 0 if calculation fails - } - - return $totalSize; - } } diff --git a/tests/src/Unit/Drafling/Domain/Model/ProjectTest.php b/tests/src/Unit/Drafling/Domain/Model/ProjectTest.php new file mode 100644 index 00000000..1f4af9bd --- /dev/null +++ b/tests/src/Unit/Drafling/Domain/Model/ProjectTest.php @@ -0,0 +1,289 @@ +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->projectPath); + } + + public function testProjectConstructionWithDefaults(): void + { + $project = new Project( + 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->projectPath); // Should default to null + } + + public function testWithUpdates(): void + { + $original = new Project( + 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 Project( + 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 Project( + 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 Project( + 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 testGenerateDirectoryName(): void + { + $project = new Project( + id: 'proj_dir', + name: 'My Blog Project', + description: 'Description', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + ); + + $this->assertSame('my-blog-project', $project->generateDirectoryName()); + } + + public function testGenerateDirectoryNameWithSpecialCharacters(): void + { + $project = new Project( + id: 'proj_special', + name: 'My Blog Project!!! @#$', + description: 'Description', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + ); + + $this->assertSame('my-blog-project', $project->generateDirectoryName()); + } + + public function testGetConfiguration(): void + { + $project = new Project( + id: 'proj_config', + name: 'Config Test', + description: 'Testing configuration', + template: 'blog', + status: 'active', + tags: ['web', 'test'], + entryDirs: ['posts', 'pages'], + memory: ['config memory'], + ); + + $expected = [ + 'project' => [ + 'name' => 'Config Test', + 'description' => 'Testing configuration', + 'template' => 'blog', + 'status' => 'active', + 'tags' => ['web', 'test'], + 'memory' => ['config memory'], + 'entries' => [ + 'dirs' => ['posts', 'pages'], + ], + ], + ]; + + $this->assertSame($expected, $project->getConfiguration()); + } + + public function testJsonSerialize(): void + { + $project = new Project( + 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('proj_json', $serialized['project_id']); + $this->assertSame('JSON Test', $serialized['title']); + $this->assertSame('published', $serialized['status']); + $this->assertSame('api', $serialized['project_type']); + + // Check metadata + $this->assertSame('Testing JSON serialization', $serialized['metadata']['description']); + $this->assertSame(['api', 'json'], $serialized['metadata']['tags']); + $this->assertSame(['endpoints'], $serialized['metadata']['entry_dirs']); + $this->assertSame(['json memory'], $serialized['metadata']['memory']); + + // Check timestamps are present and valid + $this->assertArrayHasKey('created_at', $serialized); + $this->assertArrayHasKey('updated_at', $serialized); + + // Validate timestamp format (ISO 8601) + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', + $serialized['created_at'], + ); + } + + public function testProjectIsImmutable(): void + { + $project = new Project( + 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/Drafling/Domain/ValueObject/EntryIdTest.php b/tests/src/Unit/Drafling/Domain/ValueObject/EntryIdTest.php new file mode 100644 index 00000000..68f48c83 --- /dev/null +++ b/tests/src/Unit/Drafling/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/Drafling/Domain/ValueObject/ProjectIdTest.php b/tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php new file mode 100644 index 00000000..96c4f4d2 --- /dev/null +++ b/tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php @@ -0,0 +1,67 @@ +assertSame('proj_123abc', $projectId->value); + } + + public function testFromStringWithEmptyValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Project ID cannot be empty'); + + ProjectId::fromString(''); + } + + public function testFromStringWithWhitespaceOnly(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Project ID cannot be empty'); + + ProjectId::fromString(" \t\n "); + } + + public function testEquality(): void + { + $id1 = ProjectId::fromString('proj_123'); + $id2 = ProjectId::fromString('proj_123'); + $id3 = ProjectId::fromString('proj_456'); + + $this->assertTrue($id1->equals($id2)); + $this->assertFalse($id1->equals($id3)); + } + + public function testToString(): void + { + $projectId = ProjectId::fromString('proj_test'); + + $this->assertSame('proj_test', (string) $projectId); + } + + public function testValueObjectIsImmutable(): void + { + $projectId = ProjectId::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 = ProjectId::fromString('proj_immutable'); + $this->assertTrue($projectId->equals($anotherProjectId)); + $this->assertNotSame($projectId, $anotherProjectId); + } +} diff --git a/tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php b/tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php new file mode 100644 index 00000000..6e269ff8 --- /dev/null +++ b/tests/src/Unit/Drafling/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/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php b/tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php new file mode 100644 index 00000000..0073ea59 --- /dev/null +++ b/tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php @@ -0,0 +1,161 @@ +assertSame('proj_123', $request->projectId); + $this->assertSame('This is a memory entry for the project', $request->memory); + } + + public function testValidateWithValidRequest(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_456', + memory: 'Valid memory content', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithEmptyProjectId(): void + { + $request = new AddProjectMemoryRequest( + projectId: '', + memory: 'Some memory content', + ); + + $errors = $request->validate(); + + $this->assertContains('Project ID cannot be empty', $errors); + } + + public function testValidateWithEmptyMemory(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_789', + memory: '', + ); + + $errors = $request->validate(); + + $this->assertContains('Memory entry cannot be empty', $errors); + } + + public function testValidateWithWhitespaceOnlyMemory(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_abc', + memory: " \t\n ", + ); + + $errors = $request->validate(); + + $this->assertContains('Memory entry cannot be empty', $errors); + } + + public function testValidateWithMultipleErrors(): void + { + $request = new AddProjectMemoryRequest( + projectId: '', + memory: ' ', + ); + + $errors = $request->validate(); + + $this->assertContains('Project ID cannot be empty', $errors); + $this->assertContains('Memory entry cannot be empty', $errors); + $this->assertCount(2, $errors); + } + + public function testValidateWithMemoryContainingOnlyWhitespace(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_test', + memory: "\n\t \r\n ", + ); + + $errors = $request->validate(); + + $this->assertContains('Memory entry cannot be empty', $errors); + } + + public function testValidateWithValidMemoryContainingWhitespace(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_valid', + memory: ' Valid memory with spaces ', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithLongMemoryContent(): void + { + $longMemory = \str_repeat('This is a long memory entry. ', 100); + + $request = new AddProjectMemoryRequest( + projectId: 'proj_long', + memory: $longMemory, + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithSpecialCharactersInMemory(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_special', + memory: 'Memory with special chars: !@#$%^&*()_+-={}[]|\\:";\'<>?,./', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithUnicodeCharactersInMemory(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_unicode', + memory: 'Memory with unicode: 🚀 Hello 世界 café résumé', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testIsReadonlyDto(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_readonly', + memory: 'Readonly memory', + ); + + // Properties should be readonly (this is ensured by PHP at compile time) + $this->assertSame('proj_readonly', $request->projectId); + $this->assertSame('Readonly memory', $request->memory); + } +} diff --git a/tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php b/tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php new file mode 100644 index 00000000..f84acdf7 --- /dev/null +++ b/tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php @@ -0,0 +1,220 @@ +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 ProjectCreateRequest( + 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 testGetName(): void + { + $request = new ProjectCreateRequest( + templateId: 'test-template', + title: 'Test Project Name', + ); + + $this->assertSame('Test Project Name', $request->getName()); + } + + public function testValidateWithValidRequest(): void + { + $request = new ProjectCreateRequest( + templateId: 'valid-template', + title: 'Valid Project', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithEmptyTemplateId(): void + { + $request = new ProjectCreateRequest( + templateId: '', + title: 'Some Project', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + } + + public function testValidateWithEmptyTitle(): void + { + $request = new ProjectCreateRequest( + templateId: 'valid-template', + title: '', + ); + + $errors = $request->validate(); + + $this->assertContains('Project title cannot be empty', $errors); + } + + public function testValidateWithMultipleErrors(): void + { + $request = new ProjectCreateRequest( + templateId: '', + title: '', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + $this->assertContains('Project title cannot be empty', $errors); + $this->assertCount(2, $errors); + } + + public function testValidateWithWhitespaceOnlyTitle(): void + { + $request = new ProjectCreateRequest( + templateId: 'valid-template', + title: " \t\n ", + ); + + $errors = $request->validate(); + + $this->assertContains('Project title cannot be empty', $errors); + } + + public function testValidateWithWhitespaceOnlyTemplateId(): void + { + $request = new ProjectCreateRequest( + templateId: " \n\t ", + title: 'Valid Title', + ); + + $errors = $request->validate(); + + $this->assertContains('Template ID cannot be empty', $errors); + } + + public function testValidateWithValidTitleContainingWhitespace(): void + { + $request = new ProjectCreateRequest( + templateId: 'valid-template', + title: ' Valid Title with Spaces ', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateAcceptsEmptyOptionalFields(): void + { + $request = new ProjectCreateRequest( + 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 ProjectCreateRequest( + templateId: 'template', + title: 'Project', + description: $longDescription, + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithSpecialCharactersInFields(): void + { + $request = new ProjectCreateRequest( + 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 ProjectCreateRequest( + 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 ProjectCreateRequest( + 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/Drafling/MCP/DTO/ProjectUpdateRequestTest.php b/tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php new file mode 100644 index 00000000..b0c06c44 --- /dev/null +++ b/tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php @@ -0,0 +1,212 @@ +assertSame('proj_123', $request->projectId); + $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 ProjectUpdateRequest(projectId: 'proj_456'); + + $this->assertSame('proj_456', $request->projectId); + $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 testGetName(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_789', + title: 'Test Project', + ); + + $this->assertSame('Test Project', $request->getName()); + } + + public function testGetNameReturnsNull(): void + { + $request = new ProjectUpdateRequest(projectId: 'proj_abc'); + + $this->assertNull($request->getName()); + } + + public function testHasUpdatesReturnsTrueWithTitle(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + title: 'New Title', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithDescription(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + description: 'New description', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithStatus(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + status: 'active', + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithTags(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + tags: ['new-tag'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithEntryDirs(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + entryDirs: ['new-dir'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithMemory(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + memory: ['new memory'], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testHasUpdatesReturnsFalseWithNoUpdates(): void + { + $request = new ProjectUpdateRequest(projectId: 'proj_123'); + + $this->assertFalse($request->hasUpdates()); + } + + public function testHasUpdatesReturnsTrueWithEmptyArrays(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + tags: [], + ); + + $this->assertTrue($request->hasUpdates()); + } + + public function testValidateWithValidRequest(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + title: 'Valid Title', + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testValidateWithEmptyProjectId(): void + { + $request = new ProjectUpdateRequest( + projectId: '', + title: 'Some Title', + ); + + $errors = $request->validate(); + + $this->assertContains('Project ID cannot be empty', $errors); + } + + public function testValidateWithNoUpdates(): void + { + $request = new ProjectUpdateRequest(projectId: 'proj_123'); + + $errors = $request->validate(); + + $this->assertContains('At least one field must be provided for update', $errors); + } + + public function testValidateWithMultipleErrors(): void + { + $request = new ProjectUpdateRequest(projectId: ''); + + $errors = $request->validate(); + + $this->assertContains('Project 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 ProjectUpdateRequest( + projectId: 'proj_valid', + tags: [], + entryDirs: [], + memory: [], + ); + + $errors = $request->validate(); + + $this->assertEmpty($errors); + } + + public function testIsReadonlyDto(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_readonly', + title: 'Original Title', + ); + + // Properties should be readonly (this is ensured by PHP at compile time) + $this->assertSame('proj_readonly', $request->projectId); + $this->assertSame('Original Title', $request->title); + } +} diff --git a/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php b/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php new file mode 100644 index 00000000..94e6856f --- /dev/null +++ b/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php @@ -0,0 +1,412 @@ +projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'This is a new memory entry for the project') + ->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']); + $this->assertSame('proj_123', $responseData['project_id']); + $this->assertSame('Test Project', $responseData['title']); + $this->assertSame('active', $responseData['status']); + $this->assertSame('blog-template', $responseData['project_type']); + $this->assertSame(2, $responseData['memory_count']); + $this->assertSame('This is a new memory entry for the project', $responseData['memory_added']); + + // Check metadata + $this->assertSame('Project description', $responseData['metadata']['description']); + $this->assertSame(['web', 'blog'], $responseData['metadata']['tags']); + $this->assertSame(['posts'], $responseData['metadata']['entry_dirs']); + $this->assertSame(['Existing memory', 'This is a new memory entry for the project'], $responseData['metadata']['memory']); + + // Check timestamp is present + $this->assertArrayHasKey('updated_at', $responseData); + } + + public function testAddMemoryToEmptyMemoryArray(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_empty', + memory: 'First memory entry', + ); + + $updatedProject = new Project( + id: 'proj_empty', + name: 'Empty Memory Project', + description: 'No previous memories', + template: 'simple', + status: 'draft', + tags: [], + entryDirs: [], + memory: ['First memory entry'], + ); + + $projectId = ProjectId::fromString('proj_empty'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'First memory entry') + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertTrue($responseData['success']); + $this->assertSame(1, $responseData['memory_count']); + $this->assertSame(['First memory entry'], $responseData['metadata']['memory']); + } + + public function testValidationErrorEmptyProjectId(): void + { + $request = new AddProjectMemoryRequest( + projectId: '', + memory: 'Valid memory', + ); + + $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->assertContains('Project ID cannot be empty', $responseData['details']); + } + + public function testValidationErrorEmptyMemory(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_123', + memory: '', + ); + + $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->assertContains('Memory entry cannot be empty', $responseData['details']); + } + + public function testValidationErrorMultipleErrors(): void + { + $request = new AddProjectMemoryRequest( + projectId: '', + memory: ' ', + ); + + $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->assertContains('Project ID cannot be empty', $responseData['details']); + $this->assertContains('Memory entry cannot be empty', $responseData['details']); + } + + public function testProjectNotFound(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_nonexistent', + memory: 'Memory for non-existent project', + ); + + $projectId = ProjectId::fromString('proj_nonexistent'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->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("Project 'proj_nonexistent' not found", $responseData['error']); + } + + public function testProjectNotFoundExceptionFromService(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_exception', + memory: 'Memory content', + ); + + $projectId = ProjectId::fromString('proj_exception'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'Memory content') + ->willThrowException(new ProjectNotFoundException('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 AddProjectMemoryRequest( + projectId: 'proj_error', + memory: 'Memory content', + ); + + $projectId = ProjectId::fromString('proj_error'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'Memory content') + ->willThrowException(new DraflingException('Service error occurred')); + + $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 occurred', $responseData['error']); + } + + public function testUnexpectedException(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_unexpected', + memory: 'Memory content', + ); + + $projectId = ProjectId::fromString('proj_unexpected'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'Memory content') + ->willThrowException(new \RuntimeException('Unexpected system 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 add memory to project: Unexpected system error', $responseData['error']); + } + + public function testLoggerIsCalled(): void + { + $request = new AddProjectMemoryRequest( + projectId: 'proj_log', + memory: 'Logged memory entry', + ); + + $updatedProject = new Project( + id: 'proj_log', + name: 'Log Test Project', + description: 'Testing logging', + template: 'test', + status: 'active', + tags: [], + entryDirs: [], + memory: ['Logged memory entry'], + ); + + $projectId = ProjectId::fromString('proj_log'); + + $this->logger + ->expects($this->exactly(2)) + ->method('info') + ->with($this->logicalOr( + $this->equalTo('Adding memory to project'), + $this->equalTo('Memory added to project successfully'), + )); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, 'Logged memory entry') + ->willReturn($updatedProject); + + ($this->toolAction)($request); + } + + public function testMemoryWithSpecialCharacters(): void + { + $specialMemory = 'Memory with special chars: !@#$%^&*()_+-={}[]|\\:";\'<>?,./ and unicode: 🚀 世界'; + + $request = new AddProjectMemoryRequest( + projectId: 'proj_special', + memory: $specialMemory, + ); + + $updatedProject = new Project( + id: 'proj_special', + name: 'Special Chars Project', + description: 'Testing special characters', + template: 'test', + status: 'active', + tags: [], + entryDirs: [], + memory: [$specialMemory], + ); + + $projectId = ProjectId::fromString('proj_special'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('addProjectMemory') + ->with($projectId, $specialMemory) + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $this->assertTrue($responseData['success']); + $this->assertSame($specialMemory, $responseData['memory_added']); + $this->assertSame([$specialMemory], $responseData['metadata']['memory']); + } + + protected function setUp(): void + { + $this->logger = $this->createMock(LoggerInterface::class); + $this->projectService = $this->createMock(ProjectServiceInterface::class); + + $this->toolAction = new AddProjectMemoryToolAction( + $this->logger, + $this->projectService, + ); + } +} diff --git a/tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php b/tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php new file mode 100644 index 00000000..dd32d295 --- /dev/null +++ b/tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php @@ -0,0 +1,373 @@ +projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->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']); + $this->assertSame('proj_123', $responseData['project_id']); + $this->assertSame('Updated Title', $responseData['title']); + $this->assertSame('active', $responseData['status']); + $this->assertSame('blog-template', $responseData['project_type']); + + // Check metadata + $this->assertSame('Updated description', $responseData['metadata']['description']); + $this->assertSame(['web', 'blog'], $responseData['metadata']['tags']); + $this->assertSame(['posts', 'pages'], $responseData['metadata']['entry_dirs']); + $this->assertSame(['Updated memory'], $responseData['metadata']['memory']); + + // Check changes applied + $expectedChanges = ['title', 'description', 'status', 'tags', 'entry_directories', 'memory']; + $this->assertSame($expectedChanges, $responseData['changes_applied']); + } + + public function testPartialProjectUpdate(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_456', + title: 'New Title Only', + ); + + $updatedProject = new Project( + id: 'proj_456', + name: 'New Title Only', + description: 'Original description', + template: 'simple-template', + status: 'draft', + tags: ['existing'], + entryDirs: ['existing-dir'], + memory: ['existing memory'], + ); + + $projectId = ProjectId::fromString('proj_456'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->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']); + $this->assertSame(['title'], $responseData['changes_applied']); + } + + public function testValidationErrors(): void + { + $request = new ProjectUpdateRequest(projectId: ''); // 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 ProjectUpdateRequest( + projectId: 'proj_nonexistent', + title: 'New Title', + ); + + $projectId = ProjectId::fromString('proj_nonexistent'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->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("Project 'proj_nonexistent' not found", $responseData['error']); + } + + public function testProjectNotFoundExceptionFromService(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_exception', + title: 'New Title', + ); + + $projectId = ProjectId::fromString('proj_exception'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->with($projectId, $request) + ->willThrowException(new ProjectNotFoundException('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 ProjectUpdateRequest( + projectId: 'proj_error', + title: 'New Title', + ); + + $projectId = ProjectId::fromString('proj_error'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->with($projectId, $request) + ->willThrowException(new DraflingException('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 ProjectUpdateRequest( + projectId: 'proj_unexpected', + title: 'New Title', + ); + + $projectId = ProjectId::fromString('proj_unexpected'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->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 project: Unexpected error', $responseData['error']); + } + + public function testEmptyArrayUpdatesAreTracked(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_empty', + tags: [], + entryDirs: [], + memory: [], + ); + + $updatedProject = new Project( + id: 'proj_empty', + name: 'Test Project', + description: 'Description', + template: 'template', + status: 'draft', + tags: [], + entryDirs: [], + memory: [], + ); + + $projectId = ProjectId::fromString('proj_empty'); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->with($projectId, $request) + ->willReturn($updatedProject); + + $result = ($this->toolAction)($request); + + $content = $result->content[0]; + $responseData = \json_decode($content->text, true); + + $expectedChanges = ['tags', 'entry_directories', 'memory']; + $this->assertSame($expectedChanges, $responseData['changes_applied']); + } + + public function testLoggerIsCalled(): void + { + $request = new ProjectUpdateRequest( + projectId: 'proj_log', + title: 'Logged Update', + ); + + $updatedProject = new Project( + id: 'proj_log', + name: 'Logged Update', + description: 'desc', + template: 'template', + status: 'draft', + tags: [], + entryDirs: [], + memory: [], + ); + + $projectId = ProjectId::fromString('proj_log'); + + $this->logger + ->expects($this->exactly(2)) + ->method('info') + ->with($this->logicalOr( + $this->equalTo('Updating project'), + $this->equalTo('Project updated successfully'), + )); + + $this->projectService + ->expects($this->once()) + ->method('projectExists') + ->with($projectId) + ->willReturn(true); + + $this->projectService + ->expects($this->once()) + ->method('updateProject') + ->with($projectId, $request) + ->willReturn($updatedProject); + + ($this->toolAction)($request); + } + + protected function setUp(): void + { + $this->logger = $this->createMock(LoggerInterface::class); + $this->projectService = $this->createMock(ProjectServiceInterface::class); + + $this->toolAction = new UpdateProjectToolAction( + $this->logger, + $this->projectService, + ); + } +} diff --git a/tests/src/Unit/Drafling/Service/ProjectServiceTest.php b/tests/src/Unit/Drafling/Service/ProjectServiceTest.php new file mode 100644 index 00000000..95c2da57 --- /dev/null +++ b/tests/src/Unit/Drafling/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('createProject') + ->with($request) + ->willReturn($createdProject); + + // Repository saves project + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->with($createdProject); + + $result = $this->projectService->createProject($request); + + $this->assertSame($createdProject, $result); + } + + public function testCreateProjectWithNonExistentTemplate(): void + { + $request = new ProjectCreateRequest( + 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->createProject($request); + } + + public function testCreateProjectStorageFailure(): void + { + $request = new ProjectCreateRequest( + 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('createProject') + ->with($request) + ->willThrowException(new \RuntimeException('Storage error')); + + $this->expectException(DraflingException::class); + $this->expectExceptionMessage('Failed to create project: Storage error'); + + $this->projectService->createProject($request); + } + + public function testUpdateProjectSuccess(): void + { + $projectId = ProjectId::fromString('proj_123'); + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + title: 'Updated Title', + ); + + $updatedProject = new Project( + 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('updateProject') + ->with($projectId, $request) + ->willReturn($updatedProject); + + // Repository saves updated project + $this->projectRepository + ->expects($this->once()) + ->method('save') + ->with($updatedProject); + + $result = $this->projectService->updateProject($projectId, $request); + + $this->assertSame($updatedProject, $result); + } + + public function testUpdateProjectNotFound(): void + { + $projectId = ProjectId::fromString('proj_nonexistent'); + $request = new ProjectUpdateRequest( + projectId: 'proj_nonexistent', + title: 'Updated Title', + ); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $this->expectException(ProjectNotFoundException::class); + $this->expectExceptionMessage("Project 'proj_nonexistent' not found"); + + $this->projectService->updateProject($projectId, $request); + } + + public function testUpdateProjectStorageFailure(): void + { + $projectId = ProjectId::fromString('proj_123'); + $request = new ProjectUpdateRequest( + projectId: 'proj_123', + title: 'Updated Title', + ); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->storageDriver + ->expects($this->once()) + ->method('updateProject') + ->with($projectId, $request) + ->willThrowException(new \RuntimeException('Update failed')); + + $this->expectException(DraflingException::class); + $this->expectExceptionMessage('Failed to update project: Update failed'); + + $this->projectService->updateProject($projectId, $request); + } + + public function testProjectExists(): void + { + $projectId = ProjectId::fromString('proj_exists'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $result = $this->projectService->projectExists($projectId); + + $this->assertTrue($result); + } + + public function testProjectNotExists(): void + { + $projectId = ProjectId::fromString('proj_notexists'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $result = $this->projectService->projectExists($projectId); + + $this->assertFalse($result); + } + + public function testGetProject(): void + { + $projectId = ProjectId::fromString('proj_get'); + $project = new Project( + 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->getProject($projectId); + + $this->assertSame($project, $result); + } + + public function testGetProjectNotFound(): void + { + $projectId = ProjectId::fromString('proj_notfound'); + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn(null); + + $result = $this->projectService->getProject($projectId); + + $this->assertNull($result); + } + + public function testListProjects(): void + { + $filters = ['status' => 'active']; + $projects = [ + new Project( + id: 'proj_1', + name: 'Project 1', + description: 'desc1', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + ), + new Project( + 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->listProjects($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(DraflingException::class); + $this->expectExceptionMessage('Failed to list projects: Database error'); + + $this->projectService->listProjects($filters); + } + + public function testDeleteProjectSuccess(): void + { + $projectId = ProjectId::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('deleteProject') + ->with($projectId) + ->willReturn(true); + + // Repository removes project + $this->projectRepository + ->expects($this->once()) + ->method('delete') + ->with($projectId); + + $result = $this->projectService->deleteProject($projectId); + + $this->assertTrue($result); + } + + public function testDeleteProjectNotFound(): void + { + $projectId = ProjectId::fromString('proj_notexist'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(false); + + $result = $this->projectService->deleteProject($projectId); + + $this->assertFalse($result); + } + + public function testDeleteProjectStorageFailure(): void + { + $projectId = ProjectId::fromString('proj_storage_fail'); + + $this->projectRepository + ->expects($this->once()) + ->method('exists') + ->with($projectId) + ->willReturn(true); + + $this->storageDriver + ->expects($this->once()) + ->method('deleteProject') + ->with($projectId) + ->willThrowException(new \RuntimeException('Delete failed')); + + $this->expectException(DraflingException::class); + $this->expectExceptionMessage('Failed to delete project: Delete failed'); + + $this->projectService->deleteProject($projectId); + } + + public function testAddProjectMemorySuccess(): void + { + $projectId = ProjectId::fromString('proj_memory'); + $memory = 'New memory entry'; + + $originalProject = new Project( + id: 'proj_memory', + name: 'Memory Test', + description: 'desc', + template: 'blog', + status: 'active', + tags: [], + entryDirs: [], + memory: ['existing memory'], + ); + + $updatedProject = new Project( + 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->addProjectMemory($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 = ProjectId::fromString('proj_notexist'); + $memory = 'Some memory'; + + $this->projectRepository + ->expects($this->once()) + ->method('findById') + ->with($projectId) + ->willReturn(null); + + $this->expectException(ProjectNotFoundException::class); + $this->expectExceptionMessage("Project 'proj_notexist' not found"); + + $this->projectService->addProjectMemory($projectId, $memory); + } + + public function testAddProjectMemoryRepositoryFailure(): void + { + $projectId = ProjectId::fromString('proj_memory_fail'); + $memory = 'Memory content'; + + $project = new Project( + 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(DraflingException::class); + $this->expectExceptionMessage('Failed to add memory to project: Save failed'); + + $this->projectService->addProjectMemory($projectId, $memory); + } + + protected function setUp(): void + { + $this->projectRepository = $this->createMock(ProjectRepositoryInterface::class); + $this->templateService = $this->createMock(TemplateServiceInterface::class); + $this->storageDriver = $this->createMock(StorageDriverInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->projectService = new ProjectService( + $this->projectRepository, + $this->templateService, + $this->storageDriver, + $this->logger, + ); + } +} From 4083d6551ab5deda46eacdad0367bb887881c013 Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 24 Sep 2025 01:10:35 +0400 Subject: [PATCH 11/16] refactor(drafling): replace `CallToolResult` with `ToolResult` across actions for improved consistency and error handling --- .../MCP/Tools/AddProjectMemoryToolAction.php | 54 ++------------ .../MCP/Tools/CreateEntryToolAction.php | 54 ++------------ .../MCP/Tools/CreateProjectToolAction.php | 54 ++------------ .../MCP/Tools/GetProjectToolAction.php | 54 ++------------ .../MCP/Tools/ListEntriesToolAction.php | 54 ++------------ .../MCP/Tools/ListProjectsToolAction.php | 36 ++-------- .../MCP/Tools/ListTemplatesToolAction.php | 36 ++-------- .../MCP/Tools/ReadEntryToolAction.php | 72 +++---------------- .../MCP/Tools/UpdateEntryToolAction.php | 72 +++---------------- .../MCP/Tools/UpdateProjectToolAction.php | 54 ++------------ src/McpServer/Action/ToolResult.php | 69 ++++++++++++++++++ .../Action/Tools/Context/ContextAction.php | 5 +- .../Action/Tools/Context/ContextGetAction.php | 26 ++----- .../Tools/Context/ContextRequestAction.php | 13 +--- .../Tools/Docs/FetchLibraryDocsAction.php | 22 ++---- .../Action/Tools/Docs/LibrarySearchAction.php | 24 ++----- .../Tools/Filesystem/DirectoryListAction.php | 26 ++----- .../Tools/Filesystem/FileApplyPatchAction.php | 38 ++-------- .../Tools/Filesystem/FileMoveAction.php | 64 ++++------------- .../Tools/Filesystem/FileReadAction.php | 38 ++-------- .../Tools/Filesystem/FileWriteAction.php | 38 ++-------- .../Action/Tools/Git/GitAddAction.php | 32 ++------- .../Action/Tools/Git/GitCommitAction.php | 38 ++-------- .../Action/Tools/Git/GitStatusAction.php | 26 ++----- .../Tools/Prompts/GetPromptToolAction.php | 31 +++----- .../Tools/Prompts/ListPromptsToolAction.php | 18 ++--- .../Actions/ProjectSwitchToolAction.php | 32 +++------ .../Actions/ProjectsListToolAction.php | 14 ++-- .../Tools/Filesystem/FileWriteActionTest.php | 16 +++-- 29 files changed, 259 insertions(+), 851 deletions(-) create mode 100644 src/McpServer/Action/ToolResult.php diff --git a/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php b/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php index d640b338..bbf795e3 100644 --- a/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php +++ b/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php @@ -11,9 +11,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -41,28 +41,13 @@ public function __invoke(AddProjectMemoryRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Add memory to project using domain service @@ -92,11 +77,7 @@ public function __invoke(AddProjectMemoryRequest $request): CallToolResult ], ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -104,14 +85,7 @@ public function __invoke(AddProjectMemoryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error during memory addition', [ @@ -119,14 +93,7 @@ public function __invoke(AddProjectMemoryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error adding memory to project', [ @@ -134,14 +101,7 @@ public function __invoke(AddProjectMemoryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to add memory to project: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to add memory to project: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/CreateEntryToolAction.php b/src/Drafling/MCP/Tools/CreateEntryToolAction.php index 73e9131c..be115d91 100644 --- a/src/Drafling/MCP/Tools/CreateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/CreateEntryToolAction.php @@ -12,9 +12,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -45,28 +45,13 @@ public function __invoke(EntryCreateRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Create entry using domain service @@ -90,11 +75,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'created_at' => $entry->createdAt->format('c'), ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -102,14 +83,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error during entry creation', [ @@ -117,14 +91,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error creating entry', [ @@ -132,14 +99,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to create entry: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to create entry: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/CreateProjectToolAction.php b/src/Drafling/MCP/Tools/CreateProjectToolAction.php index 641ddbac..ec4b406a 100644 --- a/src/Drafling/MCP/Tools/CreateProjectToolAction.php +++ b/src/Drafling/MCP/Tools/CreateProjectToolAction.php @@ -10,11 +10,11 @@ use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; +use Butschster\ContextGenerator\McpServer\Action\ToolResult; 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( @@ -43,28 +43,13 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify template exists $templateKey = TemplateKey::fromString($request->templateId); if (!$this->templateService->templateExists($templateKey)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Template '{$request->templateId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Template '{$request->templateId}' not found"); } // Create project using domain service @@ -85,11 +70,7 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult 'created_at' => (new \DateTime())->format('c'), ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (TemplateNotFoundException $e) { $this->logger->error('Template not found', [ @@ -97,14 +78,7 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error during project creation', [ @@ -112,14 +86,7 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error creating project', [ @@ -127,14 +94,7 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to create project: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to create project: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/GetProjectToolAction.php b/src/Drafling/MCP/Tools/GetProjectToolAction.php index 95a6d7ac..3f0bfa0d 100644 --- a/src/Drafling/MCP/Tools/GetProjectToolAction.php +++ b/src/Drafling/MCP/Tools/GetProjectToolAction.php @@ -13,9 +13,9 @@ use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; 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( @@ -43,15 +43,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Get project @@ -59,14 +51,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult $project = $this->projectService->getProject($projectId); if ($project === null) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->id}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->id}' not found"); } $this->logger->info('Project retrieved successfully', [ @@ -92,11 +77,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'template' => $template, ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -104,14 +85,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error getting project', [ @@ -119,14 +93,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error getting project', [ @@ -134,14 +101,7 @@ public function __invoke(GetProjectRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to get project: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to get project: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/ListEntriesToolAction.php b/src/Drafling/MCP/Tools/ListEntriesToolAction.php index 64051b0f..388289d5 100644 --- a/src/Drafling/MCP/Tools/ListEntriesToolAction.php +++ b/src/Drafling/MCP/Tools/ListEntriesToolAction.php @@ -13,9 +13,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -47,28 +47,13 @@ public function __invoke(ListEntriesRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Get entries with filters @@ -110,11 +95,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult 'filters_applied' => $request->hasFilters(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -122,14 +103,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error listing entries', [ @@ -137,14 +111,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error listing entries', [ @@ -152,14 +119,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to list entries: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to list entries: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/ListProjectsToolAction.php b/src/Drafling/MCP/Tools/ListProjectsToolAction.php index d395cb15..1410e2e5 100644 --- a/src/Drafling/MCP/Tools/ListProjectsToolAction.php +++ b/src/Drafling/MCP/Tools/ListProjectsToolAction.php @@ -9,9 +9,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -42,15 +42,7 @@ public function __invoke(ListProjectsRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Get projects with filters @@ -88,39 +80,21 @@ public function __invoke(ListProjectsRequest $request): CallToolResult 'filters_applied' => $request->hasFilters(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (DraflingException $e) { $this->logger->error('Drafling error listing projects', [ 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error listing projects', [ 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to list projects: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to list projects: ' . $e->getMessage()); } } diff --git a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php index b9143a9f..42a9f76a 100644 --- a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php +++ b/src/Drafling/MCP/Tools/ListTemplatesToolAction.php @@ -9,9 +9,9 @@ use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; 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( @@ -41,15 +41,7 @@ public function __invoke(ListTemplatesRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Get all templates @@ -68,39 +60,21 @@ public function __invoke(ListTemplatesRequest $request): CallToolResult 'filters_applied' => $request->hasFilters(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (DraflingException $e) { $this->logger->error('Drafling error listing templates', [ 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error listing templates', [ 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to list templates: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to list templates: ' . $e->getMessage()); } } diff --git a/src/Drafling/MCP/Tools/ReadEntryToolAction.php b/src/Drafling/MCP/Tools/ReadEntryToolAction.php index c4215ffb..7709e147 100644 --- a/src/Drafling/MCP/Tools/ReadEntryToolAction.php +++ b/src/Drafling/MCP/Tools/ReadEntryToolAction.php @@ -14,9 +14,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -45,28 +45,13 @@ public function __invoke(ReadEntryRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Get the entry @@ -74,14 +59,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult $entry = $this->entryService->getEntry($projectId, $entryId); if ($entry === null) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Entry '{$request->entryId}' not found in project '{$request->projectId}'", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Entry '{$request->entryId}' not found in project '{$request->projectId}'"); } $this->logger->info('Entry read successfully', [ @@ -90,11 +68,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult 'title' => $entry->title, ]); - return new CallToolResult([ - new TextContent( - text: \json_encode($entry, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($entry); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -102,14 +76,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (EntryNotFoundException $e) { $this->logger->error('Entry not found', [ @@ -118,14 +85,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error reading entry', [ @@ -134,14 +94,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error reading entry', [ @@ -150,14 +103,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to read entry: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to read entry: ' . $e->getMessage()); } } } diff --git a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php index 1e963d65..ccfff752 100644 --- a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateEntryToolAction.php @@ -14,9 +14,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -51,41 +51,19 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Verify entry exists $entryId = EntryId::fromString($request->entryId); if (!$this->entryService->entryExists($projectId, $entryId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Entry '{$request->entryId}' not found in project '{$request->projectId}'", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Entry '{$request->entryId}' not found in project '{$request->projectId}'"); } // Update entry using domain service @@ -111,11 +89,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'changes_applied' => $this->getAppliedChanges($request), ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -123,14 +97,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (EntryNotFoundException $e) { $this->logger->error('Entry not found', [ @@ -139,14 +106,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error during entry update', [ @@ -155,14 +115,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error updating entry', [ @@ -171,14 +124,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to update entry: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to update entry: ' . $e->getMessage()); } } diff --git a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php index b9bf54d7..7dd60a74 100644 --- a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php +++ b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php @@ -11,9 +11,9 @@ use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; 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( @@ -46,28 +46,13 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult // Validate request $validationErrors = $request->validate(); if (!empty($validationErrors)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Validation failed', - 'details' => $validationErrors, - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::validationError($validationErrors); } // Verify project exists $projectId = ProjectId::fromString($request->projectId); if (!$this->projectService->projectExists($projectId)) { - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => "Project '{$request->projectId}' not found", - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error("Project '{$request->projectId}' not found"); } // Update project using domain service @@ -96,11 +81,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'changes_applied' => $this->getAppliedChanges($request), ]; - return new CallToolResult([ - new TextContent( - text: \json_encode($response, JSON_PRETTY_PRINT), - ), - ]); + return ToolResult::success($response); } catch (ProjectNotFoundException $e) { $this->logger->error('Project not found', [ @@ -108,14 +89,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (DraflingException $e) { $this->logger->error('Drafling error during project update', [ @@ -123,14 +97,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unexpected error updating project', [ @@ -138,14 +105,7 @@ public function __invoke(ProjectUpdateRequest $request): CallToolResult 'error' => $e->getMessage(), ]); - return new CallToolResult([ - new TextContent( - text: \json_encode([ - 'success' => false, - 'error' => 'Failed to update project: ' . $e->getMessage(), - ], JSON_PRETTY_PRINT), - ), - ], isError: true); + return ToolResult::error('Failed to update project: ' . $e->getMessage()); } } 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/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/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] From e2944ef0298c30b04a3d055a977efd891c952f94 Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 24 Sep 2025 01:39:34 +0400 Subject: [PATCH 12/16] test(drafling): add unit and feature tests for storage and project handling --- .../Storage/FileStorage/DirectoryScanner.php | 3 +- .../Storage/FileStorage/FrontmatterParser.php | 96 ------ .../docs/technical/api_design.md | 30 ++ .../features/user_story/sample_story.md | 27 ++ .../projects/test_project_1/project.yaml | 11 + .../tests/integration/auth_test.md | 32 ++ .../projects/test_project_2/project.yaml | 9 + .../Drafling/FileStorage/templates/basic.yaml | 35 ++ .../FileStorage/templates/minimal.yaml | 17 + .../FileStorage/DirectoryScannerTest.php | 312 +++++++++++++++++ .../FileStorage/FrontmatterParserTest.php | 326 ++++++++++++++++++ .../Unit/McpServer/Action/ToolResultTest.php | 267 ++++++++++++++ 12 files changed, 1067 insertions(+), 98 deletions(-) create mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_1/docs/technical/api_design.md create mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_1/features/user_story/sample_story.md create mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_1/project.yaml create mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_1/tests/integration/auth_test.md create mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_2/project.yaml create mode 100644 tests/fixtures/Drafling/FileStorage/templates/basic.yaml create mode 100644 tests/fixtures/Drafling/FileStorage/templates/minimal.yaml create mode 100644 tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php create mode 100644 tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php create mode 100644 tests/src/Unit/McpServer/Action/ToolResultTest.php diff --git a/src/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Drafling/Storage/FileStorage/DirectoryScanner.php index 7a186082..c3960c22 100644 --- a/src/Drafling/Storage/FileStorage/DirectoryScanner.php +++ b/src/Drafling/Storage/FileStorage/DirectoryScanner.php @@ -59,10 +59,9 @@ public function scanProjects(string $projectsPath): array * Scan project directory for entry files * * @param string $projectPath Path to project directory - * @param array $entryDirs Entry directories to scan (relative to project) * @return array Array of entry file paths */ - public function scanEntries(string $projectPath, array $entryDirs = []): array + public function scanEntries(string $projectPath): array { if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { return []; diff --git a/src/Drafling/Storage/FileStorage/FrontmatterParser.php b/src/Drafling/Storage/FileStorage/FrontmatterParser.php index ddac1c70..27632e75 100644 --- a/src/Drafling/Storage/FileStorage/FrontmatterParser.php +++ b/src/Drafling/Storage/FileStorage/FrontmatterParser.php @@ -101,100 +101,4 @@ public function extractFrontmatter(string $content): array { return $this->parse($content)['frontmatter']; } - - /** - * Extract only the markdown content from file content - */ - public function extractContent(string $content): string - { - return $this->parse($content)['content']; - } - - /** - * Update frontmatter while preserving content - */ - public function updateFrontmatter(string $originalContent, array $newFrontmatter): string - { - $parsed = $this->parse($originalContent); - return $this->combine($newFrontmatter, $parsed['content']); - } - - /** - * Update content while preserving frontmatter - */ - public function updateContent(string $originalContent, string $newContent): string - { - $parsed = $this->parse($originalContent); - return $this->combine($parsed['frontmatter'], $newContent); - } - - /** - * Merge frontmatter updates with existing frontmatter - */ - public function mergeFrontmatter(string $originalContent, array $updates): string - { - $parsed = $this->parse($originalContent); - $mergedFrontmatter = \array_merge($parsed['frontmatter'], $updates); - return $this->combine($mergedFrontmatter, $parsed['content']); - } - - /** - * Validate YAML frontmatter structure - */ - public function validateFrontmatter(array $frontmatter): array - { - $errors = []; - - // Validate required fields - $requiredFields = ['entry_id', 'title', 'entry_type', 'category', 'status']; - foreach ($requiredFields as $field) { - if (!isset($frontmatter[$field]) || empty($frontmatter[$field])) { - $errors[] = "Missing required field: {$field}"; - } - } - - // Validate date fields - $dateFields = ['created_at', 'updated_at']; - foreach ($dateFields as $field) { - if (isset($frontmatter[$field]) && !\is_string($frontmatter[$field])) { - $errors[] = "Field '{$field}' must be a valid ISO 8601 date string"; - } - } - - // Validate array fields - $arrayFields = ['tags']; - foreach ($arrayFields as $field) { - if (isset($frontmatter[$field]) && !\is_array($frontmatter[$field])) { - $errors[] = "Field '{$field}' must be an array"; - } - } - - return $errors; - } - - /** - * Normalize frontmatter data types - */ - public function normalizeFrontmatter(array $frontmatter): array - { - // Ensure tags is an array - if (isset($frontmatter['tags']) && !\is_array($frontmatter['tags'])) { - $frontmatter['tags'] = []; - } - - // Ensure dates are properly formatted - $dateFields = ['created_at', 'updated_at']; - foreach ($dateFields as $field) { - if (isset($frontmatter[$field]) && \is_string($frontmatter[$field])) { - try { - $date = new \DateTime($frontmatter[$field]); - $frontmatter[$field] = $date->format('c'); // ISO 8601 format - } catch (\Exception) { - // Invalid date - leave as is, validation will catch it - } - } - } - - return $frontmatter; - } } diff --git a/tests/fixtures/Drafling/FileStorage/projects/test_project_1/docs/technical/api_design.md b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/docs/technical/api_design.md new file mode 100644 index 00000000..f13f40b3 --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/docs/technical/api_design.md @@ -0,0 +1,30 @@ +--- +entry_id: "entry_002" +title: "API Design Document" +description: "RESTful API design specifications" +entry_type: "technical" +category: "docs" +status: "draft" +created_at: "2023-01-01T12:00:00Z" +updated_at: "2023-01-01T12:00:00Z" +tags: ["api", "design"] +--- + +# API Design Document + +## Overview + +This document outlines the RESTful API design for the application. + +## Endpoints + +### Authentication +- POST /api/auth/login +- POST /api/auth/logout +- POST /api/auth/refresh + +### Users +- GET /api/users +- POST /api/users +- PUT /api/users/{id} +- DELETE /api/users/{id} \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/projects/test_project_1/features/user_story/sample_story.md b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/features/user_story/sample_story.md new file mode 100644 index 00000000..5fa7dabe --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/features/user_story/sample_story.md @@ -0,0 +1,27 @@ +--- +entry_id: "entry_001" +title: "User Login Feature" +description: "Implement user authentication system" +entry_type: "user_story" +category: "features" +status: "in_progress" +created_at: "2023-01-01T10:00:00Z" +updated_at: "2023-01-02T15:30:00Z" +tags: ["authentication", "security"] +--- + +# User Login Feature + +As a user, I want to be able to log in to the system so that I can access my personal dashboard. + +## Acceptance Criteria + +- User can enter email and password +- System validates credentials +- User is redirected to dashboard on success +- Error message shown on failure + +## Implementation Notes + +- Use JWT tokens for session management +- Implement rate limiting for security \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/projects/test_project_1/project.yaml b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/project.yaml new file mode 100644 index 00000000..267fb3d3 --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/project.yaml @@ -0,0 +1,11 @@ +project: + name: "Test Project 1" + description: "A test project for file storage testing" + template: "basic" + status: "active" + tags: ["test", "feature"] + memory: + - "Project initialization completed" + - "Basic structure set up" + entries: + dirs: ["features", "docs", "tests"] \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/projects/test_project_1/tests/integration/auth_test.md b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/tests/integration/auth_test.md new file mode 100644 index 00000000..3559363f --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/projects/test_project_1/tests/integration/auth_test.md @@ -0,0 +1,32 @@ +--- +entry_id: "entry_003" +title: "Authentication Integration Test" +description: "End-to-end authentication testing" +entry_type: "test_case" +category: "tests" +status: "pending" +created_at: "2023-01-03T09:00:00Z" +updated_at: "2023-01-03T09:00:00Z" +tags: ["integration", "auth"] +--- + +# Authentication Integration Test + +## Test Scenario + +Test the complete authentication flow from login to protected resource access. + +## Test Steps + +1. Navigate to login page +2. Enter valid credentials +3. Submit form +4. Verify redirect to dashboard +5. Access protected resource +6. Verify successful access + +## Expected Results + +- User successfully logged in +- Dashboard displayed +- Protected resources accessible \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/projects/test_project_2/project.yaml b/tests/fixtures/Drafling/FileStorage/projects/test_project_2/project.yaml new file mode 100644 index 00000000..33d1c4a9 --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/projects/test_project_2/project.yaml @@ -0,0 +1,9 @@ +project: + name: "Empty Project" + description: "A project with no entries for testing" + template: "minimal" + status: "draft" + tags: [] + memory: [] + entries: + dirs: ["notes"] \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/templates/basic.yaml b/tests/fixtures/Drafling/FileStorage/templates/basic.yaml new file mode 100644 index 00000000..7e2bedf1 --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/templates/basic.yaml @@ -0,0 +1,35 @@ +template: + key: "basic" + name: "Basic Template" + description: "A basic project template" + categories: + - name: "features" + displayName: "Features" + description: "Feature specifications and user stories" + - name: "docs" + displayName: "Documentation" + description: "Technical documentation" + - name: "tests" + displayName: "Tests" + description: "Test cases and scenarios" + entryTypes: + - key: "user_story" + displayName: "User Story" + description: "User story specification" + statuses: + - value: "draft" + displayName: "Draft" + - value: "in_progress" + displayName: "In Progress" + - value: "completed" + displayName: "Completed" + - key: "technical" + displayName: "Technical Doc" + description: "Technical documentation" + statuses: + - value: "draft" + displayName: "Draft" + - value: "review" + displayName: "Under Review" + - value: "approved" + displayName: "Approved" \ No newline at end of file diff --git a/tests/fixtures/Drafling/FileStorage/templates/minimal.yaml b/tests/fixtures/Drafling/FileStorage/templates/minimal.yaml new file mode 100644 index 00000000..dccecad1 --- /dev/null +++ b/tests/fixtures/Drafling/FileStorage/templates/minimal.yaml @@ -0,0 +1,17 @@ +template: + key: "minimal" + name: "Minimal Template" + description: "A minimal project template with basic structure" + categories: + - name: "notes" + displayName: "Notes" + description: "General notes and documentation" + entryTypes: + - key: "note" + displayName: "Note" + description: "General note or documentation" + statuses: + - value: "draft" + displayName: "Draft" + - value: "published" + displayName: "Published" \ No newline at end of file diff --git a/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php b/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php new file mode 100644 index 00000000..bf33480f --- /dev/null +++ b/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php @@ -0,0 +1,312 @@ +tempPath . '/projects'; + $projects = $this->scanner->scanProjects($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 . '/project.yaml'); + } + } + + #[Test] + public function it_returns_empty_array_for_nonexistent_projects_path(): void + { + $nonexistentPath = $this->tempPath . '/nonexistent'; + $projects = $this->scanner->scanProjects($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->scanProjects($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($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/Drafling/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/Drafling/Storage/FileStorage/FrontmatterParserTest.php b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php new file mode 100644 index 00000000..3d2e70de --- /dev/null +++ b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php @@ -0,0 +1,326 @@ +parser = new FrontmatterParser(); + } + + #[Test] + public function it_parses_content_with_frontmatter(): void + { + $content = <<<'MARKDOWN' +--- +title: "Test Entry" +description: "A test entry" +status: "draft" +tags: ["test", "example"] +created_at: "2023-01-01T10:00:00Z" +--- + +# Test Entry Content + +This is the main content of the entry. + +## Section 1 + +Some more content here. +MARKDOWN; + + $result = $this->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']); + } +} \ No newline at end of file diff --git a/tests/src/Unit/McpServer/Action/ToolResultTest.php b/tests/src/Unit/McpServer/Action/ToolResultTest.php new file mode 100644 index 00000000..e8482236 --- /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); + } +} \ No newline at end of file From f4da8d624ae535a0b490da274e0a7a5470e17898 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 23 Sep 2025 21:40:04 +0000 Subject: [PATCH 13/16] style(php-cs-fixer): fix coding standards --- .../Storage/FileStorage/FrontmatterParserTest.php | 14 +++++++------- tests/src/Unit/McpServer/Action/ToolResultTest.php | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php index 3d2e70de..63ed4f1d 100644 --- a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php +++ b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php @@ -12,12 +12,6 @@ final class FrontmatterParserTest extends TestCase { private FrontmatterParser $parser; - protected function setUp(): void - { - parent::setUp(); - $this->parser = new FrontmatterParser(); - } - #[Test] public function it_parses_content_with_frontmatter(): void { @@ -323,4 +317,10 @@ function test() { $this->assertEquals($expectedContent, $result['content']); } -} \ No newline at end of file + + 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 index e8482236..1895c6b3 100644 --- a/tests/src/Unit/McpServer/Action/ToolResultTest.php +++ b/tests/src/Unit/McpServer/Action/ToolResultTest.php @@ -264,4 +264,4 @@ public function error_handles_special_characters_in_message(): void 'error' => $errorWithSpecialChars, ], $decodedContent); } -} \ No newline at end of file +} From 4dc24c39676c84a75a74468bc55c446a37a7603d Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 24 Sep 2025 01:41:55 +0400 Subject: [PATCH 14/16] cs fix --- .../FileStorage/FileEntryRepository.php | 7 ++--- .../FileStorage/FileStorageRepositoryBase.php | 27 +++++-------------- .../FileStorage/DirectoryScannerTest.php | 2 +- .../FileStorage/FrontmatterParserTest.php | 14 +++++----- .../Unit/McpServer/Action/ToolResultTest.php | 2 +- 5 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Drafling/Storage/FileStorage/FileEntryRepository.php index 1c9797d5..b692e5e6 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Drafling/Storage/FileStorage/FileEntryRepository.php @@ -26,9 +26,7 @@ public function findByProject(ProjectId $projectId, array $filters = []): array $entries = []; try { - // Get entry directories from project or scan all directories - $entryDirs = $this->getProjectEntryDirs($projectPath); - $entryFiles = $this->directoryScanner->scanEntries($projectPath, $entryDirs); + $entryFiles = $this->directoryScanner->scanEntries($projectPath); foreach ($entryFiles as $filePath) { try { @@ -173,8 +171,7 @@ private function getProjectEntryDirs(string $projectPath): array */ private function findEntryFile(string $projectPath, string $entryId): ?string { - $entryDirs = $this->getProjectEntryDirs($projectPath); - $entryFiles = $this->directoryScanner->scanEntries($projectPath, $entryDirs); + $entryFiles = $this->directoryScanner->scanEntries($projectPath); foreach ($entryFiles as $filePath) { try { diff --git a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php index 5b8d0c16..5853393d 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php +++ b/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php @@ -8,6 +8,8 @@ use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; use Spiral\Files\FilesInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; /** * Abstract base class for file-based repositories @@ -62,8 +64,8 @@ protected function readYamlFile(string $filePath): array $content = $this->files->read($filePath); try { - return \Symfony\Component\Yaml\Yaml::parse($content) ?? []; - } catch (\Symfony\Component\Yaml\Exception\ParseException $e) { + return Yaml::parse($content) ?? []; + } catch (ParseException $e) { throw new \RuntimeException("Failed to parse YAML file '{$filePath}': {$e->getMessage()}", 0, $e); } } @@ -73,11 +75,11 @@ protected function readYamlFile(string $filePath): array */ protected function writeYamlFile(string $filePath, array $data): void { - $yamlContent = \Symfony\Component\Yaml\Yaml::dump( + $yamlContent = Yaml::dump( $data, 4, 2, - \Symfony\Component\Yaml\Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, + Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, ); $this->ensureDirectory(\dirname($filePath)); @@ -125,23 +127,6 @@ protected function generateFilename(string $title, string $extension = 'md'): st return $slug . '.' . $extension; } - /** - * Generate unique ID - */ - protected function generateId(string $prefix = ''): string - { - $id = \uniqid($prefix, true); - return \str_replace('.', '_', $id); - } - - /** - * Get current ISO 8601 timestamp - */ - protected function getCurrentTimestamp(): string - { - return (new \DateTime())->format('c'); - } - /** * Log operation with context */ diff --git a/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php b/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php index bf33480f..5926a116 100644 --- a/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php +++ b/tests/src/Feature/Drafling/Storage/FileStorage/DirectoryScannerTest.php @@ -181,7 +181,7 @@ public function it_handles_deeply_nested_entry_files(): void $nestedEntryFound = false; foreach ($entries as $entryPath) { - if (\str_contains($entryPath, 'nested_entry.md')) { + if (\str_contains((string) $entryPath, 'nested_entry.md')) { $nestedEntryFound = true; break; } diff --git a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php index 3d2e70de..63ed4f1d 100644 --- a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php +++ b/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php @@ -12,12 +12,6 @@ final class FrontmatterParserTest extends TestCase { private FrontmatterParser $parser; - protected function setUp(): void - { - parent::setUp(); - $this->parser = new FrontmatterParser(); - } - #[Test] public function it_parses_content_with_frontmatter(): void { @@ -323,4 +317,10 @@ function test() { $this->assertEquals($expectedContent, $result['content']); } -} \ No newline at end of file + + 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 index e8482236..1895c6b3 100644 --- a/tests/src/Unit/McpServer/Action/ToolResultTest.php +++ b/tests/src/Unit/McpServer/Action/ToolResultTest.php @@ -264,4 +264,4 @@ public function error_handles_special_characters_in_message(): void 'error' => $errorWithSpecialChars, ], $decodedContent); } -} \ No newline at end of file +} From 3b536e25639fd4bcf3a14c5484c673f314136733 Mon Sep 17 00:00:00 2001 From: butschster Date: Thu, 25 Sep 2025 00:17:55 +0400 Subject: [PATCH 15/16] refactor: rename Drafling -> Research --- .gitignore | 2 +- composer.json | 3 +- context.yaml | 20 +- src/Application/Kernel.php | 6 +- src/Drafling/Console/ProjectListCommand.php | 79 --- src/Drafling/Console/TemplateListCommand.php | 133 ----- src/Drafling/Domain/Model/Project.php | 126 ----- src/Drafling/Domain/ValueObject/ProjectId.php | 53 -- src/Drafling/DraflingBootloader.php | 96 ---- src/Drafling/Exception/DraflingException.php | 10 - .../Exception/EntryNotFoundException.php | 10 - .../Exception/ProjectNotFoundException.php | 10 - .../Exception/TemplateNotFoundException.php | 10 - .../MCP/DTO/AddProjectMemoryRequest.php | 38 -- .../MCP/Tools/AddProjectMemoryToolAction.php | 107 ---- .../MCP/Tools/GetProjectToolAction.php | 107 ---- .../MCP/Tools/ListProjectsToolAction.php | 150 ------ .../MCP/Tools/UpdateProjectToolAction.php | 145 ------ .../Repository/EntryRepositoryInterface.php | 43 -- .../Repository/ProjectRepositoryInterface.php | 42 -- src/Drafling/Service/DraflingService.php | 342 ------------- .../Service/DraflingServiceInterface.php | 47 -- .../Service/EntryServiceInterface.php | 70 --- src/Drafling/Service/ProjectService.php | 289 ----------- .../Service/ProjectServiceInterface.php | 64 --- .../Storage/AbstractStorageDriver.php | 137 ------ .../FileStorage/FileProjectRepository.php | 218 --------- .../Storage/FileStorage/FileStorageDriver.php | 458 ------------------ src/Drafling/Storage/StorageBootloader.php | 82 ---- .../Storage/StorageDriverInterface.php | 74 --- src/McpServer/McpServerBootloader.php | 26 +- .../Config/ResearchConfig.php} | 20 +- .../Config/ResearchConfigInterface.php} | 16 +- .../Console/ResearchInfoCommand.php} | 109 ++--- src/Research/Console/ResearchListCommand.php | 73 +++ src/Research/Console/TemplateListCommand.php | 88 ++++ .../Domain/Model/Category.php | 2 +- .../Domain/Model/Entry.php | 13 +- .../Domain/Model/EntryType.php | 2 +- src/Research/Domain/Model/Research.php | 91 ++++ .../Domain/Model/Status.php | 2 +- .../Domain/Model/Template.php | 2 +- .../Domain/ValueObject/EntryId.php | 7 +- .../Domain/ValueObject/ResearchId.php | 36 ++ .../Domain/ValueObject/TemplateKey.php | 7 +- .../Exception/EntryNotFoundException.php | 10 + src/Research/Exception/ResearchException.php | 7 + .../Exception/ResearchNotFoundException.php | 7 + .../Exception/TemplateNotFoundException.php | 10 + .../Exception/ValidationException.php | 4 +- .../MCP/DTO/EntryCreateRequest.php | 12 +- .../MCP/DTO/EntryFilters.php | 2 +- .../MCP/DTO/EntryUpdateRequest.php | 12 +- .../MCP/DTO/GetResearchRequest.php} | 10 +- .../MCP/DTO/ListEntriesRequest.php | 11 +- .../MCP/DTO/ListResearchesRequest.php} | 46 +- .../MCP/DTO/ListTemplatesRequest.php | 2 +- .../MCP/DTO/ReadEntryRequest.php | 12 +- .../MCP/DTO/ResearchCreateRequest.php} | 25 +- .../MCP/DTO/ResearchFilters.php} | 17 +- .../MCP/DTO/ResearchMemory.php} | 4 +- .../MCP/DTO/ResearchUpdateRequest.php} | 32 +- .../MCP/Tools/CreateEntryToolAction.php | 54 +-- .../MCP/Tools/CreateResearchToolAction.php} | 60 +-- .../MCP/Tools/GetResearchToolAction.php | 105 ++++ .../MCP/Tools/ListEntriesToolAction.php | 54 +-- .../MCP/Tools/ListResearchesToolAction.php | 92 ++++ .../MCP/Tools/ListTemplatesToolAction.php | 20 +- .../MCP/Tools/ReadEntryToolAction.php | 60 +-- .../MCP/Tools/UpdateEntryToolAction.php | 78 ++- .../MCP/Tools/UpdateResearchToolAction.php | 128 +++++ .../Repository/EntryRepositoryInterface.php | 43 ++ .../ResearchRepositoryInterface.php | 42 ++ .../TemplateRepositoryInterface.php | 6 +- src/Research/ResearchBootloader.php | 70 +++ .../Service/EntryService.php | 196 ++++---- .../Service/EntryServiceInterface.php | 70 +++ src/Research/Service/ResearchService.php | 286 +++++++++++ .../Service/ResearchServiceInterface.php | 64 +++ .../Service/TemplateService.php | 12 +- .../Service/TemplateServiceInterface.php | 8 +- .../Storage/FileStorage/DirectoryScanner.php | 44 +- .../FileStorage/FileEntryRepository.php | 97 ++-- .../FileStorage/FileResearchRepository.php | 211 ++++++++ .../FileStorage}/FileStorageConfig.php | 4 +- .../Storage/FileStorage/FileStorageDriver.php | 383 +++++++++++++++ .../FileStorage/FileStorageRepositoryBase.php | 11 +- .../FileStorage/FileTemplateRepository.php | 24 +- .../Storage/FileStorage/FrontmatterParser.php | 2 +- .../Storage/FileStorageBootloader.php | 61 +++ .../Storage/StorageDriverInterface.php | 57 +++ .../projects/test_project_1/project.yaml | 11 - .../projects/test_project_2/project.yaml | 9 - .../docs/technical/api_design.md | 0 .../features/user_story/sample_story.md | 0 .../projects/test_project_1/research.yaml | 10 + .../tests/integration/auth_test.md | 0 .../projects/test_project_2/research.yaml | 8 + .../FileStorage/templates/basic.yaml | 0 .../FileStorage/templates/minimal.yaml | 0 .../FileStorage/DirectoryScannerTest.php | 12 +- .../FileStorage/FrontmatterParserTest.php | 2 +- .../MCP/DTO/AddProjectMemoryRequestTest.php | 161 ------ .../Tools/AddProjectMemoryToolActionTest.php | 412 ---------------- .../Domain/Model/ProjectTest.php | 100 +--- .../Domain/ValueObject/EntryIdTest.php | 4 +- .../Domain/ValueObject/ResearchIdTest.php} | 28 +- .../Domain/ValueObject/TemplateKeyTest.php | 4 +- .../MCP/DTO/ResearchCreateRequestTest.php} | 50 +- .../MCP/DTO/ResearchUpdateRequestTest.php} | 89 ++-- .../Tools/UpdateResearchToolActionTest.php} | 174 +++---- .../Service/ProjectServiceTest.php | 172 +++---- 112 files changed, 2697 insertions(+), 4579 deletions(-) delete mode 100644 src/Drafling/Console/ProjectListCommand.php delete mode 100644 src/Drafling/Console/TemplateListCommand.php delete mode 100644 src/Drafling/Domain/Model/Project.php delete mode 100644 src/Drafling/Domain/ValueObject/ProjectId.php delete mode 100644 src/Drafling/DraflingBootloader.php delete mode 100644 src/Drafling/Exception/DraflingException.php delete mode 100644 src/Drafling/Exception/EntryNotFoundException.php delete mode 100644 src/Drafling/Exception/ProjectNotFoundException.php delete mode 100644 src/Drafling/Exception/TemplateNotFoundException.php delete mode 100644 src/Drafling/MCP/DTO/AddProjectMemoryRequest.php delete mode 100644 src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php delete mode 100644 src/Drafling/MCP/Tools/GetProjectToolAction.php delete mode 100644 src/Drafling/MCP/Tools/ListProjectsToolAction.php delete mode 100644 src/Drafling/MCP/Tools/UpdateProjectToolAction.php delete mode 100644 src/Drafling/Repository/EntryRepositoryInterface.php delete mode 100644 src/Drafling/Repository/ProjectRepositoryInterface.php delete mode 100644 src/Drafling/Service/DraflingService.php delete mode 100644 src/Drafling/Service/DraflingServiceInterface.php delete mode 100644 src/Drafling/Service/EntryServiceInterface.php delete mode 100644 src/Drafling/Service/ProjectService.php delete mode 100644 src/Drafling/Service/ProjectServiceInterface.php delete mode 100644 src/Drafling/Storage/AbstractStorageDriver.php delete mode 100644 src/Drafling/Storage/FileStorage/FileProjectRepository.php delete mode 100644 src/Drafling/Storage/FileStorage/FileStorageDriver.php delete mode 100644 src/Drafling/Storage/StorageBootloader.php delete mode 100644 src/Drafling/Storage/StorageDriverInterface.php rename src/{Drafling/Config/DraflingConfig.php => Research/Config/ResearchConfig.php} (62%) rename src/{Drafling/Config/DraflingConfigInterface.php => Research/Config/ResearchConfigInterface.php} (58%) rename src/{Drafling/Console/ProjectInfoCommand.php => Research/Console/ResearchInfoCommand.php} (59%) create mode 100644 src/Research/Console/ResearchListCommand.php create mode 100644 src/Research/Console/TemplateListCommand.php rename src/{Drafling => Research}/Domain/Model/Category.php (91%) rename src/{Drafling => Research}/Domain/Model/Entry.php (88%) rename src/{Drafling => Research}/Domain/Model/EntryType.php (94%) create mode 100644 src/Research/Domain/Model/Research.php rename src/{Drafling => Research}/Domain/Model/Status.php (79%) rename src/{Drafling => Research}/Domain/Model/Template.php (98%) rename src/{Drafling => Research}/Domain/ValueObject/EntryId.php (86%) create mode 100644 src/Research/Domain/ValueObject/ResearchId.php rename src/{Drafling => Research}/Domain/ValueObject/TemplateKey.php (83%) create mode 100644 src/Research/Exception/EntryNotFoundException.php create mode 100644 src/Research/Exception/ResearchException.php create mode 100644 src/Research/Exception/ResearchNotFoundException.php create mode 100644 src/Research/Exception/TemplateNotFoundException.php rename src/{Drafling => Research}/Exception/ValidationException.php (87%) rename src/{Drafling => Research}/MCP/DTO/EntryCreateRequest.php (94%) rename src/{Drafling => Research}/MCP/DTO/EntryFilters.php (98%) rename src/{Drafling => Research}/MCP/DTO/EntryUpdateRequest.php (95%) rename src/{Drafling/MCP/DTO/GetProjectRequest.php => Research/MCP/DTO/GetResearchRequest.php} (59%) rename src/{Drafling => Research}/MCP/DTO/ListEntriesRequest.php (85%) rename src/{Drafling/MCP/DTO/ListProjectsRequest.php => Research/MCP/DTO/ListResearchesRequest.php} (53%) rename src/{Drafling => Research}/MCP/DTO/ListTemplatesRequest.php (96%) rename src/{Drafling => Research}/MCP/DTO/ReadEntryRequest.php (71%) rename src/{Drafling/MCP/DTO/ProjectCreateRequest.php => Research/MCP/DTO/ResearchCreateRequest.php} (64%) rename src/{Drafling/MCP/DTO/ProjectFilters.php => Research/MCP/DTO/ResearchFilters.php} (83%) rename src/{Drafling/MCP/DTO/ProjectMemory.php => Research/MCP/DTO/ResearchMemory.php} (54%) rename src/{Drafling/MCP/DTO/ProjectUpdateRequest.php => Research/MCP/DTO/ResearchUpdateRequest.php} (65%) rename src/{Drafling => Research}/MCP/Tools/CreateEntryToolAction.php (59%) rename src/{Drafling/MCP/Tools/CreateProjectToolAction.php => Research/MCP/Tools/CreateResearchToolAction.php} (54%) create mode 100644 src/Research/MCP/Tools/GetResearchToolAction.php rename src/{Drafling => Research}/MCP/Tools/ListEntriesToolAction.php (65%) create mode 100644 src/Research/MCP/Tools/ListResearchesToolAction.php rename src/{Drafling => Research}/MCP/Tools/ListTemplatesToolAction.php (83%) rename src/{Drafling => Research}/MCP/Tools/ReadEntryToolAction.php (58%) rename src/{Drafling => Research}/MCP/Tools/UpdateEntryToolAction.php (57%) create mode 100644 src/Research/MCP/Tools/UpdateResearchToolAction.php create mode 100644 src/Research/Repository/EntryRepositoryInterface.php create mode 100644 src/Research/Repository/ResearchRepositoryInterface.php rename src/{Drafling => Research}/Repository/TemplateRepositoryInterface.php (74%) create mode 100644 src/Research/ResearchBootloader.php rename src/{Drafling => Research}/Service/EntryService.php (61%) create mode 100644 src/Research/Service/EntryServiceInterface.php create mode 100644 src/Research/Service/ResearchService.php create mode 100644 src/Research/Service/ResearchServiceInterface.php rename src/{Drafling => Research}/Service/TemplateService.php (93%) rename src/{Drafling => Research}/Service/TemplateServiceInterface.php (88%) rename src/{Drafling => Research}/Storage/FileStorage/DirectoryScanner.php (63%) rename src/{Drafling => Research}/Storage/FileStorage/FileEntryRepository.php (70%) create mode 100644 src/Research/Storage/FileStorage/FileResearchRepository.php rename src/{Drafling/Storage/Config => Research/Storage/FileStorage}/FileStorageConfig.php (95%) create mode 100644 src/Research/Storage/FileStorage/FileStorageDriver.php rename src/{Drafling => Research}/Storage/FileStorage/FileStorageRepositoryBase.php (91%) rename src/{Drafling => Research}/Storage/FileStorage/FileTemplateRepository.php (89%) rename src/{Drafling => Research}/Storage/FileStorage/FrontmatterParser.php (97%) create mode 100644 src/Research/Storage/FileStorageBootloader.php create mode 100644 src/Research/Storage/StorageDriverInterface.php delete mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_1/project.yaml delete mode 100644 tests/fixtures/Drafling/FileStorage/projects/test_project_2/project.yaml rename tests/fixtures/{Drafling => Research}/FileStorage/projects/test_project_1/docs/technical/api_design.md (100%) rename tests/fixtures/{Drafling => Research}/FileStorage/projects/test_project_1/features/user_story/sample_story.md (100%) create mode 100644 tests/fixtures/Research/FileStorage/projects/test_project_1/research.yaml rename tests/fixtures/{Drafling => Research}/FileStorage/projects/test_project_1/tests/integration/auth_test.md (100%) create mode 100644 tests/fixtures/Research/FileStorage/projects/test_project_2/research.yaml rename tests/fixtures/{Drafling => Research}/FileStorage/templates/basic.yaml (100%) rename tests/fixtures/{Drafling => Research}/FileStorage/templates/minimal.yaml (100%) rename tests/src/Feature/{Drafling => Research}/Storage/FileStorage/DirectoryScannerTest.php (96%) rename tests/src/Feature/{Drafling => Research}/Storage/FileStorage/FrontmatterParserTest.php (99%) delete mode 100644 tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php delete mode 100644 tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php rename tests/src/Unit/{Drafling => Research}/Domain/Model/ProjectTest.php (70%) rename tests/src/Unit/{Drafling => Research}/Domain/ValueObject/EntryIdTest.php (93%) rename tests/src/Unit/{Drafling/Domain/ValueObject/ProjectIdTest.php => Research/Domain/ValueObject/ResearchIdTest.php} (59%) rename tests/src/Unit/{Drafling => Research}/Domain/ValueObject/TemplateKeyTest.php (94%) rename tests/src/Unit/{Drafling/MCP/DTO/ProjectCreateRequestTest.php => Research/MCP/DTO/ResearchCreateRequestTest.php} (80%) rename tests/src/Unit/{Drafling/MCP/DTO/ProjectUpdateRequestTest.php => Research/MCP/DTO/ResearchUpdateRequestTest.php} (64%) rename tests/src/Unit/{Drafling/MCP/Tools/UpdateProjectToolActionTest.php => Research/MCP/Tools/UpdateResearchToolActionTest.php} (57%) rename tests/src/Unit/{Drafling => Research}/Service/ProjectServiceTest.php (68%) diff --git a/.gitignore b/.gitignore index 03cfcf73..38dd47a6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ node_modules .php-cs-fixer.cache .context .templates -.projects +.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 88459123..fcb57a3d 100644 --- a/context.yaml +++ b/context.yaml @@ -35,30 +35,30 @@ documents: - vendor/spiral/files/src/FilesInterface.php showTreeView: true - - description: Drafling Templates + - description: Research Templates outputPath: core/drafling.md sources: - type: file sourcePaths: - - src/Drafling/Config - - src/Drafling/Domain - - src/Drafling/Repository - - src/Drafling/Service - - src/Drafling/Storage/StorageDriverInterface.php + - src/Research/Config + - src/Research/Domain + - src/Research/Repository + - src/Research/Service + - src/Research/Storage/StorageDriverInterface.php - - description: Drafling FileStorage + - description: Research FileStorage outputPath: drafling/file-storage.md sources: - type: file sourcePaths: - - src/Drafling/Storage + - src/Research/Storage - - description: Drafling MCP + - description: Research MCP outputPath: drafling/mcp.md sources: - type: file sourcePaths: - - src/Drafling/MCP + - src/Research/MCP - src/McpServer/McpServerBootloader.php diff --git a/src/Application/Kernel.php b/src/Application/Kernel.php index 13438abe..4fd82840 100644 --- a/src/Application/Kernel.php +++ b/src/Application/Kernel.php @@ -20,7 +20,7 @@ use Butschster\ContextGenerator\Application\Bootloader\SchemaMapperBootloader; use Butschster\ContextGenerator\Application\Bootloader\SourceFetcherBootloader; use Butschster\ContextGenerator\Application\Bootloader\VariableBootloader; -use Butschster\ContextGenerator\Drafling\DraflingBootloader; +use Butschster\ContextGenerator\Research\ResearchBootloader; use Butschster\ContextGenerator\McpServer\McpServerBootloader; use Butschster\ContextGenerator\Template\TemplateSystemBootloader; use Butschster\ContextGenerator\Modifier\PhpContentFilter\PhpContentFilterBootloader; @@ -75,8 +75,8 @@ protected function defineBootloaders(): array // Template System TemplateSystemBootloader::class, - // Drafling - DraflingBootloader::class, + // Research + ResearchBootloader::class, // Sources TextSourceBootloader::class, diff --git a/src/Drafling/Console/ProjectListCommand.php b/src/Drafling/Console/ProjectListCommand.php deleted file mode 100644 index f3967f56..00000000 --- a/src/Drafling/Console/ProjectListCommand.php +++ /dev/null @@ -1,79 +0,0 @@ -status !== null) { - $filters['status'] = $this->status; - } - - if ($this->template !== null) { - $filters['template'] = $this->template; - } - - try { - $projects = $projectService->listProjects($filters); - - if (empty($projects)) { - $this->output->info('No Drafling projects found.'); - return Command::SUCCESS; - } - - $this->output->title('Drafling Projects'); - - $table = new Table($this->output); - $table->setHeaders(['ID', 'Name', 'Status', 'Template', 'Description', 'Tags']); - - foreach ($projects as $project) { - $table->addRow([ - Style::property($project->id), - $project->name, - $project->status, - $project->template, - $project->description ?: '-', - \implode(', ', $project->tags), - ]); - } - - $table->render(); - - return Command::SUCCESS; - - } catch (\Throwable $e) { - $this->output->error('Failed to list projects: ' . $e->getMessage()); - return Command::FAILURE; - } - } -} diff --git a/src/Drafling/Console/TemplateListCommand.php b/src/Drafling/Console/TemplateListCommand.php deleted file mode 100644 index bb57dd08..00000000 --- a/src/Drafling/Console/TemplateListCommand.php +++ /dev/null @@ -1,133 +0,0 @@ -getAllTemplates(); - - // Apply filters - if ($this->tag !== null) { - $templates = \array_filter( - $templates, - fn($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 Drafling templates found.'); - return Command::SUCCESS; - } - - $this->output->title('Drafling Templates'); - - if ($this->details) { - foreach ($templates as $template) { - $this->displayTemplateDetails($template); - } - } else { - $table = new Table($this->output); - $table->setHeaders(['ID', 'Name', 'Description', 'Tags']); - - foreach ($templates as $template) { - $table->addRow([ - Style::property($template->key), - $template->name, - $template->description ?: '-', - \implode(', ', $template->tags), - ]); - } - - $table->render(); - } - - return Command::SUCCESS; - - } catch (\Throwable $e) { - $this->output->error('Failed to list templates: ' . $e->getMessage()); - return Command::FAILURE; - } - } - - private function displayTemplateDetails($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)); - } - } - } - - if (!empty($template->entryTypes)) { - $this->output->writeln("\nEntry Types:"); - foreach ($template->entryTypes as $entryType) { - $this->output->writeln(" • {$entryType->displayName} ({$entryType->key})"); - $this->output->writeln(" Content type: {$entryType->contentType}"); - if (!empty($entryType->statuses)) { - $statuses = \array_map(static fn($status) => $status->displayName, $entryType->statuses); - $this->output->writeln(" Statuses: " . \implode(', ', $statuses)); - } - } - } - - if ($template->prompt !== null) { - $this->output->writeln("\nPrompt: {$template->prompt}"); - } - - $this->output->newLine(); - } -} diff --git a/src/Drafling/Domain/Model/Project.php b/src/Drafling/Domain/Model/Project.php deleted file mode 100644 index bb1df691..00000000 --- a/src/Drafling/Domain/Model/Project.php +++ /dev/null @@ -1,126 +0,0 @@ -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, - projectPath: $this->projectPath, - ); - } - - /** - * Create project 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], - projectPath: $this->projectPath, - ); - } - - /** - * Generate directory name for this project - */ - public function generateDirectoryName(): string - { - $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->name)); - return \trim((string) $slug, '-'); - } - - /** - * Get project configuration as array - */ - public function getConfiguration(): array - { - return [ - 'project' => [ - 'name' => $this->name, - 'description' => $this->description, - 'template' => $this->template, - 'status' => $this->status, - 'tags' => $this->tags, - 'memory' => $this->memory, - 'entries' => [ - 'dirs' => $this->entryDirs, - ], - ], - ]; - } - - /** - * Specify data which should be serialized to JSON - */ - public function jsonSerialize(): array - { - return [ - 'project_id' => $this->id, - 'title' => $this->name, - 'status' => $this->status, - 'project_type' => $this->template, - 'created_at' => (new \DateTime())->format('c'), // Would need actual creation date from domain - 'updated_at' => (new \DateTime())->format('c'), // Would need actual update date from domain - 'metadata' => [ - 'description' => $this->description, - 'tags' => $this->tags, - 'entry_dirs' => $this->entryDirs, - 'memory' => $this->memory, - ], - ]; - } -} diff --git a/src/Drafling/Domain/ValueObject/ProjectId.php b/src/Drafling/Domain/ValueObject/ProjectId.php deleted file mode 100644 index 0692e240..00000000 --- a/src/Drafling/Domain/ValueObject/ProjectId.php +++ /dev/null @@ -1,53 +0,0 @@ -value))) { - throw new \InvalidArgumentException('Project ID cannot be empty'); - } - } - - /** - * Generate new UUID-based project ID - */ - public static function generate(): self - { - return new self(\uniqid('project_', true)); - } - - /** - * Create from string - */ - public static function fromString(string $value): self - { - return new self($value); - } - - /** - * Check equality with another ProjectId - */ - public function equals(self $other): bool - { - return $this->value === $other->value; - } - - public function toString(): string - { - return $this->value; - } - - public function __toString(): string - { - return $this->value; - } -} diff --git a/src/Drafling/DraflingBootloader.php b/src/Drafling/DraflingBootloader.php deleted file mode 100644 index 4662642e..00000000 --- a/src/Drafling/DraflingBootloader.php +++ /dev/null @@ -1,96 +0,0 @@ - DraflingConfig::class, - TemplateServiceInterface::class => TemplateService::class, - DraflingServiceInterface::class => DraflingService::class, - ProjectServiceInterface::class => ProjectService::class, - EntryServiceInterface::class => EntryService::class, - ]; - } - - public function init(ConsoleBootloader $console, EnvironmentInterface $env): void - { - $console->addCommand( - ProjectListCommand::class, - TemplateListCommand::class, - ProjectInfoCommand::class, - ); - - // Initialize configuration from environment variables - $this->config->setDefaults( - DraflingConfig::CONFIG, - [ - 'enabled' => (bool) $env->get('DRAFLING_ENABLED', true), - 'templates_path' => $env->get('DRAFLING_TEMPLATES_PATH', '.templates'), - 'projects_path' => $env->get('DRAFLING_PROJECTS_PATH', '.projects'), - 'storage_driver' => $env->get('DRAFLING_STORAGE_DRIVER', 'markdown'), - 'default_entry_status' => $env->get('DRAFLING_DEFAULT_STATUS', 'draft'), - 'env_config' => [], - ], - ); - } - - public function boot( - DraflingConfigInterface $config, - StorageDriverInterface $storageDriver, - ): void { - // Verify Drafling system is enabled - if (!$config->isEnabled()) { - return; - } - - // Initialize storage driver - $storageDriver->initialize(new FileStorageConfig( - basePath: $config->getProjectsPath(), - templatesPath: $config->getTemplatesPath(), - defaultEntryStatus: $config->getDefaultEntryStatus(), - )); - } -} diff --git a/src/Drafling/Exception/DraflingException.php b/src/Drafling/Exception/DraflingException.php deleted file mode 100644 index 3038d5f8..00000000 --- a/src/Drafling/Exception/DraflingException.php +++ /dev/null @@ -1,10 +0,0 @@ -projectId)) { - $errors[] = 'Project ID cannot be empty'; - } - - if (empty(\trim($this->memory))) { - $errors[] = 'Memory entry cannot be empty'; - } - - return $errors; - } -} diff --git a/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php b/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php deleted file mode 100644 index bbf795e3..00000000 --- a/src/Drafling/MCP/Tools/AddProjectMemoryToolAction.php +++ /dev/null @@ -1,107 +0,0 @@ -logger->info('Adding memory to project', [ - 'project_id' => $request->projectId, - 'memory_length' => \strlen($request->memory), - ]); - - try { - // Validate request - $validationErrors = $request->validate(); - if (!empty($validationErrors)) { - return ToolResult::validationError($validationErrors); - } - - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); - } - - // Add memory to project using domain service - $updatedProject = $this->projectService->addProjectMemory($projectId, $request->memory); - - $this->logger->info('Memory added to project successfully', [ - 'project_id' => $request->projectId, - 'memory_count' => \count($updatedProject->memory), - 'title' => $updatedProject->name, - ]); - - // Format successful response according to MCP specification - $response = [ - 'success' => true, - 'project_id' => $updatedProject->id, - 'title' => $updatedProject->name, - 'status' => $updatedProject->status, - 'project_type' => $updatedProject->template, - 'updated_at' => (new \DateTime())->format('c'), - 'memory_count' => \count($updatedProject->memory), - 'memory_added' => $request->memory, - 'metadata' => [ - 'description' => $updatedProject->description, - 'tags' => $updatedProject->tags, - 'entry_dirs' => $updatedProject->entryDirs, - 'memory' => $updatedProject->memory, - ], - ]; - - return ToolResult::success($response); - - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (DraflingException $e) { - $this->logger->error('Drafling error during memory addition', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (\Throwable $e) { - $this->logger->error('Unexpected error adding memory to project', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error('Failed to add memory to project: ' . $e->getMessage()); - } - } -} diff --git a/src/Drafling/MCP/Tools/GetProjectToolAction.php b/src/Drafling/MCP/Tools/GetProjectToolAction.php deleted file mode 100644 index 3f0bfa0d..00000000 --- a/src/Drafling/MCP/Tools/GetProjectToolAction.php +++ /dev/null @@ -1,107 +0,0 @@ -logger->info('Getting project', [ - 'project_id' => $request->id, - ]); - - try { - // Validate request - $validationErrors = $request->validate(); - if (!empty($validationErrors)) { - return ToolResult::validationError($validationErrors); - } - - // Get project - $projectId = ProjectId::fromString($request->id); - $project = $this->projectService->getProject($projectId); - - if ($project === null) { - return ToolResult::error("Project '{$request->id}' not found"); - } - - $this->logger->info('Project retrieved successfully', [ - 'project_id' => $project->id, - 'template' => $project->template, - ]); - - $template = $this->templateService->getTemplate(TemplateKey::fromString($project->template)); - - // Format project for response - $response = [ - 'success' => true, - 'project' => [ - 'project_id' => $project->id, - 'title' => $project->name, - 'status' => $project->status, - 'metadata' => [ - 'description' => $project->description, - 'tags' => $project->tags, - 'memory' => $project->memory, - ], - ], - 'template' => $template, - ]; - - return ToolResult::success($response); - - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->id, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (DraflingException $e) { - $this->logger->error('Drafling error getting project', [ - 'project_id' => $request->id, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (\Throwable $e) { - $this->logger->error('Unexpected error getting project', [ - 'project_id' => $request->id, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error('Failed to get project: ' . $e->getMessage()); - } - } -} diff --git a/src/Drafling/MCP/Tools/ListProjectsToolAction.php b/src/Drafling/MCP/Tools/ListProjectsToolAction.php deleted file mode 100644 index 1410e2e5..00000000 --- a/src/Drafling/MCP/Tools/ListProjectsToolAction.php +++ /dev/null @@ -1,150 +0,0 @@ -logger->info('Listing projects', [ - 'has_filters' => $request->hasFilters(), - 'filters' => $request->getFilters(), - 'limit' => $request->limit, - 'offset' => $request->offset, - 'sort_by' => $request->sortBy, - ]); - - try { - // Validate request - $validationErrors = $request->validate(); - if (!empty($validationErrors)) { - return ToolResult::validationError($validationErrors); - } - - // Get projects with filters - $allProjects = $this->projectService->listProjects($request->getFilters()); - - // Apply sorting - $sortedProjects = $this->applySorting($allProjects, $request->getSortingOptions()); - - // Apply pagination - $paginatedProjects = \array_slice( - $sortedProjects, - $request->offset, - $request->limit, - ); - - // Format projects for response (using JsonSerializable) - $projectData = $paginatedProjects; - - $response = [ - 'success' => true, - 'projects' => $projectData, - 'count' => \count($paginatedProjects), - 'total_count' => \count($allProjects), - 'pagination' => [ - 'limit' => $request->limit, - 'offset' => $request->offset, - 'has_more' => ($request->offset + \count($paginatedProjects)) < \count($allProjects), - ], - 'filters_applied' => $request->hasFilters() ? $request->getFilters() : null, - ]; - - $this->logger->info('Projects listed successfully', [ - 'returned_count' => \count($paginatedProjects), - 'total_available' => \count($allProjects), - 'filters_applied' => $request->hasFilters(), - ]); - - return ToolResult::success($response); - - } catch (DraflingException $e) { - $this->logger->error('Drafling error listing projects', [ - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (\Throwable $e) { - $this->logger->error('Unexpected error listing projects', [ - 'error' => $e->getMessage(), - ]); - - return ToolResult::error('Failed to list projects: ' . $e->getMessage()); - } - } - - /** - * Apply sorting to projects array - */ - private function applySorting(array $projects, array $sortingOptions): array - { - $sortBy = $sortingOptions['sort_by']; - $sortDirection = \strtolower((string) $sortingOptions['sort_direction']); - - \usort($projects, function ($a, $b) use ($sortBy, $sortDirection) { - $valueA = $this->getProjectFieldValue($a, $sortBy); - $valueB = $this->getProjectFieldValue($b, $sortBy); - - // Handle null values - if ($valueA === $valueB) { - return 0; - } - - if ($valueA === null) { - return 1; - } - - if ($valueB === null) { - return -1; - } - - // Compare values - $result = $valueA <=> $valueB; - - return $sortDirection === 'desc' ? -$result : $result; - }); - - return $projects; - } - - /** - * Get field value from project for sorting - * @param mixed $project - */ - private function getProjectFieldValue($project, string $field): mixed - { - return match ($field) { - 'name' => $project->name, - 'status' => $project->status, - 'template' => $project->template, - 'created_at' => null, // Would need actual timestamps from domain - 'updated_at' => null, // Would need actual timestamps from domain - default => $project->name, - }; - } -} diff --git a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php b/src/Drafling/MCP/Tools/UpdateProjectToolAction.php deleted file mode 100644 index 7dd60a74..00000000 --- a/src/Drafling/MCP/Tools/UpdateProjectToolAction.php +++ /dev/null @@ -1,145 +0,0 @@ -logger->info('Updating project', [ - 'project_id' => $request->projectId, - '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); - } - - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); - } - - // Update project using domain service - $updatedProject = $this->projectService->updateProject($projectId, $request); - - $this->logger->info('Project updated successfully', [ - 'project_id' => $request->projectId, - 'title' => $updatedProject->name, - 'status' => $updatedProject->status, - ]); - - // Format successful response according to MCP specification - $response = [ - 'success' => true, - 'project_id' => $updatedProject->id, - 'title' => $updatedProject->name, - 'status' => $updatedProject->status, - 'project_type' => $updatedProject->template, - 'updated_at' => (new \DateTime())->format('c'), // Would need actual update timestamp from domain - 'metadata' => [ - 'description' => $updatedProject->description, - 'tags' => $updatedProject->tags, - 'entry_dirs' => $updatedProject->entryDirs, - 'memory' => $updatedProject->memory, - ], - 'changes_applied' => $this->getAppliedChanges($request), - ]; - - return ToolResult::success($response); - - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (DraflingException $e) { - $this->logger->error('Drafling error during project update', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error($e->getMessage()); - - } catch (\Throwable $e) { - $this->logger->error('Unexpected error updating project', [ - 'project_id' => $request->projectId, - 'error' => $e->getMessage(), - ]); - - return ToolResult::error('Failed to update project: ' . $e->getMessage()); - } - } - - /** - * Get list of changes applied based on the request - */ - private function getAppliedChanges(ProjectUpdateRequest $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/Drafling/Repository/EntryRepositoryInterface.php b/src/Drafling/Repository/EntryRepositoryInterface.php deleted file mode 100644 index ee5e57ba..00000000 --- a/src/Drafling/Repository/EntryRepositoryInterface.php +++ /dev/null @@ -1,43 +0,0 @@ -projectRepository->findById($projectId); - if ($project === null) { - $this->logger?->error('Project not found for content retrieval', [ - 'project_id' => $projectId->value, - ]); - return []; - } - - $this->logger?->info('Retrieving project content', [ - 'project_id' => $projectId->value, - 'options' => $options, - ]); - - // Get all entries for the project - $entries = $this->entryRepository->findByProject($projectId); - - // Group by category if requested - $groupByCategory = $options['categories'] ?? false; - $includeMetadata = $options['include_metadata'] ?? true; - $format = $options['format'] ?? 'full'; - - if ($groupByCategory) { - return $this->groupEntriesByCategory($entries, $format, $includeMetadata); - } - - return $this->formatEntries($entries, $format, $includeMetadata); - } - - #[\Override] - public function getProjectStructure(ProjectId $projectId): array - { - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - $this->logger?->error('Project not found for structure retrieval', [ - 'project_id' => $projectId->value, - ]); - return []; - } - - $this->logger?->info('Retrieving project structure', [ - 'project_id' => $projectId->value, - ]); - - $entries = $this->entryRepository->findByProject($projectId); - - // Get template for structure information - $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); - $template = $this->templateService->getTemplate($templateKey); - - $structure = [ - 'project' => [ - 'id' => $project->id, - 'name' => $project->name, - 'template' => $project->template, - 'status' => $project->status, - 'entry_dirs' => $project->entryDirs, - ], - 'template_info' => $template ? [ - 'categories' => \array_map(static fn($cat) => [ - 'name' => $cat->name, - 'display_name' => $cat->displayName, - 'allowed_types' => $cat->entryTypes, - ], $template->categories), - 'entry_types' => \array_map(static fn($type) => [ - 'key' => $type->key, - 'display_name' => $type->displayName, - 'statuses' => \array_map(static fn($status) => $status->value, $type->statuses), - ], $template->entryTypes), - ] : null, - 'content_summary' => [ - 'total_entries' => \count($entries), - 'by_category' => $this->countEntriesByCategory($entries), - 'by_type' => $this->countEntriesByType($entries), - 'by_status' => $this->countEntriesByStatus($entries), - ], - ]; - - return $structure; - } - - #[\Override] - public function getProjectHistory(ProjectId $projectId): array - { - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - $this->logger?->error('Project not found for history retrieval', [ - 'project_id' => $projectId->value, - ]); - return []; - } - - $this->logger?->info('Retrieving project history', [ - 'project_id' => $projectId->value, - ]); - - $entries = $this->entryRepository->findByProject($projectId); - - // Sort entries by creation and update dates to build timeline - $timeline = []; - - foreach ($entries as $entry) { - $timeline[] = [ - 'type' => 'entry_created', - 'timestamp' => $entry->createdAt, - 'entry_id' => $entry->entryId, - 'entry_title' => $entry->title, - 'category' => $entry->category, - 'entry_type' => $entry->entryType, - ]; - - // Add update event if entry was modified after creation - if ($entry->updatedAt > $entry->createdAt) { - $timeline[] = [ - 'type' => 'entry_updated', - 'timestamp' => $entry->updatedAt, - 'entry_id' => $entry->entryId, - 'entry_title' => $entry->title, - ]; - } - } - - // Sort timeline by timestamp (newest first) - \usort($timeline, static fn($a, $b) => $b['timestamp'] <=> $a['timestamp']); - - return [ - 'project_id' => $project->id, - 'timeline' => $timeline, - 'summary' => [ - 'total_events' => \count($timeline), - 'latest_activity' => $timeline[0]['timestamp'] ?? null, - ], - ]; - } - - #[\Override] - public function validateProject(Project $project): array - { - $errors = []; - - $this->logger?->debug('Validating project against template', [ - 'project_id' => $project->id, - 'template' => $project->template, - ]); - - // Check if template exists - $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); - $template = $this->templateService->getTemplate($templateKey); - - if ($template === null) { - $errors[] = "Template '{$project->template}' not found"; - return $errors; // Can't validate further without template - } - - // Validate project structure against template requirements - if (empty($project->entryDirs)) { - $errors[] = "Project must have at least one entry directory configured"; - } - - $this->logger?->info('Project validation completed', [ - 'project_id' => $project->id, - 'errors_count' => \count($errors), - ]); - - return $errors; - } - - #[\Override] - public function validateEntry(Entry $entry, Project $project): array - { - $errors = []; - - $this->logger?->debug('Validating entry against project template', [ - 'entry_id' => $entry->entryId, - 'project_id' => $project->id, - 'template' => $project->template, - ]); - - // Get template - $templateKey = \Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey::fromString($project->template); - $template = $this->templateService->getTemplate($templateKey); - - if ($template === null) { - $errors[] = "Project template '{$project->template}' not found"; - return $errors; - } - - // Validate category exists in template - if (!$template->hasCategory($entry->category)) { - $errors[] = "Category '{$entry->category}' is not valid for template '{$project->template}'"; - } - - // Validate entry type exists in template - if (!$template->hasEntryType($entry->entryType)) { - $errors[] = "Entry type '{$entry->entryType}' is not valid for template '{$project->template}'"; - } - - // Validate entry type is allowed in category - if (!$template->validateEntryInCategory($entry->category, $entry->entryType)) { - $errors[] = "Entry type '{$entry->entryType}' is not allowed in category '{$entry->category}'"; - } - - // Validate status is valid for entry type - $entryType = $template->getEntryType($entry->entryType); - if ($entryType !== null && !$entryType->hasStatus($entry->status)) { - $errors[] = "Status '{$entry->status}' is not valid for entry type '{$entry->entryType}'"; - } - - $this->logger?->info('Entry validation completed', [ - 'entry_id' => $entry->entryId, - 'errors_count' => \count($errors), - ]); - - return $errors; - } - - /** - * Group entries by category - */ - private function groupEntriesByCategory(array $entries, string $format, bool $includeMetadata): array - { - $grouped = []; - - foreach ($entries as $entry) { - $category = $entry->category; - if (!isset($grouped[$category])) { - $grouped[$category] = []; - } - $grouped[$category][] = $this->formatEntry($entry, $format, $includeMetadata); - } - - return $grouped; - } - - /** - * Format entries for output - */ - private function formatEntries(array $entries, string $format, bool $includeMetadata): array - { - return \array_map( - fn($entry) => $this->formatEntry($entry, $format, $includeMetadata), - $entries, - ); - } - - /** - * Format single entry based on requested format - */ - private function formatEntry(Entry $entry, string $format, bool $includeMetadata): array - { - $formatted = [ - 'entry_id' => $entry->entryId, - 'title' => $entry->title, - ]; - - if ($format === 'full') { - $formatted['content'] = $entry->content; - } elseif ($format === 'summary') { - // Truncate content for summary - $formatted['content_preview'] = \substr($entry->content, 0, 200) . '...'; - } - - if ($includeMetadata) { - $formatted['metadata'] = [ - 'category' => $entry->category, - 'entry_type' => $entry->entryType, - 'status' => $entry->status, - 'tags' => $entry->tags, - 'created_at' => $entry->createdAt->format('c'), - 'updated_at' => $entry->updatedAt->format('c'), - ]; - } - - return $formatted; - } - - /** - * Count entries by category - */ - private function countEntriesByCategory(array $entries): array - { - $counts = []; - foreach ($entries as $entry) { - $category = $entry->category; - $counts[$category] = ($counts[$category] ?? 0) + 1; - } - return $counts; - } - - /** - * Count entries by type - */ - private function countEntriesByType(array $entries): array - { - $counts = []; - foreach ($entries as $entry) { - $type = $entry->entryType; - $counts[$type] = ($counts[$type] ?? 0) + 1; - } - return $counts; - } - - /** - * Count entries by status - */ - private function countEntriesByStatus(array $entries): array - { - $counts = []; - foreach ($entries as $entry) { - $status = $entry->status; - $counts[$status] = ($counts[$status] ?? 0) + 1; - } - return $counts; - } -} diff --git a/src/Drafling/Service/DraflingServiceInterface.php b/src/Drafling/Service/DraflingServiceInterface.php deleted file mode 100644 index fef2359b..00000000 --- a/src/Drafling/Service/DraflingServiceInterface.php +++ /dev/null @@ -1,47 +0,0 @@ -logger?->info('Creating new project', [ - '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 project - $project = $this->storageDriver->createProject($request); - - // Save project to repository - $this->projectRepository->save($project); - - $this->logger?->info('Project created successfully', [ - 'project_id' => $project->id, - 'template' => $project->template, - 'name' => $project->name, - ]); - - return $project; - - } catch (\Throwable $e) { - $this->logger?->error('Failed to create project', [ - 'template_id' => $request->templateId, - 'title' => $request->title, - 'error' => $e->getMessage(), - ]); - - throw new DraflingException( - "Failed to create project: {$e->getMessage()}", - previous: $e, - ); - } - } - - #[\Override] - public function updateProject(ProjectId $projectId, ProjectUpdateRequest $request): Project - { - $this->logger?->info('Updating project', [ - 'project_id' => $projectId->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 project exists - if (!$this->projectRepository->exists($projectId)) { - $error = "Project '{$projectId->value}' not found"; - $this->logger?->error($error, [ - 'project_id' => $projectId->value, - ]); - throw new ProjectNotFoundException($error); - } - - try { - // Use storage driver to update the project - $updatedProject = $this->storageDriver->updateProject($projectId, $request); - - // Save updated project to repository - $this->projectRepository->save($updatedProject); - - $this->logger?->info('Project updated successfully', [ - 'project_id' => $projectId->value, - 'name' => $updatedProject->name, - 'status' => $updatedProject->status, - ]); - - return $updatedProject; - - } catch (\Throwable $e) { - $this->logger?->error('Failed to update project', [ - 'project_id' => $projectId->value, - 'error' => $e->getMessage(), - ]); - - throw new DraflingException( - "Failed to update project: {$e->getMessage()}", - previous: $e, - ); - } - } - - #[\Override] - public function projectExists(ProjectId $projectId): bool - { - $exists = $this->projectRepository->exists($projectId); - - $this->logger?->debug('Checking project existence', [ - 'project_id' => $projectId->value, - 'exists' => $exists, - ]); - - return $exists; - } - - #[\Override] - public function getProject(ProjectId $projectId): ?Project - { - $this->logger?->debug('Retrieving project', [ - 'project_id' => $projectId->value, - ]); - - $project = $this->projectRepository->findById($projectId); - - if ($project === null) { - $this->logger?->warning('Project not found', [ - 'project_id' => $projectId->value, - ]); - } else { - $this->logger?->debug('Project retrieved successfully', [ - 'project_id' => $project->id, - 'name' => $project->name, - 'template' => $project->template, - ]); - } - - return $project; - } - - #[\Override] - public function listProjects(array $filters = []): array - { - $this->logger?->info('Listing projects', [ - 'filters' => $filters, - ]); - - try { - $projects = $this->projectRepository->findAll($filters); - - $this->logger?->info('Projects retrieved successfully', [ - 'count' => \count($projects), - 'filters_applied' => !empty($filters), - ]); - - return $projects; - - } catch (\Throwable $e) { - $this->logger?->error('Failed to list projects', [ - 'filters' => $filters, - 'error' => $e->getMessage(), - ]); - - throw new DraflingException( - "Failed to list projects: {$e->getMessage()}", - previous: $e, - ); - } - } - - #[\Override] - public function deleteProject(ProjectId $projectId): bool - { - $this->logger?->info('Deleting project', [ - 'project_id' => $projectId->value, - ]); - - // Verify project exists - if (!$this->projectRepository->exists($projectId)) { - $this->logger?->warning('Attempted to delete non-existent project', [ - 'project_id' => $projectId->value, - ]); - return false; - } - - try { - // Use storage driver to delete the project and its entries - $deleted = $this->storageDriver->deleteProject($projectId); - - if ($deleted) { - // Remove from repository - $this->projectRepository->delete($projectId); - - $this->logger?->info('Project deleted successfully', [ - 'project_id' => $projectId->value, - ]); - } else { - $this->logger?->warning('Storage driver failed to delete project', [ - 'project_id' => $projectId->value, - ]); - } - - return $deleted; - - } catch (\Throwable $e) { - $this->logger?->error('Failed to delete project', [ - 'project_id' => $projectId->value, - 'error' => $e->getMessage(), - ]); - - throw new DraflingException( - "Failed to delete project: {$e->getMessage()}", - previous: $e, - ); - } - } - - #[\Override] - public function addProjectMemory(ProjectId $projectId, string $memory): Project - { - $this->logger?->info('Adding memory to project', [ - 'project_id' => $projectId->value, - 'memory_length' => \strlen($memory), - ]); - - // Verify project exists and get current project - $currentProject = $this->projectRepository->findById($projectId); - if ($currentProject === null) { - $error = "Project '{$projectId->value}' not found"; - $this->logger?->error($error, [ - 'project_id' => $projectId->value, - ]); - throw new ProjectNotFoundException($error); - } - - try { - // Create updated project with added memory - $updatedProject = $currentProject->withAddedMemory($memory); - - // Save updated project to repository - $this->projectRepository->save($updatedProject); - - $this->logger?->info('Memory added to project successfully', [ - 'project_id' => $projectId->value, - 'memory_count' => \count($updatedProject->memory), - 'name' => $updatedProject->name, - ]); - - return $updatedProject; - - } catch (\Throwable $e) { - $this->logger?->error('Failed to add memory to project', [ - 'project_id' => $projectId->value, - 'error' => $e->getMessage(), - ]); - - throw new DraflingException( - "Failed to add memory to project: {$e->getMessage()}", - previous: $e, - ); - } - } -} diff --git a/src/Drafling/Service/ProjectServiceInterface.php b/src/Drafling/Service/ProjectServiceInterface.php deleted file mode 100644 index 4e6dba40..00000000 --- a/src/Drafling/Service/ProjectServiceInterface.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -abstract class AbstractStorageDriver implements StorageDriverInterface -{ - /** @var TConfig */ - protected object $config; - - public function __construct( - protected readonly DraflingConfigInterface $draflingConfig, - protected readonly ?LoggerInterface $logger = null, - ) {} - - #[\Override] - public function initialize(object $config): void - { - $this->config = $config; - $this->logger?->debug('Storage driver initialized', [ - 'driver' => $this->getName(), - 'config' => $config, - ]); - } - - #[\Override] - public function synchronize(): void - { - $this->logger?->debug('Synchronizing storage state', [ - 'driver' => $this->getName(), - ]); - - // Base implementation - override in concrete classes - $this->performSynchronization(); - } - - /** - * Perform driver-specific synchronization - */ - abstract protected function performSynchronization(): void; - - /** - * Validate project ID format - */ - protected function validateProjectId(string $projectId): void - { - if (empty($projectId)) { - throw new \InvalidArgumentException('Project ID cannot be empty'); - } - } - - /** - * Validate entry ID format - */ - protected function validateEntryId(string $entryId): void - { - if (empty($entryId)) { - throw new \InvalidArgumentException('Entry ID cannot be empty'); - } - } - - /** - * Generate unique ID for entities - */ - protected function generateId(string $prefix = ''): string - { - $id = \uniqid($prefix, true); - return \str_replace('.', '_', $id); - } - - /** - * Get current timestamp - */ - protected function getCurrentTimestamp(): \DateTime - { - return new \DateTime(); - } - - /** - * Sanitize filename for file system safety - */ - protected function sanitizeFilename(string $filename): string - { - // Remove or replace unsafe characters - $filename = \preg_replace('/[^a-zA-Z0-9\-_\.]/', '-', $filename); - - // Remove consecutive dashes - $filename = \preg_replace('/-+/', '-', (string) $filename); - - // Trim dashes from ends - return \trim((string) $filename, '-'); - } - - /** - * Create slug from title - */ - protected function createSlug(string $title): string - { - $slug = \strtolower($title); - $slug = \preg_replace('/[^a-z0-9\s\-]/', '', $slug); - $slug = \preg_replace('/[\s\-]+/', '-', (string) $slug); - return \trim((string) $slug, '-'); - } - - /** - * Log operation with context - */ - protected function logOperation(string $operation, array $context = []): void - { - $this->logger?->info("Storage operation: {$operation}", [ - 'driver' => $this->getName(), - ...$context, - ]); - } - - /** - * Log error with context - */ - protected function logError(string $message, array $context = [], ?\Throwable $exception = null): void - { - $this->logger?->error($message, [ - 'driver' => $this->getName(), - 'exception' => $exception?->getMessage(), - ...$context, - ]); - } -} diff --git a/src/Drafling/Storage/FileStorage/FileProjectRepository.php b/src/Drafling/Storage/FileStorage/FileProjectRepository.php deleted file mode 100644 index 8b8e319c..00000000 --- a/src/Drafling/Storage/FileStorage/FileProjectRepository.php +++ /dev/null @@ -1,218 +0,0 @@ -directoryScanner->scanProjects($this->getBasePath()); - - foreach ($projectPaths as $projectPath) { - try { - $project = $this->loadProjectFromDirectory($projectPath); - if ($project !== null && $this->matchesFilters($project, $filters)) { - $projects[] = $project; - } - } catch (\Throwable $e) { - $this->logError('Failed to load project', ['path' => $projectPath], $e); - } - } - - $this->logOperation('Loaded projects', [ - 'count' => \count($projects), - 'total_scanned' => \count($projectPaths), - ]); - - return $projects; - } - - #[\Override] - public function findById(ProjectId $id): ?Project - { - $projectPath = $this->getProjectPath($id->value); - - if (!$this->files->exists($projectPath)) { - return null; - } - - try { - return $this->loadProjectFromDirectory($projectPath); - } catch (\Throwable $e) { - $this->logError('Failed to load project by ID', ['id' => $id->value, 'path' => $projectPath], $e); - return null; - } - } - - #[\Override] - public function save(Project $project): void - { - $projectPath = $this->getProjectPath($project->id); - - try { - // Ensure project directory exists - $this->ensureDirectory($projectPath); - - // Create entry directories if they don't exist - foreach ($project->entryDirs as $entryDir) { - $entryDirPath = $this->files->normalizePath($projectPath . '/' . $entryDir); - $this->ensureDirectory($entryDirPath); - } - - // Save project configuration - $this->saveProjectConfig($projectPath, $project); - - $this->logOperation('Saved project', [ - 'id' => $project->id, - 'name' => $project->name, - 'path' => $projectPath, - ]); - } catch (\Throwable $e) { - $this->logError('Failed to save project', ['id' => $project->id], $e); - throw $e; - } - } - - #[\Override] - public function delete(ProjectId $id): bool - { - $projectPath = $this->getProjectPath($id->value); - - if (!$this->files->exists($projectPath)) { - return false; - } - - try { - $deleted = $this->files->deleteDirectory($projectPath); - - if ($deleted) { - $this->logOperation('Deleted project', ['id' => $id->value, 'path' => $projectPath]); - } - - return $deleted; - } catch (\Throwable $e) { - $this->logError('Failed to delete project', ['id' => $id->value], $e); - return false; - } - } - - #[\Override] - public function exists(ProjectId $id): bool - { - $projectPath = $this->getProjectPath($id->value); - $configPath = $projectPath . '/project.yaml'; - - return $this->files->exists($configPath); - } - - /** - * Get project directory path from ID - */ - private function getProjectPath(string $projectId): string - { - $basePath = $this->getBasePath(); - return $this->files->normalizePath($basePath . '/' . $projectId); - } - - /** - * Load project from directory path - */ - private function loadProjectFromDirectory(string $projectPath): ?Project - { - $configPath = $projectPath . '/project.yaml'; - - if (!$this->files->exists($configPath)) { - throw new \RuntimeException("Project configuration not found: {$configPath}"); - } - - $config = $this->readYamlFile($configPath); - - if (!isset($config['project'])) { - throw new \RuntimeException("Invalid project configuration: missing 'project' section"); - } - - $projectData = $config['project']; - - // Extract project ID from directory name - $projectId = \basename($projectPath); - - return new Project( - id: $projectId, - name: $projectData['name'] ?? $projectId, - description: $projectData['description'] ?? '', - template: $projectData['template'] ?? '', - status: $projectData['status'] ?? 'draft', - tags: $projectData['tags'] ?? [], - entryDirs: $projectData['entries']['dirs'] ?? [], - memory: $projectData['memory'] ?? [], - projectPath: $projectPath, - ); - } - - /** - * Save project configuration to YAML file - */ - private function saveProjectConfig(string $projectPath, Project $project): void - { - $configPath = $projectPath . '/project.yaml'; - - $config = [ - 'project' => [ - 'name' => $project->name, - 'description' => $project->description, - 'template' => $project->template, - 'status' => $project->status, - 'tags' => $project->tags, - 'memory' => $project->memory, - 'entries' => [ - 'dirs' => $project->entryDirs, - ], - ], - ]; - - $this->writeYamlFile($configPath, $config); - } - - /** - * Check if project matches the provided filters - */ - private function matchesFilters(Project $project, array $filters): bool - { - // Status filter - if (isset($filters['status']) && $project->status !== $filters['status']) { - return false; - } - - // Template filter - if (isset($filters['template']) && $project->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, $project->tags, true)) { - $hasMatchingTag = true; - break; - } - } - if (!$hasMatchingTag) { - return false; - } - } - - return true; - } -} diff --git a/src/Drafling/Storage/FileStorage/FileStorageDriver.php b/src/Drafling/Storage/FileStorage/FileStorageDriver.php deleted file mode 100644 index 637f522b..00000000 --- a/src/Drafling/Storage/FileStorage/FileStorageDriver.php +++ /dev/null @@ -1,458 +0,0 @@ - - */ -final class FileStorageDriver extends AbstractStorageDriver -{ - private readonly ProjectRepositoryInterface $projectRepository; - private readonly EntryRepositoryInterface $entryRepository; - private readonly TemplateRepositoryInterface $templateRepository; - - public function __construct( - DraflingConfigInterface $draflingConfig, - FilesInterface $files, - DirectoriesInterface $dirs, - ExceptionReporterInterface $reporter, - ?LoggerInterface $logger = null, - ) { - parent::__construct($draflingConfig, $logger); - - // Initialize repositories - $frontmatterParser = new FrontmatterParser(); - $directoryScanner = new DirectoryScanner($files, $reporter); - - $this->templateRepository = new FileTemplateRepository( - $files, - $draflingConfig, - $dirs, - $frontmatterParser, - $directoryScanner, - $logger, - ); - - $this->projectRepository = new FileProjectRepository( - $files, - $draflingConfig, - $dirs, - $frontmatterParser, - $directoryScanner, - $logger, - ); - - $this->entryRepository = new FileEntryRepository( - $files, - $draflingConfig, - $dirs, - $frontmatterParser, - $directoryScanner, - $logger, - ); - } - - #[\Override] - public function supports(string $type): bool - { - return $type === 'markdown' || $type === 'file'; - } - - #[\Override] - public function getName(): string - { - return 'file_storage'; - } - - #[\Override] - public function initialize(object $config): void - { - if (!$config instanceof FileStorageConfig) { - throw new \InvalidArgumentException('FileStorageDriver requires FileStorageConfig'); - } - - // Validate configuration - $errors = $config->validate(); - if (!empty($errors)) { - throw new \InvalidArgumentException('Invalid FileStorageConfig: ' . \implode(', ', $errors)); - } - - parent::initialize($config); - } - - #[\Override] - public function createProject(ProjectCreateRequest $request): Project - { - // Validate template exists - $templateKey = TemplateKey::fromString($request->templateId); - $template = $this->templateRepository->findByKey($templateKey); - - if ($template === null) { - throw new TemplateNotFoundException("Template '{$request->templateId}' not found"); - } - - // Generate project ID and create project - $projectId = $this->generateId('proj_'); - $project = new Project( - id: $projectId, - name: $request->getName(), // Use getName() method for consistency - description: $request->description, - template: $request->templateId, - status: $this->config->defaultEntryStatus, - tags: $request->tags, - entryDirs: !empty($request->entryDirs) ? $request->entryDirs : $this->getDefaultEntryDirs($template), - memory: $request->memory, - ); - - $this->projectRepository->save($project); - $this->logOperation('Created project', ['id' => $projectId, 'name' => $request->getName()]); - - return $project; - } - - #[\Override] - public function updateProject(ProjectId $projectId, ProjectUpdateRequest $request): Project - { - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - throw new ProjectNotFoundException("Project '{$projectId->value}' not found"); - } - - if (!$request->hasUpdates()) { - return $project; - } - - $updatedProject = $project->withUpdates( - name: $request->getName(), // Use getName() method for consistency - description: $request->description, - status: $request->status, - tags: $request->tags, - entryDirs: $request->entryDirs, - memory: \array_map( - static fn(ProjectMemory $memory): string => $memory->record, - $request->memory, - ), - ); - - $this->projectRepository->save($updatedProject); - $this->logOperation('Updated project', ['id' => $projectId->value]); - - return $updatedProject; - } - - #[\Override] - public function deleteProject(ProjectId $projectId): bool - { - if (!$this->projectRepository->exists($projectId)) { - return false; - } - - $deleted = $this->projectRepository->delete($projectId); - if ($deleted) { - $this->logOperation('Deleted project', ['id' => $projectId->value]); - } - - return $deleted; - } - - #[\Override] - public function createEntry(ProjectId $projectId, EntryCreateRequest $request): Entry - { - // Verify project exists - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - throw new ProjectNotFoundException("Project '{$projectId->value}' not found"); - } - - // Get template for validation and key resolution - $templateKey = TemplateKey::fromString($project->template); - $template = $this->templateRepository->findByKey($templateKey); - if ($template === null) { - throw new TemplateNotFoundException("Template '{$project->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 = $this->getCurrentTimestamp(); - - $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->config->defaultEntryStatus, - createdAt: $now, - updatedAt: $now, - tags: $resolvedRequest->tags, - content: $resolvedRequest->content, - ); - - $this->entryRepository->save($projectId, $entry); - $this->logOperation('Created entry', [ - 'project_id' => $projectId->value, - 'entry_id' => $entryId, - 'title' => $entry->title, - ]); - - return $entry; - } - - #[\Override] - public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry - { - $entry = $this->entryRepository->findById($projectId, $entryId); - if ($entry === null) { - throw new EntryNotFoundException("Entry '{$entryId->value}' not found in project '{$projectId->value}'"); - } - - if (!$request->hasUpdates()) { - return $entry; - } - - // Resolve status if provided - $resolvedRequest = $request; - if ($request->status !== null) { - $project = $this->projectRepository->findById($projectId); - if ($project !== null) { - $templateKey = TemplateKey::fromString($project->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($projectId, $updatedEntry); - $this->logOperation('Updated entry', [ - 'project_id' => $projectId->value, - 'entry_id' => $entryId->value, - ]); - - return $updatedEntry; - } - - #[\Override] - public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool - { - if (!$this->entryRepository->exists($projectId, $entryId)) { - return false; - } - - $deleted = $this->entryRepository->delete($projectId, $entryId); - if ($deleted) { - $this->logOperation('Deleted entry', [ - 'project_id' => $projectId->value, - 'entry_id' => $entryId->value, - ]); - } - - return $deleted; - } - - /** - * Get project repository - */ - public function getProjectRepository(): ProjectRepositoryInterface - { - return $this->projectRepository; - } - - /** - * Get entry repository - */ - public function getEntryRepository(): EntryRepositoryInterface - { - return $this->entryRepository; - } - - /** - * Get template repository - */ - public function getTemplateRepository(): TemplateRepositoryInterface - { - return $this->templateRepository; - } - - #[\Override] - protected function performSynchronization(): void - { - // Refresh template cache - $this->templateRepository->refresh(); - - $this->logOperation('Synchronized file storage'); - } - - /** - * 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 project 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/Drafling/Storage/StorageBootloader.php b/src/Drafling/Storage/StorageBootloader.php deleted file mode 100644 index a039234c..00000000 --- a/src/Drafling/Storage/StorageBootloader.php +++ /dev/null @@ -1,82 +0,0 @@ - static function ( - DraflingConfigInterface $config, - FilesInterface $files, - LoggerInterface $logger, - ExceptionReporterInterface $reporter, - DirectoriesInterface $dirs, - ): StorageDriverInterface { - $driver = new FileStorageDriver($config, $files, $dirs, $reporter, $logger); - - // Initialize with typed configuration - $storageConfig = FileStorageConfig::fromArray([ - 'base_path' => $config->getProjectsPath(), - 'templates_path' => $config->getTemplatesPath(), - 'default_entry_status' => $config->getDefaultEntryStatus(), - ]); - - $driver->initialize($storageConfig); - - return $driver; - }, - - // Repositories - bind to storage driver repositories - TemplateRepositoryInterface::class => static function (StorageDriverInterface $driver): TemplateRepositoryInterface { - if ($driver instanceof FileStorageDriver) { - return $driver->getTemplateRepository(); - } - - throw new \RuntimeException('Storage driver does not support template repository'); - }, - - ProjectRepositoryInterface::class => static function (StorageDriverInterface $driver): ProjectRepositoryInterface { - if ($driver instanceof FileStorageDriver) { - return $driver->getProjectRepository(); - } - - throw new \RuntimeException('Storage driver does not support project repository'); - }, - - EntryRepositoryInterface::class => static function (StorageDriverInterface $driver): EntryRepositoryInterface { - if ($driver instanceof FileStorageDriver) { - return $driver->getEntryRepository(); - } - - throw new \RuntimeException('Storage driver does not support entry repository'); - }, - ]; - } - - public function boot(StorageDriverInterface $storageDriver): void - { - // Synchronize storage on boot - $storageDriver->synchronize(); - } -} diff --git a/src/Drafling/Storage/StorageDriverInterface.php b/src/Drafling/Storage/StorageDriverInterface.php deleted file mode 100644 index 3ba77c85..00000000 --- a/src/Drafling/Storage/StorageDriverInterface.php +++ /dev/null @@ -1,74 +0,0 @@ -isGitOperationsEnabled()) { diff --git a/src/Drafling/Config/DraflingConfig.php b/src/Research/Config/ResearchConfig.php similarity index 62% rename from src/Drafling/Config/DraflingConfig.php rename to src/Research/Config/ResearchConfig.php index 4caa2e5a..2bb482fa 100644 --- a/src/Drafling/Config/DraflingConfig.php +++ b/src/Research/Config/ResearchConfig.php @@ -2,39 +2,31 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Config; +namespace Butschster\ContextGenerator\Research\Config; use Spiral\Core\InjectableConfig; -/** - * Drafling system configuration - */ -final class DraflingConfig extends InjectableConfig implements DraflingConfigInterface +final class ResearchConfig extends InjectableConfig implements ResearchConfigInterface { - public const string CONFIG = 'drafling'; + public const string CONFIG = 'research'; protected array $config = [ 'enabled' => true, 'templates_path' => '.templates', - 'projects_path' => '.projects', + 'researches_path' => '.researches', 'storage_driver' => 'markdown', 'default_entry_status' => 'draft', 'env_config' => [], ]; - public function isEnabled(): bool - { - return (bool) $this->config['enabled']; - } - public function getTemplatesPath(): string { return (string) $this->config['templates_path']; } - public function getProjectsPath(): string + public function getResearchesPath(): string { - return (string) $this->config['projects_path']; + return (string) $this->config['researches_path']; } public function getStorageDriver(): string diff --git a/src/Drafling/Config/DraflingConfigInterface.php b/src/Research/Config/ResearchConfigInterface.php similarity index 58% rename from src/Drafling/Config/DraflingConfigInterface.php rename to src/Research/Config/ResearchConfigInterface.php index 673499d3..a25bd787 100644 --- a/src/Drafling/Config/DraflingConfigInterface.php +++ b/src/Research/Config/ResearchConfigInterface.php @@ -2,12 +2,9 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Config; +namespace Butschster\ContextGenerator\Research\Config; -/** - * Configuration interface for Drafling system - */ -interface DraflingConfigInterface +interface ResearchConfigInterface { /** * Get templates directory path @@ -15,9 +12,9 @@ interface DraflingConfigInterface public function getTemplatesPath(): string; /** - * Get projects base directory path + * Get researches base directory path */ - public function getProjectsPath(): string; + public function getResearchesPath(): string; /** * Get storage driver name @@ -29,11 +26,6 @@ public function getStorageDriver(): string; */ public function getDefaultEntryStatus(): string; - /** - * Check if Drafling system is enabled - */ - public function isEnabled(): bool; - /** * Get environment variable configuration */ diff --git a/src/Drafling/Console/ProjectInfoCommand.php b/src/Research/Console/ResearchInfoCommand.php similarity index 59% rename from src/Drafling/Console/ProjectInfoCommand.php rename to src/Research/Console/ResearchInfoCommand.php index 88ae4acb..4346057c 100644 --- a/src/Drafling/Console/ProjectInfoCommand.php +++ b/src/Research/Console/ResearchInfoCommand.php @@ -2,100 +2,79 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Console; +namespace Butschster\ContextGenerator\Research\Console; use Butschster\ContextGenerator\Console\BaseCommand; use Butschster\ContextGenerator\Console\Renderer\Style; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; -use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; +use Butschster\ContextGenerator\Research\Domain\Model\Research; +use Butschster\ContextGenerator\Research\Domain\Model\Template; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; +use Butschster\ContextGenerator\Research\Service\TemplateServiceInterface; use Spiral\Console\Attribute\Argument; -use Spiral\Console\Attribute\Option; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; #[AsCommand( - name: 'drafling:project', - description: 'Show detailed information about a Drafling project', - aliases: ['drafling:project:info'], + name: 'research', + description: 'Show detailed information about research', )] -final class ProjectInfoCommand extends BaseCommand +final class ResearchInfoCommand extends BaseCommand { - #[Argument( - name: 'project_id', - description: 'Project ID to show information for', - )] - protected string $projectId; - - #[Option( - name: 'entries', - shortcut: 'e', - description: 'Show project entries', - )] - protected bool $showEntries = false; - - #[Option( - name: 'stats', - shortcut: 's', - description: 'Show project statistics', - )] - protected bool $showStats = false; + #[Argument(description: 'Research ID to show information for')] + protected string $researchId; public function __invoke( - ProjectServiceInterface $projectService, + ResearchServiceInterface $service, EntryServiceInterface $entryService, TemplateServiceInterface $templateService, ): int { try { - $projectId = new ProjectId($this->projectId); + $researchId = new ResearchId($this->researchId); - // Get project information - $project = $projectService->getProject($projectId); - if ($project === null) { - $this->output->error("Project not found: {$this->projectId}"); + // 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($project->template)); + $template = $templateService->getTemplate(new TemplateKey($research->template)); - // Display project information - $this->displayProjectInfo($project, $template); + // Display research information + $this->displayInfo($research, $template); // Show entries if requested - if ($this->showEntries) { - $this->displayProjectEntries($entryService, $projectId); - } + $this->displayEntries($entryService, $researchId); // Show statistics if requested - if ($this->showStats) { - $this->displayProjectStatistics($entryService, $projectId); - } + $this->displayStatistics($entryService, $researchId); return Command::SUCCESS; } catch (\Throwable $e) { - $this->output->error('Failed to get project information: ' . $e->getMessage()); + $this->output->error('Failed to get research information: ' . $e->getMessage()); return Command::FAILURE; } } - private function displayProjectInfo($project, $template): void + private function displayInfo(Research $research, ?Template $template): void { - $this->output->title("Project Information"); + $this->output->title("Research Information"); $this->output->definitionList( - ['ID', Style::property($project->id)], - ['Name', $project->name], - ['Description', $project->description ?: 'None'], - ['Status', $project->status], - ['Template', $project->template . ($template ? " ({$template->name})" : ' (template not found)')], - ['Tags', empty($project->tags) ? 'None' : \implode(', ', $project->tags)], - ['Entry Directories', empty($project->entryDirs) ? 'None' : \implode(', ', $project->entryDirs)], - ['Project Path', $project->projectPath ?? 'Not set'], + ['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) { @@ -110,15 +89,15 @@ private function displayProjectInfo($project, $template): void } } - private function displayProjectEntries(EntryServiceInterface $entryService, ProjectId $projectId): void + private function displayEntries(EntryServiceInterface $entryService, ResearchId $researchId): void { - $this->output->section('Project Entries'); + $this->output->section('Entries'); try { - $entries = $entryService->getEntries($projectId); + $entries = $entryService->findAll($researchId); if (empty($entries)) { - $this->output->info('No entries found in this project.'); + $this->output->info('No entries found in this research.'); return; } @@ -141,16 +120,16 @@ private function displayProjectEntries(EntryServiceInterface $entryService, Proj $table->render(); } catch (\Throwable $e) { - $this->output->error('Failed to load project entries: ' . $e->getMessage()); + $this->output->error('Failed to load research entries: ' . $e->getMessage()); } } - private function displayProjectStatistics(EntryServiceInterface $entryService, ProjectId $projectId): void + private function displayStatistics(EntryServiceInterface $entryService, ResearchId $researchId): void { - $this->output->section('Project Statistics'); + $this->output->section('Statistics'); try { - $entries = $entryService->getEntries($projectId); + $entries = $entryService->findAll($researchId); // Calculate statistics $totalEntries = \count($entries); @@ -210,7 +189,7 @@ private function displayProjectStatistics(EntryServiceInterface $entryService, P } } catch (\Throwable $e) { - $this->output->error('Failed to calculate project statistics: ' . $e->getMessage()); + $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/Drafling/Domain/Model/Category.php b/src/Research/Domain/Model/Category.php similarity index 91% rename from src/Drafling/Domain/Model/Category.php rename to src/Research/Domain/Model/Category.php index 54f40293..8382538a 100644 --- a/src/Drafling/Domain/Model/Category.php +++ b/src/Research/Domain/Model/Category.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\Model; +namespace Butschster\ContextGenerator\Research\Domain\Model; /** * Template category definition diff --git a/src/Drafling/Domain/Model/Entry.php b/src/Research/Domain/Model/Entry.php similarity index 88% rename from src/Drafling/Domain/Model/Entry.php rename to src/Research/Domain/Model/Entry.php index 0ee4710d..29c8089c 100644 --- a/src/Drafling/Domain/Model/Entry.php +++ b/src/Research/Domain/Model/Entry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\Model; +namespace Butschster\ContextGenerator\Research\Domain\Model; /** * Entry represents an individual markdown document with structured metadata @@ -61,17 +61,6 @@ public function withUpdates( ); } - /** - * Generate filename for this entry - */ - public function generateFilename(): string - { - $slug = \preg_replace('/[^a-z0-9]+/', '-', \strtolower($this->title)); - $slug = \trim((string) $slug, '-'); - - return "{$slug}.md"; - } - /** * Specify data which should be serialized to JSON */ diff --git a/src/Drafling/Domain/Model/EntryType.php b/src/Research/Domain/Model/EntryType.php similarity index 94% rename from src/Drafling/Domain/Model/EntryType.php rename to src/Research/Domain/Model/EntryType.php index a956824a..37ebbd0e 100644 --- a/src/Drafling/Domain/Model/EntryType.php +++ b/src/Research/Domain/Model/EntryType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\Model; +namespace Butschster\ContextGenerator\Research\Domain\Model; /** * Entry type definition with validation rules and statuses 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/Drafling/Domain/Model/Status.php b/src/Research/Domain/Model/Status.php similarity index 79% rename from src/Drafling/Domain/Model/Status.php rename to src/Research/Domain/Model/Status.php index 9cce294d..ec993247 100644 --- a/src/Drafling/Domain/Model/Status.php +++ b/src/Research/Domain/Model/Status.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\Model; +namespace Butschster\ContextGenerator\Research\Domain\Model; /** * Template status definition with display properties diff --git a/src/Drafling/Domain/Model/Template.php b/src/Research/Domain/Model/Template.php similarity index 98% rename from src/Drafling/Domain/Model/Template.php rename to src/Research/Domain/Model/Template.php index 54771bdf..e6e801a0 100644 --- a/src/Drafling/Domain/Model/Template.php +++ b/src/Research/Domain/Model/Template.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\Model; +namespace Butschster\ContextGenerator\Research\Domain\Model; /** * Template definition with categories, entry types, and metadata diff --git a/src/Drafling/Domain/ValueObject/EntryId.php b/src/Research/Domain/ValueObject/EntryId.php similarity index 86% rename from src/Drafling/Domain/ValueObject/EntryId.php rename to src/Research/Domain/ValueObject/EntryId.php index 36e4b77d..efe6fceb 100644 --- a/src/Drafling/Domain/ValueObject/EntryId.php +++ b/src/Research/Domain/ValueObject/EntryId.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\ValueObject; +namespace Butschster\ContextGenerator\Research\Domain\ValueObject; /** * Entry identifier value object @@ -41,11 +41,6 @@ public function equals(self $other): bool return $this->value === $other->value; } - public function toString(): string - { - return $this->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/Drafling/Domain/ValueObject/TemplateKey.php b/src/Research/Domain/ValueObject/TemplateKey.php similarity index 83% rename from src/Drafling/Domain/ValueObject/TemplateKey.php rename to src/Research/Domain/ValueObject/TemplateKey.php index bcd13856..77a74dc7 100644 --- a/src/Drafling/Domain/ValueObject/TemplateKey.php +++ b/src/Research/Domain/ValueObject/TemplateKey.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Domain\ValueObject; +namespace Butschster\ContextGenerator\Research\Domain\ValueObject; /** * Template key value object @@ -33,11 +33,6 @@ public function equals(self $other): bool return $this->value === $other->value; } - public function toString(): string - { - return $this->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 @@ +projectId)) { - $errors[] = 'Project ID cannot be empty'; + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; } if (empty($this->category)) { @@ -157,7 +157,7 @@ public function withResolvedKeys( ?string $resolvedStatus = null, ): self { return new self( - projectId: $this->projectId, + researchId: $this->researchId, category: $resolvedCategory, entryType: $resolvedEntryType, content: $this->content, diff --git a/src/Drafling/MCP/DTO/EntryFilters.php b/src/Research/MCP/DTO/EntryFilters.php similarity index 98% rename from src/Drafling/MCP/DTO/EntryFilters.php rename to src/Research/MCP/DTO/EntryFilters.php index 05432240..543200a1 100644 --- a/src/Drafling/MCP/DTO/EntryFilters.php +++ b/src/Research/MCP/DTO/EntryFilters.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; diff --git a/src/Drafling/MCP/DTO/EntryUpdateRequest.php b/src/Research/MCP/DTO/EntryUpdateRequest.php similarity index 95% rename from src/Drafling/MCP/DTO/EntryUpdateRequest.php rename to src/Research/MCP/DTO/EntryUpdateRequest.php index a6a3bffd..e79e9807 100644 --- a/src/Drafling/MCP/DTO/EntryUpdateRequest.php +++ b/src/Research/MCP/DTO/EntryUpdateRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; @@ -12,8 +12,8 @@ final readonly class EntryUpdateRequest { public function __construct( - #[Field(description: 'Project ID')] - public string $projectId, + #[Field(description: 'Research ID')] + public string $researchId, #[Field(description: 'Entry ID to update')] public string $entryId, #[Field( @@ -106,8 +106,8 @@ public function validate(): array { $errors = []; - if (empty($this->projectId)) { - $errors[] = 'Project ID cannot be empty'; + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; } if (empty($this->entryId)) { @@ -148,7 +148,7 @@ public function validate(): array public function withResolvedStatus(?string $resolvedStatus): self { return new self( - projectId: $this->projectId, + researchId: $this->researchId, entryId: $this->entryId, title: $this->title, description: $this->description, diff --git a/src/Drafling/MCP/DTO/GetProjectRequest.php b/src/Research/MCP/DTO/GetResearchRequest.php similarity index 59% rename from src/Drafling/MCP/DTO/GetProjectRequest.php rename to src/Research/MCP/DTO/GetResearchRequest.php index b87e6373..801c0fa4 100644 --- a/src/Drafling/MCP/DTO/GetProjectRequest.php +++ b/src/Research/MCP/DTO/GetResearchRequest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; /** - * DTO for getting a single project by ID + * DTO for getting a single research by ID */ -final readonly class GetProjectRequest +final readonly class GetResearchRequest { public function __construct( - #[Field(description: 'Project ID')] + #[Field(description: 'Research ID')] public string $id, ) {} @@ -24,7 +24,7 @@ public function validate(): array $errors = []; if (empty($this->id)) { - $errors[] = 'Project ID cannot be empty'; + $errors[] = 'Research ID cannot be empty'; } return $errors; diff --git a/src/Drafling/MCP/DTO/ListEntriesRequest.php b/src/Research/MCP/DTO/ListEntriesRequest.php similarity index 85% rename from src/Drafling/MCP/DTO/ListEntriesRequest.php rename to src/Research/MCP/DTO/ListEntriesRequest.php index c068514c..79a55043 100644 --- a/src/Drafling/MCP/DTO/ListEntriesRequest.php +++ b/src/Research/MCP/DTO/ListEntriesRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; @@ -13,10 +13,10 @@ { public function __construct( #[Field( - description: 'Project ID to list entries from', + description: 'Research ID to list entries from', default: null, )] - public string $projectId, + public string $researchId, #[Field( description: 'Entry filtering criteria', default: null, @@ -41,9 +41,8 @@ public function validate(): array { $errors = []; - // Validate project ID - if (empty(\trim($this->projectId))) { - $errors[] = 'Project ID is required'; + if (empty(\trim($this->researchId))) { + $errors[] = 'Research ID is required'; } diff --git a/src/Drafling/MCP/DTO/ListProjectsRequest.php b/src/Research/MCP/DTO/ListResearchesRequest.php similarity index 53% rename from src/Drafling/MCP/DTO/ListProjectsRequest.php rename to src/Research/MCP/DTO/ListResearchesRequest.php index 0ca1b39e..93f2ad4d 100644 --- a/src/Drafling/MCP/DTO/ListProjectsRequest.php +++ b/src/Research/MCP/DTO/ListResearchesRequest.php @@ -2,44 +2,34 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; use Spiral\JsonSchemaGenerator\Attribute\Constraint\Range; /** - * DTO for listing projects with filtering and pagination options + * DTO for listing researches with filtering and pagination options */ -final readonly class ListProjectsRequest +final readonly class ListResearchesRequest { public function __construct( #[Field( - description: 'Project filtering criteria', + description: 'Research filtering criteria', default: null, )] - public ?ProjectFilters $filters = null, + public ?ResearchFilters $filters = null, #[Field( - description: 'Maximum number of projects to return', + description: 'Maximum number to return', default: 20, )] #[Range(min: 1, max: 100)] public int $limit = 20, #[Field( - description: 'Number of projects to skip (for pagination)', + description: 'Number to skip (for pagination)', default: 0, )] #[Range(min: 0, max: 10000)] public int $offset = 0, - #[Field( - description: 'Sort projects by field (name, status, created_at, updated_at)', - default: 'name', - )] - public string $sortBy = 'name', - #[Field( - description: 'Sort direction (asc or desc)', - default: 'asc', - )] - public string $sortDirection = 'asc', ) {} /** @@ -65,17 +55,6 @@ public function getPaginationOptions(): array ]; } - /** - * Get sorting options - */ - public function getSortingOptions(): array - { - return [ - 'sort_by' => $this->sortBy, - 'sort_direction' => $this->sortDirection, - ]; - } - /** * Check if any filters are applied */ @@ -100,17 +79,6 @@ public function validate(): array $errors[] = 'Offset must be non-negative'; } - // Validate sorting - $validSortFields = ['name', 'status', 'created_at', 'updated_at', 'template']; - if (!\in_array($this->sortBy, $validSortFields, true)) { - $errors[] = 'Sort field must be one of: ' . \implode(', ', $validSortFields); - } - - $validSortDirections = ['asc', 'desc']; - if (!\in_array(\strtolower($this->sortDirection), $validSortDirections, true)) { - $errors[] = 'Sort direction must be either "asc" or "desc"'; - } - // Validate filters if provided if ($this->filters !== null) { $filterErrors = $this->filters->validate(); diff --git a/src/Drafling/MCP/DTO/ListTemplatesRequest.php b/src/Research/MCP/DTO/ListTemplatesRequest.php similarity index 96% rename from src/Drafling/MCP/DTO/ListTemplatesRequest.php rename to src/Research/MCP/DTO/ListTemplatesRequest.php index 22c4aff6..b4ce3960 100644 --- a/src/Drafling/MCP/DTO/ListTemplatesRequest.php +++ b/src/Research/MCP/DTO/ListTemplatesRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; diff --git a/src/Drafling/MCP/DTO/ReadEntryRequest.php b/src/Research/MCP/DTO/ReadEntryRequest.php similarity index 71% rename from src/Drafling/MCP/DTO/ReadEntryRequest.php rename to src/Research/MCP/DTO/ReadEntryRequest.php index 8376eca3..20169717 100644 --- a/src/Drafling/MCP/DTO/ReadEntryRequest.php +++ b/src/Research/MCP/DTO/ReadEntryRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; @@ -13,10 +13,10 @@ { public function __construct( #[Field( - description: 'Project ID containing the entry', + description: 'Research ID containing the entry', default: null, )] - public string $projectId, + public string $researchId, #[Field( description: 'Entry ID to retrieve', default: null, @@ -31,9 +31,9 @@ public function validate(): array { $errors = []; - // Validate project ID - if (empty(\trim($this->projectId))) { - $errors[] = 'Project ID is required'; + // Validate research ID + if (empty(\trim($this->researchId))) { + $errors[] = 'Research ID is required'; } // Validate entry ID diff --git a/src/Drafling/MCP/DTO/ProjectCreateRequest.php b/src/Research/MCP/DTO/ResearchCreateRequest.php similarity index 64% rename from src/Drafling/MCP/DTO/ProjectCreateRequest.php rename to src/Research/MCP/DTO/ResearchCreateRequest.php index 95f9f1c3..7265e896 100644 --- a/src/Drafling/MCP/DTO/ProjectCreateRequest.php +++ b/src/Research/MCP/DTO/ResearchCreateRequest.php @@ -2,27 +2,24 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; -/** - * Data Transfer Object for project creation requests - */ -final readonly class ProjectCreateRequest +final readonly class ResearchCreateRequest { public function __construct( - #[Field(description: 'Template ID to use for the project')] + #[Field(description: 'Template ID to use for the research')] public string $templateId, - #[Field(description: 'Project title')] + #[Field(description: 'Research title')] public string $title, #[Field( - description: 'Project description (optional)', + description: 'Research description (optional)', default: '', )] public string $description = '', #[Field( - description: 'Project tags for organization (optional)', + description: 'Research tags for organization (optional)', default: [], )] /** @var string[] */ @@ -41,14 +38,6 @@ public function __construct( public array $memory = [], ) {} - /** - * Get project name (using title field) - */ - public function getName(): string - { - return $this->title; - } - /** * Validate the request data */ @@ -61,7 +50,7 @@ public function validate(): array } if (empty(\trim($this->title))) { - $errors[] = 'Project title cannot be empty'; + $errors[] = 'Research title cannot be empty'; } return $errors; diff --git a/src/Drafling/MCP/DTO/ProjectFilters.php b/src/Research/MCP/DTO/ResearchFilters.php similarity index 83% rename from src/Drafling/MCP/DTO/ProjectFilters.php rename to src/Research/MCP/DTO/ResearchFilters.php index a1a91f4e..32660b45 100644 --- a/src/Drafling/MCP/DTO/ProjectFilters.php +++ b/src/Research/MCP/DTO/ResearchFilters.php @@ -2,36 +2,33 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; use Spiral\JsonSchemaGenerator\Attribute\Constraint\Enum; -/** - * DTO for project filtering criteria - */ -final readonly class ProjectFilters +final readonly class ResearchFilters { public function __construct( #[Field( - description: 'Filter by project status', + description: 'Filter by research status', default: null, )] #[Enum(values: ['draft', 'active', 'published', 'archived'])] public ?string $status = null, #[Field( - description: 'Filter by template/project type', + description: 'Filter by template/research type', default: null, )] public ?string $template = null, #[Field( - description: 'Filter by project tags (projects must have any of these tags)', + description: 'Filter by research tags (researches must have any of these tags)', default: null, )] /** @var string[]|null */ public ?array $tags = null, #[Field( - description: 'Filter by project name (partial match)', + description: 'Filter by research name (partial match)', default: null, )] public ?string $nameContains = null, @@ -70,7 +67,7 @@ public function hasFilters(): bool { return $this->status !== null || $this->template !== null - || ($this->tags !== null && !empty($this->tags)) + || (!empty($this->tags)) || $this->nameContains !== null; } diff --git a/src/Drafling/MCP/DTO/ProjectMemory.php b/src/Research/MCP/DTO/ResearchMemory.php similarity index 54% rename from src/Drafling/MCP/DTO/ProjectMemory.php rename to src/Research/MCP/DTO/ResearchMemory.php index eaf60b4c..d7d4a9ef 100644 --- a/src/Drafling/MCP/DTO/ProjectMemory.php +++ b/src/Research/MCP/DTO/ResearchMemory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; -final readonly class ProjectMemory +final readonly class ResearchMemory { public function __construct( public string $record, diff --git a/src/Drafling/MCP/DTO/ProjectUpdateRequest.php b/src/Research/MCP/DTO/ResearchUpdateRequest.php similarity index 65% rename from src/Drafling/MCP/DTO/ProjectUpdateRequest.php rename to src/Research/MCP/DTO/ResearchUpdateRequest.php index d1ce2e2b..a4619b25 100644 --- a/src/Drafling/MCP/DTO/ProjectUpdateRequest.php +++ b/src/Research/MCP/DTO/ResearchUpdateRequest.php @@ -2,32 +2,32 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\DTO; +namespace Butschster\ContextGenerator\Research\MCP\DTO; use Spiral\JsonSchemaGenerator\Attribute\Field; /** - * Data Transfer Object for project update requests + * Data Transfer Object for research update requests */ -final readonly class ProjectUpdateRequest +final readonly class ResearchUpdateRequest { public function __construct( - #[Field(description: 'Project ID to update')] - public string $projectId, + #[Field(description: 'Research ID to update')] + public string $researchId, #[Field( - description: 'New project title (optional)', + description: 'New research title (optional)', )] public ?string $title = null, #[Field( - description: 'New project description (optional)', + description: 'New research description (optional)', )] public ?string $description = null, #[Field( - description: 'New project status (optional)', + description: 'New research status (optional)', )] public ?string $status = null, #[Field( - description: 'New project tags (optional)', + description: 'New research tags (optional)', default: null, )] /** @var string[]|null */ @@ -42,18 +42,10 @@ public function __construct( description: 'New memory entries (optional)', default: null, )] - /** @var ProjectMemory[]|null */ + /** @var ResearchMemory[]|null */ public ?array $memory = null, ) {} - /** - * Get project name (alias for title) - */ - public function getName(): ?string - { - return $this->title; - } - /** * Check if there are any updates to apply */ @@ -74,8 +66,8 @@ public function validate(): array { $errors = []; - if (empty($this->projectId)) { - $errors[] = 'Project ID cannot be empty'; + if (empty($this->researchId)) { + $errors[] = 'Research ID cannot be empty'; } if (!$this->hasUpdates()) { diff --git a/src/Drafling/MCP/Tools/CreateEntryToolAction.php b/src/Research/MCP/Tools/CreateEntryToolAction.php similarity index 59% rename from src/Drafling/MCP/Tools/CreateEntryToolAction.php rename to src/Research/MCP/Tools/CreateEntryToolAction.php index be115d91..20c55f72 100644 --- a/src/Drafling/MCP/Tools/CreateEntryToolAction.php +++ b/src/Research/MCP/Tools/CreateEntryToolAction.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryCreateRequest; -use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +namespace Butschster\ContextGenerator\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\EntryCreateRequest; +use Butschster\ContextGenerator\Research\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Action\ToolResult; @@ -18,8 +18,8 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_create_entry', - description: 'Add new content entries to project categories with template validation and automatic title generation', + name: 'research-create-entry', + description: 'Add new content entries to research categories', title: 'Create Entry', )] #[InputSchema(class: EntryCreateRequest::class)] @@ -28,14 +28,14 @@ public function __construct( private LoggerInterface $logger, private EntryServiceInterface $entryService, - private ProjectServiceInterface $projectService, + private ResearchServiceInterface $service, ) {} - #[Post(path: '/tools/call/drafling_create_entry', name: 'tools.drafling_create_entry')] + #[Post(path: '/tools/call/research-create-entry', name: 'tools.research-create-entry')] public function __invoke(EntryCreateRequest $request): CallToolResult { $this->logger->info('Creating new entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'category' => $request->category, 'entry_type' => $request->entryType, 'has_description' => $request->description !== null, @@ -48,17 +48,17 @@ public function __invoke(EntryCreateRequest $request): CallToolResult return ToolResult::validationError($validationErrors); } - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); + // 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($projectId, $request); + $entry = $this->entryService->createEntry($researchId, $request); $this->logger->info('Entry created successfully', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $entry->entryId, 'title' => $entry->title, ]); @@ -71,23 +71,23 @@ public function __invoke(EntryCreateRequest $request): CallToolResult 'entry_type' => $entry->entryType, 'category' => $entry->category, 'status' => $entry->status, - 'content_type' => 'markdown', // Default content type for Drafling + 'content_type' => 'markdown', 'created_at' => $entry->createdAt->format('c'), ]; return ToolResult::success($response); - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); return ToolResult::error($e->getMessage()); - } catch (DraflingException $e) { - $this->logger->error('Drafling error during entry creation', [ - 'project_id' => $request->projectId, + } catch (ResearchException $e) { + $this->logger->error('Research error during entry creation', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); @@ -95,7 +95,7 @@ public function __invoke(EntryCreateRequest $request): CallToolResult } catch (\Throwable $e) { $this->logger->error('Unexpected error creating entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); diff --git a/src/Drafling/MCP/Tools/CreateProjectToolAction.php b/src/Research/MCP/Tools/CreateResearchToolAction.php similarity index 54% rename from src/Drafling/MCP/Tools/CreateProjectToolAction.php rename to src/Research/MCP/Tools/CreateResearchToolAction.php index ec4b406a..85b6d450 100644 --- a/src/Drafling/MCP/Tools/CreateProjectToolAction.php +++ b/src/Research/MCP/Tools/CreateResearchToolAction.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; +namespace Butschster\ContextGenerator\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\TemplateNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchCreateRequest; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; +use Butschster\ContextGenerator\Research\Service\TemplateServiceInterface; use Butschster\ContextGenerator\McpServer\Action\ToolResult; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; @@ -18,23 +18,23 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_create_project', - description: 'Create a new project from an existing template with validation and proper initialization', - title: 'Create Project', + name: 'research-create', + description: 'Create a new research from an existing template with validation and proper initialization', + title: 'Create Research', )] -#[InputSchema(class: ProjectCreateRequest::class)] -final readonly class CreateProjectToolAction +#[InputSchema(class: ResearchCreateRequest::class)] +final readonly class CreateResearchToolAction { public function __construct( private LoggerInterface $logger, - private ProjectServiceInterface $projectService, + private ResearchServiceInterface $service, private TemplateServiceInterface $templateService, ) {} - #[Post(path: '/tools/call/drafling_create_project', name: 'tools.drafling_create_project')] - public function __invoke(ProjectCreateRequest $request): CallToolResult + #[Post(path: '/tools/call/research-create', name: 'tools.research-create')] + public function __invoke(ResearchCreateRequest $request): CallToolResult { - $this->logger->info('Creating new project', [ + $this->logger->info('Creating new research', [ 'template_id' => $request->templateId, 'title' => $request->title, ]); @@ -52,21 +52,21 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult return ToolResult::error("Template '{$request->templateId}' not found"); } - // Create project using domain service - $project = $this->projectService->createProject($request); + // Create research using domain service + $research = $this->service->create($request); - $this->logger->info('Project created successfully', [ - 'project_id' => $project->id, - 'template' => $project->template, + $this->logger->info('Research created successfully', [ + 'research_id' => $research->id, + 'template' => $research->template, ]); // Format successful response according to MCP specification $response = [ 'success' => true, - 'project_id' => $project->id, - 'title' => $project->name, - 'template_id' => $project->template, - 'status' => $project->status, + 'research_id' => $research->id, + 'title' => $research->name, + 'template_id' => $research->template, + 'status' => $research->status, 'created_at' => (new \DateTime())->format('c'), ]; @@ -80,8 +80,8 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult return ToolResult::error($e->getMessage()); - } catch (DraflingException $e) { - $this->logger->error('Drafling error during project creation', [ + } catch (ResearchException $e) { + $this->logger->error('Error during research creation', [ 'template_id' => $request->templateId, 'error' => $e->getMessage(), ]); @@ -89,12 +89,12 @@ public function __invoke(ProjectCreateRequest $request): CallToolResult return ToolResult::error($e->getMessage()); } catch (\Throwable $e) { - $this->logger->error('Unexpected error creating project', [ + $this->logger->error('Unexpected error creating research', [ 'template_id' => $request->templateId, 'error' => $e->getMessage(), ]); - return ToolResult::error('Failed to create project: ' . $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/Drafling/MCP/Tools/ListEntriesToolAction.php b/src/Research/MCP/Tools/ListEntriesToolAction.php similarity index 65% rename from src/Drafling/MCP/Tools/ListEntriesToolAction.php rename to src/Research/MCP/Tools/ListEntriesToolAction.php index 388289d5..3fbe6c3e 100644 --- a/src/Drafling/MCP/Tools/ListEntriesToolAction.php +++ b/src/Research/MCP/Tools/ListEntriesToolAction.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\Model\Entry; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ListEntriesRequest; -use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +namespace Butschster\ContextGenerator\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\Model\Entry; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\ListEntriesRequest; +use Butschster\ContextGenerator\Research\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Action\ToolResult; @@ -19,8 +19,8 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_list_entries', - description: 'Retrieve a list of entries from a project with filtering, sorting, and pagination support', + name: 'research-entries', + description: 'Retrieve a list of entries from a research', title: 'List Entries', )] #[InputSchema(class: ListEntriesRequest::class)] @@ -29,14 +29,14 @@ public function __construct( private LoggerInterface $logger, private EntryServiceInterface $entryService, - private ProjectServiceInterface $projectService, + private ResearchServiceInterface $service, ) {} - #[Post(path: '/tools/call/drafling_list_entries', name: 'tools.drafling_list_entries')] + #[Post(path: '/tools/call/research-entries', name: 'tools.research-entries')] public function __invoke(ListEntriesRequest $request): CallToolResult { $this->logger->info('Listing entries', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'has_filters' => $request->hasFilters(), 'filters' => $request->getFilters(), 'limit' => $request->limit, @@ -50,14 +50,14 @@ public function __invoke(ListEntriesRequest $request): CallToolResult return ToolResult::validationError($validationErrors); } - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); + // 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->getEntries($projectId, $request->getFilters()); + $allEntries = $this->entryService->findAll($researchId, $request->getFilters()); // Apply pagination $paginatedEntries = \array_slice( @@ -89,7 +89,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult ]; $this->logger->info('Entries listed successfully', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'returned_count' => \count($paginatedEntries), 'total_available' => \count($allEntries), 'filters_applied' => $request->hasFilters(), @@ -97,17 +97,17 @@ public function __invoke(ListEntriesRequest $request): CallToolResult return ToolResult::success($response); - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); return ToolResult::error($e->getMessage()); - } catch (DraflingException $e) { - $this->logger->error('Drafling error listing entries', [ - 'project_id' => $request->projectId, + } catch (ResearchException $e) { + $this->logger->error('Error listing research entries', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); @@ -115,7 +115,7 @@ public function __invoke(ListEntriesRequest $request): CallToolResult } catch (\Throwable $e) { $this->logger->error('Unexpected error listing entries', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'error' => $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/Drafling/MCP/Tools/ListTemplatesToolAction.php b/src/Research/MCP/Tools/ListTemplatesToolAction.php similarity index 83% rename from src/Drafling/MCP/Tools/ListTemplatesToolAction.php rename to src/Research/MCP/Tools/ListTemplatesToolAction.php index 42a9f76a..1a0c3484 100644 --- a/src/Drafling/MCP/Tools/ListTemplatesToolAction.php +++ b/src/Research/MCP/Tools/ListTemplatesToolAction.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; +namespace Butschster\ContextGenerator\Research\MCP\Tools; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ListTemplatesRequest; -use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\MCP\DTO\ListTemplatesRequest; +use Butschster\ContextGenerator\Research\Service\TemplateServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Action\ToolResult; @@ -15,8 +15,8 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_list_templates', - description: 'Retrieve all available templates for creating projects, with optional filtering and detailed information', + name: 'research-templates-list', + description: 'Retrieve all available templates for creating researches', title: 'List Templates', )] #[InputSchema(class: ListTemplatesRequest::class)] @@ -27,7 +27,7 @@ public function __construct( private TemplateServiceInterface $templateService, ) {} - #[Post(path: '/tools/call/drafling_list_templates', name: 'tools.drafling_list_templates')] + #[Post(path: '/tools/call/research-templates-list', name: 'tools.research-templates-list')] public function __invoke(ListTemplatesRequest $request): CallToolResult { $this->logger->info('Listing templates', [ @@ -45,7 +45,7 @@ public function __invoke(ListTemplatesRequest $request): CallToolResult } // Get all templates - $allTemplates = $this->templateService->getAllTemplates(); + $allTemplates = $this->templateService->findAll(); // Apply filters $filteredTemplates = $this->applyFilters($allTemplates, $request); @@ -62,8 +62,8 @@ public function __invoke(ListTemplatesRequest $request): CallToolResult return ToolResult::success($response); - } catch (DraflingException $e) { - $this->logger->error('Drafling error listing templates', [ + } catch (ResearchException $e) { + $this->logger->error('Error listing research templates', [ 'error' => $e->getMessage(), ]); diff --git a/src/Drafling/MCP/Tools/ReadEntryToolAction.php b/src/Research/MCP/Tools/ReadEntryToolAction.php similarity index 58% rename from src/Drafling/MCP/Tools/ReadEntryToolAction.php rename to src/Research/MCP/Tools/ReadEntryToolAction.php index 7709e147..c83f2603 100644 --- a/src/Drafling/MCP/Tools/ReadEntryToolAction.php +++ b/src/Research/MCP/Tools/ReadEntryToolAction.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ReadEntryRequest; -use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +namespace Butschster\ContextGenerator\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\ValueObject\EntryId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\EntryNotFoundException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\ReadEntryRequest; +use Butschster\ContextGenerator\Research\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Action\ToolResult; @@ -20,8 +20,8 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_read_entry', - description: 'Retrieve detailed information about a specific entry including content and metadata', + name: 'research-entry-read', + description: 'Retrieve detailed information about a specific entry', title: 'Read Entry', )] #[InputSchema(class: ReadEntryRequest::class)] @@ -30,14 +30,14 @@ public function __construct( private LoggerInterface $logger, private EntryServiceInterface $entryService, - private ProjectServiceInterface $projectService, + private ResearchServiceInterface $service, ) {} - #[Post(path: '/tools/call/drafling_read_entry', name: 'tools.drafling_read_entry')] + #[Post(path: '/tools/call/research-entry-read', name: 'tools.research-entry-read')] public function __invoke(ReadEntryRequest $request): CallToolResult { $this->logger->info('Reading entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, ]); @@ -48,31 +48,31 @@ public function __invoke(ReadEntryRequest $request): CallToolResult return ToolResult::validationError($validationErrors); } - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); + // 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($projectId, $entryId); + $entry = $this->entryService->getEntry($researchId, $entryId); if ($entry === null) { - return ToolResult::error("Entry '{$request->entryId}' not found in project '{$request->projectId}'"); + return ToolResult::error("Entry '{$request->entryId}' not found in research '{$request->researchId}'"); } $this->logger->info('Entry read successfully', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'title' => $entry->title, ]); return ToolResult::success($entry); - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); @@ -80,16 +80,16 @@ public function __invoke(ReadEntryRequest $request): CallToolResult } catch (EntryNotFoundException $e) { $this->logger->error('Entry not found', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); return ToolResult::error($e->getMessage()); - } catch (DraflingException $e) { - $this->logger->error('Drafling error reading entry', [ - 'project_id' => $request->projectId, + } catch (ResearchException $e) { + $this->logger->error('Error reading research entry', [ + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); @@ -98,7 +98,7 @@ public function __invoke(ReadEntryRequest $request): CallToolResult } catch (\Throwable $e) { $this->logger->error('Unexpected error reading entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); diff --git a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php b/src/Research/MCP/Tools/UpdateEntryToolAction.php similarity index 57% rename from src/Drafling/MCP/Tools/UpdateEntryToolAction.php rename to src/Research/MCP/Tools/UpdateEntryToolAction.php index ccfff752..f6d14bbb 100644 --- a/src/Drafling/MCP/Tools/UpdateEntryToolAction.php +++ b/src/Research/MCP/Tools/UpdateEntryToolAction.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryUpdateRequest; -use Butschster\ContextGenerator\Drafling\Service\EntryServiceInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +namespace Butschster\ContextGenerator\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\ValueObject\EntryId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\EntryNotFoundException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\EntryUpdateRequest; +use Butschster\ContextGenerator\Research\Service\EntryServiceInterface; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Action\ToolResult; @@ -20,8 +20,8 @@ use Psr\Log\LoggerInterface; #[Tool( - name: 'drafling_update_entry', - description: 'Update existing content entries with new title, content, status, or tags while preserving entry metadata', + name: 'research-entry-update', + description: 'Update existing research entries with new title, content, status, or tags while preserving entry metadata', title: 'Update Entry', )] #[InputSchema(class: EntryUpdateRequest::class)] @@ -30,14 +30,14 @@ public function __construct( private LoggerInterface $logger, private EntryServiceInterface $entryService, - private ProjectServiceInterface $projectService, + private ResearchServiceInterface $service, ) {} - #[Post(path: '/tools/call/drafling_update_entry', name: 'tools.drafling_update_entry')] + #[Post(path: '/tools/call/research-entry-update', name: 'tools.research-entry-update')] public function __invoke(EntryUpdateRequest $request): CallToolResult { $this->logger->info('Updating entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'has_title' => $request->title !== null, 'has_description' => $request->description !== null, @@ -54,46 +54,32 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult return ToolResult::validationError($validationErrors); } - // Verify project exists - $projectId = ProjectId::fromString($request->projectId); - if (!$this->projectService->projectExists($projectId)) { - return ToolResult::error("Project '{$request->projectId}' not found"); + $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($projectId, $entryId)) { - return ToolResult::error("Entry '{$request->entryId}' not found in project '{$request->projectId}'"); + 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($projectId, $entryId, $request); + $updatedEntry = $this->entryService->updateEntry($researchId, $entryId, $request); $this->logger->info('Entry updated successfully', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'title' => $updatedEntry->title, ]); - // Format successful response according to MCP specification - $response = [ + return ToolResult::success([ 'success' => true, - 'entry_id' => $updatedEntry->entryId, - 'title' => $updatedEntry->title, - 'entry_type' => $updatedEntry->entryType, - 'category' => $updatedEntry->category, - 'status' => $updatedEntry->status, - 'content_type' => 'markdown', // Default content type for Drafling - 'updated_at' => $updatedEntry->updatedAt->format('c'), - 'tags' => $updatedEntry->tags, - 'changes_applied' => $this->getAppliedChanges($request), - ]; - - return ToolResult::success($response); - - } catch (ProjectNotFoundException $e) { - $this->logger->error('Project not found', [ - 'project_id' => $request->projectId, + ]); + } catch (ResearchNotFoundException $e) { + $this->logger->error('Research not found', [ + 'research_id' => $request->researchId, 'error' => $e->getMessage(), ]); @@ -101,16 +87,16 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult } catch (EntryNotFoundException $e) { $this->logger->error('Entry not found', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); return ToolResult::error($e->getMessage()); - } catch (DraflingException $e) { - $this->logger->error('Drafling error during entry update', [ - 'project_id' => $request->projectId, + } catch (ResearchException $e) { + $this->logger->error('Error during research entry update', [ + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); @@ -119,7 +105,7 @@ public function __invoke(EntryUpdateRequest $request): CallToolResult } catch (\Throwable $e) { $this->logger->error('Unexpected error updating entry', [ - 'project_id' => $request->projectId, + 'research_id' => $request->researchId, 'entry_id' => $request->entryId, 'error' => $e->getMessage(), ]); 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/Drafling/Service/EntryService.php b/src/Research/Service/EntryService.php similarity index 61% rename from src/Drafling/Service/EntryService.php rename to src/Research/Service/EntryService.php index 1a4c239e..1b766987 100644 --- a/src/Drafling/Service/EntryService.php +++ b/src/Research/Service/EntryService.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Service; - -use Butschster\ContextGenerator\Drafling\Domain\Model\Entry; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\EntryNotFoundException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryCreateRequest; -use Butschster\ContextGenerator\Drafling\MCP\DTO\EntryUpdateRequest; -use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Storage\StorageDriverInterface; +namespace Butschster\ContextGenerator\Research\Service; + +use Butschster\ContextGenerator\Research\Domain\Model\Entry; +use Butschster\ContextGenerator\Research\Domain\ValueObject\EntryId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\EntryNotFoundException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\Exception\TemplateNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\EntryCreateRequest; +use Butschster\ContextGenerator\Research\MCP\DTO\EntryUpdateRequest; +use Butschster\ContextGenerator\Research\Repository\EntryRepositoryInterface; +use Butschster\ContextGenerator\Research\Repository\ResearchRepositoryInterface; +use Butschster\ContextGenerator\Research\Storage\StorageDriverInterface; use Psr\Log\LoggerInterface; /** @@ -26,39 +26,39 @@ { public function __construct( private EntryRepositoryInterface $entryRepository, - private ProjectRepositoryInterface $projectRepository, + private ResearchRepositoryInterface $researches, private TemplateServiceInterface $templateService, private StorageDriverInterface $storageDriver, private ?LoggerInterface $logger = null, ) {} #[\Override] - public function createEntry(ProjectId $projectId, EntryCreateRequest $request): Entry + public function createEntry(ResearchId $researchId, EntryCreateRequest $request): Entry { $this->logger?->info('Creating new entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'category' => $request->category, 'entry_type' => $request->entryType, ]); - // Verify project exists - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - $error = "Project '{$projectId->value}' not found"; + // Verify research exists + $research = $this->researches->findById($researchId); + if ($research === null) { + $error = "Research '{$researchId->value}' not found"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, ]); - throw new ProjectNotFoundException($error); + throw new ResearchNotFoundException($error); } // Get and validate template - $templateKey = TemplateKey::fromString($project->template); + $templateKey = TemplateKey::fromString($research->template); $template = $this->templateService->getTemplate($templateKey); if ($template === null) { - $error = "Template '{$project->template}' not found"; + $error = "Template '{$research->template}' not found"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, - 'template' => $project->template, + 'research_id' => $researchId->value, + 'template' => $research->template, ]); throw new TemplateNotFoundException($error); } @@ -66,35 +66,35 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): // Resolve display names to internal keys $resolvedCategory = $this->templateService->resolveCategoryKey($template, $request->category); if ($resolvedCategory === null) { - $error = "Category '{$request->category}' not found in template '{$project->template}'"; + $error = "Category '{$request->category}' not found in template '{$research->template}'"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'category' => $request->category, - 'template' => $project->template, + 'template' => $research->template, ]); - throw new DraflingException($error); + throw new ResearchException($error); } $resolvedEntryType = $this->templateService->resolveEntryTypeKey($template, $request->entryType); if ($resolvedEntryType === null) { - $error = "Entry type '{$request->entryType}' not found in template '{$project->template}'"; + $error = "Entry type '{$request->entryType}' not found in template '{$research->template}'"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_type' => $request->entryType, - 'template' => $project->template, + 'template' => $research->template, ]); - throw new DraflingException($error); + 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, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'category' => $request->category, 'entry_type' => $request->entryType, ]); - throw new DraflingException($error); + throw new ResearchException($error); } // Resolve status if provided, otherwise use entry type default @@ -103,11 +103,11 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): if ($resolvedStatus === null) { $error = "Status '{$request->status}' not found for entry type '{$request->entryType}'"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'status' => $request->status, 'entry_type' => $request->entryType, ]); - throw new DraflingException($error); + throw new ResearchException($error); } } else { // Use default status from entry type @@ -124,13 +124,13 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): ); // Use storage driver to create the entry - $entry = $this->storageDriver->createEntry($projectId, $resolvedRequest); + $entry = $this->storageDriver->createEntry($researchId, $resolvedRequest); // Save entry to repository - $this->entryRepository->save($projectId, $entry); + $this->entryRepository->save($researchId, $entry); $this->logger?->info('Entry created successfully', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entry->entryId, 'title' => $entry->title, 'category' => $entry->category, @@ -141,11 +141,11 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): } catch (\Throwable $e) { $this->logger?->error('Failed to create entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'error' => $e->getMessage(), ]); - throw new DraflingException( + throw new ResearchException( "Failed to create entry: {$e->getMessage()}", previous: $e, ); @@ -153,32 +153,32 @@ public function createEntry(ProjectId $projectId, EntryCreateRequest $request): } #[\Override] - public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateRequest $request): Entry + public function updateEntry(ResearchId $researchId, EntryId $entryId, EntryUpdateRequest $request): Entry { $this->logger?->info('Updating entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'has_title' => $request->title !== null, 'has_content' => $request->content !== null, 'has_status' => $request->status !== null, ]); - // Verify project exists - $project = $this->projectRepository->findById($projectId); - if ($project === null) { - $error = "Project '{$projectId->value}' not found"; + // Verify research exists + $research = $this->researches->findById($researchId); + if ($research === null) { + $error = "Research '{$researchId->value}' not found"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, ]); - throw new ProjectNotFoundException($error); + throw new ResearchNotFoundException($error); } // Verify entry exists - $existingEntry = $this->entryRepository->findById($projectId, $entryId); + $existingEntry = $this->entryRepository->findById($researchId, $entryId); if ($existingEntry === null) { - $error = "Entry '{$entryId->value}' not found in project '{$projectId->value}'"; + $error = "Entry '{$entryId->value}' not found in research '{$researchId->value}'"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); throw new EntryNotFoundException($error); @@ -187,7 +187,7 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR // Resolve status if provided $resolvedStatus = $request->status; if ($request->status !== null) { - $templateKey = TemplateKey::fromString($project->template); + $templateKey = TemplateKey::fromString($research->template); $template = $this->templateService->getTemplate($templateKey); if ($template !== null) { @@ -200,12 +200,12 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR if ($resolvedStatusValue === null) { $error = "Status '{$request->status}' not found for entry type '{$existingEntry->entryType}'"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'status' => $request->status, 'entry_type' => $existingEntry->entryType, ]); - throw new DraflingException($error); + throw new ResearchException($error); } $resolvedStatus = $resolvedStatusValue; @@ -217,13 +217,13 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR $resolvedRequest = $request->withResolvedStatus($resolvedStatus); // Use storage driver to update the entry - $updatedEntry = $this->storageDriver->updateEntry($projectId, $entryId, $resolvedRequest); + $updatedEntry = $this->storageDriver->updateEntry($researchId, $entryId, $resolvedRequest); // Save updated entry to repository - $this->entryRepository->save($projectId, $updatedEntry); + $this->entryRepository->save($researchId, $updatedEntry); $this->logger?->info('Entry updated successfully', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'title' => $updatedEntry->title, ]); @@ -232,12 +232,12 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR } catch (\Throwable $e) { $this->logger?->error('Failed to update entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'error' => $e->getMessage(), ]); - throw new DraflingException( + throw new ResearchException( "Failed to update entry: {$e->getMessage()}", previous: $e, ); @@ -245,12 +245,12 @@ public function updateEntry(ProjectId $projectId, EntryId $entryId, EntryUpdateR } #[\Override] - public function entryExists(ProjectId $projectId, EntryId $entryId): bool + public function entryExists(ResearchId $researchId, EntryId $entryId): bool { - $exists = $this->entryRepository->exists($projectId, $entryId); + $exists = $this->entryRepository->exists($researchId, $entryId); $this->logger?->debug('Checking entry existence', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'exists' => $exists, ]); @@ -259,27 +259,27 @@ public function entryExists(ProjectId $projectId, EntryId $entryId): bool } #[\Override] - public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry + public function getEntry(ResearchId $researchId, EntryId $entryId): ?Entry { $this->logger?->info('Retrieving single entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); - // Verify project exists - if (!$this->projectRepository->exists($projectId)) { - $error = "Project '{$projectId->value}' not found"; + // Verify research exists + if (!$this->researches->exists($researchId)) { + $error = "Research '{$researchId->value}' not found"; $this->logger?->error($error, [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, ]); - throw new ProjectNotFoundException($error); + throw new ResearchNotFoundException($error); } try { - $entry = $this->entryRepository->findById($projectId, $entryId); + $entry = $this->entryRepository->findById($researchId, $entryId); $this->logger?->info('Entry retrieval completed', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'found' => $entry !== null, ]); @@ -288,12 +288,12 @@ public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry } catch (\Throwable $e) { $this->logger?->error('Failed to retrieve entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'error' => $e->getMessage(), ]); - throw new DraflingException( + throw new ResearchException( "Failed to retrieve entry: {$e->getMessage()}", previous: $e, ); @@ -301,26 +301,26 @@ public function getEntry(ProjectId $projectId, EntryId $entryId): ?Entry } #[\Override] - public function getEntries(ProjectId $projectId, array $filters = []): array + public function findAll(ResearchId $researchId, array $filters = []): array { $this->logger?->info('Retrieving entries', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'filters' => $filters, ]); - // Verify project exists - if (!$this->projectRepository->exists($projectId)) { - $this->logger?->warning('Attempted to get entries for non-existent project', [ - 'project_id' => $projectId->value, + // 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->findByProject($projectId, $filters); + $entries = $this->entryRepository->findByResearch($researchId, $filters); $this->logger?->info('Entries retrieved successfully', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'count' => \count($entries), 'filters_applied' => !empty($filters), ]); @@ -329,12 +329,12 @@ public function getEntries(ProjectId $projectId, array $filters = []): array } catch (\Throwable $e) { $this->logger?->error('Failed to retrieve entries', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'filters' => $filters, 'error' => $e->getMessage(), ]); - throw new DraflingException( + throw new ResearchException( "Failed to retrieve entries: {$e->getMessage()}", previous: $e, ); @@ -342,17 +342,17 @@ public function getEntries(ProjectId $projectId, array $filters = []): array } #[\Override] - public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool + public function deleteEntry(ResearchId $researchId, EntryId $entryId): bool { $this->logger?->info('Deleting entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); // Verify entry exists - if (!$this->entryRepository->exists($projectId, $entryId)) { + if (!$this->entryRepository->exists($researchId, $entryId)) { $this->logger?->warning('Attempted to delete non-existent entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); return false; @@ -360,19 +360,19 @@ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool try { // Use storage driver to delete the entry - $deleted = $this->storageDriver->deleteEntry($projectId, $entryId); + $deleted = $this->storageDriver->deleteEntry($researchId, $entryId); if ($deleted) { // Remove from repository - $this->entryRepository->delete($projectId, $entryId); + $this->entryRepository->delete($researchId, $entryId); $this->logger?->info('Entry deleted successfully', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); } else { $this->logger?->warning('Storage driver failed to delete entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ]); } @@ -381,12 +381,12 @@ public function deleteEntry(ProjectId $projectId, EntryId $entryId): bool } catch (\Throwable $e) { $this->logger?->error('Failed to delete entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'error' => $e->getMessage(), ]); - throw new DraflingException( + 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(); } @@ -147,7 +147,7 @@ public function refreshTemplates(): void /** * Get entry type from template by key */ - private function getEntryTypeByKey(Template $template, string $key): ?\Butschster\ContextGenerator\Drafling\Domain\Model\EntryType + private function getEntryTypeByKey(Template $template, string $key): ?\Butschster\ContextGenerator\Research\Domain\Model\EntryType { foreach ($template->entryTypes as $entryType) { if ($entryType->key === $key) { diff --git a/src/Drafling/Service/TemplateServiceInterface.php b/src/Research/Service/TemplateServiceInterface.php similarity index 88% rename from src/Drafling/Service/TemplateServiceInterface.php rename to src/Research/Service/TemplateServiceInterface.php index 9d6ba230..994aaf63 100644 --- a/src/Drafling/Service/TemplateServiceInterface.php +++ b/src/Research/Service/TemplateServiceInterface.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Service; +namespace Butschster\ContextGenerator\Research\Service; -use Butschster\ContextGenerator\Drafling\Domain\Model\Template; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Domain\Model\Template; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; /** * Service interface for template operations @@ -17,7 +17,7 @@ interface TemplateServiceInterface * * @return Template[] */ - public function getAllTemplates(): array; + public function findAll(): array; /** * Get a template by key diff --git a/src/Drafling/Storage/FileStorage/DirectoryScanner.php b/src/Research/Storage/FileStorage/DirectoryScanner.php similarity index 63% rename from src/Drafling/Storage/FileStorage/DirectoryScanner.php rename to src/Research/Storage/FileStorage/DirectoryScanner.php index c3960c22..8dd31955 100644 --- a/src/Drafling/Storage/FileStorage/DirectoryScanner.php +++ b/src/Research/Storage/FileStorage/DirectoryScanner.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Files\FilesInterface; use Symfony\Component\Finder\Finder; /** - * Scans and manages project directory structures using Symfony Finder + * Scans and manages research directory structures using Symfony Finder */ final readonly class DirectoryScanner { @@ -19,32 +19,32 @@ public function __construct( ) {} /** - * Scan project root directory for project subdirectories + * Scan research root directory * - * @return array Array of project directory paths + * @return array Array of research directory paths */ - public function scanProjects(string $projectsPath): array + public function scanResearches(string $path): array { - if (!$this->files->isDirectory($projectsPath)) { + if (!$this->files->isDirectory($path)) { return []; } - $projects = []; + $researches = []; try { $finder = new Finder(); $finder ->directories() - ->in($projectsPath) + ->in($path) ->depth(0) // Only immediate subdirectories ->filter(static function (\SplFileInfo $file): bool { - // Check if this directory contains a project.yaml file - $configPath = $file->getRealPath() . '/project.yaml'; + // Check if this directory contains a research.yaml file + $configPath = $file->getRealPath() . '/research.yaml'; return \file_exists($configPath); }); foreach ($finder as $directory) { - $projects[] = $directory->getRealPath(); + $researches[] = $directory->getRealPath(); } } catch (\Throwable $e) { $this->reporter->report($e); @@ -52,18 +52,18 @@ public function scanProjects(string $projectsPath): array // Return empty array - calling code can handle this gracefully } - return $projects; + return $researches; } /** - * Scan project directory for entry files + * Scan research directory for entry files * - * @param string $projectPath Path to project directory + * @param string $path Path to research directory * @return array Array of entry file paths */ - public function scanEntries(string $projectPath): array + public function scanEntries(string $path): array { - if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { return []; } @@ -73,7 +73,7 @@ public function scanEntries(string $projectPath): array $finder = new Finder(); $finder ->files() - ->in($projectPath) + ->in($path) ->name('*.md'); foreach ($finder as $file) { @@ -88,11 +88,11 @@ public function scanEntries(string $projectPath): array } /** - * Get all subdirectories in project that could contain entries + * Get all subdirectories in research that could contain entries */ - public function getEntryDirectories(string $projectPath): array + public function getEntryDirectories(string $path): array { - if (!$this->files->exists($projectPath) || !$this->files->isDirectory($projectPath)) { + if (!$this->files->exists($path) || !$this->files->isDirectory($path)) { return []; } @@ -102,12 +102,12 @@ public function getEntryDirectories(string $projectPath): array $finder = new Finder(); $finder ->directories() - ->in($projectPath) + ->in($path) ->depth(0) // Only immediate subdirectories ->filter(static function (\SplFileInfo $file): bool { // Skip special directories $name = $file->getFilename(); - return !\in_array($name, ['.project', 'resources', '.git', '.idea', 'node_modules'], true); + return !\in_array($name, ['.research', 'resources', '.git', '.idea', 'node_modules'], true); }); foreach ($finder as $directory) { diff --git a/src/Drafling/Storage/FileStorage/FileEntryRepository.php b/src/Research/Storage/FileStorage/FileEntryRepository.php similarity index 70% rename from src/Drafling/Storage/FileStorage/FileEntryRepository.php rename to src/Research/Storage/FileStorage/FileEntryRepository.php index b692e5e6..4820b8f3 100644 --- a/src/Drafling/Storage/FileStorage/FileEntryRepository.php +++ b/src/Research/Storage/FileStorage/FileEntryRepository.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; -use Butschster\ContextGenerator\Drafling\Domain\Model\Entry; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Repository\EntryRepositoryInterface; +use Butschster\ContextGenerator\Research\Domain\Model\Entry; +use Butschster\ContextGenerator\Research\Domain\ValueObject\EntryId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Repository\EntryRepositoryInterface; /** * File-based entry repository implementation @@ -15,18 +15,18 @@ final class FileEntryRepository extends FileStorageRepositoryBase implements EntryRepositoryInterface { #[\Override] - public function findByProject(ProjectId $projectId, array $filters = []): array + public function findByResearch(ResearchId $researchId, array $filters = []): array { - $projectPath = $this->getProjectPath($projectId->value); + $researchPath = $this->getResearchPath($researchId->value); - if (!$this->files->exists($projectPath)) { + if (!$this->files->exists($researchPath)) { return []; } $entries = []; try { - $entryFiles = $this->directoryScanner->scanEntries($projectPath); + $entryFiles = $this->directoryScanner->scanEntries($researchPath); foreach ($entryFiles as $filePath) { try { @@ -39,23 +39,23 @@ public function findByProject(ProjectId $projectId, array $filters = []): array } } - $this->logOperation('Loaded entries for project', [ - 'project_id' => $projectId->value, + $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 project', ['project_id' => $projectId->value], $e); + $this->logError('Failed to scan entries for research', ['research_id' => $researchId->value], $e); } return $entries; } #[\Override] - public function findById(ProjectId $projectId, EntryId $entryId): ?Entry + public function findById(ResearchId $researchId, EntryId $entryId): ?Entry { - $projectPath = $this->getProjectPath($projectId->value); - $entryFile = $this->findEntryFile($projectPath, $entryId->value); + $researchPath = $this->getResearchPath($researchId->value); + $entryFile = $this->findEntryFile($researchPath, $entryId->value); if ($entryFile === null) { return null; @@ -65,7 +65,7 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry return $this->loadEntryFromFile($entryFile); } catch (\Throwable $e) { $this->logError('Failed to load entry by ID', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ], $e); return null; @@ -73,25 +73,25 @@ public function findById(ProjectId $projectId, EntryId $entryId): ?Entry } #[\Override] - public function save(ProjectId $projectId, Entry $entry): void + public function save(ResearchId $researchId, Entry $entry): void { - $projectPath = $this->getProjectPath($projectId->value); + $researchPath = $this->getResearchPath($researchId->value); - if (!$this->files->exists($projectPath)) { - throw new \RuntimeException("Project directory not found: {$projectPath}"); + if (!$this->files->exists($researchPath)) { + throw new \RuntimeException("Research directory not found: {$researchPath}"); } try { - $this->saveEntryToFile($projectPath, $entry); + $this->saveEntryToFile($researchPath, $entry); $this->logOperation('Saved entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entry->entryId, 'title' => $entry->title, ]); } catch (\Throwable $e) { $this->logError('Failed to save entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entry->entryId, ], $e); throw $e; @@ -99,10 +99,10 @@ public function save(ProjectId $projectId, Entry $entry): void } #[\Override] - public function delete(ProjectId $projectId, EntryId $entryId): bool + public function delete(ResearchId $researchId, EntryId $entryId): bool { - $projectPath = $this->getProjectPath($projectId->value); - $entryFile = $this->findEntryFile($projectPath, $entryId->value); + $researchPath = $this->getResearchPath($researchId->value); + $entryFile = $this->findEntryFile($researchPath, $entryId->value); if ($entryFile === null) { return false; @@ -113,7 +113,7 @@ public function delete(ProjectId $projectId, EntryId $entryId): bool if ($deleted) { $this->logOperation('Deleted entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, 'file' => $entryFile, ]); @@ -122,7 +122,7 @@ public function delete(ProjectId $projectId, EntryId $entryId): bool return $deleted; } catch (\Throwable $e) { $this->logError('Failed to delete entry', [ - 'project_id' => $projectId->value, + 'research_id' => $researchId->value, 'entry_id' => $entryId->value, ], $e); return false; @@ -130,48 +130,27 @@ public function delete(ProjectId $projectId, EntryId $entryId): bool } #[\Override] - public function exists(ProjectId $projectId, EntryId $entryId): bool + public function exists(ResearchId $researchId, EntryId $entryId): bool { - $projectPath = $this->getProjectPath($projectId->value); - return $this->findEntryFile($projectPath, $entryId->value) !== null; + $researchPath = $this->getResearchPath($researchId->value); + return $this->findEntryFile($researchPath, $entryId->value) !== null; } /** - * Get project directory path from ID + * Get research directory path from ID */ - private function getProjectPath(string $projectId): string + private function getResearchPath(string $researchId): string { $basePath = $this->getBasePath(); - return $this->files->normalizePath($basePath . '/' . $projectId); - } - - /** - * Get entry directories for a project - */ - private function getProjectEntryDirs(string $projectPath): array - { - $configPath = $projectPath . '/project.yaml'; - - if (!$this->files->exists($configPath)) { - // Fallback: scan all directories - return $this->directoryScanner->getEntryDirectories($projectPath); - } - - try { - $config = $this->readYamlFile($configPath); - return $config['project']['entries']['dirs'] ?? []; - } catch (\Throwable) { - // Fallback: scan all directories - return $this->directoryScanner->getEntryDirectories($projectPath); - } + return $this->files->normalizePath($basePath . '/' . $researchId); } /** * Find entry file by entry ID */ - private function findEntryFile(string $projectPath, string $entryId): ?string + private function findEntryFile(string $researchPath, string $entryId): ?string { - $entryFiles = $this->directoryScanner->scanEntries($projectPath); + $entryFiles = $this->directoryScanner->scanEntries($researchPath); foreach ($entryFiles as $filePath) { try { @@ -239,14 +218,14 @@ private function loadEntryFromFile(string $filePath): ?Entry /** * Save entry to markdown file */ - private function saveEntryToFile(string $projectPath, Entry $entry): void + 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($projectPath . '/' . $entry->category . '/' . $entry->entryType); + $categoryPath = $this->files->normalizePath($researchPath . '/' . $entry->category . '/' . $entry->entryType); $this->ensureDirectory($categoryPath); $filename = $this->generateFilename($entry->title); 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/Drafling/Storage/Config/FileStorageConfig.php b/src/Research/Storage/FileStorage/FileStorageConfig.php similarity index 95% rename from src/Drafling/Storage/Config/FileStorageConfig.php rename to src/Research/Storage/FileStorage/FileStorageConfig.php index 75092939..750e7bd4 100644 --- a/src/Drafling/Storage/Config/FileStorageConfig.php +++ b/src/Research/Storage/FileStorage/FileStorageConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\Config; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; /** * Configuration for file-based storage driver @@ -26,7 +26,7 @@ public function __construct( public static function fromArray(array $config): self { return new self( - basePath: $config['base_path'] ?? '.projects', + basePath: $config['base_path'] ?? '.researches', templatesPath: $config['templates_path'] ?? '.templates', defaultEntryStatus: $config['default_entry_status'] ?? 'draft', createDirectoriesOnDemand: $config['create_directories_on_demand'] ?? true, 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/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php b/src/Research/Storage/FileStorage/FileStorageRepositoryBase.php similarity index 91% rename from src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php rename to src/Research/Storage/FileStorage/FileStorageRepositoryBase.php index 5853393d..0b0bc18e 100644 --- a/src/Drafling/Storage/FileStorage/FileStorageRepositoryBase.php +++ b/src/Research/Storage/FileStorage/FileStorageRepositoryBase.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; use Butschster\ContextGenerator\DirectoriesInterface; -use Butschster\ContextGenerator\Drafling\Config\DraflingConfigInterface; +use Butschster\ContextGenerator\Research\Config\ResearchConfigInterface; +use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Files\FilesInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Yaml\Exception\ParseException; @@ -18,8 +19,9 @@ abstract class FileStorageRepositoryBase { public function __construct( protected readonly FilesInterface $files, - protected readonly DraflingConfigInterface $config, + protected readonly ResearchConfigInterface $config, private readonly DirectoriesInterface $dirs, + protected readonly ExceptionReporterInterface $reporter, protected readonly FrontmatterParser $frontmatterParser = new FrontmatterParser(), protected readonly ?DirectoryScanner $directoryScanner = null, protected readonly ?LoggerInterface $logger = null, @@ -30,7 +32,7 @@ public function __construct( */ protected function getBasePath(): string { - return (string) $this->dirs->getRootPath()->join($this->config->getProjectsPath()); + return (string) $this->dirs->getRootPath()->join($this->config->getResearchesPath()); } /** @@ -66,6 +68,7 @@ protected function readYamlFile(string $filePath): array 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); } } diff --git a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php b/src/Research/Storage/FileStorage/FileTemplateRepository.php similarity index 89% rename from src/Drafling/Storage/FileStorage/FileTemplateRepository.php rename to src/Research/Storage/FileStorage/FileTemplateRepository.php index 5f2639db..f8ddbd1b 100644 --- a/src/Drafling/Storage/FileStorage/FileTemplateRepository.php +++ b/src/Research/Storage/FileStorage/FileTemplateRepository.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; - -use Butschster\ContextGenerator\Drafling\Domain\Model\Category; -use Butschster\ContextGenerator\Drafling\Domain\Model\EntryType; -use Butschster\ContextGenerator\Drafling\Domain\Model\Status; -use Butschster\ContextGenerator\Drafling\Domain\Model\Template; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; -use Butschster\ContextGenerator\Drafling\Repository\TemplateRepositoryInterface; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; + +use Butschster\ContextGenerator\Research\Domain\Model\Category; +use Butschster\ContextGenerator\Research\Domain\Model\EntryType; +use Butschster\ContextGenerator\Research\Domain\Model\Status; +use Butschster\ContextGenerator\Research\Domain\Model\Template; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Repository\TemplateRepositoryInterface; use Symfony\Component\Finder\Finder; /** @@ -77,6 +77,7 @@ private function loadTemplatesFromFilesystem(): array $templates[] = $template; } } catch (\Throwable $e) { + $this->reporter->report($e); $this->logError('Failed to load template', ['file' => $file->getRealPath()], $e); } } @@ -98,6 +99,7 @@ private function loadTemplateFromFile(string $filePath): ?Template $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; } @@ -167,7 +169,7 @@ private function createCategoryFromData(array $data): Category */ private function createEntryTypeFromData(string $key, array $data): EntryType { - $requiredFields = ['display_name', 'content_type', 'default_status']; + $requiredFields = ['display_name']; foreach ($requiredFields as $field) { if (!isset($data[$field])) { throw new \RuntimeException("Missing required entry type field: {$field}"); @@ -185,8 +187,8 @@ private function createEntryTypeFromData(string $key, array $data): EntryType return new EntryType( key: $key, displayName: $data['display_name'], - contentType: $data['content_type'], - defaultStatus: $data['default_status'], + contentType: $data['content_type'] ?? 'markdown', + defaultStatus: $data['default_status'] ?? 'draft', statuses: $statuses, ); } diff --git a/src/Drafling/Storage/FileStorage/FrontmatterParser.php b/src/Research/Storage/FileStorage/FrontmatterParser.php similarity index 97% rename from src/Drafling/Storage/FileStorage/FrontmatterParser.php rename to src/Research/Storage/FileStorage/FrontmatterParser.php index 27632e75..b9018598 100644 --- a/src/Drafling/Storage/FileStorage/FrontmatterParser.php +++ b/src/Research/Storage/FileStorage/FrontmatterParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Butschster\ContextGenerator\Drafling\Storage\FileStorage; +namespace Butschster\ContextGenerator\Research\Storage\FileStorage; use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Exception\ParseException; diff --git a/src/Research/Storage/FileStorageBootloader.php b/src/Research/Storage/FileStorageBootloader.php new file mode 100644 index 00000000..f52f25f4 --- /dev/null +++ b/src/Research/Storage/FileStorageBootloader.php @@ -0,0 +1,61 @@ + FrontmatterParser::class, + TemplateRepositoryInterface::class => FileTemplateRepository::class, + ResearchRepositoryInterface::class => FileResearchRepository::class, + EntryRepositoryInterface::class => FileEntryRepository::class, + + // Storage driver + StorageDriverInterface::class => static function ( + ResearchConfigInterface $config, + FilesInterface $files, + LoggerInterface $logger, + ExceptionReporterInterface $reporter, + DirectoriesInterface $dirs, + TemplateRepositoryInterface $templateRepository, + ResearchRepositoryInterface $researchRepository, + EntryRepositoryInterface $entryRepository, + ): StorageDriverInterface { + return 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->scanProjects($projectsPath); + $projects = $this->scanner->scanResearches($projectsPath); $this->assertCount(2, $projects); @@ -31,7 +31,7 @@ public function it_scans_projects_correctly(): void // Verify each project has a project.yaml file foreach ($projects as $projectPath) { - $this->assertFileExists($projectPath . '/project.yaml'); + $this->assertFileExists($projectPath . '/research.yaml'); } } @@ -39,7 +39,7 @@ public function it_scans_projects_correctly(): void public function it_returns_empty_array_for_nonexistent_projects_path(): void { $nonexistentPath = $this->tempPath . '/nonexistent'; - $projects = $this->scanner->scanProjects($nonexistentPath); + $projects = $this->scanner->scanResearches($nonexistentPath); $this->assertEmpty($projects); } @@ -53,7 +53,7 @@ public function it_ignores_directories_without_project_yaml(): void \file_put_contents($invalidProjectPath . '/readme.txt', 'Not a project'); $projectsPath = $this->tempPath . '/projects'; - $projects = $this->scanner->scanProjects($projectsPath); + $projects = $this->scanner->scanResearches($projectsPath); // Should still only find the 2 valid projects $this->assertCount(2, $projects); @@ -228,7 +228,7 @@ protected function setUp(): void { parent::setUp(); - $this->testDataPath = \dirname(__DIR__, 5) . '/fixtures/Drafling/FileStorage'; + $this->testDataPath = \dirname(__DIR__, 5) . '/fixtures/Research/FileStorage'; $this->tempPath = \sys_get_temp_dir() . '/scanner_test_' . \uniqid(); // Copy fixture data to temp directory diff --git a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php b/tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php similarity index 99% rename from tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php rename to tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php index 63ed4f1d..302ec235 100644 --- a/tests/src/Feature/Drafling/Storage/FileStorage/FrontmatterParserTest.php +++ b/tests/src/Feature/Research/Storage/FileStorage/FrontmatterParserTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\Drafling\Storage\FileStorage; -use Butschster\ContextGenerator\Drafling\Storage\FileStorage\FrontmatterParser; +use Butschster\ContextGenerator\Research\Storage\FileStorage\FrontmatterParser; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php b/tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php deleted file mode 100644 index 0073ea59..00000000 --- a/tests/src/Unit/Drafling/MCP/DTO/AddProjectMemoryRequestTest.php +++ /dev/null @@ -1,161 +0,0 @@ -assertSame('proj_123', $request->projectId); - $this->assertSame('This is a memory entry for the project', $request->memory); - } - - public function testValidateWithValidRequest(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_456', - memory: 'Valid memory content', - ); - - $errors = $request->validate(); - - $this->assertEmpty($errors); - } - - public function testValidateWithEmptyProjectId(): void - { - $request = new AddProjectMemoryRequest( - projectId: '', - memory: 'Some memory content', - ); - - $errors = $request->validate(); - - $this->assertContains('Project ID cannot be empty', $errors); - } - - public function testValidateWithEmptyMemory(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_789', - memory: '', - ); - - $errors = $request->validate(); - - $this->assertContains('Memory entry cannot be empty', $errors); - } - - public function testValidateWithWhitespaceOnlyMemory(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_abc', - memory: " \t\n ", - ); - - $errors = $request->validate(); - - $this->assertContains('Memory entry cannot be empty', $errors); - } - - public function testValidateWithMultipleErrors(): void - { - $request = new AddProjectMemoryRequest( - projectId: '', - memory: ' ', - ); - - $errors = $request->validate(); - - $this->assertContains('Project ID cannot be empty', $errors); - $this->assertContains('Memory entry cannot be empty', $errors); - $this->assertCount(2, $errors); - } - - public function testValidateWithMemoryContainingOnlyWhitespace(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_test', - memory: "\n\t \r\n ", - ); - - $errors = $request->validate(); - - $this->assertContains('Memory entry cannot be empty', $errors); - } - - public function testValidateWithValidMemoryContainingWhitespace(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_valid', - memory: ' Valid memory with spaces ', - ); - - $errors = $request->validate(); - - $this->assertEmpty($errors); - } - - public function testValidateWithLongMemoryContent(): void - { - $longMemory = \str_repeat('This is a long memory entry. ', 100); - - $request = new AddProjectMemoryRequest( - projectId: 'proj_long', - memory: $longMemory, - ); - - $errors = $request->validate(); - - $this->assertEmpty($errors); - } - - public function testValidateWithSpecialCharactersInMemory(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_special', - memory: 'Memory with special chars: !@#$%^&*()_+-={}[]|\\:";\'<>?,./', - ); - - $errors = $request->validate(); - - $this->assertEmpty($errors); - } - - public function testValidateWithUnicodeCharactersInMemory(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_unicode', - memory: 'Memory with unicode: 🚀 Hello 世界 café résumé', - ); - - $errors = $request->validate(); - - $this->assertEmpty($errors); - } - - public function testIsReadonlyDto(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_readonly', - memory: 'Readonly memory', - ); - - // Properties should be readonly (this is ensured by PHP at compile time) - $this->assertSame('proj_readonly', $request->projectId); - $this->assertSame('Readonly memory', $request->memory); - } -} diff --git a/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php b/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php deleted file mode 100644 index 94e6856f..00000000 --- a/tests/src/Unit/Drafling/MCP/Tools/AddProjectMemoryToolActionTest.php +++ /dev/null @@ -1,412 +0,0 @@ -projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'This is a new memory entry for the project') - ->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']); - $this->assertSame('proj_123', $responseData['project_id']); - $this->assertSame('Test Project', $responseData['title']); - $this->assertSame('active', $responseData['status']); - $this->assertSame('blog-template', $responseData['project_type']); - $this->assertSame(2, $responseData['memory_count']); - $this->assertSame('This is a new memory entry for the project', $responseData['memory_added']); - - // Check metadata - $this->assertSame('Project description', $responseData['metadata']['description']); - $this->assertSame(['web', 'blog'], $responseData['metadata']['tags']); - $this->assertSame(['posts'], $responseData['metadata']['entry_dirs']); - $this->assertSame(['Existing memory', 'This is a new memory entry for the project'], $responseData['metadata']['memory']); - - // Check timestamp is present - $this->assertArrayHasKey('updated_at', $responseData); - } - - public function testAddMemoryToEmptyMemoryArray(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_empty', - memory: 'First memory entry', - ); - - $updatedProject = new Project( - id: 'proj_empty', - name: 'Empty Memory Project', - description: 'No previous memories', - template: 'simple', - status: 'draft', - tags: [], - entryDirs: [], - memory: ['First memory entry'], - ); - - $projectId = ProjectId::fromString('proj_empty'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'First memory entry') - ->willReturn($updatedProject); - - $result = ($this->toolAction)($request); - - $content = $result->content[0]; - $responseData = \json_decode($content->text, true); - - $this->assertTrue($responseData['success']); - $this->assertSame(1, $responseData['memory_count']); - $this->assertSame(['First memory entry'], $responseData['metadata']['memory']); - } - - public function testValidationErrorEmptyProjectId(): void - { - $request = new AddProjectMemoryRequest( - projectId: '', - memory: 'Valid memory', - ); - - $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->assertContains('Project ID cannot be empty', $responseData['details']); - } - - public function testValidationErrorEmptyMemory(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_123', - memory: '', - ); - - $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->assertContains('Memory entry cannot be empty', $responseData['details']); - } - - public function testValidationErrorMultipleErrors(): void - { - $request = new AddProjectMemoryRequest( - projectId: '', - memory: ' ', - ); - - $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->assertContains('Project ID cannot be empty', $responseData['details']); - $this->assertContains('Memory entry cannot be empty', $responseData['details']); - } - - public function testProjectNotFound(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_nonexistent', - memory: 'Memory for non-existent project', - ); - - $projectId = ProjectId::fromString('proj_nonexistent'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->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("Project 'proj_nonexistent' not found", $responseData['error']); - } - - public function testProjectNotFoundExceptionFromService(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_exception', - memory: 'Memory content', - ); - - $projectId = ProjectId::fromString('proj_exception'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'Memory content') - ->willThrowException(new ProjectNotFoundException('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 AddProjectMemoryRequest( - projectId: 'proj_error', - memory: 'Memory content', - ); - - $projectId = ProjectId::fromString('proj_error'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'Memory content') - ->willThrowException(new DraflingException('Service error occurred')); - - $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 occurred', $responseData['error']); - } - - public function testUnexpectedException(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_unexpected', - memory: 'Memory content', - ); - - $projectId = ProjectId::fromString('proj_unexpected'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'Memory content') - ->willThrowException(new \RuntimeException('Unexpected system 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 add memory to project: Unexpected system error', $responseData['error']); - } - - public function testLoggerIsCalled(): void - { - $request = new AddProjectMemoryRequest( - projectId: 'proj_log', - memory: 'Logged memory entry', - ); - - $updatedProject = new Project( - id: 'proj_log', - name: 'Log Test Project', - description: 'Testing logging', - template: 'test', - status: 'active', - tags: [], - entryDirs: [], - memory: ['Logged memory entry'], - ); - - $projectId = ProjectId::fromString('proj_log'); - - $this->logger - ->expects($this->exactly(2)) - ->method('info') - ->with($this->logicalOr( - $this->equalTo('Adding memory to project'), - $this->equalTo('Memory added to project successfully'), - )); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, 'Logged memory entry') - ->willReturn($updatedProject); - - ($this->toolAction)($request); - } - - public function testMemoryWithSpecialCharacters(): void - { - $specialMemory = 'Memory with special chars: !@#$%^&*()_+-={}[]|\\:";\'<>?,./ and unicode: 🚀 世界'; - - $request = new AddProjectMemoryRequest( - projectId: 'proj_special', - memory: $specialMemory, - ); - - $updatedProject = new Project( - id: 'proj_special', - name: 'Special Chars Project', - description: 'Testing special characters', - template: 'test', - status: 'active', - tags: [], - entryDirs: [], - memory: [$specialMemory], - ); - - $projectId = ProjectId::fromString('proj_special'); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('addProjectMemory') - ->with($projectId, $specialMemory) - ->willReturn($updatedProject); - - $result = ($this->toolAction)($request); - - $content = $result->content[0]; - $responseData = \json_decode($content->text, true); - - $this->assertTrue($responseData['success']); - $this->assertSame($specialMemory, $responseData['memory_added']); - $this->assertSame([$specialMemory], $responseData['metadata']['memory']); - } - - protected function setUp(): void - { - $this->logger = $this->createMock(LoggerInterface::class); - $this->projectService = $this->createMock(ProjectServiceInterface::class); - - $this->toolAction = new AddProjectMemoryToolAction( - $this->logger, - $this->projectService, - ); - } -} diff --git a/tests/src/Unit/Drafling/Domain/Model/ProjectTest.php b/tests/src/Unit/Research/Domain/Model/ProjectTest.php similarity index 70% rename from tests/src/Unit/Drafling/Domain/Model/ProjectTest.php rename to tests/src/Unit/Research/Domain/Model/ProjectTest.php index 1f4af9bd..25bc7eb6 100644 --- a/tests/src/Unit/Drafling/Domain/Model/ProjectTest.php +++ b/tests/src/Unit/Research/Domain/Model/ProjectTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\Domain\Model; +namespace Tests\Unit\Research\Domain\Model; -use Butschster\ContextGenerator\Drafling\Domain\Model\Project; +use Butschster\ContextGenerator\Research\Domain\Model\Research; use PHPUnit\Framework\TestCase; /** @@ -14,7 +14,7 @@ final class ProjectTest extends TestCase { public function testProjectConstruction(): void { - $project = new Project( + $project = new Research( id: 'proj_123', name: 'Test Project', description: 'A test project', @@ -23,7 +23,7 @@ public function testProjectConstruction(): void tags: ['web', 'blog'], entryDirs: ['posts', 'pages'], memory: ['Initial memory entry', 'Another memory'], - projectPath: '/path/to/project', + path: '/path/to/project', ); $this->assertSame('proj_123', $project->id); @@ -34,12 +34,12 @@ public function testProjectConstruction(): void $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->projectPath); + $this->assertSame('/path/to/project', $project->path); } public function testProjectConstructionWithDefaults(): void { - $project = new Project( + $project = new Research( id: 'proj_456', name: 'Minimal Project', description: 'Basic project', @@ -52,12 +52,12 @@ public function testProjectConstructionWithDefaults(): void $this->assertSame('proj_456', $project->id); $this->assertSame('Minimal Project', $project->name); $this->assertSame([], $project->memory); // Should default to empty array - $this->assertNull($project->projectPath); // Should default to null + $this->assertNull($project->path); // Should default to null } public function testWithUpdates(): void { - $original = new Project( + $original = new Research( id: 'proj_789', name: 'Original Name', description: 'Original description', @@ -98,7 +98,7 @@ public function testWithUpdates(): void public function testWithUpdatesPartial(): void { - $original = new Project( + $original = new Research( id: 'proj_abc', name: 'Test', description: 'Description', @@ -121,7 +121,7 @@ public function testWithUpdatesPartial(): void public function testWithAddedMemory(): void { - $project = new Project( + $project = new Research( id: 'proj_mem', name: 'Memory Test', description: 'Testing memory', @@ -147,7 +147,7 @@ public function testWithAddedMemory(): void public function testWithAddedMemoryToEmptyArray(): void { - $project = new Project( + $project = new Research( id: 'proj_empty', name: 'Empty Memory', description: 'No memory yet', @@ -164,69 +164,9 @@ public function testWithAddedMemoryToEmptyArray(): void $this->assertSame(['First memory entry'], $updated->memory); } - public function testGenerateDirectoryName(): void - { - $project = new Project( - id: 'proj_dir', - name: 'My Blog Project', - description: 'Description', - template: 'blog', - status: 'active', - tags: [], - entryDirs: [], - ); - - $this->assertSame('my-blog-project', $project->generateDirectoryName()); - } - - public function testGenerateDirectoryNameWithSpecialCharacters(): void - { - $project = new Project( - id: 'proj_special', - name: 'My Blog Project!!! @#$', - description: 'Description', - template: 'blog', - status: 'active', - tags: [], - entryDirs: [], - ); - - $this->assertSame('my-blog-project', $project->generateDirectoryName()); - } - - public function testGetConfiguration(): void - { - $project = new Project( - id: 'proj_config', - name: 'Config Test', - description: 'Testing configuration', - template: 'blog', - status: 'active', - tags: ['web', 'test'], - entryDirs: ['posts', 'pages'], - memory: ['config memory'], - ); - - $expected = [ - 'project' => [ - 'name' => 'Config Test', - 'description' => 'Testing configuration', - 'template' => 'blog', - 'status' => 'active', - 'tags' => ['web', 'test'], - 'memory' => ['config memory'], - 'entries' => [ - 'dirs' => ['posts', 'pages'], - ], - ], - ]; - - $this->assertSame($expected, $project->getConfiguration()); - } - public function testJsonSerialize(): void { - $project = new Project( + $project = new Research( id: 'proj_json', name: 'JSON Test', description: 'Testing JSON serialization', @@ -239,31 +179,19 @@ public function testJsonSerialize(): void $serialized = $project->jsonSerialize(); - $this->assertSame('proj_json', $serialized['project_id']); $this->assertSame('JSON Test', $serialized['title']); $this->assertSame('published', $serialized['status']); - $this->assertSame('api', $serialized['project_type']); + $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(['endpoints'], $serialized['metadata']['entry_dirs']); $this->assertSame(['json memory'], $serialized['metadata']['memory']); - - // Check timestamps are present and valid - $this->assertArrayHasKey('created_at', $serialized); - $this->assertArrayHasKey('updated_at', $serialized); - - // Validate timestamp format (ISO 8601) - $this->assertMatchesRegularExpression( - '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', - $serialized['created_at'], - ); } public function testProjectIsImmutable(): void { - $project = new Project( + $project = new Research( id: 'proj_immutable', name: 'Immutable Test', description: 'Testing immutability', diff --git a/tests/src/Unit/Drafling/Domain/ValueObject/EntryIdTest.php b/tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php similarity index 93% rename from tests/src/Unit/Drafling/Domain/ValueObject/EntryIdTest.php rename to tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php index 68f48c83..ea9f8927 100644 --- a/tests/src/Unit/Drafling/Domain/ValueObject/EntryIdTest.php +++ b/tests/src/Unit/Research/Domain/ValueObject/EntryIdTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\Domain\ValueObject; +namespace Tests\Unit\Research\Domain\ValueObject; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\EntryId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\EntryId; use PHPUnit\Framework\TestCase; /** diff --git a/tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php b/tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php similarity index 59% rename from tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php rename to tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php index 96c4f4d2..2d2c64c5 100644 --- a/tests/src/Unit/Drafling/Domain/ValueObject/ProjectIdTest.php +++ b/tests/src/Unit/Research/Domain/ValueObject/ResearchIdTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\Domain\ValueObject; +namespace Tests\Unit\Research\Domain\ValueObject; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; use PHPUnit\Framework\TestCase; /** * Unit tests for ProjectId value object */ -final class ProjectIdTest extends TestCase +final class ResearchIdTest extends TestCase { public function testFromString(): void { - $projectId = ProjectId::fromString('proj_123abc'); + $projectId = ResearchId::fromString('proj_123abc'); $this->assertSame('proj_123abc', $projectId->value); } @@ -22,24 +22,24 @@ public function testFromString(): void public function testFromStringWithEmptyValue(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Project ID cannot be empty'); + $this->expectExceptionMessage('Research ID cannot be empty'); - ProjectId::fromString(''); + ResearchId::fromString(''); } public function testFromStringWithWhitespaceOnly(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Project ID cannot be empty'); + $this->expectExceptionMessage('Research ID cannot be empty'); - ProjectId::fromString(" \t\n "); + ResearchId::fromString(" \t\n "); } public function testEquality(): void { - $id1 = ProjectId::fromString('proj_123'); - $id2 = ProjectId::fromString('proj_123'); - $id3 = ProjectId::fromString('proj_456'); + $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)); @@ -47,20 +47,20 @@ public function testEquality(): void public function testToString(): void { - $projectId = ProjectId::fromString('proj_test'); + $projectId = ResearchId::fromString('proj_test'); $this->assertSame('proj_test', (string) $projectId); } public function testValueObjectIsImmutable(): void { - $projectId = ProjectId::fromString('proj_immutable'); + $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 = ProjectId::fromString('proj_immutable'); + $anotherProjectId = ResearchId::fromString('proj_immutable'); $this->assertTrue($projectId->equals($anotherProjectId)); $this->assertNotSame($projectId, $anotherProjectId); } diff --git a/tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php b/tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php similarity index 94% rename from tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php rename to tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php index 6e269ff8..8cfdc692 100644 --- a/tests/src/Unit/Drafling/Domain/ValueObject/TemplateKeyTest.php +++ b/tests/src/Unit/Research/Domain/ValueObject/TemplateKeyTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\Domain\ValueObject; +namespace Tests\Unit\Research\Domain\ValueObject; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; use PHPUnit\Framework\TestCase; /** diff --git a/tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php b/tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php similarity index 80% rename from tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php rename to tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php index f84acdf7..4ae70e4d 100644 --- a/tests/src/Unit/Drafling/MCP/DTO/ProjectCreateRequestTest.php +++ b/tests/src/Unit/Research/MCP/DTO/ResearchCreateRequestTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\MCP\DTO; +namespace Tests\Unit\Research\MCP\DTO; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchCreateRequest; use PHPUnit\Framework\TestCase; /** * Unit tests for ProjectCreateRequest DTO */ -final class ProjectCreateRequestTest extends TestCase +final class ResearchCreateRequestTest extends TestCase { public function testConstructionWithAllFields(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'blog-template', title: 'My Blog Project', description: 'A personal blog about tech', @@ -33,7 +33,7 @@ public function testConstructionWithAllFields(): void public function testConstructionWithMinimalFields(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'simple-template', title: 'Simple Project', ); @@ -46,19 +46,9 @@ public function testConstructionWithMinimalFields(): void $this->assertSame([], $request->memory); } - public function testGetName(): void - { - $request = new ProjectCreateRequest( - templateId: 'test-template', - title: 'Test Project Name', - ); - - $this->assertSame('Test Project Name', $request->getName()); - } - public function testValidateWithValidRequest(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'valid-template', title: 'Valid Project', ); @@ -70,7 +60,7 @@ public function testValidateWithValidRequest(): void public function testValidateWithEmptyTemplateId(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: '', title: 'Some Project', ); @@ -82,19 +72,19 @@ public function testValidateWithEmptyTemplateId(): void public function testValidateWithEmptyTitle(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'valid-template', title: '', ); $errors = $request->validate(); - $this->assertContains('Project title cannot be empty', $errors); + $this->assertContains('Research title cannot be empty', $errors); } public function testValidateWithMultipleErrors(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: '', title: '', ); @@ -102,25 +92,25 @@ public function testValidateWithMultipleErrors(): void $errors = $request->validate(); $this->assertContains('Template ID cannot be empty', $errors); - $this->assertContains('Project title cannot be empty', $errors); + $this->assertContains('Research title cannot be empty', $errors); $this->assertCount(2, $errors); } public function testValidateWithWhitespaceOnlyTitle(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'valid-template', title: " \t\n ", ); $errors = $request->validate(); - $this->assertContains('Project title cannot be empty', $errors); + $this->assertContains('Research title cannot be empty', $errors); } public function testValidateWithWhitespaceOnlyTemplateId(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: " \n\t ", title: 'Valid Title', ); @@ -132,7 +122,7 @@ public function testValidateWithWhitespaceOnlyTemplateId(): void public function testValidateWithValidTitleContainingWhitespace(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'valid-template', title: ' Valid Title with Spaces ', ); @@ -144,7 +134,7 @@ public function testValidateWithValidTitleContainingWhitespace(): void public function testValidateAcceptsEmptyOptionalFields(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'template', title: 'Project', description: '', @@ -162,7 +152,7 @@ public function testValidateWithLongDescription(): void { $longDescription = \str_repeat('This is a very long description. ', 100); - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'template', title: 'Project', description: $longDescription, @@ -175,7 +165,7 @@ public function testValidateWithLongDescription(): void public function testValidateWithSpecialCharactersInFields(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'template-with-dashes_and_underscores', title: 'Project with Special Chars: !@#$%', description: 'Description with unicode: 🚀 café', @@ -191,7 +181,7 @@ public function testValidateWithSpecialCharactersInFields(): void public function testArrayFieldsAreEmptyByDefault(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'template', title: 'Project', ); @@ -208,7 +198,7 @@ public function testArrayFieldsAreEmptyByDefault(): void public function testIsReadonlyDto(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'readonly-template', title: 'Readonly Project', ); diff --git a/tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php b/tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php similarity index 64% rename from tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php rename to tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php index b0c06c44..3f74bd9a 100644 --- a/tests/src/Unit/Drafling/MCP/DTO/ProjectUpdateRequestTest.php +++ b/tests/src/Unit/Research/MCP/DTO/ResearchUpdateRequestTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\MCP\DTO; +namespace Tests\Unit\Research\MCP\DTO; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectUpdateRequest; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchUpdateRequest; use PHPUnit\Framework\TestCase; /** * Unit tests for ProjectUpdateRequest DTO */ -final class ProjectUpdateRequestTest extends TestCase +final class ResearchUpdateRequestTest extends TestCase { public function testConstructionWithAllFields(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'Updated Title', description: 'Updated description', status: 'published', @@ -24,7 +24,7 @@ public function testConstructionWithAllFields(): void memory: ['memory1', 'memory2'], ); - $this->assertSame('proj_123', $request->projectId); + $this->assertSame('proj_123', $request->researchId); $this->assertSame('Updated Title', $request->title); $this->assertSame('Updated description', $request->description); $this->assertSame('published', $request->status); @@ -35,9 +35,9 @@ public function testConstructionWithAllFields(): void public function testConstructionWithMinimalFields(): void { - $request = new ProjectUpdateRequest(projectId: 'proj_456'); + $request = new ResearchUpdateRequest(researchId: 'proj_456'); - $this->assertSame('proj_456', $request->projectId); + $this->assertSame('proj_456', $request->researchId); $this->assertNull($request->title); $this->assertNull($request->description); $this->assertNull($request->status); @@ -46,27 +46,10 @@ public function testConstructionWithMinimalFields(): void $this->assertNull($request->memory); } - public function testGetName(): void - { - $request = new ProjectUpdateRequest( - projectId: 'proj_789', - title: 'Test Project', - ); - - $this->assertSame('Test Project', $request->getName()); - } - - public function testGetNameReturnsNull(): void - { - $request = new ProjectUpdateRequest(projectId: 'proj_abc'); - - $this->assertNull($request->getName()); - } - public function testHasUpdatesReturnsTrueWithTitle(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'New Title', ); @@ -75,8 +58,8 @@ public function testHasUpdatesReturnsTrueWithTitle(): void public function testHasUpdatesReturnsTrueWithDescription(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', description: 'New description', ); @@ -85,8 +68,8 @@ public function testHasUpdatesReturnsTrueWithDescription(): void public function testHasUpdatesReturnsTrueWithStatus(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', status: 'active', ); @@ -95,8 +78,8 @@ public function testHasUpdatesReturnsTrueWithStatus(): void public function testHasUpdatesReturnsTrueWithTags(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', tags: ['new-tag'], ); @@ -105,8 +88,8 @@ public function testHasUpdatesReturnsTrueWithTags(): void public function testHasUpdatesReturnsTrueWithEntryDirs(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', entryDirs: ['new-dir'], ); @@ -115,8 +98,8 @@ public function testHasUpdatesReturnsTrueWithEntryDirs(): void public function testHasUpdatesReturnsTrueWithMemory(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', memory: ['new memory'], ); @@ -125,15 +108,15 @@ public function testHasUpdatesReturnsTrueWithMemory(): void public function testHasUpdatesReturnsFalseWithNoUpdates(): void { - $request = new ProjectUpdateRequest(projectId: 'proj_123'); + $request = new ResearchUpdateRequest(researchId: 'proj_123'); $this->assertFalse($request->hasUpdates()); } public function testHasUpdatesReturnsTrueWithEmptyArrays(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', tags: [], ); @@ -142,8 +125,8 @@ public function testHasUpdatesReturnsTrueWithEmptyArrays(): void public function testValidateWithValidRequest(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'Valid Title', ); @@ -154,19 +137,19 @@ public function testValidateWithValidRequest(): void public function testValidateWithEmptyProjectId(): void { - $request = new ProjectUpdateRequest( - projectId: '', + $request = new ResearchUpdateRequest( + researchId: '', title: 'Some Title', ); $errors = $request->validate(); - $this->assertContains('Project ID cannot be empty', $errors); + $this->assertContains('Research ID cannot be empty', $errors); } public function testValidateWithNoUpdates(): void { - $request = new ProjectUpdateRequest(projectId: 'proj_123'); + $request = new ResearchUpdateRequest(researchId: 'proj_123'); $errors = $request->validate(); @@ -175,19 +158,19 @@ public function testValidateWithNoUpdates(): void public function testValidateWithMultipleErrors(): void { - $request = new ProjectUpdateRequest(projectId: ''); + $request = new ResearchUpdateRequest(researchId: ''); $errors = $request->validate(); - $this->assertContains('Project ID cannot be empty', $errors); + $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 ProjectUpdateRequest( - projectId: 'proj_valid', + $request = new ResearchUpdateRequest( + researchId: 'proj_valid', tags: [], entryDirs: [], memory: [], @@ -200,13 +183,13 @@ public function testValidateWithEmptyArraysIsValid(): void public function testIsReadonlyDto(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_readonly', + $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->projectId); + $this->assertSame('proj_readonly', $request->researchId); $this->assertSame('Original Title', $request->title); } } diff --git a/tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php b/tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php similarity index 57% rename from tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php rename to tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php index dd32d295..668036bb 100644 --- a/tests/src/Unit/Drafling/MCP/Tools/UpdateProjectToolActionTest.php +++ b/tests/src/Unit/Research/MCP/Tools/UpdateResearchToolActionTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\MCP\Tools; - -use Butschster\ContextGenerator\Drafling\Domain\Model\Project; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectUpdateRequest; -use Butschster\ContextGenerator\Drafling\MCP\Tools\UpdateProjectToolAction; -use Butschster\ContextGenerator\Drafling\Service\ProjectServiceInterface; +namespace Tests\Unit\Research\MCP\Tools; + +use Butschster\ContextGenerator\Research\Domain\Model\Research; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchUpdateRequest; +use Butschster\ContextGenerator\Research\MCP\Tools\UpdateResearchToolAction; +use Butschster\ContextGenerator\Research\Service\ResearchServiceInterface; use Mcp\Types\CallToolResult; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,16 +19,16 @@ /** * Unit tests for UpdateProjectToolAction */ -final class UpdateProjectToolActionTest extends TestCase +final class UpdateResearchToolActionTest extends TestCase { private LoggerInterface&MockObject $logger; - private ProjectServiceInterface&MockObject $projectService; - private UpdateProjectToolAction $toolAction; + private ResearchServiceInterface&MockObject $projectService; + private UpdateResearchToolAction $toolAction; public function testSuccessfulProjectUpdate(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'Updated Title', description: 'Updated description', status: 'active', @@ -37,7 +37,7 @@ public function testSuccessfulProjectUpdate(): void memory: ['Updated memory'], ); - $updatedProject = new Project( + $updatedProject = new Research( id: 'proj_123', name: 'Updated Title', description: 'Updated description', @@ -48,17 +48,17 @@ public function testSuccessfulProjectUpdate(): void memory: ['Updated memory'], ); - $projectId = ProjectId::fromString('proj_123'); + $projectId = ResearchId::fromString('proj_123'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) ->willReturn($updatedProject); @@ -71,30 +71,16 @@ public function testSuccessfulProjectUpdate(): void $responseData = \json_decode($content->text, true); $this->assertTrue($responseData['success']); - $this->assertSame('proj_123', $responseData['project_id']); - $this->assertSame('Updated Title', $responseData['title']); - $this->assertSame('active', $responseData['status']); - $this->assertSame('blog-template', $responseData['project_type']); - - // Check metadata - $this->assertSame('Updated description', $responseData['metadata']['description']); - $this->assertSame(['web', 'blog'], $responseData['metadata']['tags']); - $this->assertSame(['posts', 'pages'], $responseData['metadata']['entry_dirs']); - $this->assertSame(['Updated memory'], $responseData['metadata']['memory']); - - // Check changes applied - $expectedChanges = ['title', 'description', 'status', 'tags', 'entry_directories', 'memory']; - $this->assertSame($expectedChanges, $responseData['changes_applied']); } public function testPartialProjectUpdate(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_456', + $request = new ResearchUpdateRequest( + researchId: 'proj_456', title: 'New Title Only', ); - $updatedProject = new Project( + $updatedProject = new Research( id: 'proj_456', name: 'New Title Only', description: 'Original description', @@ -105,17 +91,17 @@ public function testPartialProjectUpdate(): void memory: ['existing memory'], ); - $projectId = ProjectId::fromString('proj_456'); + $projectId = ResearchId::fromString('proj_456'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) ->willReturn($updatedProject); @@ -128,12 +114,11 @@ public function testPartialProjectUpdate(): void $responseData = \json_decode($content->text, true); $this->assertTrue($responseData['success']); - $this->assertSame(['title'], $responseData['changes_applied']); } public function testValidationErrors(): void { - $request = new ProjectUpdateRequest(projectId: ''); // Empty project ID + $request = new ResearchUpdateRequest(researchId: ''); // Empty project ID $result = ($this->toolAction)($request); @@ -150,16 +135,16 @@ public function testValidationErrors(): void public function testProjectNotFound(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_nonexistent', + $request = new ResearchUpdateRequest( + researchId: 'proj_nonexistent', title: 'New Title', ); - $projectId = ProjectId::fromString('proj_nonexistent'); + $projectId = ResearchId::fromString('proj_nonexistent'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(false); @@ -172,29 +157,29 @@ public function testProjectNotFound(): void $responseData = \json_decode($content->text, true); $this->assertFalse($responseData['success']); - $this->assertSame("Project 'proj_nonexistent' not found", $responseData['error']); + $this->assertSame("Research 'proj_nonexistent' not found", $responseData['error']); } public function testProjectNotFoundExceptionFromService(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_exception', + $request = new ResearchUpdateRequest( + researchId: 'proj_exception', title: 'New Title', ); - $projectId = ProjectId::fromString('proj_exception'); + $projectId = ResearchId::fromString('proj_exception'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) - ->willThrowException(new ProjectNotFoundException('Project not found in service')); + ->willThrowException(new ResearchNotFoundException('Project not found in service')); $result = ($this->toolAction)($request); @@ -210,24 +195,24 @@ public function testProjectNotFoundExceptionFromService(): void public function testDraflingException(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_error', + $request = new ResearchUpdateRequest( + researchId: 'proj_error', title: 'New Title', ); - $projectId = ProjectId::fromString('proj_error'); + $projectId = ResearchId::fromString('proj_error'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) - ->willThrowException(new DraflingException('Service error')); + ->willThrowException(new ResearchException('Service error')); $result = ($this->toolAction)($request); @@ -243,22 +228,22 @@ public function testDraflingException(): void public function testUnexpectedException(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_unexpected', + $request = new ResearchUpdateRequest( + researchId: 'proj_unexpected', title: 'New Title', ); - $projectId = ProjectId::fromString('proj_unexpected'); + $projectId = ResearchId::fromString('proj_unexpected'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) ->willThrowException(new \RuntimeException('Unexpected error')); @@ -271,19 +256,19 @@ public function testUnexpectedException(): void $responseData = \json_decode($content->text, true); $this->assertFalse($responseData['success']); - $this->assertSame('Failed to update project: Unexpected error', $responseData['error']); + $this->assertSame('Failed to update research: Unexpected error', $responseData['error']); } public function testEmptyArrayUpdatesAreTracked(): void { - $request = new ProjectUpdateRequest( - projectId: 'proj_empty', + $request = new ResearchUpdateRequest( + researchId: 'proj_empty', tags: [], entryDirs: [], memory: [], ); - $updatedProject = new Project( + $updatedProject = new Research( id: 'proj_empty', name: 'Test Project', description: 'Description', @@ -294,17 +279,17 @@ public function testEmptyArrayUpdatesAreTracked(): void memory: [], ); - $projectId = ProjectId::fromString('proj_empty'); + $projectId = ResearchId::fromString('proj_empty'); $this->projectService ->expects($this->once()) - ->method('projectExists') + ->method('exists') ->with($projectId) ->willReturn(true); $this->projectService ->expects($this->once()) - ->method('updateProject') + ->method('update') ->with($projectId, $request) ->willReturn($updatedProject); @@ -312,60 +297,15 @@ public function testEmptyArrayUpdatesAreTracked(): void $content = $result->content[0]; $responseData = \json_decode($content->text, true); - - $expectedChanges = ['tags', 'entry_directories', 'memory']; - $this->assertSame($expectedChanges, $responseData['changes_applied']); - } - - public function testLoggerIsCalled(): void - { - $request = new ProjectUpdateRequest( - projectId: 'proj_log', - title: 'Logged Update', - ); - - $updatedProject = new Project( - id: 'proj_log', - name: 'Logged Update', - description: 'desc', - template: 'template', - status: 'draft', - tags: [], - entryDirs: [], - memory: [], - ); - - $projectId = ProjectId::fromString('proj_log'); - - $this->logger - ->expects($this->exactly(2)) - ->method('info') - ->with($this->logicalOr( - $this->equalTo('Updating project'), - $this->equalTo('Project updated successfully'), - )); - - $this->projectService - ->expects($this->once()) - ->method('projectExists') - ->with($projectId) - ->willReturn(true); - - $this->projectService - ->expects($this->once()) - ->method('updateProject') - ->with($projectId, $request) - ->willReturn($updatedProject); - - ($this->toolAction)($request); + $this->assertTrue($responseData['success']); } protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->projectService = $this->createMock(ProjectServiceInterface::class); + $this->projectService = $this->createMock(ResearchServiceInterface::class); - $this->toolAction = new UpdateProjectToolAction( + $this->toolAction = new UpdateResearchToolAction( $this->logger, $this->projectService, ); diff --git a/tests/src/Unit/Drafling/Service/ProjectServiceTest.php b/tests/src/Unit/Research/Service/ProjectServiceTest.php similarity index 68% rename from tests/src/Unit/Drafling/Service/ProjectServiceTest.php rename to tests/src/Unit/Research/Service/ProjectServiceTest.php index 95c2da57..1dc7fc6f 100644 --- a/tests/src/Unit/Drafling/Service/ProjectServiceTest.php +++ b/tests/src/Unit/Research/Service/ProjectServiceTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Tests\Unit\Drafling\Service; - -use Butschster\ContextGenerator\Drafling\Domain\Model\Project; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\ProjectId; -use Butschster\ContextGenerator\Drafling\Domain\ValueObject\TemplateKey; -use Butschster\ContextGenerator\Drafling\Exception\DraflingException; -use Butschster\ContextGenerator\Drafling\Exception\ProjectNotFoundException; -use Butschster\ContextGenerator\Drafling\Exception\TemplateNotFoundException; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectCreateRequest; -use Butschster\ContextGenerator\Drafling\MCP\DTO\ProjectUpdateRequest; -use Butschster\ContextGenerator\Drafling\Repository\ProjectRepositoryInterface; -use Butschster\ContextGenerator\Drafling\Service\ProjectService; -use Butschster\ContextGenerator\Drafling\Service\TemplateServiceInterface; -use Butschster\ContextGenerator\Drafling\Storage\StorageDriverInterface; +namespace Tests\Unit\Research\Service; + +use Butschster\ContextGenerator\Research\Domain\Model\Research; +use Butschster\ContextGenerator\Research\Domain\ValueObject\ResearchId; +use Butschster\ContextGenerator\Research\Domain\ValueObject\TemplateKey; +use Butschster\ContextGenerator\Research\Exception\ResearchException; +use Butschster\ContextGenerator\Research\Exception\ResearchNotFoundException; +use Butschster\ContextGenerator\Research\Exception\TemplateNotFoundException; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchCreateRequest; +use Butschster\ContextGenerator\Research\MCP\DTO\ResearchUpdateRequest; +use Butschster\ContextGenerator\Research\Repository\ResearchRepositoryInterface; +use Butschster\ContextGenerator\Research\Service\ResearchService; +use Butschster\ContextGenerator\Research\Service\TemplateServiceInterface; +use Butschster\ContextGenerator\Research\Storage\StorageDriverInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -25,21 +25,21 @@ */ final class ProjectServiceTest extends TestCase { - private ProjectRepositoryInterface&MockObject $projectRepository; + private ResearchRepositoryInterface&MockObject $projectRepository; private TemplateServiceInterface&MockObject $templateService; private StorageDriverInterface&MockObject $storageDriver; private LoggerInterface&MockObject $logger; - private ProjectService $projectService; + private ResearchService $projectService; public function testCreateProjectSuccess(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'blog-template', title: 'My Blog', ); $templateKey = TemplateKey::fromString('blog-template'); - $createdProject = new Project( + $createdProject = new Research( id: 'proj_123', name: 'My Blog', description: '', @@ -59,7 +59,7 @@ public function testCreateProjectSuccess(): void // Storage driver creates project $this->storageDriver ->expects($this->once()) - ->method('createProject') + ->method('createResearch') ->with($request) ->willReturn($createdProject); @@ -69,14 +69,14 @@ public function testCreateProjectSuccess(): void ->method('save') ->with($createdProject); - $result = $this->projectService->createProject($request); + $result = $this->projectService->create($request); $this->assertSame($createdProject, $result); } public function testCreateProjectWithNonExistentTemplate(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'non-existent-template', title: 'Test Project', ); @@ -92,12 +92,12 @@ public function testCreateProjectWithNonExistentTemplate(): void $this->expectException(TemplateNotFoundException::class); $this->expectExceptionMessage("Template 'non-existent-template' not found"); - $this->projectService->createProject($request); + $this->projectService->create($request); } public function testCreateProjectStorageFailure(): void { - $request = new ProjectCreateRequest( + $request = new ResearchCreateRequest( templateId: 'valid-template', title: 'Test Project', ); @@ -112,25 +112,25 @@ public function testCreateProjectStorageFailure(): void $this->storageDriver ->expects($this->once()) - ->method('createProject') + ->method('createResearch') ->with($request) ->willThrowException(new \RuntimeException('Storage error')); - $this->expectException(DraflingException::class); - $this->expectExceptionMessage('Failed to create project: Storage error'); + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to create research: Storage error'); - $this->projectService->createProject($request); + $this->projectService->create($request); } public function testUpdateProjectSuccess(): void { - $projectId = ProjectId::fromString('proj_123'); - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $projectId = ResearchId::fromString('proj_123'); + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'Updated Title', ); - $updatedProject = new Project( + $updatedProject = new Research( id: 'proj_123', name: 'Updated Title', description: 'description', @@ -150,7 +150,7 @@ public function testUpdateProjectSuccess(): void // Storage driver updates project $this->storageDriver ->expects($this->once()) - ->method('updateProject') + ->method('updateResearch') ->with($projectId, $request) ->willReturn($updatedProject); @@ -160,16 +160,16 @@ public function testUpdateProjectSuccess(): void ->method('save') ->with($updatedProject); - $result = $this->projectService->updateProject($projectId, $request); + $result = $this->projectService->update($projectId, $request); $this->assertSame($updatedProject, $result); } public function testUpdateProjectNotFound(): void { - $projectId = ProjectId::fromString('proj_nonexistent'); - $request = new ProjectUpdateRequest( - projectId: 'proj_nonexistent', + $projectId = ResearchId::fromString('proj_nonexistent'); + $request = new ResearchUpdateRequest( + researchId: 'proj_nonexistent', title: 'Updated Title', ); @@ -179,17 +179,17 @@ public function testUpdateProjectNotFound(): void ->with($projectId) ->willReturn(false); - $this->expectException(ProjectNotFoundException::class); - $this->expectExceptionMessage("Project 'proj_nonexistent' not found"); + $this->expectException(ResearchNotFoundException::class); + $this->expectExceptionMessage("Research 'proj_nonexistent' not found"); - $this->projectService->updateProject($projectId, $request); + $this->projectService->update($projectId, $request); } public function testUpdateProjectStorageFailure(): void { - $projectId = ProjectId::fromString('proj_123'); - $request = new ProjectUpdateRequest( - projectId: 'proj_123', + $projectId = ResearchId::fromString('proj_123'); + $request = new ResearchUpdateRequest( + researchId: 'proj_123', title: 'Updated Title', ); @@ -201,19 +201,19 @@ public function testUpdateProjectStorageFailure(): void $this->storageDriver ->expects($this->once()) - ->method('updateProject') + ->method('updateResearch') ->with($projectId, $request) ->willThrowException(new \RuntimeException('Update failed')); - $this->expectException(DraflingException::class); - $this->expectExceptionMessage('Failed to update project: Update failed'); + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to update research: Update failed'); - $this->projectService->updateProject($projectId, $request); + $this->projectService->update($projectId, $request); } public function testProjectExists(): void { - $projectId = ProjectId::fromString('proj_exists'); + $projectId = ResearchId::fromString('proj_exists'); $this->projectRepository ->expects($this->once()) @@ -221,14 +221,14 @@ public function testProjectExists(): void ->with($projectId) ->willReturn(true); - $result = $this->projectService->projectExists($projectId); + $result = $this->projectService->exists($projectId); $this->assertTrue($result); } public function testProjectNotExists(): void { - $projectId = ProjectId::fromString('proj_notexists'); + $projectId = ResearchId::fromString('proj_notexists'); $this->projectRepository ->expects($this->once()) @@ -236,15 +236,15 @@ public function testProjectNotExists(): void ->with($projectId) ->willReturn(false); - $result = $this->projectService->projectExists($projectId); + $result = $this->projectService->exists($projectId); $this->assertFalse($result); } public function testGetProject(): void { - $projectId = ProjectId::fromString('proj_get'); - $project = new Project( + $projectId = ResearchId::fromString('proj_get'); + $project = new Research( id: 'proj_get', name: 'Test Project', description: 'description', @@ -260,14 +260,14 @@ public function testGetProject(): void ->with($projectId) ->willReturn($project); - $result = $this->projectService->getProject($projectId); + $result = $this->projectService->get($projectId); $this->assertSame($project, $result); } public function testGetProjectNotFound(): void { - $projectId = ProjectId::fromString('proj_notfound'); + $projectId = ResearchId::fromString('proj_notfound'); $this->projectRepository ->expects($this->once()) @@ -275,7 +275,7 @@ public function testGetProjectNotFound(): void ->with($projectId) ->willReturn(null); - $result = $this->projectService->getProject($projectId); + $result = $this->projectService->get($projectId); $this->assertNull($result); } @@ -284,7 +284,7 @@ public function testListProjects(): void { $filters = ['status' => 'active']; $projects = [ - new Project( + new Research( id: 'proj_1', name: 'Project 1', description: 'desc1', @@ -293,7 +293,7 @@ public function testListProjects(): void tags: [], entryDirs: [], ), - new Project( + new Research( id: 'proj_2', name: 'Project 2', description: 'desc2', @@ -310,7 +310,7 @@ public function testListProjects(): void ->with($filters) ->willReturn($projects); - $result = $this->projectService->listProjects($filters); + $result = $this->projectService->findAll($filters); $this->assertSame($projects, $result); } @@ -325,15 +325,15 @@ public function testListProjectsFailure(): void ->with($filters) ->willThrowException(new \RuntimeException('Database error')); - $this->expectException(DraflingException::class); - $this->expectExceptionMessage('Failed to list projects: Database error'); + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to list researches: Database error'); - $this->projectService->listProjects($filters); + $this->projectService->findAll($filters); } public function testDeleteProjectSuccess(): void { - $projectId = ProjectId::fromString('proj_delete'); + $projectId = ResearchId::fromString('proj_delete'); // Project exists $this->projectRepository @@ -345,7 +345,7 @@ public function testDeleteProjectSuccess(): void // Storage driver deletes project $this->storageDriver ->expects($this->once()) - ->method('deleteProject') + ->method('deleteResearch') ->with($projectId) ->willReturn(true); @@ -355,14 +355,14 @@ public function testDeleteProjectSuccess(): void ->method('delete') ->with($projectId); - $result = $this->projectService->deleteProject($projectId); + $result = $this->projectService->delete($projectId); $this->assertTrue($result); } public function testDeleteProjectNotFound(): void { - $projectId = ProjectId::fromString('proj_notexist'); + $projectId = ResearchId::fromString('proj_notexist'); $this->projectRepository ->expects($this->once()) @@ -370,14 +370,14 @@ public function testDeleteProjectNotFound(): void ->with($projectId) ->willReturn(false); - $result = $this->projectService->deleteProject($projectId); + $result = $this->projectService->delete($projectId); $this->assertFalse($result); } public function testDeleteProjectStorageFailure(): void { - $projectId = ProjectId::fromString('proj_storage_fail'); + $projectId = ResearchId::fromString('proj_storage_fail'); $this->projectRepository ->expects($this->once()) @@ -387,22 +387,22 @@ public function testDeleteProjectStorageFailure(): void $this->storageDriver ->expects($this->once()) - ->method('deleteProject') + ->method('deleteResearch') ->with($projectId) ->willThrowException(new \RuntimeException('Delete failed')); - $this->expectException(DraflingException::class); - $this->expectExceptionMessage('Failed to delete project: Delete failed'); + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to delete research: Delete failed'); - $this->projectService->deleteProject($projectId); + $this->projectService->delete($projectId); } public function testAddProjectMemorySuccess(): void { - $projectId = ProjectId::fromString('proj_memory'); + $projectId = ResearchId::fromString('proj_memory'); $memory = 'New memory entry'; - $originalProject = new Project( + $originalProject = new Research( id: 'proj_memory', name: 'Memory Test', description: 'desc', @@ -413,7 +413,7 @@ public function testAddProjectMemorySuccess(): void memory: ['existing memory'], ); - $updatedProject = new Project( + $updatedProject = new Research( id: 'proj_memory', name: 'Memory Test', description: 'desc', @@ -435,7 +435,7 @@ public function testAddProjectMemorySuccess(): void ->method('save') ->with($updatedProject); - $result = $this->projectService->addProjectMemory($projectId, $memory); + $result = $this->projectService->addMemory($projectId, $memory); $this->assertEquals($updatedProject->memory, $result->memory); $this->assertContains('New memory entry', $result->memory); @@ -444,7 +444,7 @@ public function testAddProjectMemorySuccess(): void public function testAddProjectMemoryProjectNotFound(): void { - $projectId = ProjectId::fromString('proj_notexist'); + $projectId = ResearchId::fromString('proj_notexist'); $memory = 'Some memory'; $this->projectRepository @@ -453,18 +453,18 @@ public function testAddProjectMemoryProjectNotFound(): void ->with($projectId) ->willReturn(null); - $this->expectException(ProjectNotFoundException::class); - $this->expectExceptionMessage("Project 'proj_notexist' not found"); + $this->expectException(ResearchNotFoundException::class); + $this->expectExceptionMessage("Research 'proj_notexist' not found"); - $this->projectService->addProjectMemory($projectId, $memory); + $this->projectService->addMemory($projectId, $memory); } public function testAddProjectMemoryRepositoryFailure(): void { - $projectId = ProjectId::fromString('proj_memory_fail'); + $projectId = ResearchId::fromString('proj_memory_fail'); $memory = 'Memory content'; - $project = new Project( + $project = new Research( id: 'proj_memory_fail', name: 'Test', description: 'desc', @@ -486,20 +486,20 @@ public function testAddProjectMemoryRepositoryFailure(): void ->method('save') ->willThrowException(new \RuntimeException('Save failed')); - $this->expectException(DraflingException::class); - $this->expectExceptionMessage('Failed to add memory to project: Save failed'); + $this->expectException(ResearchException::class); + $this->expectExceptionMessage('Failed to add memory to research: Save failed'); - $this->projectService->addProjectMemory($projectId, $memory); + $this->projectService->addMemory($projectId, $memory); } protected function setUp(): void { - $this->projectRepository = $this->createMock(ProjectRepositoryInterface::class); + $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 ProjectService( + $this->projectService = new ResearchService( $this->projectRepository, $this->templateService, $this->storageDriver, From e23eaf0a1de24111700bae1aafdd2480eebdd9c3 Mon Sep 17 00:00:00 2001 From: butschster Date: Thu, 25 Sep 2025 00:23:58 +0400 Subject: [PATCH 16/16] fix cs --- .../Storage/FileStorageBootloader.php | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/Research/Storage/FileStorageBootloader.php b/src/Research/Storage/FileStorageBootloader.php index f52f25f4..4b5e38a1 100644 --- a/src/Research/Storage/FileStorageBootloader.php +++ b/src/Research/Storage/FileStorageBootloader.php @@ -33,29 +33,18 @@ public function defineSingletons(): array EntryRepositoryInterface::class => FileEntryRepository::class, // Storage driver - StorageDriverInterface::class => static function ( - ResearchConfigInterface $config, - FilesInterface $files, - LoggerInterface $logger, - ExceptionReporterInterface $reporter, - DirectoriesInterface $dirs, - TemplateRepositoryInterface $templateRepository, - ResearchRepositoryInterface $researchRepository, - EntryRepositoryInterface $entryRepository, - ): StorageDriverInterface { - return 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, - ); - }, + 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, + ), ]; } }