Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 51 additions & 57 deletions src/services/code-index/__tests__/service-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import { OpenAiEmbedder } from "../embedders/openai"
import { CodeIndexOllamaEmbedder } from "../embedders/ollama"
import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible"
import { GeminiEmbedder } from "../embedders/gemini"
import { QdrantVectorStore } from "../vector-store/qdrant-client"
import { CodeIndexVectorStoreAdapter } from "../vector-store-adapter"

// Mock the embedders and vector store
vitest.mock("../embedders/openai")
vitest.mock("../embedders/ollama")
vitest.mock("../embedders/openai-compatible")
vitest.mock("../embedders/gemini")
vitest.mock("../vector-store/qdrant-client")
vitest.mock("../vector-store-adapter")
const hoisted = vitest.hoisted(() => ({ createFactoryMock: vitest.fn() }))
const createFactoryMock = hoisted.createFactoryMock
vitest.mock("../vector-store/factory", () => ({
VectorStoreFactory: { create: hoisted.createFactoryMock },
}))

// Mock the embedding models module
vitest.mock("../../../shared/embeddingModels", () => ({
Expand All @@ -32,7 +37,9 @@ const MockedOpenAiEmbedder = OpenAiEmbedder as MockedClass<typeof OpenAiEmbedder
const MockedCodeIndexOllamaEmbedder = CodeIndexOllamaEmbedder as MockedClass<typeof CodeIndexOllamaEmbedder>
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
const MockedGeminiEmbedder = GeminiEmbedder as MockedClass<typeof GeminiEmbedder>
const MockedQdrantVectorStore = QdrantVectorStore as MockedClass<typeof QdrantVectorStore>
const MockedCodeIndexVectorStoreAdapter = CodeIndexVectorStoreAdapter as unknown as MockedClass<
typeof CodeIndexVectorStoreAdapter
>

// Import the mocked functions
import { getDefaultModelId, getModelDimension } from "../../../shared/embeddingModels"
Expand All @@ -46,6 +53,20 @@ describe("CodeIndexServiceFactory", () => {

beforeEach(() => {
vitest.clearAllMocks()
createFactoryMock.mockReset()
// Default shared adapter stub
createFactoryMock.mockReturnValue({
provider: () => "qdrant",
capabilities: () => ({ deleteByFilter: true }),
collectionName: () => "ws-abcdef0123456789",
ensureCollection: vitest.fn(),
upsert: vitest.fn(),
search: vitest.fn(),
clearAll: vitest.fn(),
deleteCollection: vitest.fn(),
collectionExists: vitest.fn(),
deleteByFilter: vitest.fn(),
})

mockConfigManager = {
getConfig: vitest.fn(),
Expand Down Expand Up @@ -362,12 +383,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
3072,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const args0 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(args0[1]).toBe(3072)
})

it("should use config.modelId for Ollama provider", () => {
Expand All @@ -387,12 +405,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("ollama", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
768,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const adapterArgs = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(adapterArgs[1]).toBe(768)
})

it("should use config.modelId for OpenAI Compatible provider", () => {
Expand All @@ -412,12 +427,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
3072,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const adapterArgs2 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(adapterArgs2[1]).toBe(3072)
})

it("should prioritize getModelDimension over manual modelDimension for OpenAI Compatible provider", () => {
Expand All @@ -444,12 +456,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
modelDimension, // Should use model's built-in dimension, not manual
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const argsA = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(argsA[1]).toBe(modelDimension)
})

it("should use manual modelDimension only when model has no built-in dimension", () => {
Expand All @@ -475,12 +484,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
manualDimension, // Should use manual dimension as fallback
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const argsB = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(argsB[1]).toBe(manualDimension)
})

it("should fall back to getModelDimension when manual modelDimension is not set for OpenAI Compatible", () => {
Expand All @@ -504,12 +510,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId)
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
768,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const argsC = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(argsC[1]).toBe(768)
})

it("should throw error when manual modelDimension is invalid for OpenAI Compatible", () => {
Expand Down Expand Up @@ -573,12 +576,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001")
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
3072,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const adapterArgs3 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(adapterArgs3[1]).toBe(3072)
})

it("should use default model dimension for Gemini when modelId not specified", () => {
Expand All @@ -598,12 +598,9 @@ describe("CodeIndexServiceFactory", () => {
// Assert
expect(mockGetDefaultModelId).toHaveBeenCalledWith("gemini")
expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001")
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
3072,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const argsD = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(argsD[1]).toBe(3072)
})

it("should use default model when config.modelId is undefined", () => {
Expand All @@ -622,12 +619,9 @@ describe("CodeIndexServiceFactory", () => {

// Assert
expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "default-model")
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
"/test/workspace",
"http://localhost:6333",
1536,
"test-key",
)
expect(MockedCodeIndexVectorStoreAdapter).toHaveBeenCalled()
const adapterArgs4 = (MockedCodeIndexVectorStoreAdapter as any).mock.calls[0]
expect(adapterArgs4[1]).toBe(1536)
})

it("should throw error when vector dimension cannot be determined", () => {
Expand Down
61 changes: 61 additions & 0 deletions src/services/code-index/__tests__/vector-store-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { CodeIndexVectorStoreAdapter } from "../vector-store-adapter"

vi.mock("../../../utils/path", () => ({ getWorkspacePath: () => "/test/workspace" }))

describe("CodeIndexVectorStoreAdapter", () => {
let mockAdapter: any
let store: CodeIndexVectorStoreAdapter

beforeEach(() => {
mockAdapter = {
collectionName: vi.fn().mockReturnValue("ws-abcdef0123456789"),
ensureCollection: vi.fn().mockResolvedValue(true),
upsert: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockResolvedValue([]),
deleteByFilter: vi.fn().mockResolvedValue(undefined),
clearAll: vi.fn().mockResolvedValue(undefined),
deleteCollection: vi.fn().mockResolvedValue(undefined),
collectionExists: vi.fn().mockResolvedValue(true),
capabilities: vi.fn().mockReturnValue({ deleteByFilter: true }),
}
store = new CodeIndexVectorStoreAdapter(mockAdapter, 1536)
})

it("initialize delegates to adapter via ensureOnce policy and returns created flag", async () => {
const created = await store.initialize()
expect(created).toBe(true)
expect(mockAdapter.ensureCollection).toHaveBeenCalledWith("ws-abcdef0123456789", 1536)
})

it("search builds directory prefix filter using pathSegments.*", async () => {
await store.search([0.1, 0.2], "src/utils")
const call = mockAdapter.search.mock.calls[0]
const filter = call[2]
expect(filter).toEqual({
must: [
{ key: "pathSegments.0", match: { value: "src" } },
{ key: "pathSegments.1", match: { value: "utils" } },
],
})
})

it("deletePointsByMultipleFilePaths builds OR filter and calls deleteByFilter", async () => {
mockAdapter.collectionExists.mockResolvedValueOnce(true)
await store.deletePointsByMultipleFilePaths(["/test/workspace/src/a.ts", "src/b.ts"])
const filter = mockAdapter.deleteByFilter.mock.calls[0][0]
expect(filter.should).toBeDefined()
expect(filter.should.length).toBe(2)
// First branch should match src/a.ts exactly
expect(filter.should[0].must).toEqual([
{ key: "pathSegments.0", match: { value: "src" } },
{ key: "pathSegments.1", match: { value: "a.ts" } },
])
})

it("deletePointsByMultipleFilePaths is no-op when collection is missing (parity)", async () => {
mockAdapter.collectionExists.mockResolvedValueOnce(false)
await store.deletePointsByMultipleFilePaths(["src/a.ts"]) // should not call deleteByFilter
expect(mockAdapter.deleteByFilter).not.toHaveBeenCalled()
})
})
12 changes: 9 additions & 3 deletions src/services/code-index/service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { GeminiEmbedder } from "./embedders/gemini"
import { MistralEmbedder } from "./embedders/mistral"
import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway"
import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels"
import { QdrantVectorStore } from "./vector-store/qdrant-client"
import { VectorStoreFactory } from "../vector-store/factory"
import { CodeIndexVectorStoreAdapter } from "./vector-store-adapter"
import { codeParser, DirectoryScanner, FileWatcher } from "./processors"
import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces"
import { CodeIndexConfigManager } from "./config-manager"
Expand Down Expand Up @@ -145,8 +146,13 @@ export class CodeIndexServiceFactory {
throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing"))
}

// Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?)
return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey)
const shared = VectorStoreFactory.create({
provider: "qdrant",
workspacePath: this.workspacePath,
dimension: vectorSize,
qdrant: { url: config.qdrantUrl, apiKey: config.qdrantApiKey },
})
return new CodeIndexVectorStoreAdapter(shared, vectorSize)
}

/**
Expand Down
96 changes: 96 additions & 0 deletions src/services/code-index/vector-store-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { IVectorStore, VectorStoreSearchResult, Payload } from "./interfaces"
import { VectorDatabaseAdapter } from "../vector-store/interfaces"
import { CollectionManager } from "../vector-store/collection-manager"
import { FilterTranslator, QdrantFilterTranslator } from "../vector-store/filters"
import { getWorkspacePath } from "../../utils/path"

/**
* Bridges Code Index's `IVectorStore` interface to the provider-agnostic
* `VectorDatabaseAdapter`.
*
* Notes
* - initialize() uses ensure-once and returns true on create/recreate (parity).
* - search() builds Qdrant-compatible directory prefix filters via translator.
* - deletePointsBy* no-ops when collection is missing (legacy parity).
*/
export class CodeIndexVectorStoreAdapter implements IVectorStore {
private ensured = false
constructor(
private readonly adapter: VectorDatabaseAdapter,
private readonly dimension: number,
private readonly translator: FilterTranslator = new QdrantFilterTranslator(),
private readonly workspaceRoot: string = getWorkspacePath() ?? "",
) {}

async initialize(): Promise<boolean> {
const name = this.adapter.collectionName()
const created = await CollectionManager.ensureOnce(
(n, d) => this.adapter.ensureCollection(n, d),
name,
this.dimension,
)
this.ensured = true
return created
}

/** Upserts points by mapping to adapter records. */
async upsertPoints(points: Array<{ id: string; vector: number[]; payload: Record<string, any> }>): Promise<void> {
const records = points.map((p) => ({ id: p.id, vector: p.vector, payload: p.payload }))
await this.adapter.upsert(records)
}

/**
* Vector similarity search.
* @param queryVector Query embedding.
* @param directoryPrefix Optional directory prefix filter; '.' or './' result in no filter.
* @param minScore Optional minimum similarity score.
* @param maxResults Optional max number of results.
*/
async search(
queryVector: number[],
directoryPrefix?: string,
minScore?: number,
maxResults?: number,
): Promise<VectorStoreSearchResult[]> {
const filter = this.translator.directoryPrefixToFilter(directoryPrefix)
const results = await this.adapter.search(queryVector, maxResults ?? 10, filter, minScore)
return results.map((r) => ({ id: r.id, score: (r as any).score, payload: r.payload as Payload }))
}

/** Deletes all points for a single file path. */
async deletePointsByFilePath(filePath: string): Promise<void> {
await this.deletePointsByMultipleFilePaths([filePath])
}

/**
* Deletes all points for the provided file paths. For parity, this is a no-op
* when the collection does not exist.
*/
async deletePointsByMultipleFilePaths(filePaths: string[]): Promise<void> {
if (filePaths.length === 0) return
const caps = this.adapter.capabilities()
const filter = this.translator.filePathsToDeleteFilter(filePaths, this.workspaceRoot)
if (caps.deleteByFilter && typeof this.adapter.deleteByFilter === "function" && filter) {
// Match legacy behavior: no-op if collection doesn't exist
if (!(await this.adapter.collectionExists())) return
await this.adapter.deleteByFilter(filter)
} else {
// No-op fallback for now since Qdrant supports delete-by-filter
}
}

/** Clears all points from the current collection. */
async clearCollection(): Promise<void> {
await this.adapter.clearAll()
}

/** Deletes the current collection. */
async deleteCollection(): Promise<void> {
await this.adapter.deleteCollection()
}

/** Returns true if the current collection exists. */
async collectionExists(): Promise<boolean> {
return this.adapter.collectionExists()
}
}
Loading