loader.ts

 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}