From d886ea81cf5a962536aca14e6f0949a8378487e0 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 15 Aug 2026 15:36:28 -0700 Subject: [PATCH] fix(pi): New database files are not created This make sure that we either use existing users's neovim databases or actually create a new database --- README.md | 2 +- lua/fff/core.lua | 5 +- packages/bun.lock | 1 + packages/pi-fff/README.md | 22 +++++-- packages/pi-fff/package.json | 1 + packages/pi-fff/src/aux-finders.ts | 22 ++----- packages/pi-fff/src/file-picker.ts | 73 ++++++++++++++++++++++ packages/pi-fff/src/index.ts | 61 ++++++++----------- packages/pi-fff/src/paths.ts | 58 ++++++++++++++++++ packages/pi-fff/test/aux-dedup.test.ts | 18 +++++- packages/pi-fff/test/aux-pool.test.ts | 77 ++++++++++++++++++++--- packages/pi-fff/test/db-paths.test.ts | 84 ++++++++++++++++++++++++++ packages/pi-fff/test/extension.test.ts | 20 +++--- packages/pi-fff/tsconfig.json | 4 +- 14 files changed, 363 insertions(+), 85 deletions(-) create mode 100644 packages/pi-fff/src/file-picker.ts create mode 100644 packages/pi-fff/test/db-paths.test.ts diff --git a/README.md b/README.md index 65a3c6fb2..d825fdc33 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Three operating modes, switchable at runtime with `/fff-mode`: | `tools-only` | Only tool injection. Keeps pi's native editor autocomplete. | | `override` | Replaces pi's built-in `grep`, `find`, and `multi_grep` with FFF implementations. | -Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`. +Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`. The databases default to your existing fff.nvim ones when present, otherwise `~/.pi/agent/fff/`. ### Agent-facing tools diff --git a/lua/fff/core.lua b/lua/fff/core.lua index 7fadc29c0..e0dde3dae 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -167,10 +167,7 @@ M.ensure_initialized = function() end end - local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency') - local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history') - - local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true) + local ok, result = pcall(fuzzy.init_db, config.frecency.db_path, config.history.db_path, true) if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end setup_global_autocmds(config) diff --git a/packages/bun.lock b/packages/bun.lock index 78518e013..17997e97b 100644 --- a/packages/bun.lock +++ b/packages/bun.lock @@ -35,6 +35,7 @@ "@ff-labs/fff-node": "*", }, "devDependencies": { + "@types/bun": "^1.3.8", "@types/node": "^22.0.0", "typescript": "^5.0.0", }, diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 53a8b39ef..09aba814a 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -132,16 +132,28 @@ Mode precedence: ## Flags - `--fff-mode ` — set mode (see above) -- `--fff-frecency-db ` — path to frecency database (also: `FFF_FRECENCY_DB` env) -- `--fff-history-db ` — path to query history database (also: `FFF_HISTORY_DB` env) +- `--fff-frecency-db ` — path to frecency database (also: `FFF_FRECENCY_DB` env). Optional; see [Data](#data) for the default. +- `--fff-history-db ` — path to query history database (also: `FFF_HISTORY_DB` env). Optional; see [Data](#data) for the default. - `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. - `--fff-enable-home-scan` — index the home directory when launched from `$HOME` (also: `FFF_ENABLE_HOME_SCAN` env). Enabled by default. Disable with `--fff-enable-home-scan=false` or `FFF_ENABLE_HOME_SCAN=0` if your `$HOME` contains huge trees (toolchains, kernel sources, build outputs) that make the background index run for a long time. When launched from `$HOME` with this enabled, pi shows a warning that the whole home tree is being indexed. ## Data -When database paths are provided, FFF stores: -- frecency database — file access frequency/recency -- history database — query-to-file selection history +FFF uses two LMDB databases: +- frecency database - file access frequency/recency, used to rank results +- history database - query-to-file selection history + +Each path is resolved independently, in this order: + +1. CLI flag — `--fff-frecency-db` / `--fff-history-db` +2. Env var — `FFF_FRECENCY_DB` / `FFF_HISTORY_DB` +3. An existing [fff.nvim](https://github.com/dmtrKovalenko/fff.nvim) database, so pi reuses the frecency you built up in your editor: + - frecency: `$XDG_CACHE_HOME/nvim/fff_nvim` + - history: `$XDG_DATA_HOME/nvim/fff_queries` + - `XDG_CACHE_HOME` defaults to `~/.cache` and `XDG_DATA_HOME` to `~/.local/share`; on Windows both fall back under `%LOCALAPPDATA%\nvim-data`. Only directories count — a plain file at those paths is ignored. +4. pi-local directory, created on demand — `$PI_CODING_AGENT_DIR/fff/{frecency,history}`, defaulting to `~/.pi/agent/fff/{frecency,history}` + +The extension only reads these databases; it never records the agent's own searches into your Neovim history. If a database cannot be opened, the finder starts without persistence and pi shows a warning instead of failing. No project files are uploaded anywhere by this extension. It runs locally and only uses the configured LLM through pi itself. diff --git a/packages/pi-fff/package.json b/packages/pi-fff/package.json index e25fc3063..922ea609e 100644 --- a/packages/pi-fff/package.json +++ b/packages/pi-fff/package.json @@ -49,6 +49,7 @@ "@sinclair/typebox": "*" }, "devDependencies": { + "@types/bun": "^1.3.8", "@types/node": "^22.0.0", "typescript": "^5.0.0" } diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts index 73ba022d6..45f4170b4 100644 --- a/packages/pi-fff/src/aux-finders.ts +++ b/packages/pi-fff/src/aux-finders.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import type { FileFinderApi } from "@ff-labs/fff-node"; +import type { FilePickerFactory } from "./file-picker"; import { HOME_DIR } from "./paths"; -import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; export const MAX_AUX = 3; export const IDLE_TTL_MS = 5 * 60 * 1000; @@ -16,10 +16,9 @@ interface AuxPicker { export interface AuxOpts { enableFsRootScanning: boolean; enableHomeDirScanning?: boolean; + pickers: FilePickerFactory; // Called before a newly spawned aux picker starts a scan that covers $HOME. onHomeDirScan?: (root: string) => void; - frecencyDbPath?: string; - historyDbPath?: string; } export class AuxFinderPool { @@ -101,26 +100,13 @@ export class AuxFinderPool { this.opts.onHomeDirScan?.(root); } - const { FileFinder } = await loadSdk(); - const result = FileFinder.create({ + const finder = await this.opts.pickers.create({ basePath: root, - frecencyDbPath: this.opts.frecencyDbPath, - historyDbPath: this.opts.historyDbPath, - aiMode: true, enableHomeDirScanning, enableFsRootScanning: this.opts.enableFsRootScanning, }); - if (!result.ok) { - throw new Error(`Failed to create aux file finder for ${root}: ${result.error}`); - } - - await result.value.waitForScan(SCAN_TIMEOUT_MS); - const entry: AuxPicker = { - root, - finder: result.value, - lastUsed: Date.now(), - }; + const entry: AuxPicker = { root, finder, lastUsed: Date.now() }; this.entries.push(entry); return entry; } diff --git a/packages/pi-fff/src/file-picker.ts b/packages/pi-fff/src/file-picker.ts new file mode 100644 index 000000000..1be6681dd --- /dev/null +++ b/packages/pi-fff/src/file-picker.ts @@ -0,0 +1,73 @@ +import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node"; +import { type FileFinderStatic, loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; + +export interface PickerOptions { + basePath: string; + enableHomeDirScanning?: boolean; + enableFsRootScanning?: boolean; +} + +/** Opens every picker in this pi process — the cwd picker and the aux pickers — + * on the same frecency/history databases. */ +export class FilePickerFactory { + private dbDisabled = false; + private readonly frecencyDbPath: string; + private readonly historyDbPath: string; + private readonly onDbFailure?: (error: string) => void; + + constructor(opts: { + frecencyDbPath: string; + historyDbPath: string; + onDbFailure?: (error: string) => void; + }) { + this.frecencyDbPath = opts.frecencyDbPath; + this.historyDbPath = opts.historyDbPath; + this.onDbFailure = opts.onDbFailure; + } + + /** True once the databases were given up on, so pickers open without them. */ + get databasesDisabled(): boolean { + return this.dbDisabled; + } + + /** Opens a scanned, ready-to-use picker. Throws if it cannot be created. */ + async create(options: PickerOptions): Promise { + const { FileFinder } = await loadSdk(); + const result = this.openWithDbFallback(FileFinder, options); + + if (!result.ok) { + throw new Error( + `Failed to create FFF file picker for ${options.basePath}: ${result.error}`, + ); + } + + // waitForScan() also resolves on timeout, so this bounds startup rather + // than guaranteeing a complete index. + await result.value.waitForScan(SCAN_TIMEOUT_MS); + return result.value; + } + + private openWithDbFallback( + FileFinder: FileFinderStatic, + options: PickerOptions, + ): Result { + const init: InitOptions = { ...options, aiMode: true }; + if (this.dbDisabled) return FileFinder.create(init); + + const result = FileFinder.create({ + ...init, + frecencyDbPath: this.frecencyDbPath, + historyDbPath: this.historyDbPath, + }); + if (result.ok) return result; + + // A failure here is usually transient (broken lock, corruption) and self-heals + // on restart, so drop the databases instead of leaving pi without a picker + const dbLess = FileFinder.create(init); + if (!dbLess.ok) return result; // db error is the more useful one to report + + this.dbDisabled = true; + this.onDbFailure?.(result.error); + return dbLess; + } +} diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 616fcf9e6..c4988ce6b 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -22,9 +22,9 @@ import type { } from "@ff-labs/fff-node"; import { Type } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; +import { FilePickerFactory } from "./file-picker"; +import { isHomeDir, resolveDbPaths } from "./paths"; import { buildQuery } from "./query"; -import { isHomeDir } from "./paths"; -import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk"; export { SCAN_TIMEOUT_MS } from "./sdk"; @@ -162,16 +162,7 @@ export function fffFileAnnotation(item: { return ""; } -// fff-core native definition classifier (byte-level scanner in Rust) is enabled -// via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for -// downstream consumers; pi-fff does NOT use it to re-sort. -// -// Ordering policy: NO CUSTOM SORTING. The engine already returns items in -// frecency order (most-accessed files first). pi-fff only groups consecutive -// matches into per-file blocks and preserves whatever order the engine -// provided — inside a file we keep matches in source-line order because the -// engine emits them that way. - +// DO NOT ATTEMPT TO RESORT OUTPUT HERE IT ONLY CONFUSES MODELS function formatGrepOutput(result: GrepResult): string { if (result.items.length === 0) return "No matches found"; @@ -179,7 +170,6 @@ function formatGrepOutput(result: GrepResult): string { // This preserves native frecency ordering across files without re-sorting. const lines: string[] = []; let currentFile = ""; - let shown = 0; for (const match of result.items) { if (match.relativePath !== currentFile) { @@ -194,7 +184,6 @@ function formatGrepOutput(result: GrepResult): string { }); lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`); - shown++; match.contextAfter?.forEach((line: string, i: number) => { const lineNum = match.lineNumber + 1 + i; @@ -318,15 +307,14 @@ export default function fffExtension(pi: ExtensionAPI) { const toolNames = resolveToolNames(currentMode); - // DB path resolution: flag > env > undefined (no persistent DBs) - const frecencyDbPath = - (pi.getFlag("fff-frecency-db") as string | undefined) ?? - process.env.FFF_FRECENCY_DB ?? - undefined; - const historyDbPath = - (pi.getFlag("fff-history-db") as string | undefined) ?? - process.env.FFF_HISTORY_DB ?? - undefined; + // DB path resolution: flag > env > existing fff.nvim db > pi-local data dir. + const resolvedDbPaths = resolveDbPaths({ + frecency: + (pi.getFlag("fff-frecency-db") as string | undefined) ?? + process.env.FFF_FRECENCY_DB, + history: + (pi.getFlag("fff-history-db") as string | undefined) ?? process.env.FFF_HISTORY_DB, + }); // flag (boolean) > env ("1"/"true", or "0"/"false") > default. function resolveBoolOpt(flagName: string, envName: string, fallback = false): boolean { @@ -380,12 +368,21 @@ export default function fffExtension(pi: ExtensionAPI) { ); } + const pickers = new FilePickerFactory({ + frecencyDbPath: resolvedDbPaths.frecency, + historyDbPath: resolvedDbPaths.history, + onDbFailure: (error) => + uiCtx?.ui.notify( + `(fff): Failed to open frecency/history database (${error}). Continuing without frecency persistence.`, + "error", + ), + }); + const auxPool = new AuxFinderPool({ enableFsRootScanning, enableHomeDirScanning, onHomeDirScan: warnHomeDirScan, - frecencyDbPath, - historyDbPath, + pickers, }); // in case cwd changes we need to figure this out @@ -402,22 +399,14 @@ export default function fffExtension(pi: ExtensionAPI) { finderCwd = null; } - const { FileFinder } = await loadSdk(); - const result = FileFinder.create({ + // if the dbs can't be opened the factory falls back to a db-less picker, + // e.g. when some other process corrupts the lock + mainFinder = await pickers.create({ basePath: cwd, - frecencyDbPath, - historyDbPath, - aiMode: true, enableHomeDirScanning, enableFsRootScanning, }); - - if (!result.ok) - throw new Error(`Failed to create FFF file finder: ${result.error}`); - - mainFinder = result.value; finderCwd = cwd; - await mainFinder.waitForScan(SCAN_TIMEOUT_MS); return mainFinder; })().finally(() => { finderPromise = null; diff --git a/packages/pi-fff/src/paths.ts b/packages/pi-fff/src/paths.ts index 2dce44aee..c83b524a9 100644 --- a/packages/pi-fff/src/paths.ts +++ b/packages/pi-fff/src/paths.ts @@ -1,9 +1,67 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // Resolved once per process: os.homedir() hits the env/passwd on every call. export const HOME_DIR = path.resolve(os.homedir()); +// fff.nvim db dir names (`frecency.db_path` / `history.db_path` in lua/fff/conf.lua). +const NVIM_FRECENCY_DIR = "fff_nvim"; +const NVIM_HISTORY_DIR = "fff_queries"; + +export interface DbPaths { + frecency: string; + history: string; +} + export function isHomeDir(dir: string): boolean { return path.resolve(dir) === HOME_DIR; } + +// Resolution order: explicit override > existing fff.nvim db > pi-local data dir. +// Reusing the nvim db lets pi rank files by the frecency the user built in their editor. +export function resolveDbPaths(overrides: { + frecency?: string; + history?: string; +}): DbPaths { + return { + frecency: + overrides.frecency ?? + existingDir(nvimCacheDir(), NVIM_FRECENCY_DIR) ?? + path.join(piDataDir(), "fff", "frecency"), + history: + overrides.history ?? + existingDir(nvimDataDir(), NVIM_HISTORY_DIR) ?? + path.join(piDataDir(), "fff", "history"), + }; +} + +function nvimCacheDir(): string { + const xdg = process.env.XDG_CACHE_HOME; + if (xdg) return path.join(xdg, "nvim"); + if (process.platform === "win32" && process.env.LOCALAPPDATA) + return path.join(process.env.LOCALAPPDATA, "nvim-data", "cache"); + return path.join(HOME_DIR, ".cache", "nvim"); +} + +function nvimDataDir(): string { + const xdg = process.env.XDG_DATA_HOME; + if (xdg) return path.join(xdg, "nvim"); + if (process.platform === "win32" && process.env.LOCALAPPDATA) + return path.join(process.env.LOCALAPPDATA, "nvim-data"); + return path.join(HOME_DIR, ".local", "share", "nvim"); +} + +function piDataDir(): string { + return process.env.PI_CODING_AGENT_DIR ?? path.join(HOME_DIR, ".pi", "agent"); +} + +// LMDB environments are directories, so a stray file at the same path is not a db. +function existingDir(parent: string, name: string): string | undefined { + const candidate = path.join(parent, name); + try { + return fs.statSync(candidate).isDirectory() ? candidate : undefined; + } catch { + return undefined; + } +} diff --git a/packages/pi-fff/test/aux-dedup.test.ts b/packages/pi-fff/test/aux-dedup.test.ts index c874d542d..3a6a2b7ac 100644 --- a/packages/pi-fff/test/aux-dedup.test.ts +++ b/packages/pi-fff/test/aux-dedup.test.ts @@ -36,11 +36,22 @@ mock.module("@ff-labs/fff-node", () => finderModule); mock.module("@ff-labs/fff-bun", () => finderModule); const { AuxFinderPool } = await import("../src/aux-finders"); +const { FilePickerFactory } = await import("../src/file-picker"); + +function makePickers() { + return new FilePickerFactory({ + frecencyDbPath: "/dbs/frecency", + historyDbPath: "/dbs/history", + }); +} describe("AuxFinderPool concurrent dedup (#746)", () => { test("two concurrent acquires for same root share one finder", async () => { created.length = 0; - const pool = new AuxFinderPool({ enableFsRootScanning: false }); + const pool = new AuxFinderPool({ + enableFsRootScanning: false, + pickers: makePickers(), + }); const [a, b] = await Promise.all([ pool.acquire("/Users/x"), pool.acquire("/Users/x"), @@ -51,7 +62,10 @@ describe("AuxFinderPool concurrent dedup (#746)", () => { test("sequential acquire after in-flight one resolves still reuses", async () => { created.length = 0; - const pool = new AuxFinderPool({ enableFsRootScanning: false }); + const pool = new AuxFinderPool({ + enableFsRootScanning: false, + pickers: makePickers(), + }); const first = pool.acquire("/Users/x"); const second = pool.acquire("/Users/x"); await Promise.all([first, second]); diff --git a/packages/pi-fff/test/aux-pool.test.ts b/packages/pi-fff/test/aux-pool.test.ts index da12f6b41..86aa56130 100644 --- a/packages/pi-fff/test/aux-pool.test.ts +++ b/packages/pi-fff/test/aux-pool.test.ts @@ -25,10 +25,19 @@ function createMockFinder(basePath: string): MockFinder { return finder; } +// Set to make db-backed creates fail, mimicking a corrupt/locked LMDB. +let failDbCreates = false; +// Set to make every create fail, db-backed or not. +let failAllCreates = false; + const finderModule = { FileFinder: { create: (options: Record) => { createOptions.push(options); + if (failAllCreates || (failDbCreates && options.frecencyDbPath !== undefined)) { + return { ok: false as const, error: "db locked" }; + } + return { ok: true, value: createMockFinder(options.basePath as string), @@ -41,11 +50,26 @@ mock.module("@ff-labs/fff-node", () => finderModule); mock.module("@ff-labs/fff-bun", () => finderModule); const { AuxFinderPool } = await import("../src/aux-finders"); +const { FilePickerFactory } = await import("../src/file-picker"); function makePool(opts: Record = {}) { created.length = 0; createOptions.length = 0; - return new AuxFinderPool({ enableFsRootScanning: false, ...opts }); + failDbCreates = false; + failAllCreates = false; + return new AuxFinderPool({ + enableFsRootScanning: false, + pickers: makePickers(), + ...opts, + }); +} + +function makePickers(onDbFailure?: (error: string) => void) { + return new FilePickerFactory({ + frecencyDbPath: "/dbs/frecency", + historyDbPath: "/dbs/history", + onDbFailure, + }); } describe("AuxFinderPool covering reuse", () => { @@ -97,7 +121,7 @@ describe("AuxFinderPool covering reuse", () => { // Regression for #743: the agent spawning an aux picker over $HOME must warn // the user every time, not silently walk the home tree. test("notifies on every aux picker that covers $HOME", async () => { - const onHomeDirScan = mock(() => undefined); + const onHomeDirScan = mock((_root: string) => undefined); const pool = makePool({ onHomeDirScan }); const home = os.homedir(); @@ -122,10 +146,7 @@ describe("AuxFinderPool covering reuse", () => { // #700 is fixed by the process-wide LMDB env pool: same-path opens share one // env, so aux finders now reuse the session's frecency/history DBs. test("aux finders receive the pool's frecency/history db paths", async () => { - const pool = makePool({ - frecencyDbPath: "/dbs/frecency", - historyDbPath: "/dbs/history", - }); + const pool = makePool(); await pool.acquire("/a/b/c"); await pool.acquire("/x/y"); expect(createOptions.length).toBe(2); @@ -135,10 +156,50 @@ describe("AuxFinderPool covering reuse", () => { } }); - test("aux finders stay db-less when the session has no db paths", async () => { - const pool = makePool(); + test("aux finder falls back to no dbs when opening them fails", async () => { + const failures: string[] = []; + const pool = makePool({ pickers: makePickers((e) => failures.push(e)) }); + failDbCreates = true; + + const entry = await pool.acquire("/a/b/c"); + + expect(entry.root).toBe("/a/b/c"); + expect(createOptions.length).toBe(2); + expect(createOptions[0].frecencyDbPath).toBe("/dbs/frecency"); + expect(createOptions[1].frecencyDbPath).toBeUndefined(); + expect(failures).toEqual(["db locked"]); + }); + + test("a db failure on the main finder keeps later aux finders db-less", async () => { + const failures: string[] = []; + const pickers = makePickers((e) => failures.push(e)); + const pool = makePool({ pickers }); + failDbCreates = true; + + // Stands in for the main cwd picker hitting the broken db first. + // The SDK is mocked, so create() hands back a MockFinder, not a real finder. + const main = (await pickers.create({ + basePath: "/workspace", + })) as unknown as MockFinder; + expect(main.basePath).toBe("/workspace"); + expect(pickers.databasesDisabled).toBe(true); + + createOptions.length = 0; await pool.acquire("/a/b/c"); + + // No retry: the factory already gave up on the dbs, so one db-less create. + expect(createOptions.length).toBe(1); expect(createOptions[0].frecencyDbPath).toBeUndefined(); expect(createOptions[0].historyDbPath).toBeUndefined(); + expect(failures).toEqual(["db locked"]); + }); + + test("create throws when the picker cannot be opened at all", async () => { + makePool(); + failAllCreates = true; + + expect(makePickers().create({ basePath: "/nope" })).rejects.toThrow( + "Failed to create FFF file picker for /nope: db locked", + ); }); }); diff --git a/packages/pi-fff/test/db-paths.test.ts b/packages/pi-fff/test/db-paths.test.ts new file mode 100644 index 000000000..480cc8749 --- /dev/null +++ b/packages/pi-fff/test/db-paths.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveDbPaths } from "../src/paths"; + +const ENV_KEYS = ["XDG_CACHE_HOME", "XDG_DATA_HOME", "PI_CODING_AGENT_DIR"] as const; + +describe("resolveDbPaths", () => { + let tmpRoot: string; + let piDir: string; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const key of ENV_KEYS) saved[key] = process.env[key]; + + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fff-db-paths-")); + piDir = path.join(tmpRoot, "pi-agent"); + + process.env.XDG_CACHE_HOME = path.join(tmpRoot, "cache"); + process.env.XDG_DATA_HOME = path.join(tmpRoot, "data"); + process.env.PI_CODING_AGENT_DIR = piDir; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test("overrides win over discovery and fallback", () => { + mkNvimDir("cache", "fff_nvim"); + mkNvimDir("data", "fff_queries"); + + const paths = resolveDbPaths({ + frecency: "/explicit/frecency", + history: "/explicit/history", + }); + + expect(paths.frecency).toBe("/explicit/frecency"); + expect(paths.history).toBe("/explicit/history"); + }); + + test("picks existing fff.nvim databases", () => { + const frecency = mkNvimDir("cache", "fff_nvim"); + const history = mkNvimDir("data", "fff_queries"); + + expect(resolveDbPaths({})).toEqual({ frecency, history }); + }); + + test("uses the pi data dir when no nvim databases exist", () => { + expect(resolveDbPaths({})).toEqual({ + frecency: path.join(piDir, "fff", "frecency"), + history: path.join(piDir, "fff", "history"), + }); + }); + + test("ignores a plain file at the nvim candidate path", () => { + const cacheDir = path.join(tmpRoot, "cache", "nvim"); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, "fff_nvim"), "not a db"); + + expect(resolveDbPaths({}).frecency).toBe(path.join(piDir, "fff", "frecency")); + }); + + test("resolves each database independently", () => { + const history = mkNvimDir("data", "fff_queries"); + const paths = resolveDbPaths({ frecency: "/explicit/frecency" }); + + expect(paths.frecency).toBe("/explicit/frecency"); + expect(paths.history).toBe(history); + }); + + function mkNvimDir(kind: "cache" | "data", name: string): string { + const dir = path.join(tmpRoot, kind, "nvim", name); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } +}); diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index eb977b9f9..fdce31e53 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -87,7 +87,7 @@ mock.module("@sinclair/typebox", () => ({ properties, options, }), - Optional: (value: unknown) => ({ ...value, optional: true }), + Optional: (value: Record) => ({ ...value, optional: true }), String: schema("string"), Union: (items: unknown[], options?: unknown) => ({ type: "union", items, options }), }, @@ -120,11 +120,12 @@ function createPi(mode?: string) { function createContext(cwd = "/tmp/workspace") { return { cwd, + // Signatures mirror the real pi UI surface so mock.calls stays typed. ui: { - addAutocompleteProvider: mock(() => undefined), - notify: mock(() => undefined), + addAutocompleteProvider: mock((_factory: (current: any) => any) => undefined), + notify: mock((_message: string, _level?: string) => undefined), setEditorComponent: mock(() => undefined), - setStatus: mock(() => undefined), + setStatus: mock((_key: string, _text?: string) => undefined), }, }; } @@ -211,9 +212,9 @@ describe("pi-fff $HOME scan warning", () => { }); const setup = await start(undefined, os.homedir()); - const [key, text] = setup.ctx.ui.setStatus.mock.calls.at(-1) as [string, string]; - expect(key).toBe("fff"); - expect(text).toContain("12345 files"); + const lastStatus = setup.ctx.ui.setStatus.mock.calls.at(-1); + expect(lastStatus?.[0]).toBe("fff"); + expect(lastStatus?.[1]).toContain("12345 files"); // session_shutdown must stop the poller and clear the footer. await shutdown(setup); @@ -239,8 +240,9 @@ describe("pi-fff autocomplete registration", () => { expect(createCalls).toEqual([ { basePath: "/tmp/workspace", - frecencyDbPath: undefined, - historyDbPath: undefined, + // Resolved defaults are host-dependent; covered by test/db-paths.test.ts. + frecencyDbPath: expect.any(String), + historyDbPath: expect.any(String), aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, diff --git a/packages/pi-fff/tsconfig.json b/packages/pi-fff/tsconfig.json index 676cd16ef..a0ce02f7b 100644 --- a/packages/pi-fff/tsconfig.json +++ b/packages/pi-fff/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, - "types": ["node"] + "types": ["node", "bun"] }, - "include": ["src"] + "include": ["src", "test"] }