feat: 配置加载模块
This commit is contained in:
66
src/core/config.ts
Normal file
66
src/core/config.ts
Normal file
@@ -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<RuneConfig> {
|
||||||
|
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
||||||
|
try {
|
||||||
|
const content = await readFile(configPath, "utf-8");
|
||||||
|
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
||||||
|
if (!userConfig?.stages) {
|
||||||
|
return structuredClone(defaultConfig);
|
||||||
|
}
|
||||||
|
return mergeConfig(userConfig);
|
||||||
|
} catch {
|
||||||
|
return structuredClone(defaultConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(userConfig: Partial<RuneConfig>): 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);
|
||||||
|
}
|
||||||
94
tests/core/config.test.ts
Normal file
94
tests/core/config.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user