/** * v0.18.0 Step 6 — source resolution priority tests. * * Priority order (highest first): * 1. Explicit --source flag * 2. GBRAIN_SOURCE env var * 3. .gbrain-source dotfile walk-up * 4. Registered source whose local_path contains CWD (longest prefix wins) * 5. Brain-level `sources.default` config key * 6. Fallback: literal 'default' */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { resolveSourceId, getDefaultSourcePath, __testing } from '../src/core/source-resolver.ts'; import type { BrainEngine } from '../src/core/engine.ts'; // ── Stub engine ──────────────────────────────────────────── function makeStub(registeredSources: string[], paths: Array<{ id: string; local_path: string }>, defaultKey: string | null): BrainEngine { return { kind: 'pglite', executeRaw: async (sql: string, params?: unknown[]): Promise => { if (sql.includes('SELECT id FROM sources WHERE id = $1')) { const target = params?.[0]; return (registeredSources.includes(target as string) ? [{ id: target } as unknown as T] : []); } if (sql.includes('SELECT id, local_path FROM sources')) { return paths as unknown as T[]; } return []; }, getConfig: async (key: string) => (key === 'sources.default' ? defaultKey : null), } as unknown as BrainEngine; } // ── Priority 1: explicit flag ────────────────────────────── describe('resolveSourceId priority 1 — explicit flag', () => { test('wins over every other signal', async () => { const engine = makeStub(['default', 'gstack', 'wiki'], [{ id: 'wiki', local_path: '/tmp' }], 'gstack'); process.env.GBRAIN_SOURCE = 'wiki'; try { const id = await resolveSourceId(engine, 'gstack', '/tmp/whatever'); expect(id).toBe('gstack'); } finally { delete process.env.GBRAIN_SOURCE; } }); test('rejects unregistered explicit source with actionable error', async () => { const engine = makeStub(['default'], [], null); await expect(resolveSourceId(engine, 'ghost')).rejects.toThrow(/not found/); }); test('rejects invalid format', async () => { const engine = makeStub(['default'], [], null); await expect(resolveSourceId(engine, 'WRONG-case!')).rejects.toThrow(/Invalid --source/); }); }); // ── Priority 2: env var ──────────────────────────────────── describe('resolveSourceId priority 2 — GBRAIN_SOURCE env', () => { test('wins over dotfile / registered-path / default', async () => { const engine = makeStub(['default', 'env-wins'], [{ id: 'other', local_path: '/tmp' }], 'default'); process.env.GBRAIN_SOURCE = 'env-wins'; try { const id = await resolveSourceId(engine, null, '/tmp/x'); expect(id).toBe('env-wins'); } finally { delete process.env.GBRAIN_SOURCE; } }); }); // ── Priority 3: dotfile walk-up ──────────────────────────── describe('resolveSourceId priority 3 — .gbrain-source dotfile walk-up', () => { let tmpdirPath: string; beforeEach(() => { tmpdirPath = mkdtempSync(join(tmpdir(), 'gbrain-resolver-test-')); }); afterEach(() => { rmSync(tmpdirPath, { recursive: true, force: true }); }); test('finds dotfile in CWD', async () => { writeFileSync(join(tmpdirPath, '.gbrain-source'), 'gstack\n'); const engine = makeStub(['default', 'gstack'], [], null); const id = await resolveSourceId(engine, null, tmpdirPath); expect(id).toBe('gstack'); }); test('walks up ancestors to find dotfile', async () => { writeFileSync(join(tmpdirPath, '.gbrain-source'), 'wiki\n'); const deep = join(tmpdirPath, 'a', 'b', 'c'); mkdirSync(deep, { recursive: true }); const engine = makeStub(['default', 'wiki'], [], null); const id = await resolveSourceId(engine, null, deep); expect(id).toBe('wiki'); }); test('ignores dotfile with invalid content', async () => { writeFileSync(join(tmpdirPath, '.gbrain-source'), 'INVALID!\n'); const engine = makeStub(['default'], [], null); const id = await resolveSourceId(engine, null, tmpdirPath); expect(id).toBe('default'); }); }); // ── Priority 4: registered local_path match (longest prefix) ── describe('resolveSourceId priority 4 — registered local_path longest-prefix match', () => { test('picks registered source whose local_path contains CWD', async () => { const engine = makeStub( ['default', 'gstack'], [{ id: 'gstack', local_path: '/tmp/gstack' }], null, ); const id = await resolveSourceId(engine, null, '/tmp/gstack/plans/foo'); expect(id).toBe('gstack'); }); test('longest prefix wins when paths are nested (per Codex second pass)', async () => { // Codex flagged: overlapping paths need longest-prefix resolution. // If gstack at /tmp/gstack and plans at /tmp/gstack/plans both // exist, CWD inside plans/ must pick plans. const engine = makeStub( ['default', 'gstack', 'plans'], [ { id: 'gstack', local_path: '/tmp/gstack' }, { id: 'plans', local_path: '/tmp/gstack/plans' }, ], null, ); const id = await resolveSourceId(engine, null, '/tmp/gstack/plans/deeper'); expect(id).toBe('plans'); }); test("CWD outside any registered path falls through to default", async () => { const engine = makeStub( ['default', 'gstack'], [{ id: 'gstack', local_path: '/tmp/gstack' }], null, ); const id = await resolveSourceId(engine, null, '/some/other/dir'); expect(id).toBe('default'); }); }); // ── Priority 5: brain-level default ──────────────────────── describe('resolveSourceId priority 5 — sources.default config key', () => { test("returns configured default when no higher signal present", async () => { const engine = makeStub(['default', 'custom'], [], 'custom'); const id = await resolveSourceId(engine, null, '/some/random/dir'); expect(id).toBe('custom'); }); }); // ── Priority 6: fallback ──────────────────────────────────── describe('resolveSourceId priority 6 — fallback', () => { test("returns 'default' when no signal at all", async () => { const engine = makeStub(['default'], [], null); const id = await resolveSourceId(engine, null, '/random/dir'); expect(id).toBe('default'); }); }); // ── getDefaultSourcePath ─────────────────────────────────── describe('getDefaultSourcePath', () => { function makeStubWithPaths( registeredSources: string[], sourcePaths: Record, defaultKey: string | null, ): BrainEngine { return { kind: 'pglite', executeRaw: async (sql: string, params?: unknown[]): Promise => { if (sql.includes('SELECT id FROM sources WHERE id = $1')) { const target = params?.[0]; return (registeredSources.includes(target as string) ? [{ id: target } as unknown as T] : []); } if (sql.includes('SELECT local_path FROM sources WHERE id = $1')) { const target = params?.[0] as string; if (target in sourcePaths) { return [{ local_path: sourcePaths[target] } as unknown as T]; } return []; } if (sql.includes('SELECT id, local_path FROM sources')) { return Object.entries(sourcePaths) .filter(([_, p]) => p !== null) .map(([id, local_path]) => ({ id, local_path }) as unknown as T); } return []; }, getConfig: async (key: string) => (key === 'sources.default' ? defaultKey : null), } as unknown as BrainEngine; } test('returns local_path of resolved default source', async () => { const engine = makeStubWithPaths(['default'], { default: '/path/to/brain' }, null); const path = await getDefaultSourcePath(engine, '/random/dir'); expect(path).toBe('/path/to/brain'); }); test('returns null when source has no local_path', async () => { const engine = makeStubWithPaths(['default'], { default: null }, null); const path = await getDefaultSourcePath(engine, '/random/dir'); expect(path).toBeNull(); }); test('throws on DB error (does not silently swallow)', async () => { const engine = { kind: 'pglite', executeRaw: async () => { throw new Error('connection refused'); }, getConfig: async () => null, } as unknown as BrainEngine; await expect(getDefaultSourcePath(engine, '/random/dir')).rejects.toThrow(/connection refused/); }); test('falls back to legacy sync.repo_path config when sources.local_path is null', async () => { // Pre-v0.18 brains: 'default' source exists but local_path is NULL; the // repo path lives in the global config table under sync.repo_path. const engine = { kind: 'pglite', executeRaw: async (sql: string, params?: unknown[]): Promise => { if (sql.includes('SELECT id FROM sources WHERE id = $1')) { return [{ id: params?.[0] } as unknown as T]; } if (sql.includes('SELECT local_path FROM sources WHERE id = $1')) { return [{ local_path: null } as unknown as T]; } if (sql.includes('SELECT id, local_path FROM sources')) { return []; } return []; }, getConfig: async (key: string) => { if (key === 'sources.default') return null; if (key === 'sync.repo_path') return '/legacy/brain/path'; return null; }, } as unknown as BrainEngine; const path = await getDefaultSourcePath(engine, '/random/dir'); expect(path).toBe('/legacy/brain/path'); }); test('respects source resolution chain (registered local_path wins over default)', async () => { // CWD is inside /custom/path → wiki source matches by path → wiki's local_path returned. const engine = makeStubWithPaths( ['default', 'wiki'], { default: '/default/path', wiki: '/custom/path' }, 'default', ); const path = await getDefaultSourcePath(engine, '/custom/path/sub'); expect(path).toBe('/custom/path'); }); }); // ── Regex validation ─────────────────────────────────────── describe('SOURCE_ID_RE', () => { test('accepts valid ids', () => { for (const id of ['default', 'wiki', 'gstack', 'yc-media', 'garrys-list', 'a', '123']) { expect(__testing.SOURCE_ID_RE.test(id)).toBe(true); } }); test('rejects invalid ids', () => { for (const id of ['', 'a'.repeat(33), 'Upper', 'has_underscore', 'trailing-', '-leading', 'with spaces', 'with.dots']) { expect(__testing.SOURCE_ID_RE.test(id)).toBe(false); } }); });