1import { readFile } from "node:fs/promises";
2import { resolve } from "node:path";
3import { defaultConfig } from "./defaults.js";
4import type { RumiloConfig } from "./schema.js";
5import { ConfigError } from "../util/errors.js";
6import toml from "toml";
7
8export interface LoadedConfig {
9 config: RumiloConfig;
10 path?: string;
11}
12
13function mergeConfig(base: RumiloConfig, override: Partial<RumiloConfig>): RumiloConfig {
14 return {
15 defaults: {
16 ...base.defaults,
17 ...override.defaults,
18 },
19 web: {
20 ...base.web,
21 ...override.web,
22 },
23 repo: {
24 ...base.repo,
25 ...override.repo,
26 },
27 custom_models: {
28 ...base.custom_models,
29 ...override.custom_models,
30 },
31 };
32}
33
34function validateConfig(config: RumiloConfig): void {
35 if (!config.defaults.model) {
36 throw new ConfigError("defaults.model is required");
37 }
38 if (typeof config.defaults.cleanup !== "boolean") {
39 throw new ConfigError("defaults.cleanup must be a boolean");
40 }
41}
42
43export async function loadConfig(): Promise<LoadedConfig> {
44 const base = structuredClone(defaultConfig);
45 const configHome = process.env["XDG_CONFIG_HOME"] || `${process.env["HOME"] ?? ""}/.config`;
46 const configPath = resolve(configHome, "rumilo", "config.toml");
47
48 try {
49 const raw = await readFile(configPath, "utf8");
50 const parsed = toml.parse(raw) as Partial<RumiloConfig>;
51 const merged = mergeConfig(base, parsed);
52 validateConfig(merged);
53 return { config: merged, path: configPath };
54 } catch (error: any) {
55 if (error?.code === "ENOENT") {
56 validateConfig(base);
57 return { config: base };
58 }
59
60 if (error instanceof Error) {
61 throw new ConfigError(error.message);
62 }
63
64 throw new ConfigError(String(error));
65 }
66}
67
68export function applyConfigOverrides(
69 config: RumiloConfig,
70 overrides: Partial<RumiloConfig>,
71): RumiloConfig {
72 const merged = mergeConfig(config, overrides);
73 validateConfig(merged);
74 return merged;
75}