Эх сурвалжийг харах

Implement qmd collection rename command

- Added collectionRename() function to rename collections
- Updates collection name in database (changes virtual path prefix)
- Added CLI handler for "qmd collection rename <old> <new>"
- Supports alias "mv" for rename command
- Includes comprehensive error handling:
  * Checks if old collection exists
  * Prevents renaming to existing collection name
  * Validates required arguments
- Fixed bug in collectionAdd where --name flag was ignored
  * indexFiles now accepts pwd and name parameters
  * Collection name is properly passed through to getOrCreateCollection
- Updated help text and CLAUDE.md documentation
- Added 4 new tests for rename functionality (all passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Tobi Lutke 5 сар өмнө
parent
commit
27f9e8b630
4 өөрчлөгдсөн 112 нэмэгдсэн , 9 устгасан
  1. 1 1
      .beads/issues.jsonl
  2. 4 0
      CLAUDE.md
  3. 56 0
      cli.test.ts
  4. 51 8
      qmd.ts

+ 1 - 1
.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"}

+ 4 - 0
CLAUDE.md

@@ -10,6 +10,7 @@ Use Bun instead of Node.js (`bun` not `node`, `bun install` not `npm install`).
 qmd collection add . --name <n>   # Create/index collection
 qmd collection list               # List all collections with details
 qmd collection remove <name>      # Remove a collection by name
+qmd collection rename <old> <new> # 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
 

+ 56 - 0
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:");
+  });
 });

+ 51 - 8
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<void> {
   const db = getDb();
   const pwd = getPwd();
@@ -1401,9 +1432,9 @@ async function dropCollection(globPattern: string): Promise<void> {
   // Don't close db - indexFiles will use it and close at the end
 }
 
-async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
+async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, name?: string): Promise<void> {
   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<void> {
   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<void> {
   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 <name> --mask <pattern>  - Create/index collection");
   console.log("  qmd collection list           - List all collections with details");
   console.log("  qmd collection remove <name>  - Remove a collection by name");
+  console.log("  qmd collection rename <old> <new>  - 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 <old-name> <new-name>");
+          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;