import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; const isWindows = process.platform === "win32"; describe("searchHomeDirectories", () => { let tempRoot: string; let homeDir: string; let outsideDir: string; beforeEach(() => { tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); homeDir = path.join(tempRoot, "home"); outsideDir = path.join(tempRoot, "outside"); mkdirSync(homeDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true }); mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); }); afterEach(() => { rmSync(tempRoot, { recursive: true, force: true }); }); it("returns an empty list for blank queries", async () => { await expect( searchHomeDirectories({ homeDir, query: " ", limit: 10, }), ).resolves.toEqual([]); }); it("returns only existing directories", async () => { const results = await searchHomeDirectories({ homeDir, query: "proj", limit: 10, }); expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, "projects", "paseo")); expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); }); it("supports home-relative path query syntax", async () => { const results = await searchHomeDirectories({ homeDir, query: "~/projects/pa", limit: 10, }); expect(results).toEqual([path.join(homeDir, "projects", "paseo")]); }); it("prioritizes exact segment matches before segment-prefix matches", async () => { const exactSegmentPath = path.join(homeDir, "something", "faro", "something-else"); const prefixSegmentPath = path.join(homeDir, "something", "somethingelse", "faro-bla"); mkdirSync(exactSegmentPath, { recursive: true }); mkdirSync(prefixSegmentPath, { recursive: true }); const results = await searchHomeDirectories({ homeDir, query: "faro", limit: 30, }); const exactIndex = results.indexOf(exactSegmentPath); const prefixIndex = results.indexOf(prefixSegmentPath); expect(exactIndex).toBeGreaterThanOrEqual(0); expect(prefixIndex).toBeGreaterThanOrEqual(0); expect(exactIndex).toBeLessThan(prefixIndex); }); it("prioritizes partial matches that appear earlier in the path", async () => { const earlierPath = path.join(homeDir, "farofoo"); const laterPath = path.join(homeDir, "x", "y", "farofoo"); mkdirSync(earlierPath, { recursive: true }); mkdirSync(laterPath, { recursive: true }); const results = await searchHomeDirectories({ homeDir, query: "arofo", limit: 30, }); const earlierIndex = results.indexOf(earlierPath); const laterIndex = results.indexOf(laterPath); expect(earlierIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0); expect(earlierIndex).toBeLessThan(laterIndex); }); it.skipIf(isWindows)("returns home-root suggestions when query is '~'", async () => { const results = await searchHomeDirectories({ homeDir, query: "~", limit: 20, }); expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, "documents")); expect(results).not.toContain(path.join(homeDir, ".hidden")); }); it("does not return hidden directories during tree search", async () => { const results = await searchHomeDirectories({ homeDir, query: "cache", limit: 20, }); expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); }); it("does not return paths that escape home through symlinks", async () => { const results = await searchHomeDirectories({ homeDir, query: "outside", limit: 20, }); expect(results).not.toContain(path.join(homeDir, "outside-link")); expect(results).not.toContain(path.join(outsideDir, "outside-match")); }); it("respects the result limit", async () => { const results = await searchHomeDirectories({ homeDir, query: "p", limit: 1, }); expect(results).toHaveLength(1); }); }); describe("searchWorkspaceEntries", () => { let tempRoot: string; let workspaceDir: string; let outsideDir: string; beforeEach(() => { tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-"))); workspaceDir = path.join(tempRoot, "workspace"); outsideDir = path.join(tempRoot, "outside"); mkdirSync(path.join(workspaceDir, "src", "components"), { recursive: true, }); mkdirSync(path.join(workspaceDir, "docs"), { recursive: true }); mkdirSync(path.join(outsideDir, "escaped"), { recursive: true }); writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n"); writeFileSync( path.join(workspaceDir, "src", "components", "chat-input.tsx"), "export const ChatInput = null;\n", ); writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n"); symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link")); }); afterEach(() => { rmSync(tempRoot, { recursive: true, force: true }); }); it("returns relative file and directory suggestions for workspace queries", async () => { const results = await searchWorkspaceEntries({ cwd: workspaceDir, query: "chat", limit: 20, includeFiles: true, includeDirectories: true, }); expect(results).toContainEqual({ path: "src/components/chat-input.tsx", kind: "file", }); expect(results.some((entry) => entry.path === path.join(workspaceDir, "src"))).toBe(false); }); it("filters entries by kind", async () => { const dirsOnly = await searchWorkspaceEntries({ cwd: workspaceDir, query: "src", limit: 20, includeFiles: false, includeDirectories: true, }); expect(dirsOnly.some((entry) => entry.kind === "file")).toBe(false); expect(dirsOnly.some((entry) => entry.path === "src")).toBe(true); const filesOnly = await searchWorkspaceEntries({ cwd: workspaceDir, query: "readme", limit: 20, includeFiles: true, includeDirectories: false, }); expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]); }); it("supports path-style queries and does not escape cwd through symlinks", async () => { const pathResults = await searchWorkspaceEntries({ cwd: workspaceDir, query: "src/co", limit: 20, includeFiles: true, includeDirectories: true, }); expect(pathResults).toContainEqual({ path: "src/components", kind: "directory", }); const escapedResults = await searchWorkspaceEntries({ cwd: workspaceDir, query: "escaped", limit: 20, includeFiles: true, includeDirectories: true, }); expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false); }); it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => { mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true }); writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), ""); for (let index = 0; index < 120; index += 1) { mkdirSync(path.join(workspaceDir, "node_modules", `pkg-${index}`), { recursive: true }); writeFileSync( path.join(workspaceDir, "node_modules", `pkg-${index}`, "index.js"), "module.exports = {};\n", ); } writeFileSync(path.join(workspaceDir, "node_modules", "_layout.tsx"), ""); const results = await searchWorkspaceEntries({ cwd: workspaceDir, query: "_layout.tsx", limit: 20, includeFiles: true, includeDirectories: true, maxEntriesScanned: 60, }); expect(results).toContainEqual({ path: "packages/app/src/app/_layout.tsx", kind: "file", }); expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false); }); });