diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 18c5531..0bf90e1 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"qmd-4ru","title":"Update document retrieval for new schema","description":"Functions like getDocument, findDocument, getMultipleDocuments need to work with new schema (path instead of filepath, content joins, virtual paths).","status":"closed","priority":0,"issue_type":"task","created_at":"2025-12-12T15:29:53.911881-05:00","updated_at":"2025-12-12T15:56:11.054888-05:00","closed_at":"2025-12-12T15:56:11.054888-05:00","dependencies":[{"issue_id":"qmd-4ru","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.912607-05:00","created_by":"daemon"}]} -{"id":"qmd-afe","title":"implement qmd collection rename, which changes the global path prefix for the collection","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-12T15:55:54.779325-05:00","updated_at":"2025-12-12T15:55:54.779325-05:00"} +{"id":"qmd-afe","title":"implement qmd collection rename, which changes the global path prefix for the collection","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:55:54.779325-05:00","updated_at":"2025-12-12T16:29:24.153196-05:00","closed_at":"2025-12-12T16:29:24.153196-05:00"} {"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/","notes":"## Completed\n- ✅ Implemented content-addressable storage (content table with hash→doc mapping)\n- ✅ Refactored documents table as file system layer (collection_id, path, hash)\n- ✅ Added collection names (e.g., \"pages\", \"journals\", \"archive\")\n- ✅ Implemented virtual paths (qmd://collection-name/path/to/file.md)\n- ✅ Added hierarchical context support (collection-scoped)\n- ✅ Successfully migrated existing database\n- ✅ Updated search functions to work with new schema\n- ✅ Updated indexing logic to use content-addressable storage\n- ✅ Orphaned content hash cleanup\n\n## Still TODO\n- Fix migration SQL to properly extract basename (currently needs manual fix)\n- Implement `qmd collection add . --name \u003cname\u003e --mask '**/*.md'`\n- Implement `qmd ls [path]` for exploring virtual file tree","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-10T10:57:35.497489-05:00","updated_at":"2025-12-12T15:39:48.879143-05:00","closed_at":"2025-12-12T15:39:48.879143-05:00"} {"id":"qmd-bx1","title":"Fix migration SQL for proper basename extraction","description":"The migration currently generates collection names incorrectly (uses full path instead of basename). Need to fix the SQL in migrateToContentAddressable to properly extract the directory basename.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-12T15:29:53.757723-05:00","updated_at":"2025-12-12T15:50:29.349134-05:00","closed_at":"2025-12-12T15:50:29.349134-05:00","dependencies":[{"issue_id":"qmd-bx1","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.758524-05:00","created_by":"daemon"}]} {"id":"qmd-c0m","title":"Comprehensive CLI review and consistency pass","description":"Review entire CLI command structure:\n- Consistent naming (add vs create, remove vs delete)\n- Consistent flag usage (--name, --mask, etc)\n- Update help text for all commands\n- Ensure virtual paths work everywhere\n- Test all commands end-to-end","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-12T15:29:38.083564-05:00","updated_at":"2025-12-12T16:06:51.544695-05:00","closed_at":"2025-12-12T16:06:51.544695-05:00"} diff --git a/CLAUDE.md b/CLAUDE.md index a739d78..9e429d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ Use Bun instead of Node.js (`bun` not `node`, `bun install` not `npm install`). qmd collection add . --name # Create/index collection qmd collection list # List all collections with details qmd collection remove # Remove a collection by name +qmd collection rename # Rename a collection qmd ls [collection[/path]] # List collections or files in a collection qmd context add [path] "text" # Add context for path (defaults to current dir) qmd context list # List all contexts @@ -36,6 +37,9 @@ qmd collection add ~/Documents/notes --name mynotes --mask '**/*.md' # Remove a collection qmd collection remove mynotes +# Rename a collection +qmd collection rename mynotes my-notes + # List all files in a collection qmd ls mynotes diff --git a/cli.test.ts b/cli.test.ts index 9ab5fc5..82272b7 100644 --- a/cli.test.ts +++ b/cli.test.ts @@ -616,4 +616,60 @@ describe("CLI Collection Commands", () => { expect(exitCode).toBe(1); expect(stderr).toContain("Unknown subcommand"); }); + + test("renames a collection", async () => { + // First verify the collection exists + const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath }); + expect(listBefore).toMatch(/^fixtures$/m); // Collection name on its own line + + // Rename it + const { stdout, exitCode } = await runQmd(["collection", "rename", "fixtures", "my-fixtures"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + expect(stdout).toContain("✓ Renamed collection 'fixtures' to 'my-fixtures'"); + expect(stdout).toContain("qmd://fixtures/"); + expect(stdout).toContain("qmd://my-fixtures/"); + + // Verify the new name exists and old name is gone + const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath }); + expect(listAfter).toMatch(/^my-fixtures$/m); // Collection name on its own line + expect(listAfter).not.toMatch(/^fixtures$/m); // Old name should not appear as collection name + }); + + test("handles renaming non-existent collection", async () => { + const { stderr, exitCode } = await runQmd(["collection", "rename", "nonexistent", "newname"], { dbPath: localDbPath }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Collection not found"); + }); + + test("handles renaming to existing collection name", async () => { + // Create a second collection in a temp directory + const tempDir = await mkdtemp(join(tmpdir(), "qmd-second-")); + await writeFile(join(tempDir, "test.md"), "# Test"); + const addResult = await runQmd(["collection", "add", tempDir, "--name", "second"], { dbPath: localDbPath }); + + if (addResult.exitCode !== 0) { + console.error("Failed to add second collection:", addResult.stderr); + } + expect(addResult.exitCode).toBe(0); + + // Verify both collections exist + const { stdout: listBoth } = await runQmd(["collection", "list"], { dbPath: localDbPath }); + expect(listBoth).toMatch(/^fixtures$/m); + expect(listBoth).toMatch(/^second$/m); + + // Try to rename fixtures to second (which already exists) + const { stderr, exitCode } = await runQmd(["collection", "rename", "fixtures", "second"], { dbPath: localDbPath }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Collection name already exists"); + }); + + test("handles missing rename arguments", async () => { + const { stderr: stderr1, exitCode: exitCode1 } = await runQmd(["collection", "rename"], { dbPath: localDbPath }); + expect(exitCode1).toBe(1); + expect(stderr1).toContain("Usage:"); + + const { stderr: stderr2, exitCode: exitCode2 } = await runQmd(["collection", "rename", "fixtures"], { dbPath: localDbPath }); + expect(exitCode2).toBe(1); + expect(stderr2).toContain("Usage:"); + }); }); diff --git a/qmd.ts b/qmd.ts index ee8d077..621aaf2 100755 --- a/qmd.ts +++ b/qmd.ts @@ -1334,7 +1334,7 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P // Create the collection and index files console.log(`Creating collection '${name}'...`); - await indexFiles(globPattern); + await indexFiles(pwd, globPattern, name); console.log(`${c.green}✓${c.reset} Collection '${name}' created successfully`); } @@ -1378,6 +1378,37 @@ function collectionRemove(name: string): void { closeDb(); } +function collectionRename(oldName: string, newName: string): void { + const db = getDb(); + + // Check if old collection exists + const coll = getCollectionByName(db, oldName); + if (!coll) { + console.error(`${c.yellow}Collection not found: ${oldName}${c.reset}`); + console.error(`Run 'qmd collection list' to see available collections.`); + closeDb(); + process.exit(1); + } + + // Check if new name already exists + const existing = getCollectionByName(db, newName); + if (existing) { + console.error(`${c.yellow}Collection name already exists: ${newName}${c.reset}`); + console.error(`Choose a different name or remove the existing collection first.`); + closeDb(); + process.exit(1); + } + + // Update the collection name + db.prepare(`UPDATE collections SET name = ?, updated_at = ? WHERE id = ?`) + .run(newName, new Date().toISOString(), coll.id); + + console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`); + console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`); + + closeDb(); +} + async function dropCollection(globPattern: string): Promise { const db = getDb(); const pwd = getPwd(); @@ -1401,9 +1432,9 @@ async function dropCollection(globPattern: string): Promise { // Don't close db - indexFiles will use it and close at the end } -async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise { +async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, name?: string): Promise { const db = getDb(); - const pwd = getPwd(); + const resolvedPwd = pwd || getPwd(); const now = new Date().toISOString(); const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"]; @@ -1411,13 +1442,13 @@ async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise { clearCache(db); // Get or create collection for this (pwd, glob) - const collectionId = getOrCreateCollection(db, pwd, globPattern); - console.log(`Collection: ${pwd} (${globPattern})`); + const collectionId = getOrCreateCollection(db, resolvedPwd, globPattern, name); + console.log(`Collection: ${resolvedPwd} (${globPattern})`); progress.indeterminate(); const glob = new Glob(globPattern); const files: string[] = []; - for await (const file of glob.scan({ cwd: pwd, onlyFiles: true, followSymlinks: true })) { + for await (const file of glob.scan({ cwd: resolvedPwd, onlyFiles: true, followSymlinks: true })) { // Skip node_modules, hidden folders (.*), and other common excludes const parts = file.split("/"); const shouldSkip = parts.some(part => @@ -1450,7 +1481,7 @@ async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise { const startTime = Date.now(); for (const relativeFile of files) { - const filepath = getRealPath(resolve(pwd, relativeFile)); + const filepath = getRealPath(resolve(resolvedPwd, relativeFile)); const path = relativeFile; // Use relative path as-is seenPaths.add(path); @@ -2339,6 +2370,7 @@ function showHelp(): void { console.log(" qmd collection add [path] --name --mask - Create/index collection"); console.log(" qmd collection list - List all collections with details"); console.log(" qmd collection remove - Remove a collection by name"); + console.log(" qmd collection rename - Rename a collection"); console.log(" qmd ls [collection[/path]] - List collections or files in a collection"); console.log(" qmd context add [path] \"text\" - Add context for path (defaults to current dir)"); console.log(" qmd context list - List all contexts"); @@ -2544,9 +2576,20 @@ switch (cli.command) { break; } + case "rename": + case "mv": { + if (!cli.args[1] || !cli.args[2]) { + console.error("Usage: qmd collection rename "); + console.error(" Use 'qmd collection list' to see available collections"); + process.exit(1); + } + collectionRename(cli.args[1], cli.args[2]); + break; + } + default: console.error(`Unknown subcommand: ${subcommand}`); - console.error("Available: list, add, remove"); + console.error("Available: list, add, remove, rename"); process.exit(1); } break;