1import { readFile } from "node:fs/promises";
2import { resolve } from "node:path";
3import { Value } from "@sinclair/typebox/value";
4import { defaultConfig } from "./defaults.js";
5import { ConfigSchema, PartialConfigSchema } from "./schema.js";
6import type { RumiloConfig } from "./schema.js";
7import { ConfigError } from "../util/errors.js";
8import toml from "toml";
9
10export interface LoadedConfig {
11 config: RumiloConfig;
12 path?: string;
13}
14
15function mergeConfig(base: RumiloConfig, override: Partial<RumiloConfig>): RumiloConfig {
16 return {
17 defaults: {
18 ...base.defaults,
19 ...override.defaults,
20 },
21 web: {
22 ...base.web,
23 ...override.web,
24 },
25 repo: {
26 ...base.repo,
27 ...override.repo,
28 },
29 custom_models: {
30 ...base.custom_models,
31 ...override.custom_models,
32 },
33 };
34}
35
36function validatePartialConfig(parsed: unknown): asserts parsed is Partial<RumiloConfig> {
37 if (!Value.Check(PartialConfigSchema, parsed)) {
38 const errors = [...Value.Errors(PartialConfigSchema, parsed)];
39 const details = errors
40 .map((e) => ` ${e.path}: ${e.message} (got ${JSON.stringify(e.value)})`)
41 .join("\n");
42 throw new ConfigError(
43 `Invalid config:\n${details}`,
44 );
45 }
46}
47
48function validateFullConfig(config: unknown): asserts config is RumiloConfig {
49 if (!Value.Check(ConfigSchema, config)) {
50 const errors = [...Value.Errors(ConfigSchema, config)];
51 const details = errors
52 .map((e) => ` ${e.path}: ${e.message} (got ${JSON.stringify(e.value)})`)
53 .join("\n");
54 throw new ConfigError(
55 `Invalid merged config:\n${details}`,
56 );
57 }
58}
59
60export async function loadConfig(): Promise<LoadedConfig> {
61 const base = structuredClone(defaultConfig);
62 const configHome = process.env["XDG_CONFIG_HOME"] || `${process.env["HOME"] ?? ""}/.config`;
63 const configPath = resolve(configHome, "rumilo", "config.toml");
64
65 try {
66 const raw = await readFile(configPath, "utf8");
67 const parsed: unknown = toml.parse(raw);
68 validatePartialConfig(parsed);
69 const merged = mergeConfig(base, parsed);
70 validateFullConfig(merged);
71 return { config: merged, path: configPath };
72 } catch (error: any) {
73 if (error?.code === "ENOENT") {
74 validateFullConfig(base);
75 return { config: base };
76 }
77
78 if (error instanceof ConfigError) {
79 throw error;
80 }
81
82 if (error instanceof Error) {
83 throw new ConfigError(error.message);
84 }
85
86 throw new ConfigError(String(error));
87 }
88}
89
90export function applyConfigOverrides(
91 config: RumiloConfig,
92 overrides: Partial<RumiloConfig>,
93): RumiloConfig {
94 const merged = mergeConfig(config, overrides);
95 validateFullConfig(merged);
96 return merged;
97}