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