From 44e41e496b71a8acb6aa8851ed0966f3197abf78 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 8 Jun 2026 17:20:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8A=B6=E6=80=81=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=99=A8=E5=8F=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/scanner.ts | 55 ++++++++++++++++++++++++++ tests/core/scanner.test.ts | 79 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/core/scanner.ts create mode 100644 tests/core/scanner.test.ts diff --git a/src/core/scanner.ts b/src/core/scanner.ts new file mode 100644 index 0000000..1ade412 --- /dev/null +++ b/src/core/scanner.ts @@ -0,0 +1,55 @@ +import { readdir, stat, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ChangeStatus } from "../types.ts"; +import { getChangesDir, getArchiveDir } from "./config.ts"; +import { parseTasks } from "./task-parser.ts"; + +export async function scanChanges( + projectRoot: string, +): Promise { + const changesDir = getChangesDir(projectRoot); + const results: ChangeStatus[] = []; + + try { + const entries = await readdir(changesDir); + for (const entry of entries) { + const entryPath = join(changesDir, entry); + const entryStat = await stat(entryPath); + if (!entryStat.isDirectory()) continue; + + const docs = await readdir(entryPath); + const documents = docs.filter((d) => d.endsWith(".md")); + + let taskProgress: { completed: number; total: number } | null = null; + const taskFile = docs.find((d) => d === "task.md"); + if (taskFile) { + const content = await readFile(join(entryPath, taskFile), "utf-8"); + const tasks = parseTasks(content); + taskProgress = { + completed: tasks.filter((t) => t.checked).length, + total: tasks.length, + }; + } + + results.push({ name: entry, documents, taskProgress }); + } + } catch { + } + + return results; +} + +export async function scanArchives(projectRoot: string): Promise { + const archiveDir = getArchiveDir(projectRoot); + try { + const entries = await readdir(archiveDir); + const dirs: string[] = []; + for (const entry of entries) { + const entryStat = await stat(join(archiveDir, entry)); + if (entryStat.isDirectory()) dirs.push(entry); + } + return dirs; + } catch { + return []; + } +} diff --git a/tests/core/scanner.test.ts b/tests/core/scanner.test.ts new file mode 100644 index 0000000..42ff04a --- /dev/null +++ b/tests/core/scanner.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { scanChanges, scanArchives } from "../../src/core/scanner.ts"; + +const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("scanChanges", () => { + it("无 changes 目录时返回空数组", async () => { + await mkdir(join(TMP_DIR, ".rune"), { recursive: true }); + const changes = await scanChanges(TMP_DIR); + expect(changes).toEqual([]); + }); + + it("扫描到变更及其文档", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "user-auth"), { recursive: true }); + await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计"); + await writeFile( + join(changesDir, "user-auth", "task.md"), + `- [x] 任务一\n- [ ] 任务二`, + ); + + const changes = await scanChanges(TMP_DIR); + expect(changes).toHaveLength(1); + expect(changes[0].name).toBe("user-auth"); + expect(changes[0].documents).toContain("design.md"); + expect(changes[0].documents).toContain("task.md"); + expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 }); + }); + + it("无 task.md 时 taskProgress 为 null", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "feature-a"), { recursive: true }); + await writeFile(join(changesDir, "feature-a", "design.md"), "# 设计"); + + const changes = await scanChanges(TMP_DIR); + expect(changes).toHaveLength(1); + expect(changes[0].taskProgress).toBeNull(); + }); + + it("扫描多个并行变更", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "auth"), { recursive: true }); + await mkdir(join(changesDir, "payment"), { recursive: true }); + await writeFile(join(changesDir, "auth", "task.md"), `- [ ] 任务`); + await writeFile(join(changesDir, "payment", "task.md"), `- [x] 任务`); + + const changes = await scanChanges(TMP_DIR); + expect(changes).toHaveLength(2); + }); +}); + +describe("scanArchives", () => { + it("无 archive 目录时返回空数组", async () => { + await mkdir(join(TMP_DIR, ".rune"), { recursive: true }); + const archives = await scanArchives(TMP_DIR); + expect(archives).toEqual([]); + }); + + it("扫描归档目录", async () => { + const archiveDir = join(TMP_DIR, ".rune", "archive"); + await mkdir(join(archiveDir, "2026-06-08-user-auth"), { recursive: true }); + await mkdir(join(archiveDir, "2026-06-09-payment"), { recursive: true }); + + const archives = await scanArchives(TMP_DIR); + expect(archives).toHaveLength(2); + expect(archives).toContain("2026-06-08-user-auth"); + expect(archives).toContain("2026-06-09-payment"); + }); +});