Tobi Lutke 5 ヶ月 前
コミット
e67fb83a17
4 ファイル変更478 行追加37 行削除
  1. 3 0
      .beads/issues.jsonl
  2. 417 0
      cli.test.ts
  3. 39 36
      qmd.ts
  4. 19 1
      store.ts

+ 3 - 0
.beads/issues.jsonl

@@ -0,0 +1,3 @@
+{"id":"qmd-ama","title":"Refactor database system","description":"All documents should be stored as content addressable hash, e.g. hash, doc, created_at,\n┃ updated_at. documents should be a file system layer on top e.g. collection, path, hash,\n┃ created_at, updated_at. (collection,path)\n┃\n┃\n\n┃ All documents should be stored as content addressable hash, e.g. hash, doc, created_at,\n┃ updated_at. documents should be a file system layer on top e.g. collection_id, path, hash,\n┃ created_at, updated_at. (collection,path) is unique. There is also collection which stores PWD\n┃ + glob pattern, name (\\w+). Every document is treated as path qmd://collection.name/","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T10:57:35.497489-05:00","updated_at":"2025-12-10T10:57:35.497489-05:00"}
+{"id":"qmd-deh","title":"Refactor database introduce qmd collection *","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T10:56:04.516137-05:00","updated_at":"2025-12-10T10:56:04.516137-05:00"}
+{"id":"qmd-p1h","title":"Create collection add|remove","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T10:57:00.717864-05:00","updated_at":"2025-12-10T10:57:00.717864-05:00"}

+ 417 - 0
cli.test.ts

@@ -0,0 +1,417 @@
+/**
+ * CLI Integration Tests
+ *
+ * Tests all qmd CLI commands using a temporary test database via INDEX_PATH.
+ * These tests spawn actual qmd processes to verify end-to-end functionality.
+ */
+
+import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
+import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
+import { tmpdir } from "os";
+import { join } from "path";
+
+// Test fixtures directory and database path
+let testDir: string;
+let testDbPath: string;
+let fixturesDir: string;
+let testCounter = 0; // Unique counter for each test run
+
+// Get the directory where this test file lives (same as qmd.ts)
+const qmdDir = import.meta.dir;
+const qmdScript = join(qmdDir, "qmd.ts");
+
+// Helper to run qmd command with test database
+async function runQmd(
+  args: string[],
+  options: { cwd?: string; env?: Record<string, string>; dbPath?: string } = {}
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+  const workingDir = options.cwd || fixturesDir;
+  const dbPath = options.dbPath || testDbPath;
+  const proc = Bun.spawn(["bun", qmdScript, ...args], {
+    cwd: workingDir,
+    env: {
+      ...process.env,
+      INDEX_PATH: dbPath,
+      PWD: workingDir, // Must explicitly set PWD since getPwd() checks this
+      ...options.env,
+    },
+    stdout: "pipe",
+    stderr: "pipe",
+  });
+
+  const stdout = await new Response(proc.stdout).text();
+  const stderr = await new Response(proc.stderr).text();
+  const exitCode = await proc.exited;
+
+  return { stdout, stderr, exitCode };
+}
+
+// Get a fresh database path for isolated tests
+function getFreshDbPath(): string {
+  testCounter++;
+  return join(testDir, `test-${testCounter}.sqlite`);
+}
+
+// Setup test fixtures
+beforeAll(async () => {
+  // Create temp directory structure
+  testDir = await mkdtemp(join(tmpdir(), "qmd-test-"));
+  testDbPath = join(testDir, "test.sqlite");
+  fixturesDir = join(testDir, "fixtures");
+
+  await mkdir(fixturesDir, { recursive: true });
+  await mkdir(join(fixturesDir, "notes"), { recursive: true });
+  await mkdir(join(fixturesDir, "docs"), { recursive: true });
+
+  // Create test markdown files
+  await writeFile(
+    join(fixturesDir, "README.md"),
+    `# Test Project
+
+This is a test project for QMD CLI testing.
+
+## Features
+
+- Full-text search with BM25
+- Vector similarity search
+- Hybrid search with reranking
+`
+  );
+
+  await writeFile(
+    join(fixturesDir, "notes", "meeting.md"),
+    `# Team Meeting Notes
+
+Date: 2024-01-15
+
+## Attendees
+- Alice
+- Bob
+- Charlie
+
+## Discussion Topics
+- Project timeline review
+- Resource allocation
+- Technical debt prioritization
+
+## Action Items
+1. Alice to update documentation
+2. Bob to fix authentication bug
+3. Charlie to review pull requests
+`
+  );
+
+  await writeFile(
+    join(fixturesDir, "notes", "ideas.md"),
+    `# Product Ideas
+
+## Feature Requests
+- Dark mode support
+- Keyboard shortcuts
+- Export to PDF
+
+## Technical Improvements
+- Improve search performance
+- Add caching layer
+- Optimize database queries
+`
+  );
+
+  await writeFile(
+    join(fixturesDir, "docs", "api.md"),
+    `# API Documentation
+
+## Endpoints
+
+### GET /search
+Search for documents.
+
+Parameters:
+- q: Search query (required)
+- limit: Max results (default: 10)
+
+### GET /document/:id
+Retrieve a specific document.
+
+### POST /index
+Index new documents.
+`
+  );
+});
+
+// Cleanup after all tests
+afterAll(async () => {
+  if (testDir) {
+    await rm(testDir, { recursive: true, force: true });
+  }
+});
+
+describe("CLI Help", () => {
+  test("shows help with --help flag", async () => {
+    const { stdout, exitCode } = await runQmd(["--help"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Usage:");
+    expect(stdout).toContain("qmd add");
+    expect(stdout).toContain("qmd search");
+  });
+
+  test("shows help with no arguments", async () => {
+    const { stdout, exitCode } = await runQmd([]);
+    expect(exitCode).toBe(1);
+    expect(stdout).toContain("Usage:");
+  });
+});
+
+describe("CLI Add Command", () => {
+  test("adds files from current directory", async () => {
+    const { stdout, exitCode } = await runQmd(["add", "."]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Collection:");
+    expect(stdout).toContain("Indexed:");
+  });
+
+  test("adds files with custom glob pattern", async () => {
+    const { stdout, exitCode } = await runQmd(["add", "notes/*.md"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Collection:");
+    // Should find meeting.md and ideas.md in notes/
+    expect(stdout).toContain("notes/*.md");
+  });
+
+  test("adds files with --drop flag recreates collection", async () => {
+    // First add
+    await runQmd(["add", "."]);
+    // Then drop and re-add
+    const { stdout, exitCode } = await runQmd(["add", "--drop", "."]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Dropped collection:");
+  });
+});
+
+describe("CLI Status Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("shows index status", async () => {
+    const { stdout, exitCode } = await runQmd(["status"]);
+    expect(exitCode).toBe(0);
+    // Should show collection info
+    expect(stdout).toContain("Collection");
+  });
+});
+
+describe("CLI Search Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("searches for documents with BM25", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "meeting"]);
+    expect(exitCode).toBe(0);
+    // Should find meeting.md
+    expect(stdout.toLowerCase()).toContain("meeting");
+  });
+
+  test("searches with limit option", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "-n", "1", "test"]);
+    expect(exitCode).toBe(0);
+  });
+
+  test("searches with all results option", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "--all", "the"]);
+    expect(exitCode).toBe(0);
+  });
+
+  test("returns no results message for non-matching query", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("No results");
+  });
+
+  test("requires query argument", async () => {
+    const { stdout, stderr, exitCode } = await runQmd(["search"]);
+    expect(exitCode).toBe(1);
+    // Error message goes to stderr
+    expect(stderr).toContain("Usage:");
+  });
+});
+
+describe("CLI Get Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("retrieves document content by path", async () => {
+    const { stdout, exitCode } = await runQmd(["get", "README.md"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Project");
+  });
+
+  test("retrieves document from subdirectory", async () => {
+    const { stdout, exitCode } = await runQmd(["get", "notes/meeting.md"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Team Meeting");
+  });
+
+  test("handles non-existent file", async () => {
+    const { stdout, exitCode } = await runQmd(["get", "nonexistent.md"]);
+    // Should indicate file not found
+    expect(exitCode).toBe(1);
+  });
+});
+
+describe("CLI Multi-Get Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("retrieves multiple documents by pattern", async () => {
+    const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]);
+    expect(exitCode).toBe(0);
+    // Should contain content from both notes files
+    expect(stdout).toContain("Meeting");
+    expect(stdout).toContain("Ideas");
+  });
+
+  test("retrieves documents by comma-separated paths", async () => {
+    const { stdout, exitCode } = await runQmd([
+      "multi-get",
+      "README.md,notes/meeting.md",
+    ]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Project");
+    expect(stdout).toContain("Team Meeting");
+  });
+});
+
+describe("CLI Update Command", () => {
+  let localDbPath: string;
+
+  beforeEach(async () => {
+    // Use a fresh database for this test suite
+    localDbPath = getFreshDbPath();
+    // Ensure we have indexed files
+    await runQmd(["add", "."], { dbPath: localDbPath });
+  });
+
+  test("updates all collections", async () => {
+    const { stdout, exitCode } = await runQmd(["update"], { dbPath: localDbPath });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Updating");
+  });
+});
+
+describe("CLI Add-Context Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("adds context to a path", async () => {
+    const { stdout, exitCode } = await runQmd([
+      "add-context",
+      "notes",
+      "Personal notes and meeting logs",
+    ]);
+    expect(exitCode).toBe(0);
+  });
+
+  test("requires path and text arguments", async () => {
+    const { stderr, exitCode } = await runQmd(["add-context"]);
+    expect(exitCode).toBe(1);
+    // Error message goes to stderr
+    expect(stderr).toContain("Usage:");
+  });
+});
+
+describe("CLI Cleanup Command", () => {
+  beforeEach(async () => {
+    // Ensure we have indexed files
+    await runQmd(["add", "."]);
+  });
+
+  test("cleans up orphaned entries", async () => {
+    const { stdout, exitCode } = await runQmd(["cleanup"]);
+    expect(exitCode).toBe(0);
+  });
+});
+
+describe("CLI Error Handling", () => {
+  test("handles unknown command", async () => {
+    const { stderr, exitCode } = await runQmd(["unknowncommand"]);
+    expect(exitCode).toBe(1);
+    // Should indicate unknown command
+    expect(stderr).toContain("Unknown command");
+  });
+
+  test("uses INDEX_PATH environment variable", async () => {
+    // Verify the test DB path is being used by creating a separate index
+    const customDbPath = join(testDir, "custom.sqlite");
+    const { exitCode } = await runQmd(["add", "."], {
+      env: { INDEX_PATH: customDbPath },
+    });
+    expect(exitCode).toBe(0);
+
+    // The custom database should exist
+    const file = Bun.file(customDbPath);
+    expect(await file.exists()).toBe(true);
+  });
+});
+
+describe("CLI Output Formats", () => {
+  beforeEach(async () => {
+    await runQmd(["add", "."]);
+  });
+
+  test("search with --json flag outputs JSON", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "--json", "test"]);
+    expect(exitCode).toBe(0);
+    // Should be valid JSON
+    const parsed = JSON.parse(stdout);
+    expect(Array.isArray(parsed)).toBe(true);
+  });
+
+  test("search with --files flag outputs file paths", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "--files", "meeting"]);
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain(".md");
+  });
+
+  test("search output includes snippets by default", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "API"]);
+    expect(exitCode).toBe(0);
+    // If results found, should have snippet content
+    if (!stdout.includes("No results")) {
+      expect(stdout.toLowerCase()).toContain("api");
+    }
+  });
+});
+
+describe("CLI Search with Collection Filter", () => {
+  let localDbPath: string;
+
+  beforeEach(async () => {
+    // Use a fresh database for this test suite
+    localDbPath = getFreshDbPath();
+    // Create multiple collections
+    await runQmd(["add", "notes/*.md"], { dbPath: localDbPath });
+    await runQmd(["add", "docs/*.md"], { dbPath: localDbPath });
+  });
+
+  test("filters search by collection name", async () => {
+    const { stdout, exitCode } = await runQmd([
+      "search",
+      "-c",
+      "notes",
+      "meeting",
+    ], { dbPath: localDbPath });
+    expect(exitCode).toBe(0);
+    // Should find results from notes collection
+    expect(stdout.toLowerCase()).toContain("meeting");
+  });
+});

+ 39 - 36
qmd.ts

@@ -5,6 +5,7 @@ import { parseArgs } from "util";
 import * as sqliteVec from "sqlite-vec";
 import {
   getDb,
+  closeDb,
   getDbPath,
   getPwd,
   getRealPath,
@@ -116,7 +117,7 @@ function checkIndexHealth(db: Database): void {
 
   // Check if most recent document update is older than 2 weeks
   if (daysStale !== null && daysStale >= 14) {
-    process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update-all' to refresh.${c.reset}\n`);
+    process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update' to refresh.${c.reset}\n`);
   }
 }
 
@@ -447,7 +448,7 @@ function showStatus(): void {
     console.log(`\n${c.dim}No collections. Run 'qmd add .' to index markdown files.${c.reset}`);
   }
 
-  db.close();
+  closeDb();
 }
 
 // Update display_paths for all documents that have empty display_path
@@ -481,7 +482,7 @@ function updateDisplayPaths(db: Database): number {
   return updated;
 }
 
-async function updateAllCollections(): Promise<void> {
+async function updateCollections(): Promise<void> {
   const db = getDb();
   cleanupDuplicateCollections(db);
 
@@ -492,7 +493,7 @@ async function updateAllCollections(): Promise<void> {
 
   if (collections.length === 0) {
     console.log(`${c.dim}No collections found. Run 'qmd add .' to index markdown files.${c.reset}`);
-    db.close();
+    closeDb();
     return;
   }
 
@@ -502,8 +503,7 @@ async function updateAllCollections(): Promise<void> {
     console.log(`${c.green}✓${c.reset} Updated ${pathsUpdated} display paths`);
   }
 
-  db.close();
-
+  // Don't close db here - indexFiles will reuse it and close at the end
   console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
 
   for (let i = 0; i < collections.length; i++) {
@@ -544,7 +544,7 @@ async function addContext(pathArg: string, contextText: string): Promise<void> {
 
   console.log(`${c.green}✓${c.reset} Added context for: ${shortPath(pathPrefix)}`);
   console.log(`${c.dim}Context: ${contextText}${c.reset}`);
-  db.close();
+  closeDb();
 }
 
 function getDocument(filename: string, fromLine?: number, maxLines?: number): void {
@@ -591,7 +591,7 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number): vo
         console.error(`  ${s}`);
       }
     }
-    db.close();
+    closeDb();
     process.exit(1);
   }
 
@@ -613,7 +613,7 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number): vo
     console.log(`Folder Context: ${context}\n---\n`);
   }
   console.log(output);
-  db.close();
+  closeDb();
 }
 
 // Multi-get: fetch multiple documents by glob pattern or comma-separated list
@@ -652,7 +652,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
     files = matchFilesByGlob(db, pattern);
     if (files.length === 0) {
       console.error(`No files matched pattern: ${pattern}`);
-      db.close();
+      closeDb();
       process.exit(1);
     }
   }
@@ -701,7 +701,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
     });
   }
 
-  db.close();
+  closeDb();
 
   // Output based on format
   if (format === "json") {
@@ -800,8 +800,7 @@ async function dropCollection(globPattern: string): Promise<void> {
   const collection = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
 
   if (!collection) {
-    console.log(`No collection found for ${pwd} with pattern ${globPattern}`);
-    db.close();
+    // No collection to drop - this is fine, we'll create one during indexing
     return;
   }
 
@@ -814,8 +813,7 @@ async function dropCollection(globPattern: string): Promise<void> {
   console.log(`Dropped collection: ${pwd} (${globPattern})`);
   console.log(`Removed ${deleted.changes} documents`);
   console.log(`(Vectors kept for potential reuse)`);
-
-  db.close();
+  // Don't close db - indexFiles will use it and close at the end
 }
 
 async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
@@ -851,7 +849,7 @@ async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
   if (total === 0) {
     progress.clear();
     console.log("No files found matching pattern.");
-    db.close();
+    closeDb();
     return;
   }
 
@@ -953,7 +951,7 @@ async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
     console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
   }
 
-  db.close();
+  closeDb();
 }
 
 function renderProgressBar(percent: number, width: number = 30): string {
@@ -986,7 +984,7 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean =
 
   if (hashesToEmbed.length === 0) {
     console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
-    db.close();
+    closeDb();
     return;
   }
 
@@ -1021,7 +1019,7 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean =
 
   if (allChunks.length === 0) {
     console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
-    db.close();
+    closeDb();
     return;
   }
 
@@ -1099,7 +1097,7 @@ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean =
   if (errors > 0) {
     console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
   }
-  db.close();
+  closeDb();
 }
 
 // Sanitize a term for FTS5: remove punctuation except apostrophes
@@ -1144,9 +1142,15 @@ function normalizeBM25(score: number): number {
   return 1 / (1 + Math.exp(-(absScore - 5) / 3));
 }
 
-// Get collection ID by name (matches pwd suffix)
+// Get collection ID by name (matches pwd or glob_pattern suffix)
 function getCollectionIdByName(db: Database, name: string): number | null {
-  const result = db.prepare(`SELECT id FROM collections WHERE pwd LIKE ? ORDER BY LENGTH(pwd) DESC LIMIT 1`).get(`%${name}`) as { id: number } | null;
+  // Search both pwd and glob_pattern columns for the name
+  const result = db.prepare(`
+    SELECT id FROM collections
+    WHERE pwd LIKE ? OR glob_pattern LIKE ?
+    ORDER BY LENGTH(pwd) DESC
+    LIMIT 1
+  `).get(`%${name}%`, `%${name}%`) as { id: number } | null;
   return result?.id || null;
 }
 
@@ -1467,7 +1471,7 @@ function search(query: string, opts: OutputOptions): void {
     collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
     if (collectionId === undefined) {
       console.error(`Collection not found: ${opts.collection}`);
-      db.close();
+      closeDb();
       process.exit(1);
     }
   }
@@ -1482,7 +1486,7 @@ function search(query: string, opts: OutputOptions): void {
     context: getContextForFile(db, r.file),
   }));
 
-  db.close();
+  closeDb();
 
   if (resultsWithContext.length === 0) {
     console.log("No results found.");
@@ -1500,7 +1504,7 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string =
     collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
     if (collectionId === undefined) {
       console.error(`Collection not found: ${opts.collection}`);
-      db.close();
+      closeDb();
       process.exit(1);
     }
   }
@@ -1508,7 +1512,7 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string =
   const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
   if (!tableExists) {
     console.error("Vector index not found. Run 'qmd embed' first to create embeddings.");
-    db.close();
+    closeDb();
     return;
   }
 
@@ -1540,7 +1544,7 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string =
     .slice(0, opts.limit)
     .map(r => ({ ...r, context: getContextForFile(db, r.file) }));
 
-  db.close();
+  closeDb();
 
   if (results.length === 0) {
     console.log("No results found.");
@@ -1625,7 +1629,7 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
     collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
     if (collectionId === undefined) {
       console.error(`Collection not found: ${opts.collection}`);
-      db.close();
+      closeDb();
       process.exit(1);
     }
   }
@@ -1665,7 +1669,7 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
 
   if (candidates.length === 0) {
     console.log("No results found.");
-    db.close();
+    closeDb();
     return;
   }
 
@@ -1709,7 +1713,7 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
     };
   }).sort((a, b) => b.score - a.score);
 
-  db.close();
+  closeDb();
   outputResults(finalResults, query, opts);
 }
 
@@ -1788,7 +1792,7 @@ function showHelp(): void {
   console.log("  qmd get <file>[:line] [-l N] [--from N]  - Get document (optionally from line, max N lines)");
   console.log("  qmd multi-get <pattern> [-l N] [--max-bytes N]  - Get multiple docs by glob or comma-separated list");
   console.log("  qmd status                    - Show index status and collections");
-  console.log("  qmd update-all                - Re-index all collections");
+  console.log("  qmd update                    - Re-index all collections");
   console.log("  qmd embed [-f]                - Create vector embeddings (chunks ~6KB each)");
   console.log("  qmd cleanup                   - Remove cache and orphaned data, vacuum DB");
   console.log("  qmd search <query>            - Full-text search (BM25)");
@@ -1842,9 +1846,8 @@ switch (cli.command) {
     const globPattern = (!globArg || globArg === ".") ? DEFAULT_GLOB : globArg;
     if (cli.values.drop) {
       await dropCollection(globPattern);
-    } else {
-      await indexFiles(globPattern);
     }
+    await indexFiles(globPattern);
     break;
   }
 
@@ -1896,8 +1899,8 @@ switch (cli.command) {
     showStatus();
     break;
 
-  case "update-all":
-    await updateAllCollections();
+  case "update":
+    await updateCollections();
     break;
 
   case "embed":
@@ -1984,7 +1987,7 @@ switch (cli.command) {
     db.exec(`VACUUM`);
     console.log(`${c.green}✓${c.reset} Database vacuumed`);
 
-    db.close();
+    closeDb();
     break;
   }
 

+ 19 - 1
store.ts

@@ -66,6 +66,10 @@ export function resolve(...paths: string[]): string {
 }
 
 export function getDefaultDbPath(indexName: string = "index"): string {
+  // Allow override via INDEX_PATH for testing
+  if (Bun.env.INDEX_PATH) {
+    return Bun.env.INDEX_PATH;
+  }
   const cacheDir = Bun.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
   const qmdCacheDir = resolve(cacheDir, "qmd");
   try { Bun.spawnSync(["mkdir", "-p", qmdCacheDir]); } catch {}
@@ -357,6 +361,14 @@ export function getDb(): Database {
   return _legacyDb;
 }
 
+/** @deprecated Use store.db.close() instead. Closes the legacy db and resets singleton. */
+export function closeDb(): void {
+  if (_legacyDb) {
+    _legacyDb.close();
+    _legacyDb = null;
+  }
+}
+
 /** @deprecated Use store.ensureVecTable() instead */
 export function ensureVecTable(db: Database, dimensions: number): void {
   ensureVecTableInternal(db, dimensions);
@@ -642,7 +654,13 @@ export function getContextForFile(db: Database, filepath: string): string | null
 }
 
 export function getCollectionIdByName(db: Database, name: string): number | null {
-  const result = db.prepare(`SELECT id FROM collections WHERE pwd LIKE ? ORDER BY LENGTH(pwd) DESC LIMIT 1`).get(`%${name}`) as { id: number } | null;
+  // Search both pwd and glob_pattern columns for the name
+  const result = db.prepare(`
+    SELECT id FROM collections
+    WHERE pwd LIKE ? OR glob_pattern LIKE ?
+    ORDER BY LENGTH(pwd) DESC
+    LIMIT 1
+  `).get(`%${name}%`, `%${name}%`) as { id: number } | null;
   return result?.id || null;
 }