1import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { ConfigError } from "../src/util/errors.js";
6import { loadConfig } from "../src/config/loader.js";
7
8describe("config validation", () => {
9 let configDir: string;
10 let configPath: string;
11 const originalEnv = { ...process.env };
12
13 beforeEach(() => {
14 configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test-"));
15 configPath = join(configDir, "config.toml");
16 process.env["XDG_CONFIG_HOME"] = join(configDir, "..");
17 // loadConfig looks for <XDG_CONFIG_HOME>/rumilo/config.toml
18 // So we need the dir structure to match
19 const rumiloDir = join(configDir, "..", "rumilo");
20 require("node:fs").mkdirSync(rumiloDir, { recursive: true });
21 configPath = join(rumiloDir, "config.toml");
22 });
23
24 afterEach(() => {
25 process.env = { ...originalEnv };
26 try {
27 rmSync(configDir, { recursive: true, force: true });
28 // Also clean up the rumilo dir we created
29 const rumiloDir = join(configDir, "..", "rumilo");
30 rmSync(rumiloDir, { recursive: true, force: true });
31 } catch {}
32 });
33
34 test("rejects defaults.model with wrong type (number instead of string)", async () => {
35 writeFileSync(
36 configPath,
37 `[defaults]\nmodel = 42\ncleanup = true\n`,
38 );
39 await expect(loadConfig()).rejects.toThrow(ConfigError);
40 await expect(loadConfig()).rejects.toThrow(/defaults\/model/);
41 });
42
43 test("rejects defaults.cleanup with wrong type (string instead of boolean)", async () => {
44 writeFileSync(
45 configPath,
46 `[defaults]\nmodel = "anthropic:claude-sonnet-4-20250514"\ncleanup = "yes"\n`,
47 );
48 await expect(loadConfig()).rejects.toThrow(ConfigError);
49 await expect(loadConfig()).rejects.toThrow(/defaults\/cleanup/);
50 });
51
52 test("rejects repo.default_depth with wrong type (string instead of number)", async () => {
53 writeFileSync(
54 configPath,
55 `[repo]\ndefault_depth = "deep"\n`,
56 );
57 await expect(loadConfig()).rejects.toThrow(ConfigError);
58 await expect(loadConfig()).rejects.toThrow(/repo\/default_depth/);
59 });
60
61 test("rejects repo.default_depth below minimum (0)", async () => {
62 writeFileSync(
63 configPath,
64 `[repo]\ndefault_depth = 0\n`,
65 );
66 await expect(loadConfig()).rejects.toThrow(ConfigError);
67 await expect(loadConfig()).rejects.toThrow(/default_depth/);
68 });
69
70 test("rejects unknown top-level section type (number instead of object)", async () => {
71 // web should be an object but we pass a string value at top level
72 writeFileSync(
73 configPath,
74 `[defaults]\nmodel = "x"\ncleanup = true\n[web]\nmodel = 123\n`,
75 );
76 await expect(loadConfig()).rejects.toThrow(ConfigError);
77 });
78
79 test("accepts valid partial config (only [repo] section)", async () => {
80 writeFileSync(
81 configPath,
82 `[repo]\nmodel = "anthropic:claude-sonnet-4-20250514"\ndefault_depth = 5\n`,
83 );
84 const { config } = await loadConfig();
85 expect(config.repo.model).toBe("anthropic:claude-sonnet-4-20250514");
86 expect(config.repo.default_depth).toBe(5);
87 // defaults should come from defaultConfig
88 expect(config.defaults.model).toBe("anthropic:claude-sonnet-4-20250514");
89 });
90
91 test("accepts valid complete config", async () => {
92 writeFileSync(
93 configPath,
94 [
95 `[defaults]`,
96 `model = "openai:gpt-4"`,
97 `cleanup = false`,
98 ``,
99 `[web]`,
100 `model = "openai:gpt-4"`,
101 ``,
102 `[repo]`,
103 `model = "openai:gpt-4"`,
104 `default_depth = 3`,
105 `blob_limit = "10m"`,
106 ].join("\n"),
107 );
108 const { config } = await loadConfig();
109 expect(config.defaults.model).toBe("openai:gpt-4");
110 expect(config.defaults.cleanup).toBe(false);
111 expect(config.repo.default_depth).toBe(3);
112 });
113
114 test("error message includes path and expected type for diagnostics", async () => {
115 writeFileSync(
116 configPath,
117 `[defaults]\nmodel = 42\ncleanup = true\n`,
118 );
119 try {
120 await loadConfig();
121 throw new Error("should have thrown");
122 } catch (e: any) {
123 expect(e).toBeInstanceOf(ConfigError);
124 expect(e.message).toContain("/defaults/model");
125 expect(e.message).toMatch(/string/i);
126 }
127 });
128});