diff --git a/packages/server/resolve-file.test.ts b/packages/server/resolve-file.test.ts index bab3a750..cc4302cb 100644 --- a/packages/server/resolve-file.test.ts +++ b/packages/server/resolve-file.test.ts @@ -106,6 +106,33 @@ describe("resolveMarkdownFile", () => { }); }); + test("resolves @ filename via fallback when @ file does not exist", async () => { + const root = createTempProject({ "README.md": "# Hello" }); + const result = resolveMarkdownFile("@README.md", root); + expect(result).toEqual({ + kind: "found", + path: resolve(root, "README.md"), + }); + }); + + test("prioritizes real @ filename before fallback", async () => { + const root = createTempProject({ "@README.md": "# At" }); + const result = resolveMarkdownFile("@README.md", root); + expect(result).toEqual({ + kind: "found", + path: resolve(root, "@README.md"), + }); + }); + + test("resolves quoted @ filename", async () => { + const root = createTempProject({ "README.md": "# Hello" }); + const result = resolveMarkdownFile('"@README.md"', root); + expect(result).toEqual({ + kind: "found", + path: resolve(root, "README.md"), + }); + }); + test("resolves relative paths with Windows separators", async () => { const root = createTempProject({ "docs/test-plan.md": "# Test plan\n" }); const result = resolveMarkdownFile("docs\\test-plan.md", root); @@ -147,6 +174,19 @@ describe("resolveMarkdownFile", () => { } }); + test("returns ambiguous in @ fallback when target exists multiple times", async () => { + const root = createTempProject({ + "docs/plan.md": "# Plan 1", + "api/plan.md": "# Plan 2", + }); + const result = resolveMarkdownFile("@plan.md", root); + expect(result.kind).toBe("ambiguous"); + if (result.kind === "ambiguous") { + expect(result.input).toBe("@plan.md"); + expect(result.matches).toHaveLength(2); + } + }); + // Ignored directories test("skips node_modules", async () => { @@ -190,6 +230,12 @@ describe("resolveMarkdownFile", () => { expect(result.kind).toBe("not_found"); }); + test("returns not_found for @ path that cannot be resolved", async () => { + const root = createTempProject(); + const result = resolveMarkdownFile("@nope.md", root); + expect(result).toEqual({ kind: "not_found", input: "@nope.md" }); + }); + test("handles deeply nested files", async () => { const root = createTempProject({ "a/b/c/d/deep.md": "# Deep", diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index a07fc4bb..19999360 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -78,6 +78,20 @@ function isSearchableMarkdownPath(input: string): boolean { return MARKDOWN_PATH_REGEX.test(input.trim()); } +function stripWrappingQuotes(input: string): string { + if (input.length < 2) { + return input; + } + + const first = input[0]; + const last = input[input.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return input.slice(1, -1); + } + + return input; +} + /** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */ function hasWindowsDriveLetter(input: string): boolean { return /^[a-zA-Z]:[/\\]/.test(input); @@ -155,12 +169,10 @@ export function isAbsoluteMarkdownPath( * @param input - User-provided path (absolute, relative, or bare filename) * @param projectRoot - Project root directory to search within */ -export function resolveMarkdownFile( +function resolveMarkdownFileCore( input: string, projectRoot: string, ): ResolveResult { - // Trim whitespace/CR that may leak from Windows shell pipelines - input = input.trim(); const normalizedInput = normalizeMarkdownPathInput(input); const searchInput = normalizeSeparators(normalizedInput); const isBareFilename = !searchInput.includes("/"); @@ -218,6 +230,47 @@ export function resolveMarkdownFile( return { kind: "not_found", input }; } +/** + * Resolve a markdown file path within a project root. + * + * @param input - User-provided path (absolute, relative, or bare filename) + * @param projectRoot - Project root directory to search within + */ +export function resolveMarkdownFile( + input: string, + projectRoot: string, +): ResolveResult { + const originalInput = input.trim(); + const unquotedInput = stripWrappingQuotes(originalInput); + + const primary = resolveMarkdownFileCore(unquotedInput, projectRoot); + if (primary.kind === "found") { + return primary; + } + if (primary.kind === "ambiguous") { + return { ...primary, input: originalInput }; + } + + if (!unquotedInput.startsWith("@")) { + return { kind: "not_found", input: originalInput }; + } + + const normalizedInput = unquotedInput.replace(/^@+/, ""); + if (!normalizedInput) { + return { kind: "not_found", input: originalInput }; + } + + const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot); + if (fallback.kind === "found") { + return fallback; + } + if (fallback.kind === "ambiguous") { + return { ...fallback, input: originalInput }; + } + + return { kind: "not_found", input: originalInput }; +} + /** * Check if a directory contains at least one markdown file. * Used to validate folder annotation targets.