diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..57c4e82 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,66 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { defaultConfig } from "../defaults/config.ts"; +import type { RuneConfig } from "../types.ts"; +import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts"; + +export function findProjectRoot( + startDir: string = process.cwd(), +): string | null { + let dir = startDir; + while (true) { + if (existsSync(join(dir, RUNE_DIR))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export async function loadConfig(projectRoot: string): Promise { + const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE); + try { + const content = await readFile(configPath, "utf-8"); + const userConfig = parseYaml(content) as Partial | null; + if (!userConfig?.stages) { + return structuredClone(defaultConfig); + } + return mergeConfig(userConfig); + } catch { + return structuredClone(defaultConfig); + } +} + +function mergeConfig(userConfig: Partial): RuneConfig { + const result: RuneConfig = { stages: {} }; + const stageKeys = ["discuss", "plan", "build", "archive"] as const; + + for (const stage of stageKeys) { + if (userConfig.stages?.[stage]) { + result.stages[stage] = userConfig.stages[stage]!; + } else if (defaultConfig.stages[stage]) { + result.stages[stage] = structuredClone(defaultConfig.stages[stage]!); + } + } + + return result; +} + +export function getRuneDir(projectRoot: string): string { + return join(projectRoot, RUNE_DIR); +} + +export function getChangesDir(projectRoot: string): string { + return join(projectRoot, RUNE_DIR, CHANGES_DIR); +} + +export function getArchiveDir(projectRoot: string): string { + return join(projectRoot, RUNE_DIR, ARCHIVE_DIR); +} + +export function getChangeDir(projectRoot: string, changeName: string): string { + return join(projectRoot, RUNE_DIR, CHANGES_DIR, changeName); +} diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts new file mode 100644 index 0000000..b68a1b0 --- /dev/null +++ b/tests/core/config.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { loadConfig, findProjectRoot, getRuneDir } from "../../src/core/config.ts"; + +const TMP_DIR = join(import.meta.dir, "__tmp_config_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("findProjectRoot", () => { + it("当前目录存在 .rune/ 时返回当前目录", async () => { + await mkdir(join(TMP_DIR, ".rune")); + expect(findProjectRoot(TMP_DIR)).toBe(TMP_DIR); + }); + + it("子目录中向上查找到 .rune/", async () => { + await mkdir(join(TMP_DIR, ".rune")); + const sub = join(TMP_DIR, "src", "commands"); + await mkdir(sub, { recursive: true }); + expect(findProjectRoot(sub)).toBe(TMP_DIR); + }); + + it("找不到 .rune/ 时返回 null", () => { + expect(findProjectRoot("/tmp/__nonexistent__")).toBeNull(); + }); +}); + +describe("loadConfig", () => { + it("无配置文件时返回默认配置", async () => { + await mkdir(join(TMP_DIR, ".rune")); + const config = await loadConfig(TMP_DIR); + expect(config.stages.discuss).toBeDefined(); + expect(config.stages.plan).toBeDefined(); + expect(config.stages.build).toBeDefined(); + expect(config.stages.archive).toBeDefined(); + }); + + it("用户配置覆盖默认配置(全量替换)", async () => { + const runeDir = join(TMP_DIR, ".rune"); + await mkdir(runeDir, { recursive: true }); + await writeFile( + join(runeDir, "config.yaml"), + `stages: + discuss: + prompt: 自定义讨论提示词 +`, + ); + const config = await loadConfig(TMP_DIR); + expect(config.stages.discuss!.prompt).toBe("自定义讨论提示词"); + expect(config.stages.plan).toBeDefined(); + expect(config.stages.build).toBeDefined(); + expect(config.stages.archive).toBeDefined(); + }); + + it("用户配置 plan 时完全替换内置 documents", async () => { + const runeDir = join(TMP_DIR, ".rune"); + await mkdir(runeDir, { recursive: true }); + await writeFile( + join(runeDir, "config.yaml"), + `stages: + plan: + documents: + - name: spec + prompt: 生成规格文档 +`, + ); + const config = await loadConfig(TMP_DIR); + expect(config.stages.plan!.documents).toHaveLength(1); + expect(config.stages.plan!.documents[0].name).toBe("spec"); + }); + + it("YAML 解析错误时返回默认配置", async () => { + const runeDir = join(TMP_DIR, ".rune"); + await mkdir(runeDir, { recursive: true }); + await writeFile( + join(runeDir, "config.yaml"), + `stages: [invalid yaml {{{`, + ); + const config = await loadConfig(TMP_DIR); + expect(config.stages.discuss).toBeDefined(); + }); +}); + +describe("getRuneDir", () => { + it("返回 .rune 目录路径", () => { + expect(getRuneDir("/project")).toBe("/project/.rune"); + }); +});