diff --git a/docs/PLAN.md b/docs/PLAN.md index d56a5e2..5b6a9d4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -44,7 +44,7 @@ updated: 2026-07-12T10:00:00Z src/ ├── extension.ts # composition root (DI, register) ├── ports.ts # IFileSystem, ITaskRepository, IConfigProvider -├── error.ts # AppError + factories (единый каталог ошибок) +├── error.ts # AppErrorVariant + AppError { variant, message } ├── model/task.ts ├── storage/ │ ├── FsTaskRepository.ts @@ -132,10 +132,10 @@ use-cases + `ITaskRepository`. Ошибки — `AppError` из `error.ts`. ### neverthrow + `AppError` -- Use-cases возвращают `ResultAsync` вместо кастомных - `{ ok, reason }`. -- Каталог ошибок: `src/error.ts` (`AppErrorCode`, factories `AppError.noFolder()` - и т.д.). +- Use-cases возвращают `ResultAsync`. +- `AppErrorVariant` — const-object кодов (`NOT_FOUND: "not-found"`). +- `AppError` — `{ variant, message }`; `message` из шаблона с `{placeholders}` + через `appError(variant, params?)`. ### Прочее относительно v0 diff --git a/src/commands/changeStatus.test.ts b/src/commands/changeStatus.test.ts index 10f0a00..860a2e8 100644 --- a/src/commands/changeStatus.test.ts +++ b/src/commands/changeStatus.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { err } from "neverthrow"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; -import { AppError } from "@/error"; +import { appError, AppErrorVariant } from "@/error"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { changeStatus } from "./changeStatus"; @@ -38,13 +38,15 @@ describe("changeStatus", () => { expect(stored?.status).toBe("in-progress"); }); - it("returns not_found for unknown id", async () => { + it("returns not-found for unknown id", async () => { const result = await changeStatus(repo, { folderPath: folder, id: "missing", status: "done", }); - expect(result).toEqual(err(AppError.notFound())); + expect(result).toEqual( + err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })), + ); }); it("rejects missing folder", async () => { @@ -53,7 +55,7 @@ describe("changeStatus", () => { id: "x", status: "done", }); - expect(result).toEqual(err(AppError.noFolder())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER))); }); it("rejects missing id", async () => { @@ -62,7 +64,7 @@ describe("changeStatus", () => { id: undefined, status: "done", }); - expect(result).toEqual(err(AppError.noId())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_ID))); }); it("rejects missing status", async () => { @@ -71,6 +73,6 @@ describe("changeStatus", () => { id: "x", status: undefined, }); - expect(result).toEqual(err(AppError.noStatus())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_STATUS))); }); }); diff --git a/src/commands/changeStatus.ts b/src/commands/changeStatus.ts index 0eaf651..1d9febf 100644 --- a/src/commands/changeStatus.ts +++ b/src/commands/changeStatus.ts @@ -1,5 +1,5 @@ import { errAsync, okAsync, ResultAsync } from "neverthrow"; -import { AppError } from "@/error"; +import { appError, AppError, AppErrorVariant } from "@/error"; import { Task, TaskStatus } from "@/model/task"; import { ITaskRepository } from "@/ports"; @@ -14,13 +14,13 @@ export function changeStatus( input: ChangeStatusInput, ): ResultAsync { if (input.folderPath === undefined) { - return errAsync(AppError.noFolder()); + return errAsync(appError(AppErrorVariant.NO_FOLDER)); } if (input.id === undefined) { - return errAsync(AppError.noId()); + return errAsync(appError(AppErrorVariant.NO_ID)); } if (input.status === undefined) { - return errAsync(AppError.noStatus()); + return errAsync(appError(AppErrorVariant.NO_STATUS)); } const folderPath = input.folderPath; @@ -30,7 +30,7 @@ export function changeStatus( return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen( (existing) => { if (!existing) { - return errAsync(AppError.notFound()); + return errAsync(appError(AppErrorVariant.NOT_FOUND, { id })); } const next: Task = { ...existing, status }; @@ -38,7 +38,9 @@ export function changeStatus( () => ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen( (updated) => - updated ? okAsync(updated) : errAsync(AppError.notFound()), + updated + ? okAsync(updated) + : errAsync(appError(AppErrorVariant.NOT_FOUND, { id })), ), ); }, diff --git a/src/commands/createTask.test.ts b/src/commands/createTask.test.ts index 3efa569..e0e2fac 100644 --- a/src/commands/createTask.test.ts +++ b/src/commands/createTask.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { err } from "neverthrow"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; -import { AppError } from "@/error"; +import { appError, AppErrorVariant } from "@/error"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { createTask } from "./createTask"; @@ -39,7 +39,7 @@ describe("createTask", () => { folderPath: folder, title: " ", }); - expect(result).toEqual(err(AppError.emptyTitle())); + expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE))); expect(await repo.list(folder)).toEqual([]); }); @@ -48,7 +48,7 @@ describe("createTask", () => { folderPath: folder, title: undefined, }); - expect(result).toEqual(err(AppError.emptyTitle())); + expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE))); }); it("rejects missing folder", async () => { @@ -56,6 +56,6 @@ describe("createTask", () => { folderPath: undefined, title: "Task", }); - expect(result).toEqual(err(AppError.noFolder())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER))); }); }); diff --git a/src/commands/createTask.ts b/src/commands/createTask.ts index 11672bd..1399902 100644 --- a/src/commands/createTask.ts +++ b/src/commands/createTask.ts @@ -1,5 +1,5 @@ import { errAsync, ResultAsync } from "neverthrow"; -import { AppError } from "@/error"; +import { appError, AppError, AppErrorVariant } from "@/error"; import { Task } from "@/model/task"; import { ITaskRepository } from "@/ports"; @@ -13,12 +13,12 @@ export function createTask( input: CreateTaskInput, ): ResultAsync { if (input.folderPath === undefined) { - return errAsync(AppError.noFolder()); + return errAsync(appError(AppErrorVariant.NO_FOLDER)); } const title = input.title?.trim() ?? ""; if (!title) { - return errAsync(AppError.emptyTitle()); + return errAsync(appError(AppErrorVariant.EMPTY_TITLE)); } return ResultAsync.fromSafePromise( diff --git a/src/commands/deleteTask.test.ts b/src/commands/deleteTask.test.ts index bdd3c0f..9bf1116 100644 --- a/src/commands/deleteTask.test.ts +++ b/src/commands/deleteTask.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { err, ok } from "neverthrow"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; -import { AppError } from "@/error"; +import { appError, AppErrorVariant } from "@/error"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { deleteTask } from "./deleteTask"; @@ -32,12 +32,14 @@ describe("deleteTask", () => { expect(await repo.list(folder)).toEqual([]); }); - it("returns not_found for unknown id", async () => { + it("returns not-found for unknown id", async () => { const result = await deleteTask(repo, { folderPath: folder, id: "missing", }); - expect(result).toEqual(err(AppError.notFound())); + expect(result).toEqual( + err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })), + ); }); it("rejects missing folder", async () => { @@ -45,7 +47,7 @@ describe("deleteTask", () => { folderPath: undefined, id: "x", }); - expect(result).toEqual(err(AppError.noFolder())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER))); }); it("rejects missing id", async () => { @@ -53,6 +55,6 @@ describe("deleteTask", () => { folderPath: folder, id: undefined, }); - expect(result).toEqual(err(AppError.noId())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_ID))); }); }); diff --git a/src/commands/deleteTask.ts b/src/commands/deleteTask.ts index 3d27466..70f92e9 100644 --- a/src/commands/deleteTask.ts +++ b/src/commands/deleteTask.ts @@ -1,5 +1,5 @@ import { errAsync, ResultAsync } from "neverthrow"; -import { AppError } from "@/error"; +import { appError, AppError, AppErrorVariant } from "@/error"; import { ITaskRepository } from "@/ports"; export type DeleteTaskInput = { @@ -12,10 +12,10 @@ export function deleteTask( input: DeleteTaskInput, ): ResultAsync { if (input.folderPath === undefined) { - return errAsync(AppError.noFolder()); + return errAsync(appError(AppErrorVariant.NO_FOLDER)); } if (input.id === undefined) { - return errAsync(AppError.noId()); + return errAsync(appError(AppErrorVariant.NO_ID)); } const folderPath = input.folderPath; @@ -24,7 +24,7 @@ export function deleteTask( return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen( (existing) => { if (!existing) { - return errAsync(AppError.notFound()); + return errAsync(appError(AppErrorVariant.NOT_FOUND, { id })); } return ResultAsync.fromSafePromise(repo.delete(folderPath, id)); }, diff --git a/src/commands/openTask.test.ts b/src/commands/openTask.test.ts index 5c3d8f1..22ce741 100644 --- a/src/commands/openTask.test.ts +++ b/src/commands/openTask.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { err } from "neverthrow"; import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem"; -import { AppError } from "@/error"; +import { appError, AppErrorVariant } from "@/error"; import { FsTaskRepository } from "@/storage/FsTaskRepository"; import { openTask } from "./openTask"; @@ -34,12 +34,14 @@ describe("openTask", () => { expect(result.value.task).toEqual(created); }); - it("returns not_found for unknown id", async () => { + it("returns not-found for unknown id", async () => { const result = await openTask(repo, { folderPath: folder, id: "missing", }); - expect(result).toEqual(err(AppError.notFound())); + expect(result).toEqual( + err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })), + ); }); it("rejects missing folder", async () => { @@ -47,7 +49,7 @@ describe("openTask", () => { folderPath: undefined, id: "x", }); - expect(result).toEqual(err(AppError.noFolder())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER))); }); it("rejects missing id", async () => { @@ -55,6 +57,6 @@ describe("openTask", () => { folderPath: folder, id: undefined, }); - expect(result).toEqual(err(AppError.noId())); + expect(result).toEqual(err(appError(AppErrorVariant.NO_ID))); }); }); diff --git a/src/commands/openTask.ts b/src/commands/openTask.ts index 253e6f9..f792c74 100644 --- a/src/commands/openTask.ts +++ b/src/commands/openTask.ts @@ -1,5 +1,5 @@ import { errAsync, okAsync, ResultAsync } from "neverthrow"; -import { AppError } from "@/error"; +import { appError, AppError, AppErrorVariant } from "@/error"; import { Task } from "@/model/task"; import { ITaskRepository } from "@/ports"; @@ -18,10 +18,10 @@ export function openTask( input: OpenTaskInput, ): ResultAsync { if (input.folderPath === undefined) { - return errAsync(AppError.noFolder()); + return errAsync(appError(AppErrorVariant.NO_FOLDER)); } if (input.id === undefined) { - return errAsync(AppError.noId()); + return errAsync(appError(AppErrorVariant.NO_ID)); } const folderPath = input.folderPath; @@ -30,7 +30,7 @@ export function openTask( return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen( (task) => { if (!task) { - return errAsync(AppError.notFound()); + return errAsync(appError(AppErrorVariant.NOT_FOUND, { id })); } return okAsync({ path: repo.getFilePath(folderPath, task), diff --git a/src/error.test.ts b/src/error.test.ts new file mode 100644 index 0000000..f22d2ed --- /dev/null +++ b/src/error.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { appError, AppErrorVariant, formatMessage } from "./error"; + +describe("formatMessage", () => { + it("substitutes placeholders", () => { + expect(formatMessage("Task not found: {id}", { id: "abc" })).toBe( + "Task not found: abc", + ); + }); + + it("drops empty placeholders and cleans spacing", () => { + expect(formatMessage("Task not found: {id}", {})).toBe("Task not found:"); + expect(formatMessage("Invalid message{detail}", {})).toBe( + "Invalid message", + ); + expect( + formatMessage("Invalid message{detail}", { detail: ": explode" }), + ).toBe("Invalid message: explode"); + }); +}); + +describe("appError", () => { + it("builds variant + message", () => { + expect(appError(AppErrorVariant.NOT_FOUND, { id: "x" })).toEqual({ + variant: AppErrorVariant.NOT_FOUND, + message: "Task not found: x", + }); + }); + + it("works without params", () => { + expect(appError(AppErrorVariant.NO_FOLDER)).toEqual({ + variant: AppErrorVariant.NO_FOLDER, + message: "Task folder is not set", + }); + }); +}); diff --git a/src/error.ts b/src/error.ts index 5f6f8a8..f7547d1 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,20 +1,57 @@ -/** All application-level error codes. */ -export type AppErrorCode = - | "no_folder" - | "no_id" - | "no_status" - | "empty_title" - | "not_found"; +export const AppErrorVariant = { + NO_FOLDER: "no-folder", + NO_ID: "no-id", + NO_STATUS: "no-status", + NO_DESCRIPTION: "no-description", + EMPTY_TITLE: "empty-title", + NOT_FOUND: "not-found", + INVALID_MESSAGE: "invalid-message", +} as const; + +export type AppErrorVariant = + (typeof AppErrorVariant)[keyof typeof AppErrorVariant]; -/** Discriminated app error returned via neverthrow `Result` / `ResultAsync`. */ export type AppError = { - readonly code: AppErrorCode; + readonly variant: AppErrorVariant; + readonly message: string; }; -export const AppError = { - noFolder: (): AppError => ({ code: "no_folder" }), - noId: (): AppError => ({ code: "no_id" }), - noStatus: (): AppError => ({ code: "no_status" }), - emptyTitle: (): AppError => ({ code: "empty_title" }), - notFound: (): AppError => ({ code: "not_found" }), -} as const; +export type AppErrorParams = Record; + +/** Message templates. Placeholders: `{name}` — replaced via `appError(variant, params)`. */ +const APP_ERROR_MESSAGES: Record = { + [AppErrorVariant.NO_FOLDER]: "Task folder is not set", + [AppErrorVariant.NO_ID]: "Task id is required", + [AppErrorVariant.NO_STATUS]: "Task status is required", + [AppErrorVariant.NO_DESCRIPTION]: "Task description is required", + [AppErrorVariant.EMPTY_TITLE]: "Task title is empty", + [AppErrorVariant.NOT_FOUND]: "Task not found: {id}", + [AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}", +}; + +export function formatMessage( + template: string, + params: AppErrorParams = {}, +): string { + return template + .replace(/\{(\w+)\}/g, (_, key: string) => { + const value = params[key]; + if (value === undefined || value === "") { + return ""; + } + return String(value); + }) + .replace(/[ \t]+/g, " ") + .replace(/\s+([:,;.])/g, "$1") + .trim(); +} + +export function appError( + variant: AppErrorVariant, + params?: AppErrorParams, +): AppError { + return { + variant, + message: formatMessage(APP_ERROR_MESSAGES[variant], params), + }; +}