diff --git a/src/adapters/tauri-fs-adapter.test.ts b/src/adapters/tauri-fs-adapter.test.ts new file mode 100644 index 0000000..1bea01b --- /dev/null +++ b/src/adapters/tauri-fs-adapter.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { globToRegExp } from "./tauri-fs-adapter"; + +describe("globToRegExp", () => { + describe("wildcards", () => { + it("matches `*` against a single path segment only", () => { + const re = globToRegExp("*.ts"); + expect(re.test("index.ts")).toBe(true); + expect(re.test("index.js")).toBe(false); + // `*` must not cross a path separator + expect(re.test("src/index.ts")).toBe(false); + }); + + it("matches `**` recursively across path separators", () => { + const re = globToRegExp("**/*.ts"); + expect(re.test("index.ts")).toBe(true); + expect(re.test("src/index.ts")).toBe(true); + expect(re.test("src/lib/util.ts")).toBe(true); + expect(re.test("src/util.js")).toBe(false); + }); + + it("matches `?` against exactly one non-separator character", () => { + const re = globToRegExp("src/index.?s"); + expect(re.test("src/index.ts")).toBe(true); + expect(re.test("src/index.js")).toBe(true); + expect(re.test("src/index.tsx")).toBe(false); + expect(re.test("src/index./s")).toBe(false); + }); + }); + + describe("regex metacharacters are treated literally", () => { + it("does not crash and matches literally on `+` (a regex quantifier)", () => { + // `a+b/*.ts` — the `+` must be a literal plus, not a quantifier. + const re = globToRegExp("a+b/*.ts"); + expect(re.test("a+b/index.ts")).toBe(true); + // Without escaping, `a+` would match `aaa`; assert it does NOT. + expect(re.test("aaab/index.ts")).toBe(false); + expect(re.test("ab/index.ts")).toBe(false); + }); + + it("treats parentheses as literal, not a capture group", () => { + // `(x).js` — without escaping this becomes a capture group + `.` wildcard. + const re = globToRegExp("(x).js"); + expect(re.test("(x).js")).toBe(true); + // The `.` must be literal: `xXjs` (where X is any char) must NOT match. + expect(re.test("xZjs")).toBe(false); + expect(re.test("x.js")).toBe(false); + }); + + it("does not throw on characters that would form invalid regex", () => { + // Unbalanced/structural metacharacters would throw if passed raw to RegExp. + const tricky = ["a(b.ts", "a)b.ts", "a[b.ts", "a]b.ts", "a{b.ts", "a$b^c.ts", "a|b.ts"]; + for (const pattern of tricky) { + expect(() => globToRegExp(pattern)).not.toThrow(); + // Each pattern should match itself literally. + expect(globToRegExp(pattern).test(pattern)).toBe(true); + } + }); + + it("escapes `.` so it is not a wildcard", () => { + const re = globToRegExp("file.ts"); + expect(re.test("file.ts")).toBe(true); + expect(re.test("fileXts")).toBe(false); + }); + + it("anchors the full string", () => { + const re = globToRegExp("*.ts"); + expect(re.test("a.ts.bak")).toBe(false); + expect(re.test("prefix-a.ts")).toBe(true); + }); + }); +}); diff --git a/src/adapters/tauri-fs-adapter.ts b/src/adapters/tauri-fs-adapter.ts index a636b8f..9700fd0 100644 --- a/src/adapters/tauri-fs-adapter.ts +++ b/src/adapters/tauri-fs-adapter.ts @@ -1,6 +1,53 @@ import type { FSAdapter, DirEntry } from "../../core/services/fs-adapter"; import { FilesystemClient } from "@/lib/filesystem-client"; +/** + * Convert a simple glob pattern to a RegExp. + * + * Supports: + * - `*` → match anything except path separators + * - `**` → match anything including path separators (recursive) + * - `?` → match a single character + * + * This intentionally does NOT cover brace expansion or advanced glob + * features — just the patterns needed for file discovery. + * + * All non-wildcard characters are treated literally: any regex + * metacharacter (e.g. `.`, `+`, `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`, + * `|`, `\`) is escaped so patterns like `a+b/*.ts` or `(x).js` are matched + * as literal text instead of producing an invalid or unintended RegExp. + */ +export function globToRegExp(pattern: string): RegExp { + let re = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*" && pattern[i + 1] === "*") { + // `**/` or trailing `**` + re += ".*"; + i += 2; + if (pattern[i] === "/") i++; // consume trailing slash + } else if (ch === "*") { + re += "[^/]*"; + i++; + } else if (ch === "?") { + re += "[^/]"; + i++; + } else { + // Escape every regex metacharacter so the glob segment is matched + // literally. `/` is not special in a RegExp body but is harmless to + // leave unescaped; everything in this class needs a backslash. + if (/[.+^${}()|[\]\\]/.test(ch)) { + re += "\\" + ch; + } else { + re += ch; + } + i++; + } + } + return new RegExp(`^${re}$`); +} + /** * Tauri/frontend implementation of FSAdapter. * Uses FilesystemClient directly for absolute path operations. @@ -28,10 +75,25 @@ export class TauriFSAdapter implements FSAdapter { return entries.map((entry) => entry.name); } - async glob(_pattern: string, _cwd: string): Promise { - // TODO: Implement glob using Tauri fs if needed - // For now, skill discovery doesn't use glob - throw new Error("glob not implemented in TauriFSAdapter"); + async glob(pattern: string, cwd: string): Promise { + const regex = globToRegExp(pattern); + const results: string[] = []; + + const walk = async (dir: string, prefix: string): Promise => { + const entries = await this.fsClient.listDir(dir); + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isFile && regex.test(relative)) { + results.push(relative); + } + if (entry.isDirectory) { + await walk(this.fsClient.joinPath(dir, entry.name), relative); + } + } + }; + + await walk(cwd, ""); + return results; } async mkdir(path: string, _recursive?: boolean): Promise {