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>
This commit is contained in:
Tobi Lutke 2025-12-12 16:29:49 -05:00
parent d4f971c230
commit 27f9e8b630
No known key found for this signature in database
4 changed files with 112 additions and 9 deletions

View File

@ -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"}

View File

@ -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

View File

@ -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:");
});
});

59
qmd.ts
View File

@ -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;