diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index f576cde..01dc540 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -1292,8 +1292,33 @@ function listFiles(pathArg?: string): void { let collectionName: string; let pathPrefix: string | null = null; - if (pathArg.startsWith('qmd://')) { - // Virtual path format: qmd://collection/path + const afterScheme = pathArg.startsWith('qmd://') ? pathArg.slice('qmd://'.length) : null; + if (afterScheme !== null && afterScheme.startsWith('/')) { + // Absolute-path collection: qmd:///Users/foo/bar — normalizeVirtualPath would corrupt + // this by stripping all leading slashes, so bypass parseVirtualPath entirely. + const normalized = afterScheme.replace(/\/$/, ''); + const allColls = yamlListCollections(); + const match = allColls + .filter(c => normalized === c.name || normalized.startsWith(c.name + '/')) + .sort((a, b) => b.name.length - a.name.length)[0]; + if (match) { + collectionName = match.name; + const rest = normalized.slice(match.name.length).replace(/^\//, ''); + pathPrefix = rest || null; + } else { + // Preserve the historical qmd:////collection/path alias behavior for normal + // collections when no absolute-path collection matches. + const parsed = parseVirtualPath(pathArg); + if (!parsed) { + console.error(`Invalid virtual path: ${pathArg}`); + closeDb(); + process.exit(1); + } + collectionName = parsed.collectionName; + pathPrefix = parsed.path; + } + } else if (afterScheme !== null) { + // Normal virtual path: qmd://collection-name/path const parsed = parseVirtualPath(pathArg); if (!parsed) { console.error(`Invalid virtual path: ${pathArg}`); @@ -1302,8 +1327,22 @@ function listFiles(pathArg?: string): void { } collectionName = parsed.collectionName; pathPrefix = parsed.path; + } else if (pathArg.startsWith('/')) { + // Raw absolute filesystem path — longest-prefix match against collection names + const normalized = pathArg.replace(/\/$/, ''); + const allColls = yamlListCollections(); + const match = allColls + .filter(c => normalized === c.name || normalized.startsWith(c.name + '/')) + .sort((a, b) => b.name.length - a.name.length)[0]; + if (match) { + collectionName = match.name; + const rest = normalized.slice(match.name.length).replace(/^\//, ''); + pathPrefix = rest || null; + } else { + collectionName = normalized; + } } else { - // Just collection name or collection/path + // Short collection name or name/path const parts = pathArg.split('/'); collectionName = parts[0] || ''; if (parts.length > 1) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 9c575f8..e4ceb35 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -888,6 +888,71 @@ describe("CLI ls Command", () => { expect(stdout).toContain("qmd://fixtures/docs/api.md"); }); + test("continues to normalize extra slashes for normal collection virtual paths", async () => { + const { stdout, stderr, exitCode } = await runQmd(["ls", "qmd:///fixtures/docs"], { dbPath: localDbPath }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("qmd://fixtures/docs/api.md"); + }); + + test("lists an absolute-path collection from a qmd:/// virtual path", async () => { + const env = await createIsolatedTestEnv("absolute-qmd-path"); + const absoluteDir = await mkdtemp(join(tmpdir(), "qmd-absolute-collection-")); + await writeFile(join(absoluteDir, "root.md"), "# Absolute collection\n"); + await writeFile( + join(env.configDir, "index.yml"), + `collections:\n "${absoluteDir}":\n path: "${absoluteDir}"\n pattern: "**/*.md"\n` + ); + + const update = await runQmd(["update"], { + cwd: absoluteDir, + dbPath: env.dbPath, + configDir: env.configDir, + }); + expect(update.exitCode).toBe(0); + + const { stdout, stderr, exitCode } = await runQmd(["ls", `qmd://${absoluteDir}/`], { + cwd: absoluteDir, + dbPath: env.dbPath, + configDir: env.configDir, + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain(`qmd://${absoluteDir}/root.md`); + }); + + test("lists an absolute-path collection from a raw path using the longest prefix match", async () => { + const env = await createIsolatedTestEnv("absolute-raw-path"); + const parentCollectionName = await mkdtemp(join(tmpdir(), "qmd-absolute-parent-name-")); + const childCollectionName = join(parentCollectionName, "nested"); + const parentDataDir = await mkdtemp(join(tmpdir(), "qmd-absolute-parent-data-")); + const childDataDir = await mkdtemp(join(tmpdir(), "qmd-absolute-child-data-")); + await writeFile(join(parentDataDir, "parent.md"), "# Parent collection\n"); + await writeFile(join(childDataDir, "child.md"), "# Child collection\n"); + await writeFile( + join(env.configDir, "index.yml"), + `collections:\n "${parentCollectionName}":\n path: "${parentDataDir}"\n pattern: "**/*.md"\n "${childCollectionName}":\n path: "${childDataDir}"\n pattern: "**/*.md"\n` + ); + + const update = await runQmd(["update"], { + cwd: parentDataDir, + dbPath: env.dbPath, + configDir: env.configDir, + }); + expect(update.exitCode).toBe(0); + + const { stdout, stderr, exitCode } = await runQmd(["ls", `${childCollectionName}/`], { + cwd: childDataDir, + dbPath: env.dbPath, + configDir: env.configDir, + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain(`qmd://${childCollectionName}/child.md`); + expect(stdout).not.toContain("No files found"); + expect(stdout).not.toContain(`qmd://${parentCollectionName}/parent.md`); + }); + test("handles non-existent collection", async () => { const { stderr, exitCode } = await runQmd(["ls", "nonexistent"], { dbPath: localDbPath }); expect(exitCode).toBe(1);