refactor: separate config service

This commit is contained in:
2026-07-18 13:29:38 +05:00
parent 1fbf020c80
commit 70968c450e
8 changed files with 193 additions and 32 deletions
@@ -0,0 +1,117 @@
import {
EMPTY_PROJECT_CONFIG,
getProjectConfigPath,
} from "@/model/projectConfig";
import { TaskStatus } from "@/model/taskStatus";
import { IConfigProvider } from "@/ports";
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { ProjectConfigService } from "./ProjectConfigService";
const FOLDER = "/ws/.xflow";
function configProvider(partial: {
project?: string;
global?: string;
}): IConfigProvider {
return {
getProjectTaskPath: () => partial.project,
getGlobalTaskPath: () => partial.global,
};
}
describe("ProjectConfigService", () => {
let fs: InMemoryFileSystem;
beforeEach(() => {
fs = new InMemoryFileSystem();
});
describe("load", () => {
it("returns empty config when folder path is unset", async () => {
const svc = new ProjectConfigService(fs, configProvider({}));
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
});
it("returns empty config when config.yml missing", async () => {
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
});
it("returns parsed config when config.yml exists with tags", async () => {
await fs.writeFile(
getProjectConfigPath(FOLDER),
"tags:\n - bug\n - docs\n",
);
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
const config = await svc.load();
expect(config.tags).toEqual(["bug", "docs"]);
expect(config.shownHiddenColumns).toEqual([]);
});
it("returns config with shownHiddenColumns", async () => {
await fs.writeFile(
getProjectConfigPath(FOLDER),
"tags: []\nshownHiddenColumns:\n - backlog\n",
);
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
const config = await svc.load();
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
});
it("returns empty config when config.yml is invalid", async () => {
await fs.writeFile(getProjectConfigPath(FOLDER), "tags: [\n");
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
await expect(svc.load()).resolves.toEqual(EMPTY_PROJECT_CONFIG);
});
});
describe("merge", () => {
it("creates config.yml with merged fields", async () => {
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] });
const config = await svc.load();
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
});
it("preserves existing tags when merging shownHiddenColumns", async () => {
await fs.writeFile(getProjectConfigPath(FOLDER), "tags:\n - bug\n");
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
await svc.merge({ shownHiddenColumns: [TaskStatus.CANCELLED] });
const config = await svc.load();
expect(config.tags).toEqual(["bug"]);
expect(config.shownHiddenColumns).toEqual([TaskStatus.CANCELLED]);
});
it("load after merge returns merged config (idempotent)", async () => {
const svc = new ProjectConfigService(
fs,
configProvider({ project: FOLDER }),
);
await svc.merge({
shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.UNKNOWN],
});
await svc.merge({ shownHiddenColumns: [TaskStatus.BACKLOG] });
const config = await svc.load();
expect(config.shownHiddenColumns).toEqual([TaskStatus.BACKLOG]);
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import {
EMPTY_PROJECT_CONFIG,
getProjectConfigPath,
type ProjectConfig,
} from "@/model/projectConfig";
import { IConfigProvider, IFileSystem } from "@/ports";
import { parseProjectConfig } from "./parseProjectConfig";
import { serializeProjectConfig } from "./serializeProjectConfig";
export class ProjectConfigService {
#fs: IFileSystem;
#config: IConfigProvider;
constructor(fs: IFileSystem, config: IConfigProvider) {
this.#fs = fs;
this.#config = config;
}
async load(): Promise<ProjectConfig> {
const folderPath = this.#config.getProjectTaskPath();
if (!folderPath) return { ...EMPTY_PROJECT_CONFIG };
const path = getProjectConfigPath(folderPath);
try {
const content = await this.#fs.readFile(path);
const parsed = parseProjectConfig(content);
if (parsed.isErr()) return { ...EMPTY_PROJECT_CONFIG };
return parsed.value;
} catch {
return { ...EMPTY_PROJECT_CONFIG };
}
}
async merge(partial: Partial<ProjectConfig>): Promise<void> {
const folderPath = this.#config.getProjectTaskPath();
if (!folderPath) return;
const current = await this.load();
const merged: ProjectConfig = { ...current, ...partial };
const content = serializeProjectConfig(merged);
await this.#fs.writeFile(getProjectConfigPath(folderPath), content);
}
}
+5 -5
View File
@@ -1,16 +1,16 @@
import * as yaml from "js-yaml";
import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import {
EMPTY_PROJECT_CONFIG,
normalizeTag,
type ProjectConfig,
} from "@/model/projectConfig";
import { isTaskStatus } from "@/model/taskStatus";
import { isTaskStatus, TaskStatus } from "@/model/taskStatus";
import * as yaml from "js-yaml";
import { err, ok, Result } from "neverthrow";
function parseShownHiddenColumns(
value: unknown,
): Result<string[], AppError> {
): Result<TaskStatus[], AppError> {
if (value === undefined) return ok([]);
if (!Array.isArray(value)) {
return err(
@@ -28,7 +28,7 @@ function parseShownHiddenColumns(
);
}
}
return ok(value as string[]);
return ok(value as TaskStatus[]);
}
export function parseProjectConfig(
@@ -1,3 +1,4 @@
import { TaskStatus } from "@/model/taskStatus";
import { describe, expect, it } from "vitest";
import { parseProjectConfig } from "./parseProjectConfig";
import { serializeProjectConfig } from "./serializeProjectConfig";
@@ -27,13 +28,16 @@ describe("serializeProjectConfig", () => {
it("round-trips tags with shownHiddenColumns", () => {
const yaml = serializeProjectConfig({
tags: ["bug"],
shownHiddenColumns: ["backlog", "cancelled"],
shownHiddenColumns: [TaskStatus.BACKLOG, TaskStatus.CANCELLED],
});
const parsed = parseProjectConfig(yaml);
expect(parsed.isOk()).toBe(true);
if (parsed.isErr()) return;
expect(parsed.value.tags).toEqual(["bug"]);
expect(parsed.value.shownHiddenColumns).toEqual(["backlog", "cancelled"]);
expect(parsed.value.shownHiddenColumns).toEqual([
TaskStatus.BACKLOG,
TaskStatus.CANCELLED,
]);
});
it("serializes empty shownHiddenColumns", () => {