diff --git a/src/transformers/choice.spec.ts b/src/transformers/choice.spec.ts new file mode 100644 index 0000000..4d23a82 --- /dev/null +++ b/src/transformers/choice.spec.ts @@ -0,0 +1,101 @@ +import { type TLiteral, type TUnion, Type } from "@sinclair/typebox"; +import { expect, it } from "vitest"; +import type { Options } from "yargs"; + +import { getChoiceOption } from "./choice"; + +it("should transform TUnion to yargs option", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + + const result = getChoiceOption(schema); + + expect(result).toEqual({ + type: "string", + requiresArg: true, + choices: ["foo", "bar"], + }); +}); + +it("should transform TUnion with truthy default value to yargs option", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")], { + default: "foo", + }); + + const result = getChoiceOption(schema); + + expect(result).toEqual({ + type: "string", + requiresArg: false, + choices: ["foo", "bar"], + default: "foo", + }); +}); + +it("should transform TUnion with falsy default value to yargs option", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")], { + default: "", + }); + + const result = getChoiceOption(schema); + + expect(result).toEqual({ + type: "string", + requiresArg: false, + choices: ["foo", "bar"], + default: "", + }); +}); + +it("should transform TUnion with description to yargs option", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")], { + description: "foo", + }); + + const result = getChoiceOption(schema); + + expect(result).toEqual({ + type: "string", + requiresArg: true, + choices: ["foo", "bar"], + description: "foo", + }); +}); + +it("should transform TUnion with override to yargs option", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + const overwrite: Options = { + requiresArg: false, + alias: "aliased", + }; + + const result = getChoiceOption(schema, overwrite); + + expect(result).toEqual({ + type: "string", + requiresArg: false, + choices: ["foo", "bar"], + alias: "aliased", + }); +}); + +it("should detect if it is optional", () => { + const schema = Type.Optional( + Type.Union([Type.Literal("foo"), Type.Literal("bar")]), + ); + + const result = getChoiceOption(schema); + + expect(result).toEqual({ + type: "string", + requiresArg: false, + choices: ["foo", "bar"], + }); +}); + +it("should throw error when union contains non-literal", () => { + const schema = Type.Union([Type.String(), Type.Number()]); + + expect(() => + getChoiceOption(schema as unknown as TUnion), + ).toThrowError("Choices must contain only literal values"); +}); diff --git a/src/transformers/choice.ts b/src/transformers/choice.ts new file mode 100644 index 0000000..357b1b5 --- /dev/null +++ b/src/transformers/choice.ts @@ -0,0 +1,29 @@ +import { type TLiteral, type TUnion, TypeGuard } from "@sinclair/typebox"; +import type { Options } from "yargs"; + +export function getChoiceOption( + schema: TUnion, + override: Options = {}, +): Options { + const hasDefaultValue = schema.default !== undefined; + const options = { + type: "string" as const, + requiresArg: !TypeGuard.IsOptional(schema) && !hasDefaultValue, + choices: schema.anyOf.map(item => { + if (!TypeGuard.IsLiteral(item)) { + throw new Error("Choices must contain only literal values"); + } + + return item.const.toString(); + }), + }; + + if (hasDefaultValue) + Object.assign(options, { default: schema.default as string }); + if (schema.description) + Object.assign(options, { description: schema.description }); + + Object.assign(options, override); + + return options; +} diff --git a/src/transformers/get-option.spec.ts b/src/transformers/get-option.spec.ts index c14b42c..ecd8a2b 100644 --- a/src/transformers/get-option.spec.ts +++ b/src/transformers/get-option.spec.ts @@ -4,6 +4,7 @@ import type { Options } from "yargs"; const getArrayOption = vi.fn().mockReturnValue({ type: "mocked" }); const getBooleanOption = vi.fn().mockReturnValue({ type: "mocked" }); +const getChoiceOption = vi.fn().mockReturnValue({ type: "mocked" }); const getNumberOption = vi.fn().mockReturnValue({ type: "mocked" }); const getStringOption = vi.fn().mockReturnValue({ type: "mocked" }); @@ -12,6 +13,7 @@ let component: typeof import("./get-option"); beforeAll(async () => { vi.doMock("./array", () => ({ getArrayOption })); vi.doMock("./boolean", () => ({ getBooleanOption })); + vi.doMock("./choice", () => ({ getChoiceOption })); vi.doMock("./number", () => ({ getNumberOption })); vi.doMock("./string", () => ({ getStringOption })); component = await import("./get-option"); @@ -89,6 +91,23 @@ describe("string", () => { }); }); +describe("union", () => { + it("should pass schema to union transformer", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + const result = component.getOption(schema); + expect(getChoiceOption).toBeCalledWith(schema, {}); + expect(result).toEqual({ type: "mocked" }); + }); + + it("should pass override to union transformer", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + const override: Options = { alias: "aliased" }; + const result = component.getOption(schema, override); + expect(getChoiceOption).toBeCalledWith(schema, override); + expect(result).toEqual({ type: "mocked", alias: "aliased" }); + }); +}); + describe("unaccepted type", () => { it("should return empty object when type is not supported", () => { const schema = Type.BigInt(); diff --git a/src/transformers/get-option.ts b/src/transformers/get-option.ts index 6d04edf..b234ee7 100644 --- a/src/transformers/get-option.ts +++ b/src/transformers/get-option.ts @@ -1,8 +1,14 @@ -import { type TSchema, TypeGuard } from "@sinclair/typebox"; +import { + type TLiteral, + type TSchema, + type TUnion, + TypeGuard, +} from "@sinclair/typebox"; import type { Options } from "yargs"; import { getArrayOption } from "./array"; import { getBooleanOption } from "./boolean"; +import { getChoiceOption } from "./choice"; import { getNumberOption } from "./number"; import { getStringOption } from "./string"; @@ -11,6 +17,8 @@ export function getOption(schema: TSchema, override: Options = {}) { if (TypeGuard.IsNumber(schema)) return getNumberOption(schema, override); if (TypeGuard.IsString(schema)) return getStringOption(schema, override); if (TypeGuard.IsBoolean(schema)) return getBooleanOption(schema, override); + if (TypeGuard.IsUnion(schema)) + return getChoiceOption(schema as TUnion, override); if (TypeGuard.IsArray(schema)) return getArrayOption(schema, override); return {}; diff --git a/src/transformers/index.ts b/src/transformers/index.ts index b41cd9a..a5946aa 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -1,5 +1,6 @@ export * from "./array"; export * from "./boolean"; +export * from "./choice"; export * from "./get-option"; export * from "./number"; export * from "./string"; diff --git a/tests/get-option.test.ts b/tests/get-option.test.ts index 1ea141b..1668369 100644 --- a/tests/get-option.test.ts +++ b/tests/get-option.test.ts @@ -88,6 +88,30 @@ describe("string", () => { }); }); +describe("union (choices)", () => { + it("should pass schema to union transformer", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + const result = component.getOption(schema); + expect(result).toEqual({ + type: "string", + requiresArg: true, + choices: ["foo", "bar"], + }); + }); + + it("should pass override to union transformer", () => { + const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); + const override: Options = { alias: "aliased" }; + const result = component.getOption(schema, override); + expect(result).toEqual({ + type: "string", + requiresArg: true, + choices: ["foo", "bar"], + alias: "aliased", + }); + }); +}); + describe("unaccepted type", () => { it("should return empty object when type is not supported", () => { const schema = Type.BigInt();