loader.ts

  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}