diff --git a/app/Commands/Concerns/ResolvesProject.php b/app/Commands/Concerns/ResolvesProject.php new file mode 100644 index 0000000..4404fd4 --- /dev/null +++ b/app/Commands/Concerns/ResolvesProject.php @@ -0,0 +1,39 @@ +option('project'); + + if (is_string($projectOption) && $projectOption !== '') { + return $projectOption; + } + + return app(ProjectDetectorService::class)->detect(); + } + + /** + * Check if --global flag is set. + */ + protected function isGlobal(): bool + { + return (bool) $this->option('global'); + } +} diff --git a/app/Commands/InsightsCommand.php b/app/Commands/InsightsCommand.php index 896f304..ad11e58 100644 --- a/app/Commands/InsightsCommand.php +++ b/app/Commands/InsightsCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\PatternDetectorService; use App\Services\QdrantService; use App\Services\ThemeClassifierService; @@ -17,11 +18,15 @@ class InsightsCommand extends Command { + use ResolvesProject; + protected $signature = 'insights {--themes : Show theme classification analysis} {--patterns : Show pattern detection analysis} {--classify-entry= : Classify a specific entry by ID} - {--limit=100 : Number of entries to analyze}'; + {--limit=100 : Number of entries to analyze} + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Analyze knowledge base for themes, patterns, and insights'; diff --git a/app/Commands/KnowledgeAddCommand.php b/app/Commands/KnowledgeAddCommand.php index 472ccf1..66c5a67 100644 --- a/app/Commands/KnowledgeAddCommand.php +++ b/app/Commands/KnowledgeAddCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Exceptions\Qdrant\DuplicateEntryException; use App\Services\EnhancementQueueService; use App\Services\GitContextService; @@ -20,6 +21,8 @@ class KnowledgeAddCommand extends Command { + use ResolvesProject; + protected $signature = 'add {title : The title of the knowledge entry} {--content= : The content of the knowledge entry} @@ -38,7 +41,9 @@ class KnowledgeAddCommand extends Command {--commit= : Git commit hash} {--no-git : Skip automatic git context detection} {--force : Skip write gate and duplicate detection} - {--skip-enhance : Skip queueing for Ollama enhancement}'; + {--skip-enhance : Skip queueing for Ollama enhancement} + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Add a new knowledge entry'; @@ -172,7 +177,7 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri $checkDuplicates = ! $force; try { $success = spin( - fn (): bool => $qdrant->upsert($data, 'default', $checkDuplicates), + fn (): bool => $qdrant->upsert($data, $this->resolveProject(), $checkDuplicates), 'Storing knowledge entry...' ); diff --git a/app/Commands/KnowledgeArchiveCommand.php b/app/Commands/KnowledgeArchiveCommand.php index a3a62ff..dafeb11 100644 --- a/app/Commands/KnowledgeArchiveCommand.php +++ b/app/Commands/KnowledgeArchiveCommand.php @@ -4,17 +4,22 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; class KnowledgeArchiveCommand extends Command { + use ResolvesProject; + /** * @var string */ protected $signature = 'archive {id : The ID of the entry to archive} - {--restore : Restore an archived entry}'; + {--restore : Restore an archived entry} + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string @@ -34,7 +39,8 @@ public function handle(QdrantService $qdrant): int return self::FAILURE; } - $entry = $qdrant->getById((int) $id); + $project = $this->resolveProject(); + $entry = $qdrant->getById((int) $id, $project); if ($entry === null) { $this->error("Entry not found with ID: {$id}"); @@ -67,7 +73,7 @@ private function archiveEntry(QdrantService $qdrant, array $entry): int $qdrant->updateFields((int) $entry['id'], [ 'status' => 'deprecated', 'confidence' => 0, - ]); + ], $this->resolveProject()); $this->info("Entry #{$entry['id']} has been archived."); $this->newLine(); @@ -95,7 +101,7 @@ private function restoreEntry(QdrantService $qdrant, array $entry): int $qdrant->updateFields((int) $entry['id'], [ 'status' => 'draft', 'confidence' => 50, - ]); + ], $this->resolveProject()); $this->info("Entry #{$entry['id']} has been restored."); $this->newLine(); diff --git a/app/Commands/KnowledgeExportAllCommand.php b/app/Commands/KnowledgeExportAllCommand.php index 2fe642c..2451c35 100644 --- a/app/Commands/KnowledgeExportAllCommand.php +++ b/app/Commands/KnowledgeExportAllCommand.php @@ -4,19 +4,24 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\MarkdownExporter; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; class KnowledgeExportAllCommand extends Command { + use ResolvesProject; + /** * @var string */ protected $signature = 'export:all {--format=markdown : Export format (markdown, json)} {--output=./docs : Output directory path} - {--category= : Export only entries from a specific category}'; + {--category= : Export only entries from a specific category} + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string @@ -39,7 +44,7 @@ public function handle(MarkdownExporter $markdownExporter, QdrantService $qdrant } // Get all entries (use high limit) - $entries = $qdrant->search('', $filters, 10000); + $entries = $qdrant->search('', $filters, 10000, $this->resolveProject()); if ($entries->isEmpty()) { $this->warn('No entries found to export.'); diff --git a/app/Commands/KnowledgeExportCommand.php b/app/Commands/KnowledgeExportCommand.php index 10493fd..115e900 100644 --- a/app/Commands/KnowledgeExportCommand.php +++ b/app/Commands/KnowledgeExportCommand.php @@ -4,19 +4,24 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\MarkdownExporter; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; class KnowledgeExportCommand extends Command { + use ResolvesProject; + /** * @var string */ protected $signature = 'export {id : The ID of the knowledge entry to export} {--format=markdown : Export format (markdown, json)} - {--output= : Output file path (default: stdout)}'; + {--output= : Output file path (default: stdout)} + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string @@ -39,7 +44,7 @@ public function handle(MarkdownExporter $markdownExporter): int return self::FAILURE; } - $entry = app(QdrantService::class)->getById((int) $id); + $entry = app(QdrantService::class)->getById((int) $id, $this->resolveProject()); if ($entry === null) { $this->error('Entry not found.'); diff --git a/app/Commands/KnowledgeListCommand.php b/app/Commands/KnowledgeListCommand.php index cf8b708..12e0155 100644 --- a/app/Commands/KnowledgeListCommand.php +++ b/app/Commands/KnowledgeListCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; @@ -13,6 +14,8 @@ class KnowledgeListCommand extends Command { + use ResolvesProject; + /** * @var string */ @@ -22,7 +25,9 @@ class KnowledgeListCommand extends Command {--status= : Filter by status} {--module= : Filter by module} {--limit=20 : Maximum number of entries to display} - {--offset= : Skip N entries (use point ID for pagination)}'; + {--offset= : Skip N entries (use point ID for pagination)} + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string @@ -51,7 +56,7 @@ public function handle(QdrantService $qdrant): int // Use scroll to get entries (no vector search needed) $results = spin( - fn (): \Illuminate\Support\Collection => $qdrant->scroll($filters, $limit, 'default', $parsedOffset), + fn (): \Illuminate\Support\Collection => $qdrant->scroll($filters, $limit, $this->resolveProject(), $parsedOffset), 'Fetching entries...' ); diff --git a/app/Commands/KnowledgeSearchCommand.php b/app/Commands/KnowledgeSearchCommand.php index 7e6278a..57487ca 100644 --- a/app/Commands/KnowledgeSearchCommand.php +++ b/app/Commands/KnowledgeSearchCommand.php @@ -4,13 +4,15 @@ namespace App\Commands; -use App\Enums\SearchTier; +use App\Commands\Concerns\ResolvesProject; use App\Services\EntryMetadataService; -use App\Services\TieredSearchService; +use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; class KnowledgeSearchCommand extends Command { + use ResolvesProject; + /** * @var string */ @@ -24,14 +26,15 @@ class KnowledgeSearchCommand extends Command {--limit=20 : Maximum number of results} {--semantic : Use semantic search if available} {--include-superseded : Include superseded entries in results} - {--tier= : Force searching a specific tier (working, recent, structured, archive)}'; + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string */ protected $description = 'Search knowledge entries by keyword, tag, or category'; - public function handle(TieredSearchService $tieredSearch, EntryMetadataService $metadata): int + public function handle(QdrantService $qdrant, EntryMetadataService $metadata): int { $query = $this->argument('query'); $tag = $this->option('tag'); @@ -42,7 +45,6 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $ $limit = (int) $this->option('limit'); $this->option('semantic'); $includeSuperseded = (bool) $this->option('include-superseded'); - $tierOption = $this->option('tier'); // Require at least one search parameter for entries if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) { @@ -51,18 +53,6 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $ return self::FAILURE; } - // Validate tier option - $forceTier = null; - if (is_string($tierOption) && $tierOption !== '') { - $forceTier = SearchTier::tryFrom($tierOption); - if ($forceTier === null) { - $validTiers = implode(', ', array_map(fn (SearchTier $t): string => $t->value, SearchTier::cases())); - $this->error("Invalid tier '{$tierOption}'. Valid tiers: {$validTiers}"); - - return self::FAILURE; - } - } - // Build filters for search $filters = array_filter([ 'tag' => is_string($tag) ? $tag : null, @@ -76,9 +66,24 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $ $filters['include_superseded'] = true; } - // Use tiered search + // Use project-aware search $searchQuery = is_string($query) ? $query : ''; - $results = $tieredSearch->search($searchQuery, $filters, $limit, $forceTier); + + if ($this->isGlobal()) { + $collections = $qdrant->listCollections(); + $results = collect(); + + foreach ($collections as $collection) { + $projectName = str_replace('knowledge_', '', $collection); + $projectResults = $qdrant->search($searchQuery, $filters, $limit, $projectName); + $results = $results->merge($projectResults->map(fn (array $entry): array => array_merge($entry, ['_project' => $projectName]))); + } + + $results = $results->sortByDesc('score')->take($limit)->values(); + } else { + $project = $this->resolveProject(); + $results = $qdrant->search($searchQuery, $filters, $limit, $project); + } if ($results->isEmpty()) { $this->line('No entries found.'); @@ -100,24 +105,13 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $ $content = $entry['content'] ?? ''; $score = $entry['score'] ?? 0.0; $supersededBy = $entry['superseded_by'] ?? null; - $tierLabel = $entry['tier_label'] ?? null; - $tieredScore = $entry['tiered_score'] ?? null; $isStale = $metadata->isStale($entry); $effectiveConfidence = $metadata->calculateEffectiveConfidence($entry); $confidenceLevel = $metadata->confidenceLevel($effectiveConfidence); - $scoreDisplay = 'score: '.number_format($score, 2); - if ($tieredScore !== null) { - $scoreDisplay .= ' | ranked: '.number_format((float) $tieredScore, 2); - } - - $tierDisplay = ''; - if (is_string($tierLabel)) { - $tierDisplay = " [{$tierLabel}]"; - } - - $titleLine = "[{$id}] {$title} ({$scoreDisplay}){$tierDisplay}"; + $projectLabel = isset($entry['_project']) ? " [{$entry['_project']}]" : ''; + $titleLine = "[{$id}] {$title} (score: ".number_format($score, 2).')'.$projectLabel; if ($supersededBy !== null) { $titleLine .= ' [SUPERSEDED]'; } diff --git a/app/Commands/KnowledgeShowCommand.php b/app/Commands/KnowledgeShowCommand.php index 0bb1383..40cc28e 100644 --- a/app/Commands/KnowledgeShowCommand.php +++ b/app/Commands/KnowledgeShowCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\EnhancementQueueService; use App\Services\EntryMetadataService; use App\Services\QdrantService; @@ -16,7 +17,12 @@ class KnowledgeShowCommand extends Command { - protected $signature = 'show {id : The ID of the knowledge entry to display}'; + use ResolvesProject; + + protected $signature = 'show + {id : The ID of the knowledge entry to display} + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Display full details of a knowledge entry'; @@ -34,8 +40,10 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata, En return self::FAILURE; } // @codeCoverageIgnoreEnd + $project = $this->resolveProject(); + $entry = spin( - fn (): ?array => $qdrant->getById($id), + fn (): ?array => $qdrant->getById($id, $project), 'Fetching entry...' ); @@ -45,7 +53,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata, En return self::FAILURE; } - $qdrant->incrementUsage($id); + $qdrant->incrementUsage($id, $project); $this->renderEntry($entry, $metadata, $enhancementQueue); diff --git a/app/Commands/KnowledgeStatsCommand.php b/app/Commands/KnowledgeStatsCommand.php index b667507..93ef1c0 100644 --- a/app/Commands/KnowledgeStatsCommand.php +++ b/app/Commands/KnowledgeStatsCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\KnowledgeCacheService; use App\Services\OdinSyncService; use App\Services\QdrantService; @@ -16,21 +17,27 @@ class KnowledgeStatsCommand extends Command { - protected $signature = 'stats'; + use ResolvesProject; + + protected $signature = 'stats + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Display analytics dashboard for knowledge entries'; public function handle(QdrantService $qdrant, OdinSyncService $odinSync): int { + $project = $this->resolveProject(); + $total = spin( - fn (): int => $qdrant->count(), + fn (): int => $qdrant->count($project), 'Loading knowledge base...' ); // Get a sample of entries for category/status breakdown (limit to 1000 for performance) - $entries = $qdrant->scroll([], min($total, 1000)); + $entries = $qdrant->scroll([], min($total, 1000), $project); - $this->renderDashboard($entries, $total); + $this->renderDashboard($entries, $total, $project); $cacheService = $qdrant->getCacheService(); if ($cacheService instanceof KnowledgeCacheService) { @@ -42,7 +49,7 @@ public function handle(QdrantService $qdrant, OdinSyncService $odinSync): int return self::SUCCESS; } - private function renderDashboard(Collection $entries, int $total): void + private function renderDashboard(Collection $entries, int $total, string $project): void { info("Knowledge Base: {$total} entries"); $this->newLine(); @@ -51,10 +58,14 @@ private function renderDashboard(Collection $entries, int $total): void $totalUsage = $entries->sum('usage_count'); $avgUsage = round($entries->avg('usage_count') ?? 0); + $collectionName = app(QdrantService::class)->getCollectionName($project); + $this->line('Overview'); table( ['Metric', 'Value'], [ + ['Project', $project], + ['Collection', $collectionName], ['Total Entries', (string) $total], ['Total Usage', (string) $totalUsage], ['Avg Usage', (string) $avgUsage], diff --git a/app/Commands/KnowledgeUpdateCommand.php b/app/Commands/KnowledgeUpdateCommand.php index ed86a8b..8a1428d 100644 --- a/app/Commands/KnowledgeUpdateCommand.php +++ b/app/Commands/KnowledgeUpdateCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; @@ -14,6 +15,8 @@ class KnowledgeUpdateCommand extends Command { + use ResolvesProject; + protected $signature = 'update {id : The ID of the knowledge entry to update} {--title= : New title} @@ -26,7 +29,9 @@ class KnowledgeUpdateCommand extends Command {--status= : Status (draft, validated, deprecated)} {--module= : Module name} {--source= : Source URL or reference} - {--evidence= : Supporting evidence or reference}'; + {--evidence= : Supporting evidence or reference} + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Update an existing knowledge entry'; @@ -48,9 +53,11 @@ public function handle(QdrantService $qdrant): int // @codeCoverageIgnoreEnd $id = $idArg; + $project = $this->resolveProject(); + // Fetch existing entry $entry = spin( - fn (): ?array => $qdrant->getById($id), + fn (): ?array => $qdrant->getById($id, $project), 'Fetching entry...' ); @@ -190,7 +197,7 @@ public function handle(QdrantService $qdrant): int // Save to Qdrant $success = spin( - fn (): bool => $qdrant->upsert($normalizedEntry), + fn (): bool => $qdrant->upsert($normalizedEntry, $project), 'Updating knowledge entry...' ); diff --git a/app/Commands/ProjectsCommand.php b/app/Commands/ProjectsCommand.php index 3812249..7203549 100644 --- a/app/Commands/ProjectsCommand.php +++ b/app/Commands/ProjectsCommand.php @@ -4,63 +4,57 @@ namespace App\Commands; -use App\Services\OdinSyncService; +use App\Services\ProjectDetectorService; +use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; +use function Laravel\Prompts\info; +use function Laravel\Prompts\spin; +use function Laravel\Prompts\table; +use function Laravel\Prompts\warning; + class ProjectsCommand extends Command { - /** - * @var string - */ - protected $signature = 'projects - {--local : Show only local project collections}'; - - /** - * @var string - */ - protected $description = 'List synced knowledge base projects from Odin'; - - public function handle(OdinSyncService $odinSync): int - { - if ((bool) $this->option('local')) { - $this->info('Local project listing requires Qdrant collection enumeration.'); - $this->line('Use "know stats" to see current project statistics.'); - - return self::SUCCESS; - } + protected $signature = 'projects'; - if (! $odinSync->isEnabled()) { - $this->error('Odin sync is disabled. Set ODIN_SYNC_ENABLED=true to enable.'); + protected $description = 'List all project knowledge bases'; - return self::FAILURE; - } + public function handle(QdrantService $qdrant, ProjectDetectorService $detector): int + { + $currentProject = $detector->detect(); - $this->line('Fetching projects from Odin...'); + $collections = spin( + fn (): array => $qdrant->listCollections(), + 'Fetching project collections...' + ); - if (! $odinSync->isAvailable()) { - $this->warn('Odin server is not reachable. Cannot list remote projects.'); + if ($collections === []) { + warning('No project knowledge bases found.'); return self::SUCCESS; } - $projects = $odinSync->listProjects(); + info('Project Knowledge Bases'); + $this->newLine(); - if ($projects === []) { - $this->info('No projects found on Odin server.'); + $rows = []; + foreach ($collections as $collection) { + $projectName = str_replace('knowledge_', '', $collection); + $isCurrent = $projectName === $currentProject ? ' (current)' : ''; - return self::SUCCESS; - } + $count = $qdrant->count($projectName); - $rows = []; - foreach ($projects as $project) { $rows[] = [ - $project['name'], - (string) $project['entry_count'], - $project['last_synced'] ?? 'Never', + $projectName.$isCurrent, + $collection, + (string) $count, ]; } - $this->table(['Project', 'Entries', 'Last Synced'], $rows); + table(['Project', 'Collection', 'Entries'], $rows); + + $this->newLine(); + $this->line("Current project: {$currentProject}"); return self::SUCCESS; } diff --git a/app/Commands/SyncCommand.php b/app/Commands/SyncCommand.php index 4f4058e..76f8e23 100644 --- a/app/Commands/SyncCommand.php +++ b/app/Commands/SyncCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\DeletionTracker; use App\Services\QdrantService; use GuzzleHttp\Client; @@ -13,6 +14,8 @@ class SyncCommand extends Command { + use ResolvesProject; + /** * @var string */ @@ -20,7 +23,9 @@ class SyncCommand extends Command {--pull : Pull entries from cloud only} {--push : Push local entries to cloud only} {--delete : Delete cloud entries that do not exist locally (requires --push)} - {--full-sync : Compare local vs cloud and remove orphaned cloud entries}'; + {--full-sync : Compare local vs cloud and remove orphaned cloud entries} + {--project= : Override project namespace} + {--global : Search across all projects}'; /** * @var string diff --git a/app/Commands/SynthesizeCommand.php b/app/Commands/SynthesizeCommand.php index da05dea..dd2b258 100644 --- a/app/Commands/SynthesizeCommand.php +++ b/app/Commands/SynthesizeCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Commands\Concerns\ResolvesProject; use App\Services\QdrantService; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -17,6 +18,8 @@ class SynthesizeCommand extends Command { + use ResolvesProject; + protected $signature = 'synthesize {--dedupe : Find and merge duplicate entries} {--digest : Generate a daily digest entry} @@ -24,7 +27,9 @@ class SynthesizeCommand extends Command {--dry-run : Show what would be done without making changes} {--similarity=0.92 : Similarity threshold for deduplication (0.0-1.0)} {--stale-days=30 : Days before low-confidence entries are considered stale} - {--confidence-floor=50 : Confidence threshold for stale detection}'; + {--confidence-floor=50 : Confidence threshold for stale detection} + {--project= : Override project namespace} + {--global : Search across all projects}'; protected $description = 'Synthesize knowledge: dedupe, digest, and archive stale entries'; diff --git a/app/Integrations/Qdrant/Requests/ListCollections.php b/app/Integrations/Qdrant/Requests/ListCollections.php new file mode 100644 index 0000000..127654c --- /dev/null +++ b/app/Integrations/Qdrant/Requests/ListCollections.php @@ -0,0 +1,18 @@ +app->singleton(WriteGateService::class, fn (): \App\Services\WriteGateService => new WriteGateService); + // Project detector service + $this->app->singleton(ProjectDetectorService::class, fn ($app): ProjectDetectorService => new ProjectDetectorService( + $app->make(GitContextService::class) + )); + // Qdrant vector database service $this->app->singleton(QdrantService::class, fn ($app): \App\Services\QdrantService => new QdrantService( $app->make(EmbeddingServiceInterface::class), diff --git a/app/Services/ProjectDetectorService.php b/app/Services/ProjectDetectorService.php new file mode 100644 index 0000000..1280aed --- /dev/null +++ b/app/Services/ProjectDetectorService.php @@ -0,0 +1,86 @@ +gitContext->isGitRepository()) { + return 'default'; + } + + $repoUrl = $this->gitContext->getRepositoryUrl(); + + if ($repoUrl !== null) { + $name = $this->extractNameFromUrl($repoUrl); + + if ($name !== null) { + return $this->sanitize($name); + } + } + + $repoPath = $this->gitContext->getRepositoryPath(); + + if ($repoPath !== null) { + return $this->sanitize(basename($repoPath)); + } + + return 'default'; + } + + /** + * Extract repository name from a remote URL. + * + * Handles formats: + * - https://github.com/user/repo.git + * - git@github.com:user/repo.git + * - ssh://git@github.com/user/repo.git + */ + private function extractNameFromUrl(string $url): ?string + { + // Remove trailing .git + $url = rtrim($url, '/'); + if (str_ends_with($url, '.git')) { + $url = substr($url, 0, -4); + } + + // SSH format: git@github.com:user/repo + if (preg_match('#[:/]([^/]+)$#', $url, $matches) === 1) { + return $matches[1]; + } + + return null; + } + + /** + * Sanitize a project name for use as a Qdrant collection suffix. + */ + private function sanitize(string $name): string + { + $sanitized = strtolower(trim($name)); + $sanitized = (string) preg_replace('/[^a-z0-9_-]/', '_', $sanitized); + $sanitized = (string) preg_replace('/_+/', '_', $sanitized); + $sanitized = trim($sanitized, '_'); + + if ($sanitized === '') { + return 'default'; + } + + return $sanitized; + } +} diff --git a/app/Services/QdrantService.php b/app/Services/QdrantService.php index c5d59e3..f4028d4 100644 --- a/app/Services/QdrantService.php +++ b/app/Services/QdrantService.php @@ -17,6 +17,7 @@ use App\Integrations\Qdrant\Requests\GetCollectionInfo; use App\Integrations\Qdrant\Requests\GetPoints; use App\Integrations\Qdrant\Requests\HybridSearchPoints; +use App\Integrations\Qdrant\Requests\ListCollections; use App\Integrations\Qdrant\Requests\ScrollPoints; use App\Integrations\Qdrant\Requests\SearchPoints; use App\Integrations\Qdrant\Requests\UpsertPoints; @@ -853,10 +854,37 @@ private function executeCount(string $project): int return $data['result']['points_count'] ?? 0; } + /** + * List all knowledge collections from Qdrant. + * + * @return array Collection names matching the knowledge_ prefix + * + * @codeCoverageIgnore Qdrant API integration - tested via integration tests + */ + public function listCollections(): array + { + $response = $this->connector->send(new ListCollections); + + if (! $response->successful()) { + return []; + } + + $data = $response->json(); + $collections = $data['result']['collections'] ?? []; + + return array_values(array_filter( + array_map( + fn (array $collection): string => $collection['name'] ?? '', + $collections + ), + fn (string $name): bool => str_starts_with($name, 'knowledge_') + )); + } + /** * Get collection name for project namespace. */ - private function getCollectionName(string $project): string + public function getCollectionName(string $project): string { return 'knowledge_'.str_replace(['/', '\\', ' '], '_', $project); } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8076a45..c7b3566 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -40,11 +40,6 @@ parameters: count: 1 path: app/Commands/KnowledgeListCommand.php - - - message: "#^Parameter \\#2 \\$filters of method App\\\\Services\\\\TieredSearchService\\:\\:search\\(\\) expects array\\, array\\ given\\.$#" - count: 1 - path: app/Commands/KnowledgeSearchCommand.php - - message: "#^Variable \\$tags in isset\\(\\) always exists and is not nullable\\.$#" count: 1 diff --git a/tests/Feature/Commands/KnowledgeAddCommandTest.php b/tests/Feature/Commands/KnowledgeAddCommandTest.php index 3899798..e5d3ed1 100644 --- a/tests/Feature/Commands/KnowledgeAddCommandTest.php +++ b/tests/Feature/Commands/KnowledgeAddCommandTest.php @@ -16,6 +16,7 @@ ->andReturn(['passed' => true, 'matched' => ['durable_facts'], 'reason' => '']) ->byDefault(); $this->app->instance(WriteGateService::class, $this->mockWriteGate); + mockProjectDetector(); }); afterEach(function (): void { diff --git a/tests/Feature/Commands/KnowledgeListCommandTest.php b/tests/Feature/Commands/KnowledgeListCommandTest.php index 5233902..24d9a82 100644 --- a/tests/Feature/Commands/KnowledgeListCommandTest.php +++ b/tests/Feature/Commands/KnowledgeListCommandTest.php @@ -7,6 +7,7 @@ beforeEach(function (): void { $this->qdrantMock = Mockery::mock(QdrantService::class); $this->app->instance(QdrantService::class, $this->qdrantMock); + mockProjectDetector(); }); it('lists all entries', function (): void { diff --git a/tests/Feature/Commands/KnowledgeSearchCommandTest.php b/tests/Feature/Commands/KnowledgeSearchCommandTest.php index cd5b470..f9d4905 100644 --- a/tests/Feature/Commands/KnowledgeSearchCommandTest.php +++ b/tests/Feature/Commands/KnowledgeSearchCommandTest.php @@ -3,17 +3,19 @@ declare(strict_types=1); use App\Services\EntryMetadataService; -use App\Services\TieredSearchService; +use App\Services\QdrantService; beforeEach(function (): void { - $this->mockTieredSearch = Mockery::mock(TieredSearchService::class); - $this->app->instance(TieredSearchService::class, $this->mockTieredSearch); + $this->mockQdrant = Mockery::mock(QdrantService::class); + $this->app->instance(QdrantService::class, $this->mockQdrant); $this->mockMetadata = Mockery::mock(EntryMetadataService::class); $this->mockMetadata->shouldReceive('isStale')->andReturn(false); $this->mockMetadata->shouldReceive('calculateEffectiveConfidence')->andReturn(80); $this->mockMetadata->shouldReceive('confidenceLevel')->andReturn('high'); $this->app->instance(EntryMetadataService::class, $this->mockMetadata); + + mockProjectDetector(); }); afterEach(function (): void { @@ -21,9 +23,9 @@ }); it('searches entries by keyword in title and content', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('timezone', [], 20, null) + ->with('timezone', [], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -39,9 +41,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.85, ], ])); @@ -52,9 +51,9 @@ }); it('searches entries by tag', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('', ['tag' => 'blood.notifications'], 20, null) + ->with('', ['tag' => 'blood.notifications'], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -70,9 +69,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.68, ], ])); @@ -82,9 +78,9 @@ }); it('searches entries by category', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('', ['category' => 'architecture'], 20, null) + ->with('', ['category' => 'architecture'], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -100,9 +96,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.76, ], ])); @@ -112,9 +105,9 @@ }); it('searches entries by category and module', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('', ['category' => 'architecture', 'module' => 'Blood'], 20, null) + ->with('', ['category' => 'architecture', 'module' => 'Blood'], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -130,9 +123,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.82, ], ])); @@ -144,9 +134,9 @@ }); it('searches entries by priority', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('', ['priority' => 'critical'], 20, null) + ->with('', ['priority' => 'critical'], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -162,9 +152,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.93, ], ])); @@ -174,9 +161,9 @@ }); it('searches entries by status', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('', ['status' => 'validated'], 20, null) + ->with('', ['status' => 'validated'], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -192,9 +179,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.74, ], ])); @@ -204,9 +188,9 @@ }); it('shows message when no results found', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('nonexistent', [], 20, null) + ->with('nonexistent', [], 20, 'default') ->andReturn(collect([])); $this->artisan('search', ['query' => 'nonexistent']) @@ -215,13 +199,13 @@ }); it('searches with multiple filters', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() ->with('', [ 'category' => 'testing', 'module' => 'Blood', 'priority' => 'high', - ], 20, null) + ], 20, 'default') ->andReturn(collect([ [ 'id' => 'entry-1', @@ -237,9 +221,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.83, ], ])); @@ -252,7 +233,7 @@ }); it('requires at least one search parameter', function (): void { - $this->mockTieredSearch->shouldNotReceive('search'); + $this->mockQdrant->shouldNotReceive('search'); $this->artisan('search') ->assertFailed() @@ -260,7 +241,7 @@ }); it('handles query with all filter types combined', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() ->with('laravel', [ 'tag' => 'testing', @@ -268,7 +249,7 @@ 'module' => 'Core', 'priority' => 'high', 'status' => 'validated', - ], 20, null) + ], 20, 'default') ->andReturn(collect([])); $this->artisan('search', [ @@ -283,9 +264,9 @@ }); it('uses semantic search by default', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('semantic', [], 20, null) + ->with('semantic', [], 20, 'default') ->andReturn(collect([])); $this->artisan('search', [ @@ -295,9 +276,9 @@ }); it('handles entries with missing optional fields', function (): void { - $this->mockTieredSearch->shouldReceive('search') + $this->mockQdrant->shouldReceive('search') ->once() - ->with('minimal', [], 20, null) + ->with('minimal', [], 20, 'default') ->andReturn(collect([ [ 'id' => 'minimal-entry', @@ -313,9 +294,6 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.05, ], ])); @@ -323,116 +301,3 @@ ->assertSuccessful() ->expectsOutputToContain('Category: N/A'); }); - -describe('--tier flag', function (): void { - it('passes working tier to tiered search', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->with('query', [], 20, \App\Enums\SearchTier::Working) - ->andReturn(collect([])); - - $this->artisan('search', ['query' => 'query', '--tier' => 'working']) - ->assertSuccessful() - ->expectsOutput('No entries found.'); - }); - - it('passes recent tier to tiered search', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->with('query', [], 20, \App\Enums\SearchTier::Recent) - ->andReturn(collect([])); - - $this->artisan('search', ['query' => 'query', '--tier' => 'recent']) - ->assertSuccessful() - ->expectsOutput('No entries found.'); - }); - - it('passes structured tier to tiered search', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->with('query', [], 20, \App\Enums\SearchTier::Structured) - ->andReturn(collect([])); - - $this->artisan('search', ['query' => 'query', '--tier' => 'structured']) - ->assertSuccessful() - ->expectsOutput('No entries found.'); - }); - - it('passes archive tier to tiered search', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->with('query', [], 20, \App\Enums\SearchTier::Archive) - ->andReturn(collect([])); - - $this->artisan('search', ['query' => 'query', '--tier' => 'archive']) - ->assertSuccessful() - ->expectsOutput('No entries found.'); - }); - - it('rejects invalid tier value', function (): void { - $this->mockTieredSearch->shouldNotReceive('search'); - - $this->artisan('search', ['query' => 'query', '--tier' => 'invalid']) - ->assertFailed(); - }); - - it('displays tier label in results', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->andReturn(collect([ - [ - 'id' => 'uuid-1', - 'title' => 'Test Entry', - 'content' => 'Content', - 'category' => 'testing', - 'priority' => 'medium', - 'confidence' => 80, - 'module' => null, - 'tags' => [], - 'score' => 0.90, - 'status' => 'draft', - 'usage_count' => 0, - 'created_at' => '2026-02-09T00:00:00Z', - 'updated_at' => '2026-02-09T00:00:00Z', - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.72, - ], - ])); - - $this->artisan('search', ['query' => 'test', '--tier' => 'working']) - ->assertSuccessful() - ->expectsOutputToContain('Found 1 entry') - ->expectsOutputToContain('Test Entry'); - }); - - it('displays ranked score in results', function (): void { - $this->mockTieredSearch->shouldReceive('search') - ->once() - ->andReturn(collect([ - [ - 'id' => 'uuid-1', - 'title' => 'Test Entry', - 'content' => 'Content', - 'category' => 'testing', - 'priority' => 'medium', - 'confidence' => 80, - 'module' => null, - 'tags' => [], - 'score' => 0.90, - 'status' => 'validated', - 'usage_count' => 0, - 'created_at' => '2025-01-01T00:00:00Z', - 'updated_at' => '2025-01-01T00:00:00Z', - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.65, - ], - ])); - - $this->artisan('search', ['query' => 'test']) - ->assertSuccessful() - ->expectsOutputToContain('Found 1 entry') - ->expectsOutputToContain('Test Entry'); - }); -}); diff --git a/tests/Feature/Commands/KnowledgeShowCommandTest.php b/tests/Feature/Commands/KnowledgeShowCommandTest.php index 459b271..c24e06b 100644 --- a/tests/Feature/Commands/KnowledgeShowCommandTest.php +++ b/tests/Feature/Commands/KnowledgeShowCommandTest.php @@ -7,6 +7,7 @@ beforeEach(function (): void { $this->qdrantMock = Mockery::mock(QdrantService::class); $this->app->instance(QdrantService::class, $this->qdrantMock); + mockProjectDetector(); // Default: no supersession history (overridden in specific tests) $this->qdrantMock->shouldReceive('getSupersessionHistory') @@ -32,12 +33,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('1') + ->with(1, 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('1') + ->with(1, 'default') ->andReturn(true); $this->artisan('show', ['id' => '1']) @@ -64,12 +65,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('2') + ->with(2, 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('2') + ->with(2, 'default') ->andReturn(true); $this->artisan('show', ['id' => '2']) @@ -96,12 +97,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('3') + ->with(3, 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('3') + ->with(3, 'default') ->andReturn(true); $this->artisan('show', ['id' => '3']) @@ -127,12 +128,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('4') + ->with(4, 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('4') + ->with(4, 'default') ->andReturn(true); $this->artisan('show', ['id' => '4']) @@ -142,7 +143,7 @@ it('shows error when entry not found', function (): void { $this->qdrantMock->shouldReceive('getById') ->once() - ->with('9999') + ->with(9999, 'default') ->andReturn(null); $this->artisan('show', ['id' => '9999']) @@ -153,7 +154,7 @@ it('validates id must be numeric', function (): void { $this->qdrantMock->shouldReceive('getById') ->once() - ->with('abc') + ->with('abc', 'default') ->andReturn(null); $this->artisan('show', ['id' => 'abc']) @@ -178,12 +179,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('5') + ->with(5, 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('5') + ->with(5, 'default') ->andReturn(true); // Just verify command runs successfully - timestamps render via Laravel Prompts @@ -220,12 +221,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('10') + ->with('10', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('10') + ->with('10', 'default') ->andReturn(true); $this->qdrantMock->shouldReceive('getSupersessionHistory') @@ -267,12 +268,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('11') + ->with('11', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('11') + ->with('11', 'default') ->andReturn(true); $this->qdrantMock->shouldReceive('getSupersessionHistory') @@ -318,12 +319,12 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('12') + ->with('12', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('incrementUsage') ->once() - ->with('12') + ->with('12', 'default') ->andReturn(true); $this->qdrantMock->shouldReceive('getSupersessionHistory') diff --git a/tests/Feature/Commands/KnowledgeUpdateCommandTest.php b/tests/Feature/Commands/KnowledgeUpdateCommandTest.php index 3e7b005..447a7e4 100644 --- a/tests/Feature/Commands/KnowledgeUpdateCommandTest.php +++ b/tests/Feature/Commands/KnowledgeUpdateCommandTest.php @@ -7,6 +7,7 @@ beforeEach(function (): void { $this->qdrantMock = Mockery::mock(QdrantService::class); $this->app->instance(QdrantService::class, $this->qdrantMock); + mockProjectDetector(); }); it('updates entry title', function (): void { @@ -27,7 +28,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-123') + ->with('test-id-123', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -60,7 +61,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-456') + ->with('test-id-456', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -92,7 +93,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-789') + ->with('test-id-789', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -124,7 +125,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-add') + ->with('test-id-add', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -157,7 +158,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-conf') + ->with('test-id-conf', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -189,7 +190,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-invalid') + ->with('test-id-invalid', 'default') ->andReturn($entry); $this->artisan('update', [ @@ -216,7 +217,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-cat') + ->with('test-id-cat', 'default') ->andReturn($entry); $this->artisan('update', [ @@ -243,7 +244,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-pri') + ->with('test-id-pri', 'default') ->andReturn($entry); $this->artisan('update', [ @@ -270,7 +271,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-stat') + ->with('test-id-stat', 'default') ->andReturn($entry); $this->artisan('update', [ @@ -282,7 +283,7 @@ it('fails when entry not found', function (): void { $this->qdrantMock->shouldReceive('getById') ->once() - ->with('non-existent-id') + ->with('non-existent-id', 'default') ->andReturn(null); $this->artisan('update', [ @@ -309,7 +310,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-empty') + ->with('test-id-empty', 'default') ->andReturn($entry); $this->artisan('update', [ @@ -335,7 +336,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-multi') + ->with('test-id-multi', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -373,7 +374,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-category') + ->with('test-id-category', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') @@ -405,7 +406,7 @@ $this->qdrantMock->shouldReceive('getById') ->once() - ->with('test-id-time') + ->with('test-id-time', 'default') ->andReturn($entry); $this->qdrantMock->shouldReceive('upsert') diff --git a/tests/Feature/Commands/ProjectsCommandTest.php b/tests/Feature/Commands/ProjectsCommandTest.php index f52bf83..95debb9 100644 --- a/tests/Feature/Commands/ProjectsCommandTest.php +++ b/tests/Feature/Commands/ProjectsCommandTest.php @@ -2,56 +2,60 @@ declare(strict_types=1); -use App\Services\OdinSyncService; +use App\Services\ProjectDetectorService; +use App\Services\QdrantService; beforeEach(function (): void { - $this->odinMock = Mockery::mock(OdinSyncService::class); - $this->app->instance(OdinSyncService::class, $this->odinMock); + $this->qdrantMock = Mockery::mock(QdrantService::class); + $this->app->instance(QdrantService::class, $this->qdrantMock); + + $this->detectorMock = Mockery::mock(ProjectDetectorService::class); + $this->app->instance(ProjectDetectorService::class, $this->detectorMock); +}); + +it('lists all project knowledge bases', function (): void { + $this->detectorMock->shouldReceive('detect')->andReturn('knowledge'); + + $this->qdrantMock->shouldReceive('listCollections') + ->once() + ->andReturn(['knowledge_knowledge', 'knowledge_other-project']); + + $this->qdrantMock->shouldReceive('count') + ->with('knowledge') + ->andReturn(42); + + $this->qdrantMock->shouldReceive('count') + ->with('other-project') + ->andReturn(15); + + $this->artisan('projects') + ->assertSuccessful() + ->expectsOutputToContain('Project Knowledge Bases'); }); -describe('ProjectsCommand', function (): void { - it('shows local info when --local flag is used', function (): void { - $this->artisan('projects', ['--local' => true]) - ->assertSuccessful() - ->expectsOutput('Local project listing requires Qdrant collection enumeration.'); - }); - - it('fails when odin sync is disabled', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(false); - - $this->artisan('projects') - ->assertFailed() - ->expectsOutput('Odin sync is disabled. Set ODIN_SYNC_ENABLED=true to enable.'); - }); - - it('warns when Odin is unreachable', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(false); - - $this->artisan('projects') - ->assertSuccessful() - ->expectsOutput('Odin server is not reachable. Cannot list remote projects.'); - }); - - it('shows empty projects message', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('listProjects')->once()->andReturn([]); - - $this->artisan('projects') - ->assertSuccessful() - ->expectsOutput('No projects found on Odin server.'); - }); - - it('displays projects table', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('listProjects')->once()->andReturn([ - ['name' => 'project-alpha', 'entry_count' => 42, 'last_synced' => '2025-06-01T12:00:00+00:00'], - ['name' => 'project-beta', 'entry_count' => 15, 'last_synced' => null], - ]); - - $this->artisan('projects') - ->assertSuccessful(); - }); +it('shows warning when no projects exist', function (): void { + $this->detectorMock->shouldReceive('detect')->andReturn('default'); + + $this->qdrantMock->shouldReceive('listCollections') + ->once() + ->andReturn([]); + + $this->artisan('projects') + ->assertSuccessful(); +}); + +it('shows current project indicator', function (): void { + $this->detectorMock->shouldReceive('detect')->andReturn('my-project'); + + $this->qdrantMock->shouldReceive('listCollections') + ->once() + ->andReturn(['knowledge_my-project']); + + $this->qdrantMock->shouldReceive('count') + ->with('my-project') + ->andReturn(10); + + $this->artisan('projects') + ->assertSuccessful() + ->expectsOutputToContain('Current project: my-project'); }); diff --git a/tests/Feature/KnowledgeAddCommandTest.php b/tests/Feature/KnowledgeAddCommandTest.php index cd29e6e..3c1b1e2 100644 --- a/tests/Feature/KnowledgeAddCommandTest.php +++ b/tests/Feature/KnowledgeAddCommandTest.php @@ -21,6 +21,7 @@ app()->instance(QdrantService::class, $this->qdrantService); app()->instance(WriteGateService::class, $this->writeGateService); app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + mockProjectDetector(); $this->enhancementQueue->shouldReceive('queue')->zeroOrMoreTimes(); diff --git a/tests/Feature/KnowledgeArchiveCommandTest.php b/tests/Feature/KnowledgeArchiveCommandTest.php index aedce9f..ab16563 100644 --- a/tests/Feature/KnowledgeArchiveCommandTest.php +++ b/tests/Feature/KnowledgeArchiveCommandTest.php @@ -8,6 +8,7 @@ beforeEach(function (): void { $this->qdrant = mock(QdrantService::class); app()->instance(QdrantService::class, $this->qdrant); + mockProjectDetector(); }); it('validates entry ID is numeric', function (): void { @@ -19,7 +20,7 @@ it('fails when entry not found', function (): void { $this->qdrant->shouldReceive('getById') ->once() - ->with(999) + ->with(999, 'default') ->andReturn(null); $this->artisan('archive', ['id' => '999']) @@ -38,7 +39,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->qdrant->shouldReceive('updateFields') @@ -46,7 +47,7 @@ ->with(1, [ 'status' => 'deprecated', 'confidence' => 0, - ]); + ], 'default'); $this->artisan('archive', ['id' => '1']) ->expectsOutputToContain('Entry #1 has been archived.') @@ -67,7 +68,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->artisan('archive', ['id' => '1']) @@ -86,7 +87,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->qdrant->shouldReceive('updateFields') @@ -94,7 +95,7 @@ ->with(1, [ 'status' => 'draft', 'confidence' => 50, - ]); + ], 'default'); $this->artisan('archive', ['id' => '1', '--restore' => true]) ->expectsOutputToContain('Entry #1 has been restored.') @@ -116,7 +117,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->artisan('archive', ['id' => '1', '--restore' => true]) @@ -135,7 +136,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(2) + ->with(2, 'default') ->andReturn($entry); $this->qdrant->shouldReceive('updateFields') @@ -143,7 +144,7 @@ ->with(2, [ 'status' => 'deprecated', 'confidence' => 0, - ]); + ], 'default'); $this->artisan('archive', ['id' => '2']) ->expectsOutputToContain('Entry #2 has been archived.') diff --git a/tests/Feature/KnowledgeExportAllCommandTest.php b/tests/Feature/KnowledgeExportAllCommandTest.php index e474801..289d169 100644 --- a/tests/Feature/KnowledgeExportAllCommandTest.php +++ b/tests/Feature/KnowledgeExportAllCommandTest.php @@ -12,6 +12,7 @@ app()->instance(QdrantService::class, $this->qdrant); app()->instance(MarkdownExporter::class, $this->markdownExporter); + mockProjectDetector(); // Clean and recreate temp directory for tests if (is_dir('/tmp/export-all-tests')) { @@ -57,7 +58,7 @@ $this->qdrant->shouldReceive('search') ->once() - ->with('', [], 10000) + ->with('', [], 10000, 'default') ->andReturn($entries); $this->markdownExporter->shouldReceive('exportArray') @@ -92,7 +93,7 @@ $this->qdrant->shouldReceive('search') ->once() - ->with('', [], 10000) + ->with('', [], 10000, 'default') ->andReturn($entries); $this->artisan('export:all', [ @@ -123,7 +124,7 @@ $this->qdrant->shouldReceive('search') ->once() - ->with('', ['category' => 'tutorial'], 10000) + ->with('', ['category' => 'tutorial'], 10000, 'default') ->andReturn($entries); $this->markdownExporter->shouldReceive('exportArray') @@ -141,7 +142,7 @@ it('warns when no entries found', function (): void { $this->qdrant->shouldReceive('search') ->once() - ->with('', [], 10000) + ->with('', [], 10000, 'default') ->andReturn(collect([])); $this->artisan('export:all', [ diff --git a/tests/Feature/KnowledgeExportCommandTest.php b/tests/Feature/KnowledgeExportCommandTest.php index 358ff73..799227f 100644 --- a/tests/Feature/KnowledgeExportCommandTest.php +++ b/tests/Feature/KnowledgeExportCommandTest.php @@ -12,6 +12,7 @@ app()->instance(QdrantService::class, $this->qdrant); app()->instance(MarkdownExporter::class, $this->markdownExporter); + mockProjectDetector(); // Create temp directory for tests if (! is_dir('/tmp/export-tests')) { @@ -36,7 +37,7 @@ it('fails when entry not found', function (): void { $this->qdrant->shouldReceive('getById') ->once() - ->with(999) + ->with(999, 'default') ->andReturn(null); $this->artisan('export', ['id' => '999']) @@ -59,7 +60,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->markdownExporter->shouldReceive('exportArray') @@ -93,7 +94,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $this->markdownExporter->shouldReceive('exportArray') @@ -129,7 +130,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(1) + ->with(1, 'default') ->andReturn($entry); $expectedJson = json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -159,7 +160,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(2) + ->with(2, 'default') ->andReturn($entry); $this->artisan('export', [ @@ -192,7 +193,7 @@ $this->qdrant->shouldReceive('getById') ->once() - ->with(3) + ->with(3, 'default') ->andReturn($entry); $this->markdownExporter->shouldReceive('exportArray') diff --git a/tests/Feature/KnowledgeSearchCommandTest.php b/tests/Feature/KnowledgeSearchCommandTest.php index a8fe772..2c512d9 100644 --- a/tests/Feature/KnowledgeSearchCommandTest.php +++ b/tests/Feature/KnowledgeSearchCommandTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -use App\Services\TieredSearchService; +use App\Services\QdrantService; describe('KnowledgeSearchCommand', function (): void { beforeEach(function (): void { - $this->tieredSearch = mock(TieredSearchService::class); + $this->qdrantService = mock(QdrantService::class); - app()->instance(TieredSearchService::class, $this->tieredSearch); + app()->instance(QdrantService::class, $this->qdrantService); + mockProjectDetector(); }); it('requires at least one parameter', function (): void { @@ -18,9 +19,9 @@ }); it('finds entries by keyword', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('Laravel', [], 20, null) + ->with('Laravel', [], 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -33,9 +34,6 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.90, ], ])); @@ -46,9 +44,9 @@ }); it('filters by tag', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('', ['tag' => 'php'], 20, null) + ->with('', ['tag' => 'php'], 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-2', @@ -61,9 +59,6 @@ 'status' => 'draft', 'confidence' => 90, 'score' => 0.90, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.81, ], ])); @@ -74,9 +69,9 @@ }); it('filters by category', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('', ['category' => 'tutorial'], 20, null) + ->with('', ['category' => 'tutorial'], 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -89,9 +84,6 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.90, ], ])); @@ -102,7 +94,7 @@ }); it('shows no results message', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([])); @@ -112,9 +104,9 @@ }); it('supports semantic flag', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('Laravel', [], 20, null) + ->with('Laravel', [], 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -127,9 +119,6 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.90, ], ])); @@ -143,9 +132,9 @@ }); it('combines query and filters', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('Laravel', ['category' => 'tutorial'], 20, null) + ->with('Laravel', ['category' => 'tutorial'], 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -158,9 +147,6 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.90, ], ])); @@ -174,7 +160,7 @@ }); it('shows entry details', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -188,9 +174,6 @@ 'status' => 'validated', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'structured', - 'tier_label' => 'Structured Storage', - 'tiered_score' => 0.90, ], ])); @@ -202,7 +185,7 @@ }); it('truncates long content', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -216,9 +199,6 @@ 'status' => 'draft', 'confidence' => 100, 'score' => 0.85, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.85, ], ])); @@ -228,7 +208,7 @@ }); it('displays multiple search results', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -242,9 +222,6 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.90, ], [ 'id' => 'uuid-2', @@ -257,9 +234,6 @@ 'status' => 'draft', 'confidence' => 90, 'score' => 0.85, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.76, ], ])); @@ -271,7 +245,7 @@ }); it('supports multiple filters simultaneously', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->with('', [ 'category' => 'testing', @@ -279,7 +253,7 @@ 'priority' => 'high', 'status' => 'validated', 'tag' => 'laravel', - ], 20, null) + ], 20, 'default') ->andReturn(collect([])); $this->artisan('search', [ @@ -294,7 +268,7 @@ }); it('handles empty tags array gracefully', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -308,9 +282,6 @@ 'status' => 'draft', 'confidence' => 50, 'score' => 0.75, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.37, ], ])); @@ -320,7 +291,7 @@ }); it('displays score in results', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -335,9 +306,6 @@ 'confidence' => 80, 'score' => 0.92, 'superseded_by' => null, - 'tier' => 'working', - 'tier_label' => 'Working Context', - 'tiered_score' => 0.73, ], ])); @@ -347,9 +315,9 @@ }); it('passes include_superseded filter when flag is set', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('test', Mockery::on(fn ($filters): bool => isset($filters['include_superseded']) && $filters['include_superseded'] === true), 20, null) + ->with('test', Mockery::on(fn ($filters): bool => isset($filters['include_superseded']) && $filters['include_superseded'] === true), 20, 'default') ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -377,9 +345,9 @@ }); it('does not pass include_superseded by default', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() - ->with('test', [], 20, null) + ->with('test', [], 20, 'default') ->andReturn(collect([])); $this->artisan('search', ['query' => 'test']) @@ -387,7 +355,7 @@ }); it('shows superseded indicator on superseded entries', function (): void { - $this->tieredSearch->shouldReceive('search') + $this->qdrantService->shouldReceive('search') ->once() ->andReturn(collect([ [ diff --git a/tests/Feature/KnowledgeShowCommandTest.php b/tests/Feature/KnowledgeShowCommandTest.php index e003252..6b90961 100644 --- a/tests/Feature/KnowledgeShowCommandTest.php +++ b/tests/Feature/KnowledgeShowCommandTest.php @@ -18,6 +18,7 @@ app()->instance(QdrantService::class, $this->qdrantService); app()->instance(EntryMetadataService::class, $this->metadataService); app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + mockProjectDetector(); }); describe('show command', function (): void { @@ -41,12 +42,12 @@ $this->qdrantService->shouldReceive('getById') ->once() - ->with('test-id') + ->with('test-id', 'default') ->andReturn($entry); $this->qdrantService->shouldReceive('incrementUsage') ->once() - ->with('test-id'); + ->with('test-id', 'default'); $this->metadataService->shouldReceive('isStale')->once()->andReturn(false); $this->metadataService->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(75); @@ -133,7 +134,7 @@ it('returns failure for non-existent entry', function (): void { $this->qdrantService->shouldReceive('getById') ->once() - ->with('missing-id') + ->with('missing-id', 'default') ->andReturn(null); $this->artisan('show', ['id' => 'missing-id']) diff --git a/tests/Feature/KnowledgeStatsCommandTest.php b/tests/Feature/KnowledgeStatsCommandTest.php index c9f67f7..caea984 100644 --- a/tests/Feature/KnowledgeStatsCommandTest.php +++ b/tests/Feature/KnowledgeStatsCommandTest.php @@ -12,6 +12,7 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); // Mixed entries testing all display logic in a single test due to static caching $entries = collect([ @@ -44,17 +45,23 @@ ], ]); - // Now uses count() instead of search('') + // Now uses count() with project parameter $qdrant->shouldReceive('count') ->once() + ->with('default') ->andReturn(3); - // Now uses scroll() to get sample entries + // Now uses scroll() with project parameter $qdrant->shouldReceive('scroll') ->once() - ->with([], 3) + ->with([], 3, 'default') ->andReturn($entries); + // getCollectionName is called for dashboard display + $qdrant->shouldReceive('getCollectionName') + ->with('default') + ->andReturn('knowledge_default'); + $qdrant->shouldReceive('getCacheService') ->once() ->andReturnNull(); @@ -73,6 +80,7 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); $entries = collect([ [ @@ -88,13 +96,18 @@ $qdrant->shouldReceive('count') ->once() + ->with('default') ->andReturn(1); $qdrant->shouldReceive('scroll') ->once() - ->with([], 1) + ->with([], 1, 'default') ->andReturn($entries); + $qdrant->shouldReceive('getCollectionName') + ->with('default') + ->andReturn('knowledge_default'); + $qdrant->shouldReceive('getCacheService') ->once() ->andReturn($cacheService); @@ -120,6 +133,7 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); $entries = collect([ [ @@ -135,13 +149,18 @@ $qdrant->shouldReceive('count') ->once() + ->with('default') ->andReturn(1); $qdrant->shouldReceive('scroll') ->once() - ->with([], 1) + ->with([], 1, 'default') ->andReturn($entries); + $qdrant->shouldReceive('getCollectionName') + ->with('default') + ->andReturn('knowledge_default'); + $qdrant->shouldReceive('getCacheService') ->once() ->andReturnNull(); @@ -168,9 +187,11 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); - $qdrant->shouldReceive('count')->once()->andReturn(0); - $qdrant->shouldReceive('scroll')->once()->andReturn(collect([])); + $qdrant->shouldReceive('count')->once()->with('default')->andReturn(0); + $qdrant->shouldReceive('scroll')->once()->with([], 0, 'default')->andReturn(collect([])); + $qdrant->shouldReceive('getCollectionName')->with('default')->andReturn('knowledge_default'); $qdrant->shouldReceive('getCacheService')->once()->andReturnNull(); $odinSync->shouldReceive('isEnabled')->once()->andReturn(true); $odinSync->shouldReceive('getStatus')->once()->andReturn([ @@ -188,9 +209,11 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); - $qdrant->shouldReceive('count')->once()->andReturn(0); - $qdrant->shouldReceive('scroll')->once()->andReturn(collect([])); + $qdrant->shouldReceive('count')->once()->with('default')->andReturn(0); + $qdrant->shouldReceive('scroll')->once()->with([], 0, 'default')->andReturn(collect([])); + $qdrant->shouldReceive('getCollectionName')->with('default')->andReturn('knowledge_default'); $qdrant->shouldReceive('getCacheService')->once()->andReturnNull(); $odinSync->shouldReceive('isEnabled')->once()->andReturn(true); $odinSync->shouldReceive('getStatus')->once()->andReturn([ @@ -208,6 +231,7 @@ $odinSync = mock(OdinSyncService::class); app()->instance(QdrantService::class, $qdrant); app()->instance(OdinSyncService::class, $odinSync); + mockProjectDetector(); $entries = collect([ [ @@ -223,13 +247,18 @@ $qdrant->shouldReceive('count') ->once() + ->with('default') ->andReturn(1); $qdrant->shouldReceive('scroll') ->once() - ->with([], 1) + ->with([], 1, 'default') ->andReturn($entries); + $qdrant->shouldReceive('getCollectionName') + ->with('default') + ->andReturn('knowledge_default'); + $qdrant->shouldReceive('getCacheService') ->once() ->andReturnNull(); diff --git a/tests/Pest.php b/tests/Pest.php index 156143e..92c7b97 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -43,6 +43,19 @@ function removeDirectory(string $path): void } } +if (! function_exists('mockProjectDetector')) { + /** + * Mock the ProjectDetectorService to return a fixed project name. + * Use in beforeEach() blocks for commands that use ResolvesProject trait. + */ + function mockProjectDetector(string $project = 'default'): void + { + $mock = Mockery::mock(\App\Services\ProjectDetectorService::class); + $mock->shouldReceive('detect')->andReturn($project); + app()->instance(\App\Services\ProjectDetectorService::class, $mock); + } +} + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/Integrations/Qdrant/Requests/ListCollectionsTest.php b/tests/Unit/Integrations/Qdrant/Requests/ListCollectionsTest.php new file mode 100644 index 0000000..ecdfb02 --- /dev/null +++ b/tests/Unit/Integrations/Qdrant/Requests/ListCollectionsTest.php @@ -0,0 +1,20 @@ +getMethod())->toBe(Method::GET); + }); + + it('resolves to /collections endpoint', function (): void { + $request = new ListCollections; + + expect($request->resolveEndpoint())->toBe('/collections'); + }); +}); diff --git a/tests/Unit/Services/ProjectDetectorServiceTest.php b/tests/Unit/Services/ProjectDetectorServiceTest.php new file mode 100644 index 0000000..6eab688 --- /dev/null +++ b/tests/Unit/Services/ProjectDetectorServiceTest.php @@ -0,0 +1,109 @@ +shouldReceive('isGitRepository')->andReturn(false); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('default'); + }); + + it('extracts repo name from HTTPS remote URL', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('https://github.com/conduit-ui/knowledge.git'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('knowledge'); + }); + + it('extracts repo name from SSH remote URL', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('git@github.com:conduit-ui/knowledge.git'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('knowledge'); + }); + + it('extracts repo name from HTTPS URL without .git suffix', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('https://github.com/conduit-ui/my-project'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('my-project'); + }); + + it('falls back to directory name when no remote URL', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn(null); + $gitContext->shouldReceive('getRepositoryPath')->andReturn('/home/user/projects/my-app'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('my-app'); + }); + + it('returns default when no remote URL and no repo path', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn(null); + $gitContext->shouldReceive('getRepositoryPath')->andReturn(null); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('default'); + }); + + it('sanitizes project names with special characters', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('https://github.com/user/My Project Name.git'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('my_project_name'); + }); + + it('sanitizes project names to lowercase', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('https://github.com/user/MyApp.git'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('myapp'); + }); + + it('handles SSH URLs with port numbers', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('ssh://git@gitlab.com:2222/group/project.git'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('project'); + }); + + it('handles trailing slashes in URLs', function (): void { + $gitContext = Mockery::mock(GitContextService::class); + $gitContext->shouldReceive('isGitRepository')->andReturn(true); + $gitContext->shouldReceive('getRepositoryUrl')->andReturn('https://github.com/user/repo/'); + + $detector = new ProjectDetectorService($gitContext); + + expect($detector->detect())->toBe('repo'); + }); +});