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