diff --git a/package.json b/package.json index 6f4eb88..0b26e62 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/project-context.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "chokidar": "^5.0.0", diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..240c7c4 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; + +import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry'; + +export const runtime = 'nodejs'; + +function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } { + return { + projects: projects.map((project) => ({ path: project.path })), + }; +} + +async function readPathFromBody(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new RegistryValidationError('Request body must be valid JSON.'); + } + + const path = (body as { path?: unknown }).path; + if (typeof path !== 'string' || path.trim().length === 0) { + throw new RegistryValidationError('`path` is required and must be a non-empty string.'); + } + + return path; +} + +export async function GET(): Promise { + const projects = await listProjects(); + return NextResponse.json(projectsPayload(projects), { status: 200 }); +} + +export async function POST(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await addProject(projectPath); + return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 }); + } +} + +export async function DELETE(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await removeProject(projectPath); + return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 }); + } +} diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts new file mode 100644 index 0000000..390f122 --- /dev/null +++ b/src/app/api/scan/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +import { scanForProjects } from '../../../lib/scanner'; +import type { ScanMode } from '../../../lib/scanner'; + +export const runtime = 'nodejs'; + +function parseMode(value: string | null): ScanMode { + if (!value || value === 'default') { + return 'default'; + } + + if (value === 'full-drive') { + return 'full-drive'; + } + + throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.'); +} + +function parseDepth(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('Depth must be a non-negative integer.'); + } + + return parsed; +} + +export async function GET(request: Request): Promise { + try { + const url = new URL(request.url); + const mode = parseMode(url.searchParams.get('mode')); + const maxDepth = parseDepth(url.searchParams.get('depth')); + const result = await scanForProjects({ mode, maxDepth }); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to scan projects.'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts new file mode 100644 index 0000000..0e885c9 --- /dev/null +++ b/src/lib/project-context.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; +import type { ProjectContext, ProjectSource } from './types'; + +interface BuildProjectContextOptions { + source?: ProjectSource; + addedAt?: string | null; +} + +export function buildProjectContext(root: string, options: BuildProjectContextOptions = {}): ProjectContext { + if (!root) { + throw new Error('Project root is required to build project context.'); + } + + const normalizedRoot = canonicalizeWindowsPath(root); + return { + key: windowsPathKey(normalizedRoot), + root: normalizedRoot, + displayPath: toDisplayPath(normalizedRoot), + name: path.basename(normalizedRoot), + source: options.source ?? 'local', + addedAt: options.addedAt ?? null, + }; +} diff --git a/src/lib/read-issues.ts b/src/lib/read-issues.ts index 820f18e..5639578 100644 --- a/src/lib/read-issues.ts +++ b/src/lib/read-issues.ts @@ -3,11 +3,14 @@ import path from 'node:path'; import { parseIssuesJsonl } from './parser'; import { canonicalizeWindowsPath } from './pathing'; import { readTextFileWithRetry } from './read-text-retry'; -import type { BeadIssue } from './types'; +import { buildProjectContext } from './project-context'; +import type { BeadIssueWithProject, ProjectSource } from './types'; export interface ReadIssuesOptions { projectRoot?: string; includeTombstones?: boolean; + projectSource?: ProjectSource; + projectAddedAt?: string | null; } export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] { @@ -21,15 +24,23 @@ export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): str return resolveIssuesJsonlPathCandidates(projectRoot)[0]; } -export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise { - const candidates = resolveIssuesJsonlPathCandidates(options.projectRoot); +export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise { + const projectRoot = options.projectRoot ?? process.cwd(); + const candidates = resolveIssuesJsonlPathCandidates(projectRoot); + const project = buildProjectContext(projectRoot, { + source: options.projectSource ?? 'local', + addedAt: options.projectAddedAt ?? null, + }); for (const issuesPath of candidates) { try { const jsonl = await readTextFileWithRetry(issuesPath); return parseIssuesJsonl(jsonl, { includeTombstones: options.includeTombstones ?? false, - }); + }).map((issue) => ({ + ...issue, + project, + })); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { continue; diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..eacd45b --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; + +export interface RegistryProject { + path: string; + key: string; +} + +interface RegistryDocument { + version: 1; + projects: RegistryProject[]; +} + +export class RegistryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryValidationError'; + } +} + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +export function registryFilePath(): string { + return path.join(userProfileRoot(), '.beadboard', 'projects.json'); +} + +function ensureWindowsAbsolutePath(input: string): string { + const normalized = canonicalizeWindowsPath(input.trim()); + if (!/^[A-Za-z]:\\/.test(normalized)) { + throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); + } + + return normalized; +} + +function normalizeProject(input: string): RegistryProject { + const normalized = ensureWindowsAbsolutePath(input); + return { + path: toDisplayPath(normalized), + key: windowsPathKey(normalized), + }; +} + +function normalizeProjects(input: unknown): RegistryProject[] { + if (!Array.isArray(input)) { + return []; + } + + const seen = new Set(); + const normalized: RegistryProject[] = []; + + for (const item of input) { + if (!item || typeof item !== 'object') { + continue; + } + + const candidate = item as { path?: unknown }; + if (typeof candidate.path !== 'string') { + continue; + } + + try { + const project = normalizeProject(candidate.path); + if (!seen.has(project.key)) { + seen.add(project.key); + normalized.push(project); + } + } catch { + continue; + } + } + + return normalized; +} + +async function readRegistryDocument(): Promise { + const filePath = registryFilePath(); + + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as { projects?: unknown }; + return { + version: 1, + projects: normalizeProjects(parsed.projects), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { version: 1, projects: [] }; + } + + throw error; + } +} + +async function writeRegistryDocument(document: RegistryDocument): Promise { + const filePath = registryFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +} + +export async function listProjects(): Promise { + const document = await readRegistryDocument(); + return document.projects; +} + +export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + + if (document.projects.some((entry) => entry.key === project.key)) { + return { added: false, projects: document.projects }; + } + + document.projects.push(project); + await writeRegistryDocument(document); + return { added: true, projects: document.projects }; +} + +export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + const nextProjects = document.projects.filter((entry) => entry.key !== project.key); + + if (nextProjects.length === document.projects.length) { + return { removed: false, projects: document.projects }; + } + + const nextDocument: RegistryDocument = { + version: 1, + projects: nextProjects, + }; + + await writeRegistryDocument(nextDocument); + return { removed: true, projects: nextDocument.projects }; +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..aa24225 --- /dev/null +++ b/src/lib/scanner.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; +import { listProjects } from './registry'; + +export type ScanMode = 'default' | 'full-drive'; + +export interface ScannerProject { + root: string; + key: string; + displayPath: string; +} + +export interface ScanStats { + scannedDirectories: number; + ignoredDirectories: number; + skippedDirectories: number; + elapsedMs: number; +} + +export interface ScanOptions { + mode?: ScanMode; + maxDepth?: number; + roots?: string[]; + ignoreDirectories?: string[]; +} + +export interface ScanResult { + mode: ScanMode; + roots: string[]; + projects: ScannerProject[]; + stats: ScanStats; +} + +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_IGNORE_DIRECTORIES = [ + 'node_modules', + '.git', + '.next', + 'dist', + 'build', + 'out', + 'coverage', + 'artifacts', + 'logs', + '.worktrees', // TODO: confirm whether worktrees should be scan targets. +]; + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +function toCanonicalRoot(input: string): string { + return canonicalizeWindowsPath(input); +} + +function shouldSkipFsError(error: NodeJS.ErrnoException): boolean { + return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM'; +} + +async function ensureDirectoryExists(input: string): Promise { + try { + const stat = await fs.stat(input); + return stat.isDirectory() ? input : null; + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + return null; + } + throw error; + } +} + +async function resolveFullDriveRoots(): Promise { + const candidates = ['C:\\', 'D:\\']; + const roots: string[] = []; + + for (const candidate of candidates) { + const existing = await ensureDirectoryExists(candidate); + if (existing) { + roots.push(existing); + } + } + + return roots; +} + +export async function resolveScanRoots(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const registryProjects = await listProjects(); + const roots = [ + userProfileRoot(), + ...registryProjects.map((project) => project.path), + ...(options.roots ?? []), + ]; + + if (mode === 'full-drive') { + roots.push(...(await resolveFullDriveRoots())); + } + + const seen = new Set(); + const normalizedRoots: string[] = []; + + for (const root of roots) { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (seen.has(key)) { + continue; + } + + const existing = await ensureDirectoryExists(normalized); + if (!existing) { + continue; + } + + seen.add(key); + normalizedRoots.push(existing); + } + + return normalizedRoots; +} + +function buildIgnoreSet(additional: string[] = []): Set { + return new Set( + [...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean), + ); +} + +function recordProject(projects: Map, root: string): void { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (!projects.has(key)) { + projects.set(key, { + root: normalized, + key, + displayPath: toDisplayPath(normalized), + }); + } +} + +async function scanRoot( + root: string, + maxDepth: number, + ignoreSet: Set, + projects: Map, + stats: ScanStats, +): Promise { + const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + stats.scannedDirectories += 1; + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(current.dir, { withFileTypes: true }); + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + stats.skippedDirectories += 1; + continue; + } + throw error; + } + + let hasBeads = false; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name === '.beads') { + hasBeads = true; + continue; + } + + const entryName = entry.name.toLowerCase(); + if (ignoreSet.has(entryName)) { + stats.ignoredDirectories += 1; + continue; + } + + if (current.depth < maxDepth) { + queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 }); + } + } + + if (hasBeads) { + recordProject(projects, current.dir); + } + } +} + +export async function scanForProjects(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const ignoreSet = buildIgnoreSet(options.ignoreDirectories); + const roots = await resolveScanRoots(options); + const projects = new Map(); + const stats: ScanStats = { + scannedDirectories: 0, + ignoredDirectories: 0, + skippedDirectories: 0, + elapsedMs: 0, + }; + const start = Date.now(); + + for (const root of roots) { + await scanRoot(root, maxDepth, ignoreSet, projects, stats); + } + + stats.elapsedMs = Date.now() - start; + + return { + mode, + roots, + projects: Array.from(projects.values()), + stats, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 06f2567..46a9d4f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -59,3 +59,16 @@ export interface ParseableBeadIssue extends Partial { id: string; title: string; } + +export type ProjectSource = 'local' | 'registry' | 'scanner'; + +export interface ProjectContext { + key: string; + root: string; + displayPath: string; + name: string; + source: ProjectSource; + addedAt: string | null; +} + +export type BeadIssueWithProject = BeadIssue & { project: ProjectContext }; diff --git a/tests/api/projects-route.test.ts b/tests/api/projects-route.test.ts new file mode 100644 index 0000000..0f4d77e --- /dev/null +++ b/tests/api/projects-route.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { DELETE, GET, POST } from '../../src/app/api/projects/route'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function readJson(response: Response): Promise { + return response.json(); +} + +test('GET /api/projects returns empty list initially', async () => { + await withTempUserProfile(async () => { + const response = await GET(); + assert.equal(response.status, 200); + + const body = (await readJson(response)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); + +test('POST /api/projects validates payload and path', async () => { + await withTempUserProfile(async () => { + const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' })); + assert.equal(missing.status, 400); + + const missingBody = (await readJson(missing)) as { error: string }; + assert.match(missingBody.error, /path/i); + + const invalidPath = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: '/tmp/project' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(invalidPath.status, 400); + }); +}); + +test('POST deduplicates and GET returns normalized path', async () => { + await withTempUserProfile(async () => { + const first = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(first.status, 201); + + const dup = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(dup.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: Array<{ path: string }> }; + assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]); + }); +}); + +test('DELETE /api/projects removes by normalized path', async () => { + await withTempUserProfile(async () => { + await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'D:/Repos/One' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + const removed = await DELETE( + new Request('http://localhost/api/projects', { + method: 'DELETE', + body: JSON.stringify({ path: 'd:\\repos\\one\\' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(removed.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); diff --git a/tests/lib/project-context.test.ts b/tests/lib/project-context.test.ts new file mode 100644 index 0000000..5ecd3c1 --- /dev/null +++ b/tests/lib/project-context.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildProjectContext } from '../../src/lib/project-context'; + +test('buildProjectContext derives normalized project identity', () => { + const project = buildProjectContext('C:/Repo/Project'); + + assert.equal(project.root, 'C:\\Repo\\Project'); + assert.equal(project.key, 'c:\\repo\\project'); + assert.equal(project.displayPath, 'C:/Repo/Project'); + assert.equal(project.name, 'Project'); + assert.equal(project.source, 'local'); + assert.equal(project.addedAt, null); +}); diff --git a/tests/lib/read-issues.test.ts b/tests/lib/read-issues.test.ts index 1aa4b70..586c703 100644 --- a/tests/lib/read-issues.test.ts +++ b/tests/lib/read-issues.test.ts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues'; -import { sameWindowsPath } from '../../src/lib/pathing'; +import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing'; test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => { const resolved = resolveIssuesJsonlPath('C:/Repo/Project'); @@ -38,6 +38,12 @@ test('readIssuesFromDisk parses JSONL issues from disk', async () => { assert.equal(issues.length, 1); assert.equal(issues[0].id, 'bb-1'); assert.equal(issues[0].priority, 0); + assert.equal(issues[0].project.root, canonicalizeWindowsPath(root)); + assert.equal(issues[0].project.key, windowsPathKey(root)); + assert.equal(issues[0].project.displayPath, toDisplayPath(root)); + assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root))); + assert.equal(issues[0].project.source, 'local'); + assert.equal(issues[0].project.addedAt, null); }); test('readIssuesFromDisk returns empty list when issues file does not exist', async () => { diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts new file mode 100644 index 0000000..15537df --- /dev/null +++ b/tests/lib/registry.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + addProject, + listProjects, + removeProject, + registryFilePath, + type RegistryProject, +} from '../../src/lib/registry'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { + await withTempUserProfile(async (userProfile) => { + const result = registryFilePath(); + assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); + }); +}); + +test('listProjects returns empty when registry does not exist', async () => { + await withTempUserProfile(async () => { + const result = await listProjects(); + assert.deepEqual(result, []); + }); +}); + +test('addProject persists normalized path and deduplicates case/separators', async () => { + await withTempUserProfile(async () => { + const first = await addProject('c:/Work/Alpha/'); + assert.equal(first.added, true); + + const second = await addProject('C:\\work\\alpha'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, 'C:/Work/Alpha'); + + const file = await fs.readFile(registryFilePath(), 'utf8'); + const parsed = JSON.parse(file) as { projects: RegistryProject[] }; + assert.equal(parsed.projects.length, 1); + }); +}); + +test('removeProject removes matching normalized path', async () => { + await withTempUserProfile(async () => { + await addProject('D:/Repos/One'); + await addProject('D:/Repos/Two'); + + const removed = await removeProject('d:\\repos\\one\\'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['D:/Repos/Two'], + ); + }); +}); + +test('addProject rejects non-Windows absolute paths', async () => { + await withTempUserProfile(async () => { + await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i); + await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); + }); +}); diff --git a/tests/lib/scanner.test.ts b/tests/lib/scanner.test.ts new file mode 100644 index 0000000..3b3a1c6 --- /dev/null +++ b/tests/lib/scanner.test.ts @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { addProject } from '../../src/lib/registry'; +import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner'; +import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('resolveScanRoots includes profile and registry roots by default', async () => { + await withTempUserProfile(async (userProfile) => { + const registryRoot = path.join(userProfile, 'Registered'); + await fs.mkdir(registryRoot, { recursive: true }); + await addProject(registryRoot); + + const roots = await resolveScanRoots(); + + assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true); + assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false); + }); +}); + +test('resolveScanRoots includes full-drive roots only when requested', async () => { + await withTempUserProfile(async () => { + const roots = await resolveScanRoots({ mode: 'full-drive' }); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true); + }); +}); + +test('scanForProjects respects depth limits and ignore list', async () => { + await withTempUserProfile(async (userProfile) => { + const projectRoot = path.join(userProfile, 'ProjectA'); + await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true }); + + const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored'); + await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true }); + + const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep'); + await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true }); + + const result = await scanForProjects({ maxDepth: 1 }); + const keys = result.projects.map((project) => project.key); + + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false); + }); +});