// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { collectionName, contextCollectionName, detectGitBranch, graphCollectionName, loadLinkedProjects, projectIdFromPath, resolveLinkedCollections, sanitizeBranchName } from "../../src/config.js"; describe("config", () => { // Clean up env overrides between tests const originalEnv = process.env.SOCRATICODE_PROJECT_ID; const originalBranchAware = process.env.SOCRATICODE_BRANCH_AWARE; afterEach(() => { if (originalEnv === undefined) { delete process.env.SOCRATICODE_PROJECT_ID; } else { process.env.SOCRATICODE_PROJECT_ID = originalEnv; } if (originalBranchAware === undefined) { delete process.env.SOCRATICODE_BRANCH_AWARE; } else { process.env.SOCRATICODE_BRANCH_AWARE = originalBranchAware; } }); describe("projectIdFromPath", () => { it("returns a 12-character hex string", () => { const id = projectIdFromPath("/some/project/path"); expect(id).toMatch(/^[0-9a-f]{12}$/); }); it("returns the same ID for the same path", () => { const id1 = projectIdFromPath("/some/project/path"); const id2 = projectIdFromPath("/some/project/path"); expect(id1).toBe(id2); }); it("returns different IDs for different paths", () => { const id1 = projectIdFromPath("/project/alpha"); const id2 = projectIdFromPath("/project/beta"); expect(id1).not.toBe(id2); }); it("normalizes paths (resolves relative components)", () => { const id1 = projectIdFromPath("/some/project/./path"); const id2 = projectIdFromPath("/some/project/path"); expect(id1).toBe(id2); }); it("normalizes paths with parent directory references", () => { const id1 = projectIdFromPath("/some/project/sub/../path"); const id2 = projectIdFromPath("/some/project/path"); expect(id1).toBe(id2); }); it("handles paths with trailing slashes consistently", () => { // path.resolve strips trailing slashes, so these should differ // (or be the same depending on OS behavior) const id1 = projectIdFromPath("/some/project"); const id2 = projectIdFromPath("/some/project/"); // path.resolve normalizes trailing slash, so they should match expect(id1).toBe(id2); }); it("uses SOCRATICODE_PROJECT_ID when set", () => { process.env.SOCRATICODE_PROJECT_ID = "my-shared-project"; const id = projectIdFromPath("/some/project/path"); expect(id).toBe("my-shared-project"); }); it("ignores path differences when SOCRATICODE_PROJECT_ID is set", () => { process.env.SOCRATICODE_PROJECT_ID = "shared"; const id1 = projectIdFromPath("/worktree/a"); const id2 = projectIdFromPath("/worktree/b"); expect(id1).toBe(id2); }); it("trims whitespace from SOCRATICODE_PROJECT_ID", () => { process.env.SOCRATICODE_PROJECT_ID = " my-project "; expect(projectIdFromPath("/any/path")).toBe("my-project"); }); it("throws on invalid SOCRATICODE_PROJECT_ID characters", () => { process.env.SOCRATICODE_PROJECT_ID = "invalid/name"; expect(() => projectIdFromPath("/any/path")).toThrow( /SOCRATICODE_PROJECT_ID must match/, ); }); it("falls back to hash when SOCRATICODE_PROJECT_ID is empty", () => { process.env.SOCRATICODE_PROJECT_ID = " "; const id = projectIdFromPath("/some/project/path"); expect(id).toMatch(/^[0-9a-f]{12}$/); }); }); describe("collectionName", () => { it("prefixes with codebase_", () => { expect(collectionName("abc123def456")).toBe("codebase_abc123def456"); }); it("handles empty string", () => { expect(collectionName("")).toBe("codebase_"); }); }); describe("graphCollectionName", () => { it("prefixes with codegraph_", () => { expect(graphCollectionName("abc123def456")).toBe("codegraph_abc123def456"); }); it("handles empty string", () => { expect(graphCollectionName("")).toBe("codegraph_"); }); }); describe("contextCollectionName", () => { it("prefixes with context_", () => { expect(contextCollectionName("abc123def456")).toBe("context_abc123def456"); }); it("handles empty string", () => { expect(contextCollectionName("")).toBe("context_"); }); }); describe("round-trip: path → projectId → collection names", () => { it("produces valid Qdrant-friendly collection names", () => { const projectId = projectIdFromPath("/Users/test/my-project"); const coll = collectionName(projectId); const graphColl = graphCollectionName(projectId); // Qdrant collection names must match: ^[a-zA-Z0-9_-]+$ expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/); expect(graphColl).toMatch(/^[a-zA-Z0-9_-]+$/); expect(coll).toMatch(/^codebase_[0-9a-f]{12}$/); expect(graphColl).toMatch(/^codegraph_[0-9a-f]{12}$/); }); it("produces a valid context collection name", () => { const projectId = projectIdFromPath("/Users/test/my-project"); const contextColl = contextCollectionName(projectId); expect(contextColl).toMatch(/^[a-zA-Z0-9_-]+$/); expect(contextColl).toMatch(/^context_[0-9a-f]{12}$/); }); }); // ── Linked projects ───────────────────────────────────────────────── describe("loadLinkedProjects", () => { let tmpDir: string; let projectDir: string; let linkedDirA: string; let linkedDirB: string; const originalLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-")); projectDir = path.join(tmpDir, "my-project"); linkedDirA = path.join(tmpDir, "linked-a"); linkedDirB = path.join(tmpDir, "linked-b"); fs.mkdirSync(projectDir, { recursive: true }); fs.mkdirSync(linkedDirA, { recursive: true }); fs.mkdirSync(linkedDirB, { recursive: true }); delete process.env.SOCRATICODE_LINKED_PROJECTS; }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); if (originalLinkedEnv === undefined) { delete process.env.SOCRATICODE_LINKED_PROJECTS; } else { process.env.SOCRATICODE_LINKED_PROJECTS = originalLinkedEnv; } }); it("returns empty array when no .socraticode.json exists", () => { expect(loadLinkedProjects(projectDir)).toEqual([]); }); it("reads linked projects from .socraticode.json", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: ["../linked-a", "../linked-b"] }), ); const result = loadLinkedProjects(projectDir); expect(result).toHaveLength(2); expect(result).toContain(linkedDirA); expect(result).toContain(linkedDirB); }); it("skips non-existent linked paths", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: ["../linked-a", "../does-not-exist"] }), ); const result = loadLinkedProjects(projectDir); expect(result).toHaveLength(1); expect(result).toContain(linkedDirA); }); it("skips self-references", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: [".", "../my-project"] }), ); expect(loadLinkedProjects(projectDir)).toEqual([]); }); it("reads from SOCRATICODE_LINKED_PROJECTS env var", () => { process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`; const result = loadLinkedProjects(projectDir); expect(result).toHaveLength(2); expect(result).toContain(linkedDirA); expect(result).toContain(linkedDirB); }); it("merges config file and env var without duplicates", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: ["../linked-a"] }), ); process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`; const result = loadLinkedProjects(projectDir); // linked-a appears in both sources but should be deduplicated expect(result).toHaveLength(2); expect(result).toContain(linkedDirA); expect(result).toContain(linkedDirB); }); it("handles malformed .socraticode.json gracefully", () => { fs.writeFileSync(path.join(projectDir, ".socraticode.json"), "not valid json{{{"); expect(loadLinkedProjects(projectDir)).toEqual([]); }); it("handles .socraticode.json with missing linkedProjects field", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ someOtherField: true }), ); expect(loadLinkedProjects(projectDir)).toEqual([]); }); }); describe("resolveLinkedCollections", () => { let tmpDir: string; let projectDir: string; let linkedDir: string; const savedLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS; const savedProjectIdEnv = process.env.SOCRATICODE_PROJECT_ID; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-")); projectDir = path.join(tmpDir, "main-project"); linkedDir = path.join(tmpDir, "linked-lib"); fs.mkdirSync(projectDir, { recursive: true }); fs.mkdirSync(linkedDir, { recursive: true }); delete process.env.SOCRATICODE_LINKED_PROJECTS; delete process.env.SOCRATICODE_PROJECT_ID; }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); if (savedLinkedEnv === undefined) { delete process.env.SOCRATICODE_LINKED_PROJECTS; } else { process.env.SOCRATICODE_LINKED_PROJECTS = savedLinkedEnv; } if (savedProjectIdEnv === undefined) { delete process.env.SOCRATICODE_PROJECT_ID; } else { process.env.SOCRATICODE_PROJECT_ID = savedProjectIdEnv; } }); it("returns only current project when no links configured", () => { const collections = resolveLinkedCollections(projectDir); expect(collections).toHaveLength(1); expect(collections[0].label).toBe("main-project"); expect(collections[0].name).toMatch(/^codebase_/); }); it("returns current + linked collections with correct labels", () => { fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: ["../linked-lib"] }), ); const collections = resolveLinkedCollections(projectDir); expect(collections).toHaveLength(2); // Current project is first (highest priority) expect(collections[0].label).toBe("main-project"); expect(collections[1].label).toBe("linked-lib"); // Different collection names expect(collections[0].name).not.toBe(collections[1].name); }); it("linked collections use base hash without branch suffix when SOCRATICODE_BRANCH_AWARE is true", () => { process.env.SOCRATICODE_BRANCH_AWARE = "true"; fs.writeFileSync( path.join(projectDir, ".socraticode.json"), JSON.stringify({ linkedProjects: ["../linked-lib"] }), ); // Get linked collection name with branch-aware on const withBranch = resolveLinkedCollections(projectDir); expect(withBranch).toHaveLength(2); const linkedNameWithBranch = withBranch[1].name; // Get linked collection name with branch-aware off delete process.env.SOCRATICODE_BRANCH_AWARE; const withoutBranch = resolveLinkedCollections(projectDir); const linkedNameWithoutBranch = withoutBranch[1].name; // Linked project collection name must be identical regardless of SOCRATICODE_BRANCH_AWARE expect(linkedNameWithBranch).toBe(linkedNameWithoutBranch); // And must NOT contain a branch suffix (double-underscore) expect(linkedNameWithBranch).not.toContain("__"); }); }); // ── Branch awareness ──────────────────────────────────────────────── describe("sanitizeBranchName", () => { it("passes through simple branch names", () => { expect(sanitizeBranchName("main")).toBe("main"); expect(sanitizeBranchName("develop")).toBe("develop"); }); it("replaces slashes with underscores", () => { expect(sanitizeBranchName("feat/my-feature")).toBe("feat_my-feature"); }); it("handles deeply nested branch names", () => { expect(sanitizeBranchName("feature/JIRA-123/some-work")).toBe( "feature_JIRA-123_some-work", ); }); it("collapses consecutive underscores", () => { expect(sanitizeBranchName("feat//double")).toBe("feat_double"); }); it("strips leading and trailing underscores", () => { expect(sanitizeBranchName("/leading")).toBe("leading"); expect(sanitizeBranchName("trailing/")).toBe("trailing"); }); it("preserves hyphens", () => { expect(sanitizeBranchName("my-branch-name")).toBe("my-branch-name"); }); it("replaces special characters", () => { expect(sanitizeBranchName("feat@v2.0")).toBe("feat_v2_0"); }); it("returns empty string when all characters are invalid", () => { expect(sanitizeBranchName("///")).toBe(""); expect(sanitizeBranchName("@@@")).toBe(""); expect(sanitizeBranchName("...")).toBe(""); }); }); /** Create a temporary git repo with a named branch and initial commit. */ function initTempRepo(tmpDir: string, branch: string): void { execFileSync("git", ["init", "-b", branch, tmpDir]); execFileSync("git", ["config", "user.name", "test"], { cwd: tmpDir }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: tmpDir }); execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: tmpDir }); } describe("detectGitBranch", () => { it("detects a branch in a git repo", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-git-")); try { initTempRepo(tmpDir, "test-branch"); const branch = detectGitBranch(tmpDir); expect(branch).toBe("test-branch"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it("returns null for non-git directories", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-nogit-")); try { expect(detectGitBranch(tmpDir)).toBeNull(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); }); describe("projectIdFromPath with SOCRATICODE_BRANCH_AWARE", () => { it("does not include branch suffix by default", () => { const id = projectIdFromPath("/some/project/path"); expect(id).toMatch(/^[0-9a-f]{12}$/); expect(id).not.toContain("__"); }); it("appends branch suffix when SOCRATICODE_BRANCH_AWARE=true", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-")); try { initTempRepo(tmpDir, "my-feature"); process.env.SOCRATICODE_BRANCH_AWARE = "true"; const id = projectIdFromPath(tmpDir); expect(id).toContain("__"); const parts = id.split("__"); expect(parts[0]).toMatch(/^[0-9a-f]{12}$/); expect(parts[1]).toBe("my-feature"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it("produces valid Qdrant collection names with branch suffix", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-")); try { initTempRepo(tmpDir, "feat/some-branch"); process.env.SOCRATICODE_BRANCH_AWARE = "true"; const id = projectIdFromPath(tmpDir); const coll = collectionName(id); // Must be valid Qdrant name: [a-zA-Z0-9_-]+ expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it("does not append branch when SOCRATICODE_PROJECT_ID is set", () => { process.env.SOCRATICODE_BRANCH_AWARE = "true"; process.env.SOCRATICODE_PROJECT_ID = "explicit-id"; const id = projectIdFromPath(process.cwd()); expect(id).toBe("explicit-id"); expect(id).not.toContain("__"); }); it("does not append branch when SOCRATICODE_BRANCH_AWARE is not true", () => { process.env.SOCRATICODE_BRANCH_AWARE = "false"; const id = projectIdFromPath(process.cwd()); expect(id).toMatch(/^[0-9a-f]{12}$/); }); }); });