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 { Type } from "@sinclair/typebox";
6import { Value } from "@sinclair/typebox/value";
7import { ConfigError } from "../src/util/errors.js";
8import { loadConfig } from "../src/config/loader.js";
9import { partialObject } from "../src/config/schema.js";
10
11describe("config validation", () => {
12 let configDir: string;
13 let configPath: string;
14 const originalEnv = { ...process.env };
15
16 beforeEach(() => {
17 configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test-"));
18 configPath = join(configDir, "config.toml");
19 process.env["XDG_CONFIG_HOME"] = join(configDir, "..");
20 // loadConfig looks for <XDG_CONFIG_HOME>/rumilo/config.toml
21 // So we need the dir structure to match
22 const rumiloDir = join(configDir, "..", "rumilo");
23 require("node:fs").mkdirSync(rumiloDir, { recursive: true });
24 configPath = join(rumiloDir, "config.toml");
25 });
26
27 afterEach(() => {
28 process.env = { ...originalEnv };
29 try {
30 rmSync(configDir, { recursive: true, force: true });
31 // Also clean up the rumilo dir we created
32 const rumiloDir = join(configDir, "..", "rumilo");
33 rmSync(rumiloDir, { recursive: true, force: true });
34 } catch {}
35 });
36
37 test("rejects defaults.model with wrong type (number instead of string)", async () => {
38 writeFileSync(
39 configPath,
40 `[defaults]\nmodel = 42\ncleanup = true\n`,
41 );
42 await expect(loadConfig()).rejects.toThrow(ConfigError);
43 await expect(loadConfig()).rejects.toThrow(/defaults\/model/);
44 });
45
46 test("rejects defaults.cleanup with wrong type (string instead of boolean)", async () => {
47 writeFileSync(
48 configPath,
49 `[defaults]\nmodel = "anthropic:claude-sonnet-4-20250514"\ncleanup = "yes"\n`,
50 );
51 await expect(loadConfig()).rejects.toThrow(ConfigError);
52 await expect(loadConfig()).rejects.toThrow(/defaults\/cleanup/);
53 });
54
55 test("rejects repo.default_depth with wrong type (string instead of number)", async () => {
56 writeFileSync(
57 configPath,
58 `[repo]\ndefault_depth = "deep"\n`,
59 );
60 await expect(loadConfig()).rejects.toThrow(ConfigError);
61 await expect(loadConfig()).rejects.toThrow(/repo\/default_depth/);
62 });
63
64 test("rejects repo.default_depth below minimum (0)", async () => {
65 writeFileSync(
66 configPath,
67 `[repo]\ndefault_depth = 0\n`,
68 );
69 await expect(loadConfig()).rejects.toThrow(ConfigError);
70 await expect(loadConfig()).rejects.toThrow(/default_depth/);
71 });
72
73 test("rejects unknown top-level section type (number instead of object)", async () => {
74 // web should be an object but we pass a string value at top level
75 writeFileSync(
76 configPath,
77 `[defaults]\nmodel = "x"\ncleanup = true\n[web]\nmodel = 123\n`,
78 );
79 await expect(loadConfig()).rejects.toThrow(ConfigError);
80 });
81
82 test("accepts valid partial config (only [repo] section)", async () => {
83 writeFileSync(
84 configPath,
85 `[repo]\nmodel = "anthropic:claude-sonnet-4-20250514"\ndefault_depth = 5\n`,
86 );
87 const { config } = await loadConfig();
88 expect(config.repo.model).toBe("anthropic:claude-sonnet-4-20250514");
89 expect(config.repo.default_depth).toBe(5);
90 // defaults should come from defaultConfig
91 expect(config.defaults.model).toBe("anthropic:claude-sonnet-4-20250514");
92 });
93
94 test("accepts valid complete config", async () => {
95 writeFileSync(
96 configPath,
97 [
98 `[defaults]`,
99 `model = "openai:gpt-4"`,
100 `cleanup = false`,
101 ``,
102 `[web]`,
103 `model = "openai:gpt-4"`,
104 ``,
105 `[repo]`,
106 `model = "openai:gpt-4"`,
107 `default_depth = 3`,
108 `blob_limit = "10m"`,
109 ].join("\n"),
110 );
111 const { config } = await loadConfig();
112 expect(config.defaults.model).toBe("openai:gpt-4");
113 expect(config.defaults.cleanup).toBe(false);
114 expect(config.repo.default_depth).toBe(3);
115 });
116
117 test("error message includes path and expected type for diagnostics", async () => {
118 writeFileSync(
119 configPath,
120 `[defaults]\nmodel = 42\ncleanup = true\n`,
121 );
122 try {
123 await loadConfig();
124 throw new Error("should have thrown");
125 } catch (e: any) {
126 expect(e).toBeInstanceOf(ConfigError);
127 expect(e.message).toContain("/defaults/model");
128 expect(e.message).toMatch(/string/i);
129 }
130 });
131});
132
133describe("partialObject deep-partial behavior", () => {
134 const NestedSchema = Type.Object({
135 name: Type.String(),
136 inner: Type.Object({
137 host: Type.String(),
138 port: Type.Number(),
139 }),
140 });
141
142 const PartialNested = partialObject(NestedSchema);
143
144 test("accepts empty object (all fields optional at every level)", () => {
145 const result = Value.Check(PartialNested, {});
146 expect(result).toBe(true);
147 });
148
149 test("accepts object with nested section present but inner fields omitted", () => {
150 const result = Value.Check(PartialNested, { inner: {} });
151 expect(result).toBe(true);
152 });
153
154 test("accepts object with partial inner fields of a nested object", () => {
155 const result = Value.Check(PartialNested, { inner: { host: "localhost" } });
156 expect(result).toBe(true);
157 });
158
159 test("accepts fully specified object", () => {
160 const result = Value.Check(PartialNested, {
161 name: "test",
162 inner: { host: "localhost", port: 8080 },
163 });
164 expect(result).toBe(true);
165 });
166
167 test("rejects wrong type inside nested object", () => {
168 const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } });
169 expect(result).toBe(false);
170 });
171
172 test("rejects wrong type at top level", () => {
173 const result = Value.Check(PartialNested, { name: 123 });
174 expect(result).toBe(false);
175 });
176
177 test("does not recurse into Type.Record", () => {
178 const SchemaWithRecord = Type.Object({
179 headers: Type.Record(Type.String(), Type.String()),
180 });
181 const Partial = partialObject(SchemaWithRecord);
182 // Record should remain as-is (not turned into a partial object)
183 // Valid: omitted entirely
184 expect(Value.Check(Partial, {})).toBe(true);
185 // Valid: proper record
186 expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true);
187 // Invalid: wrong value type in record
188 expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false);
189 });
190
191 test("handles deeply nested objects (3 levels)", () => {
192 const DeepSchema = Type.Object({
193 level1: Type.Object({
194 level2: Type.Object({
195 value: Type.Number(),
196 }),
197 }),
198 });
199 const PartialDeep = partialObject(DeepSchema);
200 expect(Value.Check(PartialDeep, {})).toBe(true);
201 expect(Value.Check(PartialDeep, { level1: {} })).toBe(true);
202 expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true);
203 expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true);
204 expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false);
205 });
206});