config-validation.test.ts

  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});