import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, sep } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { configToRuleset, loadConfig } from "../../src/config/loader.js"; const TMP = join("/tmp/opencode", "dispatch-config-test"); // Point the global config at a path that does not exist so these tests are // hermetic — they must not pick up this machine's real // ~/.config/dispatch/dispatch.toml. const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG; beforeEach(() => { mkdirSync(TMP, { recursive: true }); process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml"); }); afterEach(() => { rmSync(TMP, { recursive: true, force: true }); if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG; else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal; }); function writeToml(content: string): void { writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8"); } describe("loadConfig", () => { it("returns empty permissions when dispatch.toml is missing", () => { const config = loadConfig(TMP); expect(config.permissions).toEqual({}); }); it("parses simple string permissions", () => { writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`); const config = loadConfig(TMP); expect(config.permissions.read).toBe("allow"); expect(config.permissions.edit).toBe("deny"); }); it("parses nested pattern permissions", () => { writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`); const config = loadConfig(TMP); const bash = config.permissions.bash as Record; expect(bash["npm test"]).toBe("allow"); expect(bash["*"]).toBe("ask"); }); it("ignores comment lines", () => { writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`); const config = loadConfig(TMP); expect(config.permissions.read).toBe("allow"); }); it("handles ~ expansion in nested keys", () => { writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`); const config = loadConfig(TMP); const read = config.permissions.read as Record; expect(read["~/projects/*"]).toBe("allow"); }); it("handles $HOME expansion in nested keys", () => { writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`); const config = loadConfig(TMP); const read = config.permissions.read as Record; expect(read["$HOME/docs/*"]).toBe("allow"); }); it("parses quoted keys", () => { writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`); const config = loadConfig(TMP); const bash = config.permissions.bash as Record; expect(bash["git commit *"]).toBe("allow"); expect(bash["rm *"]).toBe("deny"); }); it("handles multiple permission groups", () => { writeToml( `[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`, ); const config = loadConfig(TMP); expect(config.permissions.read).toBe("allow"); const edit = config.permissions.edit as Record; expect(edit["*"]).toBe("ask"); expect(edit["src/**"]).toBe("allow"); const bash = config.permissions.bash as Record; expect(bash["npm test"]).toBe("allow"); expect(bash["*"]).toBe("ask"); }); it("preserves # inside quoted string keys", () => { writeToml(`[permissions.bash]\n"file#1" = "allow"\n`); const config = loadConfig(TMP); const bash = config.permissions.bash as Record; expect(bash["file#1"]).toBe("allow"); }); it("strips inline comments on table headers", () => { writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`); const config = loadConfig(TMP); const bash = config.permissions.bash as Record; expect(bash["*"]).toBe("allow"); }); it("expands ~ with platform path separator", () => { // Simulate a path using the OS separator const pattern = `~${sep}projects${sep}*`; writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`); const config = loadConfig(TMP); const read = config.permissions.read as Record; expect(read[pattern]).toBe("allow"); }); it("throws on TOML parse errors", () => { writeToml("this is not valid TOML [[["); expect(() => loadConfig(TMP)).toThrow(); }); }); describe("configToRuleset — new validations", () => { it("falls back to ask and warns for invalid action in string value", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const rules = configToRuleset({ permissions: { read: "allw" } }); expect(rules[0]?.action).toBe("ask"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw")); warn.mockRestore(); }); it("falls back to ask and warns for invalid action in nested pattern", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } }); expect(rules[0]?.action).toBe("ask"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID")); warn.mockRestore(); }); it("expands ~ with backslash separator in patterns", () => { const home = homedir(); // Force backslash path even on Linux to test the regex const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } }); expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`); }); }); describe("configToRuleset", () => { it("produces a rule with pattern * for string value", () => { const rules = configToRuleset({ permissions: { read: "allow" } }); expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]); }); it("produces rules for each pattern in an object value", () => { const rules = configToRuleset({ permissions: { bash: { "npm test": "allow", "*": "ask" } }, }); expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" }); expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" }); }); it("expands ~ in patterns", () => { const home = homedir(); const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } }); expect(rules[0]?.pattern).toBe(`${home}/foo/*`); }); it("expands $HOME in patterns", () => { const home = homedir(); const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } }); expect(rules[0]?.pattern).toBe(`${home}/bar/*`); }); it("handles empty permissions", () => { const rules = configToRuleset({ permissions: {} }); expect(rules).toEqual([]); }); });