From e7aae1484722c0d540478bc269305e55f83bdb05 Mon Sep 17 00:00:00 2001 From: ZeRiix Date: Mon, 13 Apr 2026 13:01:38 +0200 Subject: [PATCH 1/4] feat(06): error management dataParser file --- .../writeJsDoc/assets/index-template.md | 4 +- .../node/__snapshots__/index.test.ts.snap | 4 +- package-lock.json | 8 +- package.json | 2 +- scripts/command/help.ts | 173 ++++++++++++------ scripts/command/index.ts | 1 - scripts/command/printer.ts | 77 -------- scripts/dataParser/parsers/file.ts | 73 ++++++-- tests/command/help.test.ts | 59 +++--- tests/command/printer.test.ts | 67 ------- tsconfig.app.json | 28 +-- tsconfig.build.json | 1 + 12 files changed, 232 insertions(+), 265 deletions(-) delete mode 100644 scripts/command/printer.ts delete mode 100644 tests/command/printer.test.ts diff --git a/.codex/skills/writeJsDoc/assets/index-template.md b/.codex/skills/writeJsDoc/assets/index-template.md index 3b45eb0..a02838f 100644 --- a/.codex/skills/writeJsDoc/assets/index-template.md +++ b/.codex/skills/writeJsDoc/assets/index-template.md @@ -8,7 +8,7 @@ Describe the behavior in one short paragraph. @remarks Optional extra detail. -@see https://utils.duplojs.dev/en/v1/api/namespace/function -@see [`Alias.or.Other`](https://server-utils.duplojs.dev/en/v1/api/namespace/other) +@see https://server-utils.duplojs.dev/en/v0/api/namespace/function +@see [`Alias.or.Other`](https://server-utils.duplojs.dev/en/v0/api/namespace/other) @namespace X diff --git a/integration/node/__snapshots__/index.test.ts.snap b/integration/node/__snapshots__/index.test.ts.snap index 5230f23..f4325b0 100644 --- a/integration/node/__snapshots__/index.test.ts.snap +++ b/integration/node/__snapshots__/index.test.ts.snap @@ -10,7 +10,7 @@ exports[`node integration > command integration with nested help and execute > h Seed a target OPTIONS: - force: -f, --force - force execution + force execution SUBJECT:[string]" `; @@ -25,7 +25,7 @@ exports[`node integration > command integration with nested help and execute > h Seed a target OPTIONS: - force: -f, --force - force execution + force execution SUBJECT:[string]" `; diff --git a/package-lock.json b/package-lock.json index 51ca8df..082f068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "node": ">=22.15.1" }, "peerDependencies": { - "@duplojs/utils": ">=1.5.13 <2.0.0" + "@duplojs/utils": ">=1.6.3 <2.0.0" } }, "docs": { @@ -1086,9 +1086,9 @@ "link": true }, "node_modules/@duplojs/utils": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.5.13.tgz", - "integrity": "sha512-N4GbxGwQVhMbTu5UsMkgOrCrhRc7biWH8/HSX6w3SDS5NkVz2Rl235d7GZyaJ7NmJfFFm28LW88WM+Ub4a74hA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.6.3.tgz", + "integrity": "sha512-83qJLc1D+l9cXVRjPSdq7H+/yTrwAidWYKBU+DoBX3iYppDodpk54D1M2dqoFQ16MKYco8CR0f5GxGWN2r5KWQ==", "license": "MIT", "peer": true, "workspaces": [ diff --git a/package.json b/package.json index 0d4f67c..33a02cb 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@duplojs/dev-tools": "0.2.0" }, "peerDependencies": { - "@duplojs/utils": ">=1.5.13 <2.0.0" + "@duplojs/utils": ">=1.6.3 <2.0.0" }, "workspaces": [ "integration", diff --git a/scripts/command/help.ts b/scripts/command/help.ts index 62fdaa5..484c7df 100644 --- a/scripts/command/help.ts +++ b/scripts/command/help.ts @@ -1,8 +1,31 @@ -import { A, DP, hasSomeKinds, isType, justReturn, P, pipe } from "@duplojs/utils"; +import { A, DP, hasSomeKinds, isType, justReturn, P, pipe, Printer } from "@duplojs/utils"; import type { Command } from "./create"; -import { Printer } from "./printer"; +import type { Option } from "./options"; -function formatSubject(subject: DP.DataParser): string { +/** + * @internal + */ +export const render = Printer.render(""); + +/** + * @internal + */ +export function colorizedOption(option: Option, color: Printer.Colors) { + return Printer.colorized( + pipe( + option.aliases, + A.map((alias) => `-${alias},`), + A.push(`--${option.name}`), + A.join(" "), + ), + color, + ); +} + +/** + * @internal + */ +export function formatSubject(subject: DP.DataParser): string { return P.match(subject) .when( DP.identifier(DP.stringKind), @@ -78,76 +101,108 @@ function formatSubject(subject: DP.DataParser): string { .otherwise(justReturn("unknown")); } -export function logHelp( +/** + * @internal + */ +export function renderCommandHelp( command: Command, - depth = 0, -) { - Printer.render( - [ - Printer.indent(depth), - Printer.colorized("NAME:", "GREEN"), - command.name, - ], - ); - if (command.description) { - Printer.render( + depth: number, +): string[] { + const logs: string[] = []; + + logs.push( + render( [ - Printer.indent(depth + 1), - Printer.colorized("DESCRIPTION:", "CYAN"), - Printer.back, - Printer.indent(depth + 1), - command.description, + Printer.indent(depth), + Printer.colorizedBold("NAME:", "green"), + command.name, ], + ), + ); + + if (command.description) { + logs.push( + Printer.renderParagraph( + [ + render( + [ + Printer.indent(depth + 1), + Printer.colorizedBold("DESCRIPTION:", "cyan"), + ], + ), + render( + [ + Printer.indent(depth + 1), + command.description, + ], + ), + ], + ), ); } - if (A.minElements(command.options, 1)) { - const optionLines: string[] = []; - - command.options.forEach((option, index) => { - optionLines.push( - Printer.indent(depth + 1), - Printer.dash, - Printer.colorized(` ${option.name}: `, "cyan"), - Printer.colorizedOption(option, "gray"), - ); - - if (option.description) { - optionLines.push( - Printer.back, - Printer.indent(depth + 1), - ` ${option.description}`, - ); - } - - if (index < command.options.length - 1) { - optionLines.push(Printer.back); - } - }); - Printer.render( - [ - Printer.indent(depth + 1), - Printer.colorized("OPTIONS:", "BLUE"), - Printer.back, - ...optionLines, - ], + if (A.minElements(command.options, 1)) { + logs.push( + Printer.renderParagraph( + [ + render( + [ + Printer.indent(depth + 1), + Printer.colorizedBold("OPTIONS:", "blue"), + ], + ), + command.options.map( + (option) => Printer.renderParagraph( + [ + render( + [ + Printer.indent(depth + 1), + Printer.dash, + Printer.colorized(` ${option.name}: `, "cyan"), + colorizedOption(option, "gray"), + ], + ), + option.description && render( + [ + Printer.indent(depth + 1), + ` ${option.description}`, + ], + ), + ], + ), + ), + ], + ), ); } + if (isType(command.subject, "array")) { for (const childCommand of command.subject) { - logHelp(childCommand, depth + 1); + logs.push(...renderCommandHelp(childCommand, depth + 1)); } } else if (DP.identifier(command.subject, DP.dataParserKind)) { const formattedSubject = formatSubject(command.subject); - Printer.render( - [ - Printer.indent(depth + 1), - Printer.colorized("SUBJECT:", "MAGENTA"), - hasSomeKinds(command.subject, [DP.tupleKind, DP.arrayKind]) - ? formattedSubject - : `<${formattedSubject}>`, - ], + logs.push( + render( + [ + Printer.indent(depth + 1), + Printer.colorizedBold("SUBJECT:", "magenta"), + hasSomeKinds(command.subject, [DP.tupleKind, DP.arrayKind]) + ? formattedSubject + : `<${formattedSubject}>`, + ], + ), ); } + + return logs; +} + +export function logHelp( + command: Command, + depth = 0, +) { + // eslint-disable-next-line no-console + console.log(Printer.renderParagraph(renderCommandHelp(command, depth))); } diff --git a/scripts/command/index.ts b/scripts/command/index.ts index 84212a2..83f57f6 100644 --- a/scripts/command/index.ts +++ b/scripts/command/index.ts @@ -7,5 +7,4 @@ export * from "./options"; export * from "./errors"; export * from "./create"; export * from "./exec"; -export * from "./printer"; export * from "./help"; diff --git a/scripts/command/printer.ts b/scripts/command/printer.ts deleted file mode 100644 index 234cbcc..0000000 --- a/scripts/command/printer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { A, createEnum, type GetEnumValue, pipe, S } from "@duplojs/utils"; -import type { Option } from "./options"; - -export namespace Printer { - const codeColors = { - reset: "\x1b[0m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", - gray: "\x1b[90m", - bold: "\x1b[1m", - } as const; - - export const colorsEnum = createEnum([ - "red", - "RED", - "green", - "GREEN", - "yellow", - "YELLOW", - "blue", - "BLUE", - "magenta", - "MAGENTA", - "cyan", - "CYAN", - "gray", - "GRAY", - ]); - - export type ColorEnum = GetEnumValue; - - export const tab = "\t" as const; - export const back = "\n" as const; - export const dash = "-" as const; - - const regexCapitalize = /[A-Z]/; - export function colorized(input: string, color: ColorEnum) { - const text = `${codeColors[S.toLowerCase(color)]}${input}${codeColors.reset}`; - - if ( - S.test(color, regexCapitalize) - ) { - return `${codeColors.bold}${text}${codeColors.reset}`; - } - - return text; - } - - export function indent(level: number) { - return S.repeat(tab, level); - } - - export function parenthesize(input: string) { - return `(${input})`; - } - - export function colorizedOption(option: Option, color: ColorEnum) { - return colorized( - pipe( - option.aliases, - A.map((alias) => `-${alias},`), - A.push(`--${option.name}`), - A.join(" "), - ), - color, - ); - } - - export function render(values: string[]) { - // eslint-disable-next-line no-console - console.log(...values); - } -} diff --git a/scripts/dataParser/parsers/file.ts b/scripts/dataParser/parsers/file.ts index 2fb483e..0a851f9 100644 --- a/scripts/dataParser/parsers/file.ts +++ b/scripts/dataParser/parsers/file.ts @@ -110,7 +110,12 @@ export function file< || self.definition.maxSize !== undefined || self.definition.minSize !== undefined ) { - return DP.SymbolDataParserErrorPromiseIssue; + return DP.addIssue( + error, + "async data parser", + data, + self.definition.errorMessage, + ); } let fileInterface = data; @@ -120,7 +125,12 @@ export function file< } if (!isFileInterface(fileInterface)) { - return DP.SymbolDataParserErrorIssue; + return DP.addIssue( + error, + "file", + fileInterface, + self.definition.errorMessage, + ); } if ( @@ -130,8 +140,12 @@ export function file< .mimeType .test(fileInterface.getMimeType() ?? "") ) { - DP.addIssue(error, self, data, "Wrong mimeType."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with mime type matching ${self.definition.mimeType.source}`, + fileInterface, + "Wrong mimeType.", + ); } return fileInterface; @@ -144,7 +158,12 @@ export function file< } if (!isFileInterface(fileInterface)) { - return DP.SymbolDataParserErrorIssue; + return DP.addIssue( + error, + "file", + fileInterface, + self.definition.errorMessage, + ); } if ( @@ -154,8 +173,12 @@ export function file< .mimeType .test(fileInterface.getMimeType() ?? "") ) { - DP.addIssue(error, self, data, "Wrong mimeType."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with mime type matching ${self.definition.mimeType.source}`, + fileInterface, + "Wrong mimeType.", + ); } if ( @@ -166,31 +189,47 @@ export function file< const resultStats = await fileInterface.stat(); if (E.isLeft(resultStats)) { - DP.addIssue(error, self, data, "File not exist."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + "existing file", + fileInterface, + "File not exist.", + ); } const stat = unwrap(resultStats); if (!stat.isFile) { - DP.addIssue(error, self, data, "Is not file."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + "file", + stat, + "Is not file.", + ); } if ( self.definition.maxSize !== undefined - && stat.sizeBytes > self.definition.maxSize + && stat.sizeBytes > self.definition.maxSize ) { - DP.addIssue(error, self, data, "File is to large."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with sizeBytes <= ${self.definition.maxSize}`, + stat.sizeBytes, + "File is to large.", + ); } if ( self.definition.minSize !== undefined - && stat.sizeBytes < self.definition.minSize + && stat.sizeBytes < self.definition.minSize ) { - DP.addIssue(error, self, data, "File is to small."); - return DP.SymbolDataParserError; + return DP.addIssue( + error, + `file with sizeBytes >= ${self.definition.minSize}`, + stat.sizeBytes, + "File is to small.", + ); } } diff --git a/tests/command/help.test.ts b/tests/command/help.test.ts index bec8f94..7ac2799 100644 --- a/tests/command/help.test.ts +++ b/tests/command/help.test.ts @@ -1,5 +1,6 @@ -import { DP } from "@duplojs/utils"; +import { DP, Printer } from "@duplojs/utils"; import { DServerCommand } from "@scripts"; +import { render } from "@scripts/command/help"; describe("logHelp", () => { afterEach(() => { @@ -8,7 +9,7 @@ describe("logHelp", () => { }); it("renders name, description, and options blocks", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const command = DServerCommand.create( "root", { @@ -34,19 +35,26 @@ describe("logHelp", () => { DServerCommand.logHelp(command, 1); - expect(renderSpy).toHaveBeenCalledTimes(3); - expect(renderSpy.mock.calls[0]![0]).toEqual([ - DServerCommand.Printer.indent(1), - DServerCommand.Printer.colorized("NAME:", "GREEN"), - "root", - ]); - expect(renderSpy.mock.calls[1]![0]).toContain("Root command description"); - expect(renderSpy.mock.calls[2]![0]).toContain(DServerCommand.Printer.colorized("OPTIONS:", "BLUE")); - expect(renderSpy.mock.calls[2]![0].join("")).toContain("Enable verbose mode"); + expect(logSpy).toHaveBeenCalledTimes(1); + + const output = logSpy.mock.calls[0]![0] as string; + + expect(output).toContain( + render( + [ + `${Printer.indent(1)}${Printer.colorizedBold("NAME:", "green")}`, + "root", + ], + ), + ); + expect(output).toContain("Root command description"); + expect(output).toContain(Printer.colorizedBold("OPTIONS:", "blue")); + expect(output).toContain("--silent"); + expect(output).toContain("Enable verbose mode"); }); it("recursively renders child commands when subject is a command list", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const child = DServerCommand.create( "child", @@ -62,14 +70,15 @@ describe("logHelp", () => { DServerCommand.logHelp(root); - const renderedNames = renderSpy.mock.calls.map((call) => call[0]).flat(); + expect(logSpy).toHaveBeenCalledTimes(1); + + const output = logSpy.mock.calls[0]![0] as string; - expect(renderedNames).toContain("root"); - expect(renderedNames).toContain("child"); + expect(output).toContain("root"); + expect(output).toContain("child"); }); it("formats supported subject data parser kinds", () => { - const renderSpy = vi.spyOn(DServerCommand.Printer, "render").mockImplementation(() => undefined); const cases = [ { subject: DP.string(), @@ -130,7 +139,7 @@ describe("logHelp", () => { ]; for (const testCase of cases) { - renderSpy.mockClear(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const command = DServerCommand.create( "root", { @@ -141,12 +150,20 @@ describe("logHelp", () => { DServerCommand.logHelp(command); - const subjectCall = renderSpy.mock.calls.find( - (call) => call[0].includes(DServerCommand.Printer.colorized("SUBJECT:", "MAGENTA")), + expect(logSpy).toHaveBeenCalledTimes(1); + + const output = logSpy.mock.calls[0]![0] as string; + + expect(output).toContain( + render( + [ + `${Printer.indent(1)}${Printer.colorizedBold("SUBJECT:", "magenta")}`, + testCase.expected, + ], + ), ); - expect(subjectCall).toBeDefined(); - expect(subjectCall![0]).toContain(testCase.expected); + logSpy.mockRestore(); } }); }); diff --git a/tests/command/printer.test.ts b/tests/command/printer.test.ts deleted file mode 100644 index abce72b..0000000 --- a/tests/command/printer.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { DP, type ExpectType, pipe } from "@duplojs/utils"; -import { DServerCommand } from "@scripts"; - -describe("Printer", () => { - afterEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - }); - - it("colorized returns ANSI wrapped text with lowercase color", () => { - const result = DServerCommand.Printer.colorized("hello", "red"); - - type _CheckResult = ExpectType< - typeof result, - string, - "strict" - >; - - expect(result).toBe("\x1b[31mhello\x1b[0m"); - }); - - it("colorized returns bold ANSI wrapped text with uppercase color", () => { - const result = DServerCommand.Printer.colorized("hello", "RED"); - - expect(result).toBe("\x1b[1m\x1b[31mhello\x1b[0m\x1b[0m"); - }); - - it("indent repeats tab based on level", () => { - const result = DServerCommand.Printer.indent(3); - - expect(result).toBe("\t\t\t"); - }); - - it("parenthesize wraps text with parentheses", () => { - const result = DServerCommand.Printer.parenthesize("value"); - - expect(result).toBe("(value)"); - }); - - it("colorizedOption renders aliases and full option name", () => { - const option = DServerCommand.createOption("verbose", DP.string(), { aliases: ["v", "V"] }); - - const result = DServerCommand.Printer.colorizedOption(option, "green"); - - expect(result).toBe("\x1b[32m-v, -V, --verbose\x1b[0m"); - }); - - it("render prints all provided values", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - - DServerCommand.Printer.render(["one", "two"]); - - expect(logSpy).toHaveBeenCalledWith("one", "two"); - }); - - it("works when parenthesize is called from pipe", () => { - const result = pipe("pipe-value", DServerCommand.Printer.parenthesize); - - type _CheckResult = ExpectType< - typeof result, - string, - "strict" - >; - - expect(result).toBe("(pipe-value)"); - }); -}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 7a2567b..8eac854 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,7 @@ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ "moduleDetection": "force", /* Control what method is used to detect module-format JS files. */ /* Modules */ @@ -35,9 +35,9 @@ }, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["node", "deno", "bun"], /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node", "deno", "bun"], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ @@ -85,21 +85,21 @@ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ - "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ diff --git a/tsconfig.build.json b/tsconfig.build.json index a341b3f..b1d32f9 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,5 +6,6 @@ "declaration": true, "declarationDir": "dist", "types": null, + "stripInternal": true, }, } \ No newline at end of file From dc72d6ccf1ee6d90f159825dd9cd7256c1312373 Mon Sep 17 00:00:00 2001 From: ZeRiix Date: Tue, 14 Apr 2026 11:19:14 +0200 Subject: [PATCH 2/4] feat(06): add interpretError command + update help log --- integration/node/index.test.ts | 6 +- scripts/command/create.ts | 120 ++++++++++--- scripts/command/error.ts | 141 ++++++++++++++++ scripts/command/errors.ts | 51 ------ scripts/command/help.ts | 83 +++------ scripts/command/index.ts | 2 +- scripts/command/options/array.ts | 32 +++- scripts/command/options/base.ts | 98 ++++++++--- scripts/command/options/simple.ts | 32 +++- tests/command/create.test.ts | 234 +++++++++++++++++++++++++- tests/command/error.test.ts | 104 ++++++++++++ tests/command/help.test.ts | 123 +++++++------- tests/command/options/array.test.ts | 55 ++++-- tests/command/options/base.test.ts | 64 +++++-- tests/command/options/boolean.test.ts | 28 ++- tests/command/options/simple.test.ts | 74 ++++++-- 16 files changed, 958 insertions(+), 289 deletions(-) create mode 100644 scripts/command/error.ts delete mode 100644 scripts/command/errors.ts create mode 100644 tests/command/error.test.ts diff --git a/integration/node/index.test.ts b/integration/node/index.test.ts index 495d271..4724e77 100644 --- a/integration/node/index.test.ts +++ b/integration/node/index.test.ts @@ -286,7 +286,7 @@ describe("node integration", () => { expect(logSpy).toHaveBeenCalled(); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help root"); @@ -294,7 +294,7 @@ describe("node integration", () => { await command.execute(["--help"]); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help command"); @@ -302,7 +302,7 @@ describe("node integration", () => { await command.execute(["help-db", "--help"]); expect( stripAnsiColor( - logSpy.mock.calls.map((call) => call.join("")).join("\n"), + logSpy.mock.calls.map(([message]) => String(message)).join("\n"), ), ).toMatchSnapshot("help sub-command"); diff --git a/scripts/command/create.ts b/scripts/command/create.ts index 23f66a1..d1dfbd0 100644 --- a/scripts/command/create.ts +++ b/scripts/command/create.ts @@ -1,10 +1,10 @@ -import { type SimplifyTopLevel, type Kind, type AnyFunction, type RemoveKind, A, DP, type MaybePromise, O } from "@duplojs/utils"; +import { type SimplifyTopLevel, type Kind, type AnyFunction, type RemoveKind, A, DP, E, unwrap, type MaybePromise, O } from "@duplojs/utils"; import { createBooleanOption, type Option } from "./options"; import { createDuplojsServerUtilsKind } from "@scripts/kind"; import type { EligibleDataParser } from "./types"; -import { exitProcess } from "@scripts/common"; +import { exitProcess } from "@scripts/common/exitProcess"; +import { addIssue, addDataParserError, createError, interpretError, popErrorPath, setErrorPath, SymbolCommandError, type CommandError } from "./error"; import { logHelp } from "./help"; -import { CommandManyArgumentsError } from "./errors"; export type Subject = ( | EligibleDataParser @@ -32,6 +32,16 @@ export type Subject = ( > ); +function printError(commandError: CommandError, error?: CommandError): SymbolCommandError { + if (!error) { + // eslint-disable-next-line no-console + console.error(interpretError(commandError)); + exitProcess(1); + } + + return SymbolCommandError; +} + const commandKind = createDuplojsServerUtilsKind("command"); const helpOption = createBooleanOption("help", { aliases: ["h"] }); @@ -43,7 +53,7 @@ export interface Command extends Kind< readonly description: string | null; readonly subject: Subject | null | readonly Command[]; readonly options: readonly Option[]; - execute(args: readonly string[]): MaybePromise; + execute(args: readonly string[], error?: CommandError): Promise; } export interface CreateCommandParams< @@ -60,11 +70,14 @@ export interface CreateCommandExecuteParams< GenericSubject extends Subject, > { options: { - [GenericOptionName in GenericOptions[number]["name"]]: ReturnType< - Extract< - GenericOptions[number], - { name: GenericOptionName } - >["execute"] + [GenericOptionName in GenericOptions[number]["name"]]: Exclude< + ReturnType< + Extract< + GenericOptions[number], + { name: GenericOptionName } + >["execute"] + >, + SymbolCommandError >["result"] }; subject: DP.Output; @@ -105,24 +118,38 @@ export function create( description: params.description ?? null, options: params.options ?? [], subject: params.subject ?? null, - execute: async(args: readonly string[]) => { + execute: async(args, error?) => { + const commandError = error ?? createError(self.name); + const pathIndex = commandError.currentCommandPath.length; + if (self.subject instanceof Array) { for (const command of self.subject) { if (args[0] === command.name) { - await command.execute(A.shift(args)); + let result: Awaited> | undefined = undefined; + + setErrorPath(commandError, command.name, pathIndex); + try { + result = await command.execute(A.shift(args), commandError); + } finally { + popErrorPath(commandError); + } - return; + if (result === SymbolCommandError) { + return printError(commandError, error); + } + + return result; } } } - const help = helpOption.execute(args); + const help = helpOption.execute(args, commandError); - if (help.result) { + if (help === SymbolCommandError) { + return printError(commandError, error); + } else if (help.result) { logHelp(self); - exitProcess(0); - - return; + return void exitProcess(0); } const commandOptions = A.reduce( @@ -134,8 +161,12 @@ export function create( options: {}, restArgs: args, }), - ({ element: option, lastValue, next }) => { - const optionResult = option.execute(lastValue.restArgs); + ({ element: option, lastValue, next, exit }) => { + const optionResult = option.execute(lastValue.restArgs, commandError); + + if (optionResult === SymbolCommandError) { + return exit(SymbolCommandError); + } return next({ options: O.override( @@ -149,31 +180,72 @@ export function create( }, ); + if (commandOptions === SymbolCommandError) { + return printError(commandError, error); + } + if (self.subject === null) { await execute({ options: commandOptions.options }); } else if ( DP.identifier(self.subject, DP.arrayKind) || DP.identifier(self.subject, DP.tupleKind) ) { + const subjectResult = self.subject.parse(commandOptions.restArgs); + + if (E.isLeft(subjectResult)) { + addDataParserError( + commandError, + unwrap(subjectResult), + { + type: "subject", + }, + ); + + return printError(commandError, error); + } + await execute({ options: commandOptions.options, - subject: self.subject.parseOrThrow(commandOptions.restArgs), + subject: unwrap(subjectResult), }); } else if (DP.identifier(self.subject, DP.dataParserKind)) { if (commandOptions.restArgs.length > 1) { - throw new CommandManyArgumentsError(commandOptions.restArgs.length); + addIssue( + commandError, + { + type: "command", + expected: "exactly one subject argument", + received: commandOptions.restArgs, + message: `Expected exactly one subject argument, received ${commandOptions.restArgs.length}.`, + }, + ); + + return printError(commandError, error); + } + + const subjectResult = self.subject.parse(commandOptions.restArgs); + + if (E.isLeft(subjectResult)) { + addDataParserError( + commandError, + unwrap(subjectResult), + { + type: "subject", + }, + ); + + return printError(commandError, error); } await execute({ options: commandOptions.options, - subject: self.subject.parseOrThrow(commandOptions.restArgs), + subject: unwrap(subjectResult), }); } else { await execute({}); } - exitProcess(0); - return; + return void exitProcess(0); }, } satisfies RemoveKind, ); diff --git a/scripts/command/error.ts b/scripts/command/error.ts new file mode 100644 index 0000000..dd23d1d --- /dev/null +++ b/scripts/command/error.ts @@ -0,0 +1,141 @@ +import { type DP, Printer } from "@duplojs/utils"; + +export interface CommandErrorIssue { + readonly type: "command" | "option" | "subject"; + readonly commandPath: readonly string[]; + readonly target?: string; + readonly parserPath?: string; + readonly expected: string; + readonly received: unknown; + readonly message?: string; +} + +export interface CommandError { + readonly issues: CommandErrorIssue[]; + readonly currentCommandPath: string[]; +} + +export const SymbolCommandError = Symbol.for("SymbolCommandError"); +export type SymbolCommandError = typeof SymbolCommandError; + +export function createError( + commandName: string, +): CommandError { + return { + issues: [], + currentCommandPath: [commandName], + }; +} + +export function addIssue( + error: CommandError, + issue: Omit, +): typeof SymbolCommandError { + error.issues.push( + { + ...issue, + commandPath: [...error.currentCommandPath], + }, + ); + + return SymbolCommandError; +} + +export function setErrorPath( + error: CommandError, + value: string, + index: number, +): CommandError { + error.currentCommandPath[index] = value; + + return error; +} + +export function popErrorPath( + error: CommandError, +): CommandError { + error.currentCommandPath.pop(); + + return error; +} + +export function addDataParserError( + error: CommandError, + parseError: DP.DataParserError, + params: { + type: "option" | "subject"; + target?: string; + }, +): SymbolCommandError { + for (const issue of parseError.issues) { + error.issues.push( + { + type: params.type, + commandPath: [...error.currentCommandPath], + target: params.target, + parserPath: issue.path || undefined, + expected: issue.expected, + received: issue.data, + message: issue.message, + }, + ); + } + + return SymbolCommandError; +} + +export function interpretError( + error: CommandError, +): string { + return Printer.renderParagraph( + [ + Printer.render( + [ + Printer.colorizedBold("Command failed", "red"), + Printer.back, + Printer.indent(1), + Printer.colorizedBold("COMMAND: ", "cyan"), + error.issues[0]?.commandPath.join(" ") ?? error.currentCommandPath.join(" "), + ], + "", + ), + error.issues.map( + (issue) => Printer.renderParagraph( + [ + issue.type === "option" + && issue.target + && Printer.render( + [ + Printer.indent(1), + Printer.colorizedBold("OPTION: ", "magenta"), + `--${issue.target}`, + ], + "", + ), + issue.type === "subject" + && issue.parserPath + && Printer.render( + [ + Printer.indent(1), + Printer.colorizedBold("SUBJECT:", "magenta"), + ], + "", + ), + Printer.renderLine( + [ + Printer.colorizedBold("✖", "red"), + issue.parserPath && Printer.colorizedBold(issue.parserPath, "cyan"), + "expected", + Printer.colorized(issue.expected, "green"), + "but received", + Printer.colorized(Printer.stringify(issue.received), "red"), + ], + ), + issue.message !== undefined && `${Printer.indent(1)}↳ ${issue.message}`, + ], + ), + ), + error.issues.length === 0 && "No issue found", + ], + ); +} diff --git a/scripts/command/errors.ts b/scripts/command/errors.ts deleted file mode 100644 index adc77af..0000000 --- a/scripts/command/errors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { kindHeritage } from "@duplojs/utils"; -import { createDuplojsServerUtilsKind } from "@scripts/kind"; - -export class CommandManyArgumentsError extends kindHeritage( - "command-many-arguments-error", - createDuplojsServerUtilsKind("command-many-arguments-error"), - Error, -) { - public constructor( - public restArgumentsLength: number, - ) { - super({}, [`Expected exactly one subject argument, received ${restArgumentsLength}.`]); - } -} - -export class CommandOptionValueLooksLikeOptionError extends kindHeritage( - "command-option-value-looks-like-option-error", - createDuplojsServerUtilsKind("command-option-value-looks-like-option-error"), - Error, -) { - public constructor( - public optionName: string, - public value: string | undefined, - ) { - super({}, [`Missing value for option "${optionName}": received another option token instead of a value.`]); - } -} - -export class CommandOptionValueNotRequiredError extends kindHeritage( - "command-option-value-not-required-error", - createDuplojsServerUtilsKind("command-option-value-not-required-error"), - Error, -) { - public constructor( - public optionName: string, - ) { - super({}, [`Option "${optionName}" does not accept a value.`]); - } -} - -export class CommandOptionRequiredError extends kindHeritage( - "command-option-required-error", - createDuplojsServerUtilsKind("command-option-required-error"), - Error, -) { - public constructor( - public optionName: string, - ) { - super({}, [`Option "${optionName}" is required.`]); - } -} diff --git a/scripts/command/help.ts b/scripts/command/help.ts index 484c7df..b6c5004 100644 --- a/scripts/command/help.ts +++ b/scripts/command/help.ts @@ -1,26 +1,5 @@ import { A, DP, hasSomeKinds, isType, justReturn, P, pipe, Printer } from "@duplojs/utils"; import type { Command } from "./create"; -import type { Option } from "./options"; - -/** - * @internal - */ -export const render = Printer.render(""); - -/** - * @internal - */ -export function colorizedOption(option: Option, color: Printer.Colors) { - return Printer.colorized( - pipe( - option.aliases, - A.map((alias) => `-${alias},`), - A.push(`--${option.name}`), - A.join(" "), - ), - color, - ); -} /** * @internal @@ -111,31 +90,15 @@ export function renderCommandHelp( const logs: string[] = []; logs.push( - render( - [ - Printer.indent(depth), - Printer.colorizedBold("NAME:", "green"), - command.name, - ], - ), + `${Printer.indent(depth)}${Printer.colorizedBold("NAME:", "green")}${command.name}`, ); if (command.description) { logs.push( Printer.renderParagraph( [ - render( - [ - Printer.indent(depth + 1), - Printer.colorizedBold("DESCRIPTION:", "cyan"), - ], - ), - render( - [ - Printer.indent(depth + 1), - command.description, - ], - ), + `${Printer.indent(depth + 1)}${Printer.colorizedBold("DESCRIPTION:", "cyan")}`, + `${Printer.indent(depth + 1)}${command.description}`, ], ), ); @@ -145,29 +108,30 @@ export function renderCommandHelp( logs.push( Printer.renderParagraph( [ - render( - [ - Printer.indent(depth + 1), - Printer.colorizedBold("OPTIONS:", "blue"), - ], - ), - command.options.map( + `${Printer.indent(depth + 1)}${Printer.colorizedBold("OPTIONS:", "blue")}`, + A.map( + command.options, (option) => Printer.renderParagraph( [ - render( + A.join( [ Printer.indent(depth + 1), Printer.dash, Printer.colorized(` ${option.name}: `, "cyan"), - colorizedOption(option, "gray"), - ], - ), - option.description && render( - [ - Printer.indent(depth + 1), - ` ${option.description}`, + Printer.colorized( + pipe( + option.aliases, + A.map((alias) => `-${alias},`), + A.push(`--${option.name}`), + A.join(" "), + ), + "gray", + ), ], + "", ), + option.description + && `${Printer.indent(depth + 1)} ${option.description}`, ], ), ), @@ -184,7 +148,7 @@ export function renderCommandHelp( const formattedSubject = formatSubject(command.subject); logs.push( - render( + A.join( [ Printer.indent(depth + 1), Printer.colorizedBold("SUBJECT:", "magenta"), @@ -192,6 +156,7 @@ export function renderCommandHelp( ? formattedSubject : `<${formattedSubject}>`, ], + "", ), ); } @@ -204,5 +169,9 @@ export function logHelp( depth = 0, ) { // eslint-disable-next-line no-console - console.log(Printer.renderParagraph(renderCommandHelp(command, depth))); + console.log( + Printer.renderParagraph( + renderCommandHelp(command, depth), + ), + ); } diff --git a/scripts/command/index.ts b/scripts/command/index.ts index 83f57f6..536cc58 100644 --- a/scripts/command/index.ts +++ b/scripts/command/index.ts @@ -4,7 +4,7 @@ export * from "./types"; export * from "./options"; -export * from "./errors"; export * from "./create"; export * from "./exec"; export * from "./help"; +export * from "./error"; diff --git a/scripts/command/options/array.ts b/scripts/command/options/array.ts index b9a04c7..1a224ee 100644 --- a/scripts/command/options/array.ts +++ b/scripts/command/options/array.ts @@ -1,7 +1,7 @@ -import { type A, DP, pipe, S } from "@duplojs/utils"; +import { type A, DP, E, pipe, S, unwrap } from "@duplojs/utils"; import { initOption, type Option } from "./base"; import type { EligibleDataParser } from "../types"; -import { CommandOptionRequiredError } from "../errors"; +import { addIssue, addDataParserError } from "../error"; const defaultSeparator = ","; @@ -88,16 +88,38 @@ export function createArrayOption( return initOption( name, - ({ isHere, value }) => { + ({ isHere, value }, error) => { if (!isHere && params?.required) { - throw new CommandOptionRequiredError(name); + return addIssue( + error, + { + type: "option", + target: name, + expected: `required option --${name}`, + received: value, + message: `Option "${name}" is required.`, + }, + ); } const values = value !== undefined ? S.split(value, params?.separator ?? defaultSeparator) : undefined; - return dataParser.parseOrThrow(values); + const result = dataParser.parse(values); + + if (E.isLeft(result)) { + return addDataParserError( + error, + unwrap(result), + { + type: "option", + target: name, + }, + ); + } + + return unwrap(result); }, { description: params?.description, diff --git a/scripts/command/options/base.ts b/scripts/command/options/base.ts index fb953dc..56e1f5c 100644 --- a/scripts/command/options/base.ts +++ b/scripts/command/options/base.ts @@ -1,6 +1,6 @@ import { A, type RemoveKind, S, type Kind } from "@duplojs/utils"; import { createDuplojsServerUtilsKind } from "@scripts/kind"; -import { CommandOptionValueLooksLikeOptionError, CommandOptionValueNotRequiredError } from "../errors"; +import { addIssue, type CommandError, SymbolCommandError } from "../error"; const optionKind = createDuplojsServerUtilsKind("command-option"); const regexOption = /^(?-{1,2})(?[A-Za-z0-9][A-Za-z0-9_-]*)(?:=(?.*))?$/; @@ -13,10 +13,15 @@ export interface Option< readonly description: string | null; readonly aliases: readonly string[]; readonly hasValue: boolean; - execute(args: readonly string[]): { + execute( + args: readonly string[], + error: CommandError, + ): + | { result: GenericExecuteOutputValue; argumentRest: readonly string[]; - }; + } + | SymbolCommandError; } export interface InitOptionExecuteParams { @@ -31,7 +36,8 @@ export function initOption< name: GenericName, execute: ( params: InitOptionExecuteParams, - ) => GenericExecuteOutputValue, + error: CommandError, + ) => GenericExecuteOutputValue | SymbolCommandError, params?: { description?: string; aliases?: readonly string[]; @@ -40,7 +46,10 @@ export function initOption< ) { const self: Option = optionKind.setTo({ name, - execute: (args) => { + execute: ( + args: readonly string[], + error: CommandError, + ) => { const result = A.reduce( args, A.reduceFrom(null), @@ -66,13 +75,20 @@ export function initOption< ); if (!result) { + const executeResult = execute( + { + isHere: false, + value: undefined, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; + } + return { - result: execute( - { - isHere: false, - value: undefined, - }, - ), + result: executeResult, argumentRest: args, }; } else if (self.hasValue) { @@ -80,16 +96,32 @@ export function initOption< const isOption = S.test(value ?? "", regexOption); if (isOption) { - throw new CommandOptionValueLooksLikeOptionError(self.name, value); + return addIssue( + error, + { + type: "option", + target: self.name, + expected: `value for option --${self.name}`, + received: value, + message: `Missing value for option "${self.name}": received another option token instead of a value.`, + }, + ); + } + + const executeResult = execute( + { + isHere: true, + value, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; } return { - result: execute( - { - isHere: true, - value, - }, - ), + result: executeResult, argumentRest: A.spliceDelete( args, result.index, @@ -99,16 +131,32 @@ export function initOption< ), }; } else if (!self.hasValue && result.value !== undefined) { - throw new CommandOptionValueNotRequiredError(self.name); + return addIssue( + error, + { + type: "option", + target: self.name, + expected: `option without value --${self.name}`, + received: result.value, + message: `Option "${self.name}" does not accept a value.`, + }, + ); + } + + const executeResult = execute( + { + isHere: true, + value: undefined, + }, + error, + ); + + if (executeResult === SymbolCommandError) { + return SymbolCommandError; } return { - result: execute( - { - isHere: true, - value: undefined, - }, - ), + result: executeResult, argumentRest: A.spliceDelete(args, result.index, 1), }; }, diff --git a/scripts/command/options/simple.ts b/scripts/command/options/simple.ts index 9061ba4..4b86f9b 100644 --- a/scripts/command/options/simple.ts +++ b/scripts/command/options/simple.ts @@ -1,7 +1,7 @@ -import { DP } from "@duplojs/utils"; +import { DP, E, unwrap } from "@duplojs/utils"; import { initOption, type Option } from "./base"; import type { EligibleDataParser } from "../types"; -import { CommandOptionRequiredError } from "../errors"; +import { addIssue, addDataParserError } from "../error"; /** * {@include command/createOption/index.md} @@ -46,12 +46,34 @@ export function createOption( return initOption( name, - ({ isHere, value }) => { + ({ isHere, value }, error) => { if (!isHere && params?.required) { - throw new CommandOptionRequiredError(name); + return addIssue( + error, + { + type: "option", + target: name, + expected: `required option --${name}`, + received: value, + message: `Option "${name}" is required.`, + }, + ); } - return dataParser.parseOrThrow(value); + const result = dataParser.parse(value); + + if (E.isLeft(result)) { + return addDataParserError( + error, + unwrap(result), + { + type: "option", + target: name, + }, + ); + } + + return unwrap(result); }, { description: params?.description, diff --git a/tests/command/create.test.ts b/tests/command/create.test.ts index dd00cf4..eee83df 100644 --- a/tests/command/create.test.ts +++ b/tests/command/create.test.ts @@ -100,9 +100,11 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); - it("throws CommandManyArgumentsError when data parser subject receives many arguments", async() => { + it("renders command error when data parser subject receives many arguments", async() => { setEnvironment("TEST"); - TESTImplementation.set("exitProcess", vi.fn()); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); const command = DServerCommand.create( "root", @@ -120,7 +122,13 @@ describe("create", () => { }, ); - await expect(command.execute(["one", "two"])).rejects.toThrowError(DServerCommand.CommandManyArgumentsError); + await command.execute(["one", "two"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("Command failed"); + expect(errorSpy.mock.calls[0]![0]).toContain("subject"); + expect(errorSpy.mock.calls[0]![0]).toContain("Expected exactly one subject argument, received 2."); + expect(exitSpy).toHaveBeenCalledWith(1); }); it("runs help flow when help option is provided", async() => { @@ -139,6 +147,36 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it("renders command error when help option is malformed", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + () => undefined, + ); + + await command.execute(["--help=true"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("option without value --help"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not print root error when malformed help is executed with a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + () => undefined, + ); + + await expect(command.execute(["--help=true"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("executes data parser subject branch with a single argument", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -187,6 +225,95 @@ describe("create", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it("renders command error when tuple subject parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.number()]) as never, + }, + () => undefined, + ); + + await command.execute(["bad"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("SUBJECT:"); + expect(errorSpy.mock.calls[0]![0]).toContain("number"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("returns subject parse error without printing when tuple subject has a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.number()]) as never, + }, + () => undefined, + ); + + await expect(command.execute(["bad"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("renders command error when single subject parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + subject: DP.number() as never, + }, + () => undefined, + ); + + await command.execute(["bad"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("expected"); + expect(errorSpy.mock.calls[0]![0]).toContain("number"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("returns many-arguments error without printing when single subject has a shared error", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.string() as never, + }, + () => undefined, + ); + + await expect(command.execute(["one", "two"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("returns single subject parse error without printing when a shared error is provided", async() => { + setEnvironment("TEST"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + { + subject: DP.number() as never, + }, + () => undefined, + ); + + await expect(command.execute(["bad"], DServerCommand.createError("parent"))).resolves.toBe(DServerCommand.SymbolCommandError); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("infers typed params with array subject", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -267,6 +394,78 @@ describe("create", () => { expect(exitSpy).toHaveBeenNthCalledWith(2, 0); }); + it("renders command error when option value parsing fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + options: [DServerCommand.createOption("enabled", DP.boolean() as never)], + }, + () => undefined, + ); + + await command.execute(["--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("Command failed"); + expect(errorSpy.mock.calls[0]![0]).toContain("OPTION:"); + expect(errorSpy.mock.calls[0]![0]).toContain("--enabled"); + expect(errorSpy.mock.calls[0]![0]).toContain("expected"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("stops option reduction after a first option error", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const secondOptionSpy = vi.fn(); + TESTImplementation.set("exitProcess", exitSpy); + + const command = DServerCommand.create( + "root", + { + options: [ + DServerCommand.createOption("enabled", DP.boolean() as never), + DServerCommand.initOption("later", () => { + secondOptionSpy(); + + return true; + }), + ], + }, + () => undefined, + ); + + await command.execute(["--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(secondOptionSpy).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not capture user command execution errors", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + const userError = new Error("user crash"); + + const command = DServerCommand.create( + "root", + () => { + throw userError; + }, + ); + + await expect(command.execute([])).rejects.toThrow(userError); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalledWith(1); + }); + it("executes matching child command when subject is a command list", async() => { setEnvironment("TEST"); const exitSpy = vi.fn(); @@ -344,4 +543,33 @@ describe("create", () => { expect(renderedHelp).not.toContain("root"); expect(exitSpy).toHaveBeenCalledWith(0); }); + + it("renders root command error when a child command fails", async() => { + setEnvironment("TEST"); + const exitSpy = vi.fn(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + TESTImplementation.set("exitProcess", exitSpy); + + const child = DServerCommand.create( + "child", + { + options: [DServerCommand.createOption("enabled", DP.boolean() as never)], + }, + () => undefined, + ); + const root = DServerCommand.create( + "root", + { + subject: [child], + }, + () => undefined, + ); + + await root.execute(["child", "--enabled=yes"]); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]![0]).toContain("COMMAND:"); + expect(errorSpy.mock.calls[0]![0]).toContain("root child"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/tests/command/error.test.ts b/tests/command/error.test.ts new file mode 100644 index 0000000..edbbedf --- /dev/null +++ b/tests/command/error.test.ts @@ -0,0 +1,104 @@ +import { type ExpectType, DP, E, unwrap } from "@duplojs/utils"; +import { addDataParserError, addIssue, createError, interpretError, popErrorPath, setErrorPath, SymbolCommandError } from "@scripts/command/error"; + +describe("error", () => { + it("creates and mutates a command error path", () => { + const error = createError("root"); + + type _CheckError = ExpectType< + typeof error.currentCommandPath, + string[], + "strict" + >; + + expect(error.currentCommandPath).toEqual(["root"]); + expect(setErrorPath(error, "child", 1)).toBe(error); + expect(error.currentCommandPath).toEqual(["root", "child"]); + expect(popErrorPath(error)).toBe(error); + expect(error.currentCommandPath).toEqual(["root"]); + }); + + it("adds command issues and renders command context", () => { + const error = createError("root"); + + expect( + addIssue( + error, + { + type: "command", + expected: "exactly one subject argument", + received: ["one", "two"], + message: "Expected exactly one subject argument, received 2.", + }, + ), + ).toBe(SymbolCommandError); + + expect(error.issues).toEqual([ + { + type: "command", + commandPath: ["root"], + expected: "exactly one subject argument", + received: ["one", "two"], + message: "Expected exactly one subject argument, received 2.", + }, + ]); + expect(interpretError(error)).toContain("Expected exactly one subject argument, received 2."); + }); + + it("adds data parser issues and renders option and subject contexts", () => { + const error = createError("root"); + const optionResult = DP.boolean().parse("yes"); + + expect(E.isLeft(optionResult)).toBe(true); + if (!E.isLeft(optionResult)) { + return; + } + + expect( + addDataParserError( + error, + unwrap(optionResult), + { + type: "option", + target: "enabled", + }, + ), + ).toBe(SymbolCommandError); + + setErrorPath(error, "child", 1); + + const subjectResult = DP.tuple([DP.string(), DP.number()]).parse(["name", "bad"]); + + expect(E.isLeft(subjectResult)).toBe(true); + if (!E.isLeft(subjectResult)) { + return; + } + + expect( + addDataParserError( + error, + unwrap(subjectResult), + { + type: "subject", + }, + ), + ).toBe(SymbolCommandError); + + const output = interpretError(error); + + expect(output).toContain("COMMAND:"); + expect(output).toContain("root"); + expect(output).toContain("OPTION:"); + expect(output).toContain("--enabled"); + expect(output).toContain("SUBJECT:"); + expect(output).toContain("expected"); + expect(output).toContain("boolean"); + expect(error.issues[0]?.target).toBe("enabled"); + expect(error.issues[1]?.commandPath).toEqual(["root", "child"]); + expect(error.issues[1]?.parserPath).toBe("[1]"); + }); + + it("renders fallback text when no issue exists", () => { + expect(interpretError(createError("root"))).toContain("No issue found"); + }); +}); diff --git a/tests/command/help.test.ts b/tests/command/help.test.ts index 7ac2799..6cc6b5c 100644 --- a/tests/command/help.test.ts +++ b/tests/command/help.test.ts @@ -1,15 +1,13 @@ -import { DP, Printer } from "@duplojs/utils"; +import { type ExpectType, DP, Printer } from "@duplojs/utils"; import { DServerCommand } from "@scripts"; -import { render } from "@scripts/command/help"; -describe("logHelp", () => { +describe("help", () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); - it("renders name, description, and options blocks", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + it("renders name, description, options and subject blocks", () => { const command = DServerCommand.create( "root", { @@ -29,33 +27,49 @@ describe("logHelp", () => { }, ), ], + subject: DP.string(), }, () => Promise.resolve(undefined), ); - DServerCommand.logHelp(command, 1); - - expect(logSpy).toHaveBeenCalledTimes(1); + const lines = DServerCommand.renderCommandHelp(command, 1); - const output = logSpy.mock.calls[0]![0] as string; + type _CheckLines = ExpectType< + typeof lines, + string[], + "strict" + >; - expect(output).toContain( - render( - [ - `${Printer.indent(1)}${Printer.colorizedBold("NAME:", "green")}`, - "root", - ], - ), + expect(lines).toContain( + `${Printer.indent(1)}${Printer.colorizedBold("NAME:", "green")}root`, + ); + expect(lines.join("\n")).toContain("Root command description"); + expect(lines.join("\n")).toContain(Printer.colorizedBold("OPTIONS:", "blue")); + expect(lines.join("\n")).toContain("--silent"); + expect(lines.join("\n")).toContain("Enable verbose mode"); + expect(lines).toContain( + `${Printer.indent(2)}${Printer.colorizedBold("SUBJECT:", "magenta")}`, ); - expect(output).toContain("Root command description"); - expect(output).toContain(Printer.colorizedBold("OPTIONS:", "blue")); - expect(output).toContain("--silent"); - expect(output).toContain("Enable verbose mode"); }); - it("recursively renders child commands when subject is a command list", () => { + it("logs rendered help lines", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = DServerCommand.create( + "root", + () => Promise.resolve(undefined), + ); + + DServerCommand.logHelp(command, 1); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0]).toEqual([ + Printer.renderParagraph( + DServerCommand.renderCommandHelp(command, 1), + ), + ]); + }); + it("recursively renders child commands when subject is a command list", () => { const child = DServerCommand.create( "child", () => Promise.resolve(undefined), @@ -68,53 +82,64 @@ describe("logHelp", () => { () => Promise.resolve(undefined), ); - DServerCommand.logHelp(root); - - expect(logSpy).toHaveBeenCalledTimes(1); - - const output = logSpy.mock.calls[0]![0] as string; + const lines = DServerCommand.renderCommandHelp(root, 0); + const output = lines.join("\n"); expect(output).toContain("root"); expect(output).toContain("child"); }); + it("renders tuple subjects without angle brackets", () => { + const command = DServerCommand.create( + "root", + { + subject: DP.tuple([DP.string(), DP.coerce.number()]), + }, + () => Promise.resolve(undefined), + ); + + expect(DServerCommand.renderCommandHelp(command, 0)).toContain( + `${Printer.indent(1)}${Printer.colorizedBold("SUBJECT:", "magenta")}[string, number]`, + ); + }); + it("formats supported subject data parser kinds", () => { const cases = [ { subject: DP.string(), - expected: "", + expected: "string", }, { subject: DP.number(), - expected: "", + expected: "number", }, { subject: DP.bigint(), - expected: "", + expected: "bigint", }, { subject: DP.date(), - expected: "", + expected: "date", }, { subject: DP.time(), - expected: "