diff --git a/src/helpers/is-union-literal.spec.ts b/src/helpers/is-union-literal.spec.ts new file mode 100644 index 0000000..5112c77 --- /dev/null +++ b/src/helpers/is-union-literal.spec.ts @@ -0,0 +1,24 @@ +import { Type } from "@sinclair/typebox"; +import { expect, it } from "vitest"; + +import { isUnionLiteral } from "./is-union-literal"; + +it("should return true if schema is TUnion of TLiteral", () => { + const schema = Type.Union([ + Type.Literal("foo"), + Type.Literal(10), + Type.Literal(true), + ]); + + const result = isUnionLiteral(schema); + + expect(result).toBe(true); +}); + +it("should return false if schema is not TUnion of TLiteral", () => { + const schema = Type.Union([Type.Literal("foo")]); + + const result = isUnionLiteral(schema); + + expect(result).toBe(false); +}); diff --git a/src/helpers/is-union-literal.ts b/src/helpers/is-union-literal.ts new file mode 100644 index 0000000..802ce44 --- /dev/null +++ b/src/helpers/is-union-literal.ts @@ -0,0 +1,12 @@ +import { + type TLiteral, + type TSchema, + type TUnion, + TypeGuard, +} from "@sinclair/typebox"; + +export function isUnionLiteral(schema: TSchema): schema is TUnion { + if (!TypeGuard.IsUnion(schema)) return false; + + return schema.anyOf.every(item => TypeGuard.IsLiteral(item)); +} diff --git a/src/helpers/t-union-to-tuple.spec.ts b/src/helpers/t-union-to-tuple.spec.ts new file mode 100644 index 0000000..2bd38e9 --- /dev/null +++ b/src/helpers/t-union-to-tuple.spec.ts @@ -0,0 +1,16 @@ +import { Type } from "@sinclair/typebox"; +import { expect, it } from "vitest"; + +import { tUnionToTuple } from "./t-union-to-tuple"; + +it("should transform TUnion to tuple", () => { + const schema = Type.Union([ + Type.Literal("foo"), + Type.Literal(10), + Type.Literal(true), + ]); + + const result = tUnionToTuple(schema); + + expect(result).toEqual(["foo", 10, true]); +}); diff --git a/src/helpers/t-union-to-tuple.ts b/src/helpers/t-union-to-tuple.ts new file mode 100644 index 0000000..09c6a5f --- /dev/null +++ b/src/helpers/t-union-to-tuple.ts @@ -0,0 +1,12 @@ +import { + type Static, + type TLiteral, + type TUnion, + type UnionToTuple, +} from "@sinclair/typebox"; + +export function tUnionToTuple>( + schema: S, +): UnionToTuple> { + return schema.anyOf.map(item => item.const) as never; +} diff --git a/src/transformers/array.spec.ts b/src/transformers/array.spec.ts index d72f39e..1a6d8b6 100644 --- a/src/transformers/array.spec.ts +++ b/src/transformers/array.spec.ts @@ -1,120 +1,93 @@ import { Type } from "@sinclair/typebox"; -import { describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import type { Options } from "yargs"; -import { getArrayOption } from "./array"; +const isUnionLiteral = vi.fn(); +const tUnionToTuple = vi.fn(); +const transform = vi.fn(); -it("should transform TArray to yargs option", () => { - const schema = Type.Array(Type.String()); +let getArrayOption: typeof import("./array").getArrayOption; - const result = getArrayOption(schema); +beforeAll(async () => { + vi.doMock("@/helpers/is-union-literal", () => ({ isUnionLiteral })); + vi.doMock("@/helpers/t-union-to-tuple", () => ({ tUnionToTuple })); + vi.doMock("./transform", () => ({ transform })); - expect(result).toEqual({ - type: "array", - requiresArg: true, - }); + getArrayOption = await import("./array").then(m => m.getArrayOption); }); -describe("should transform TArray with choices to yargs option", () => { - it("when item is literal", () => { - const schema = Type.Array(Type.Literal("foo")); - - const result = getArrayOption(schema); - - expect(result).toEqual({ - type: "array", - requiresArg: true, - choices: ["foo"], - }); - }); - - it("when items are union of literal", () => { - const schema = Type.Array( - Type.Union([Type.Literal("foo"), Type.Literal("bar")]), - ); - - const result = getArrayOption(schema); - - expect(result).toEqual({ - type: "array", - requiresArg: true, - choices: ["foo", "bar"], - }); - }); - - it("should not transform when items are union of literal and non-literal", () => { - const schema = Type.Array( - Type.Union([Type.Literal("foo"), Type.Literal("bar"), Type.Boolean()]), - ); - - const result = getArrayOption(schema); - - expect(result).toEqual({ - type: "array", - requiresArg: true, - }); - }); +beforeEach(() => { + vi.resetAllMocks(); }); -it("should transform TArray with truthy default value to yargs option", () => { - const schema = Type.Array(Type.String(), { default: ["foo"] }); +it("should call transform to transform", () => { + const schema = Type.Array(Type.Any()); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); - const result = getArrayOption(schema); + const response = getArrayOption(schema); - expect(result).toEqual({ - type: "array", - requiresArg: false, - default: ["foo"], - }); + expect(transform).toBeCalledWith("array", schema, {}); + expect(response).toEqual(expectedResponse); }); -it("should transform TArray with falsy default value to yargs option", () => { - const schema = Type.Array(Type.String(), { default: [] }); +it("should take its value as choices if it is literal", () => { + const schema = Type.Array(Type.Union([Type.Literal("foo")])); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); - const result = getArrayOption(schema); + const response = getArrayOption(schema); - expect(result).toEqual({ - type: "array", - requiresArg: false, - default: [], - }); -}); - -it("should transform TArray with description to yargs option", () => { - const schema = Type.Array(Type.String(), { description: "foo" }); + const expectedOverwrites = { + choices: ["foo"], + } satisfies Options; - const result = getArrayOption(schema); - - expect(result).toEqual({ - type: "array", - requiresArg: true, - description: "foo", - }); + expect(isUnionLiteral).not.toBeCalled(); + expect(tUnionToTuple).not.toBeCalled(); + expect(transform).toBeCalledWith("array", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); -it("should transform TArray with override to yargs option", () => { - const schema = Type.Array(Type.String()); - const overwrite: Options = { - requiresArg: false, - alias: "aliased", - }; - - const result = getArrayOption(schema, overwrite); +it("should take union of literals to tuple of literals as value of choices then call transform to transform", () => { + const schema = Type.Array( + Type.Union([Type.Literal("foo"), Type.Literal(10), Type.Literal(true)]), + ); + const expectedResponse = { mocked: true }; + isUnionLiteral.mockReturnValueOnce(true); + tUnionToTuple.mockReturnValueOnce(["foo", 10, true]); + transform.mockReturnValue(expectedResponse); + + const response = getArrayOption(schema); + + const expectedOverwrites = { + choices: ["foo", 10, true], + } satisfies Options; + + expect(isUnionLiteral).toBeCalledWith(schema.items); + expect(tUnionToTuple).toBeCalledWith(schema.items); + expect(transform).toBeCalledWith("array", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); +}); - expect(result).toEqual({ - type: "array", - requiresArg: false, +it("should call transform with overwrites", () => { + const schema = Type.Array( + Type.Union([Type.Literal("foo"), Type.Literal(10), Type.Literal(true)]), + ); + const overwrites = { alias: "aliased", - }); -}); + choices: ["foo", "bar"], + } satisfies Options; + const expectedResponse = { mocked: true, ...overwrites }; + isUnionLiteral.mockReturnValueOnce(true); + tUnionToTuple.mockReturnValueOnce(["foo", 10, true]); + transform.mockReturnValue(expectedResponse); -it("should detect if it is optional", () => { - const schema = Type.Optional(Type.Array(Type.String())); + const response = getArrayOption(schema, overwrites); - const result = getArrayOption(schema); + const expectedOverwrites = overwrites satisfies Options; - expect(result).toEqual({ - type: "array", - requiresArg: false, - }); + expect(isUnionLiteral).toBeCalledWith(schema.items); + expect(tUnionToTuple).toBeCalledWith(schema.items); + expect(transform).toBeCalledWith("array", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); diff --git a/src/transformers/array.ts b/src/transformers/array.ts index 16a6fd2..4643aea 100644 --- a/src/transformers/array.ts +++ b/src/transformers/array.ts @@ -1,66 +1,45 @@ import { + type Static, type TArray, type TLiteral, type TSchema, type TUnion, TypeGuard, + type UnionToTuple, } from "@sinclair/typebox"; import type { Options } from "yargs"; -function getLiteralValue(schema: TLiteral): string { - return schema.const.toString(); -} - -function getValue(schema: TSchema): string | undefined { - if (TypeGuard.IsLiteral(schema)) return getLiteralValue(schema); - - return undefined; -} - -function getUnionValues(schema: TUnion): string[] | undefined { - const values: string[] = []; - - const isAllValid = schema.anyOf.every(item => { - const value = getValue(item); - if (!value) return false; +import { isUnionLiteral } from "@/helpers/is-union-literal"; +import { tUnionToTuple } from "@/helpers/t-union-to-tuple"; - values.push(value); +import { transform } from "./transform"; - return true; - }); +type GetChoices = T extends TLiteral + ? [Static] + : T extends TUnion + ? UnionToTuple> + : never; - if (!isAllValid) return undefined; - - return values; -} - -function getChoices(schema: TSchema): string[] | undefined { - if (TypeGuard.IsLiteral(schema)) return [getLiteralValue(schema)]; - if (TypeGuard.IsUnion(schema)) return getUnionValues(schema); +function getChoices(schema: T): GetChoices; +function getChoices(schema: TSchema) { + if (TypeGuard.IsLiteral(schema)) return [schema.const]; + if (isUnionLiteral(schema)) return tUnionToTuple(schema); return undefined; } -export function getArrayOption( - schema: TArray, - override: Options = {}, -): Options { - const hasDefaultValue = schema.default !== undefined; - const options = { - type: "array" as const, - requiresArg: !TypeGuard.IsOptional(schema) && !hasDefaultValue, +export function getArrayOption( + schema: S, + overwrites: O = {} as never, +) { + const choices = getChoices(schema.items) as GetChoices; + const mergedOverwrites = { + // @ts-expect-error We expect this type to be calculated, but seems it's too + // long for ts. If it been proved to be not nessesary, we can remove this + // type inference. + choices, + ...overwrites, }; - if (hasDefaultValue) - Object.assign(options, { default: schema.default as unknown[] }); - if (schema.description) - Object.assign(options, { description: schema.description }); - - const choices = getChoices(schema.items); - - if (choices) Object.assign(options, { choices }); - - Object.assign(options, override); - - return options; + return transform("array", schema, mergedOverwrites); } diff --git a/src/transformers/boolean.spec.ts b/src/transformers/boolean.spec.ts index b534541..c96535d 100644 --- a/src/transformers/boolean.spec.ts +++ b/src/transformers/boolean.spec.ts @@ -1,79 +1,44 @@ import { Type } from "@sinclair/typebox"; -import { expect, it } from "vitest"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import type { Options } from "yargs"; -import { getBooleanOption } from "./boolean"; +const transform = vi.fn(); -it("should transform TBoolean to yargs option", () => { - const schema = Type.Boolean(); - - const result = getBooleanOption(schema); - - expect(result).toEqual({ - type: "boolean", - requiresArg: true, - }); -}); - -it("should transform TBoolean with truthy default value to yargs option", () => { - const schema = Type.Boolean({ default: true }); +let getBooleanOption: typeof import("./boolean").getBooleanOption; - const result = getBooleanOption(schema); +beforeAll(async () => { + vi.doMock("./transform", () => ({ transform })); - expect(result).toEqual({ - type: "boolean", - requiresArg: false, - default: true, - }); + getBooleanOption = await import("./boolean").then(m => m.getBooleanOption); }); -it("should transform TBoolean with falsy default value to yargs option", () => { - const schema = Type.Boolean({ default: false }); - - const result = getBooleanOption(schema); - - expect(result).toEqual({ - type: "boolean", - requiresArg: false, - default: false, - }); +beforeEach(() => { + vi.clearAllMocks(); }); -it("should transform TBoolean with description to yargs option", () => { - const schema = Type.Boolean({ description: "foo" }); +it("should call transform to transform", () => { + const schema = Type.Boolean(); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); - const result = getBooleanOption(schema); + const response = getBooleanOption(schema); - expect(result).toEqual({ - type: "boolean", - requiresArg: true, - description: "foo", - }); + expect(transform).toBeCalledWith("boolean", schema, {}); + expect(response).toEqual(expectedResponse); }); -it("should transform TBoolean with override to yargs option", () => { +it("should call transform to transform with overwrites", () => { const schema = Type.Boolean(); - const overwrite: Options = { - requiresArg: false, - alias: "aliased", - }; - - const result = getBooleanOption(schema, overwrite); - - expect(result).toEqual({ - type: "boolean", - requiresArg: false, + const overwrites = { alias: "aliased", - }); -}); + } satisfies Options; + const expectedResponse = { mocked: true, alias: "aliased" }; + transform.mockReturnValue(expectedResponse); -it("should detect if it is optional", () => { - const schema = Type.Optional(Type.Boolean()); + const response = getBooleanOption(schema, overwrites); - const result = getBooleanOption(schema); + const expectedOverwrites = overwrites satisfies Options; - expect(result).toEqual({ - type: "boolean", - requiresArg: false, - }); + expect(transform).toBeCalledWith("boolean", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); diff --git a/src/transformers/boolean.ts b/src/transformers/boolean.ts index 5ea3470..4581432 100644 --- a/src/transformers/boolean.ts +++ b/src/transformers/boolean.ts @@ -1,22 +1,11 @@ -import { type TBoolean, TypeGuard } from "@sinclair/typebox"; +import { type TBoolean } from "@sinclair/typebox"; import type { Options } from "yargs"; -export function getBooleanOption( - schema: TBoolean, - override: Options = {}, -): Options { - const hasDefaultValue = schema.default !== undefined; - const options = { - type: "boolean" as const, - requiresArg: !TypeGuard.IsOptional(schema) && !hasDefaultValue, - }; +import { transform } from "./transform"; - if (hasDefaultValue) - Object.assign(options, { default: schema.default as boolean }); - if (schema.description) - Object.assign(options, { description: schema.description }); - - Object.assign(options, override); - - return options; +export function getBooleanOption< + S extends TBoolean, + O extends Options = object, +>(schema: S, overwrites: O = {} as never) { + return transform("boolean", schema, overwrites); } diff --git a/src/transformers/choice.spec.ts b/src/transformers/choice.spec.ts index 4d23a82..907324b 100644 --- a/src/transformers/choice.spec.ts +++ b/src/transformers/choice.spec.ts @@ -1,101 +1,87 @@ -import { type TLiteral, type TUnion, Type } from "@sinclair/typebox"; -import { expect, it } from "vitest"; +import { Type } from "@sinclair/typebox"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import type { Options } from "yargs"; -import { getChoiceOption } from "./choice"; +const isUnionLiteral = vi.fn(); +const tUnionToTuple = vi.fn(); +const transform = vi.fn(); -it("should transform TUnion to yargs option", () => { - const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); +let getChoiceOption: typeof import("./choice").getChoiceOption; - const result = getChoiceOption(schema); +beforeAll(async () => { + vi.doMock("@/helpers/is-union-literal", () => ({ isUnionLiteral })); + vi.doMock("@/helpers/t-union-to-tuple", () => ({ tUnionToTuple })); + vi.doMock("./transform", () => ({ transform })); - expect(result).toEqual({ - type: "string", - requiresArg: true, - choices: ["foo", "bar"], - }); + getChoiceOption = await import("./choice").then(m => m.getChoiceOption); }); -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", - }); +beforeEach(() => { + vi.clearAllMocks(); }); -it("should transform TUnion with falsy default value to yargs option", () => { - const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")], { - default: "", - }); +it("should take its value if it is literal", () => { + const schema = Type.Union([Type.Literal("foo")]); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); + isUnionLiteral.mockReturnValueOnce(false); - const result = getChoiceOption(schema); + const response = 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 expectedOverwrites = { + choices: ["foo"], + } satisfies Options; - const result = getChoiceOption(schema); + expect(isUnionLiteral).toBeCalledWith(schema); + expect(tUnionToTuple).not.toBeCalled(); + expect(transform).toBeCalledWith("string", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); +}); - expect(result).toEqual({ - type: "string", - requiresArg: true, - choices: ["foo", "bar"], - description: "foo", - }); +it("should call transform to transform union of literals to tuple of literals", () => { + const schema = Type.Union([ + Type.Literal("foo"), + Type.Literal(10), + Type.Literal(true), + ]); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); + isUnionLiteral.mockReturnValueOnce(true); + tUnionToTuple.mockReturnValueOnce(["foo", 10, true]); + + const response = getChoiceOption(schema); + + const expectedOverwrites = { + choices: ["foo", 10, true], + } satisfies Options; + + expect(isUnionLiteral).toBeCalledWith(schema); + expect(tUnionToTuple).toBeCalledWith(schema); + expect(transform).toBeCalledWith("string", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); -it("should transform TUnion with override to yargs option", () => { - const schema = Type.Union([Type.Literal("foo"), Type.Literal("bar")]); - const overwrite: Options = { - requiresArg: false, +it("should call transform with overwrites", () => { + const schema = Type.Union([ + Type.Literal("foo"), + Type.Literal(10), + Type.Literal(true), + ]); + const overwrites = { alias: "aliased", - }; - - const result = getChoiceOption(schema, overwrite); - - expect(result).toEqual({ - type: "string", - requiresArg: false, choices: ["foo", "bar"], - alias: "aliased", - }); -}); + } satisfies Options; + const expectedResponse = { mocked: true, ...overwrites }; + transform.mockReturnValue(expectedResponse); + isUnionLiteral.mockReturnValueOnce(true); + tUnionToTuple.mockReturnValueOnce(["foo", 10, true]); -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"], - }); -}); + const response = getChoiceOption(schema, overwrites); -it("should throw error when union contains non-literal", () => { - const schema = Type.Union([Type.String(), Type.Number()]); + const expectedOverwrites = overwrites satisfies Options; - expect(() => - getChoiceOption(schema as unknown as TUnion), - ).toThrowError("Choices must contain only literal values"); + expect(isUnionLiteral).toBeCalledWith(schema); + expect(tUnionToTuple).toBeCalledWith(schema); + expect(transform).toBeCalledWith("string", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); diff --git a/src/transformers/choice.ts b/src/transformers/choice.ts index 357b1b5..5981d68 100644 --- a/src/transformers/choice.ts +++ b/src/transformers/choice.ts @@ -1,29 +1,22 @@ -import { type TLiteral, type TUnion, TypeGuard } from "@sinclair/typebox"; +import { type Static, type TLiteral, type TUnion } 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"); - } +import { isUnionLiteral } from "@/helpers/is-union-literal"; +import { tUnionToTuple } from "@/helpers/t-union-to-tuple"; - return item.const.toString(); - }), - }; - - if (hasDefaultValue) - Object.assign(options, { default: schema.default as string }); - if (schema.description) - Object.assign(options, { description: schema.description }); +import { transform } from "./transform"; - Object.assign(options, override); +export function getChoiceOption< + S extends TLiteral | TUnion, + O extends Options = object, +>(schema: S, overwrites: O = {} as never) { + const choices = isUnionLiteral(schema) + ? tUnionToTuple(schema) + : [schema.const as Static]; + const mergedOverwrites = { + choices, + ...overwrites, + }; - return options; + return transform("string", schema, mergedOverwrites); } diff --git a/src/transformers/get-option.spec.ts b/src/transformers/get-option.spec.ts index ecd8a2b..20c3d8e 100644 --- a/src/transformers/get-option.spec.ts +++ b/src/transformers/get-option.spec.ts @@ -1,5 +1,13 @@ -import { Type } from "@sinclair/typebox"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { type TSchema, Type } from "@sinclair/typebox"; +import { + beforeAll, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; import type { Options } from "yargs"; const getArrayOption = vi.fn().mockReturnValue({ type: "mocked" }); @@ -23,88 +31,51 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("array", () => { - it("should pass schema to array transformer", () => { - const schema = Type.Array(Type.Unknown()); +const types = [ + "array", + "boolean", + "number", + "string", + "literal", + "union", +] as const; + +const schemas = { + array: Type.Array(Type.Unknown()), + boolean: Type.Boolean(), + number: Type.Number(), + string: Type.String(), + literal: Type.Literal("foo"), + union: Type.Union([Type.Literal("foo"), Type.Literal("bar")]), +} satisfies Record<(typeof types)[number], TSchema>; + +const mocks = { + array: getArrayOption, + boolean: getBooleanOption, + number: getNumberOption, + string: getStringOption, + literal: getChoiceOption, + union: getChoiceOption, +} satisfies Record<(typeof types)[number], Mock>; + +describe.each(types)("should properly handle %s", type => { + const schema = schemas[type]; + const mock = mocks[type]; + + it(`should pass schema to ${type} transformer`, () => { const result = component.getOption(schema); - expect(getArrayOption).toBeCalledWith(schema, {}); - expect(result).toEqual({ type: "mocked" }); - }); - - it("should pass override to array transformer", () => { - const schema = Type.Array(Type.Unknown()); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(getArrayOption).toBeCalledWith(schema, override); - expect(result).toEqual({ type: "mocked", alias: "aliased" }); - }); -}); -describe("boolean", () => { - it("should pass schema to boolean transformer", () => { - const schema = Type.Boolean(); - const result = component.getOption(schema); - expect(getBooleanOption).toBeCalledWith(schema, {}); + expect(mock).toBeCalledWith(schema, {}); expect(result).toEqual({ type: "mocked" }); }); - it("should pass override to boolean transformer", () => { - const schema = Type.Boolean(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(getBooleanOption).toBeCalledWith(schema, override); - expect(result).toEqual({ type: "mocked", alias: "aliased" }); - }); -}); - -describe("number", () => { - it("should pass schema to number transformer", () => { - const schema = Type.Number(); - const result = component.getOption(schema); - expect(getNumberOption).toBeCalledWith(schema, {}); - expect(result).toEqual({ type: "mocked" }); - }); + it(`should pass overwrites to ${type} transformer`, () => { + const override = { + alias: "aliased", + } satisfies Options; + component.getOption(schema, override); - it("should pass override to number transformer", () => { - const schema = Type.Number(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(getNumberOption).toBeCalledWith(schema, override); - expect(result).toEqual({ type: "mocked", alias: "aliased" }); - }); -}); - -describe("string", () => { - it("should pass schema to string transformer", () => { - const schema = Type.String(); - const result = component.getOption(schema); - expect(getStringOption).toBeCalledWith(schema, {}); - expect(result).toEqual({ type: "mocked" }); - }); - - it("should pass override to string transformer", () => { - const schema = Type.String(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(getStringOption).toBeCalledWith(schema, override); - expect(result).toEqual({ type: "mocked", alias: "aliased" }); - }); -}); - -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" }); + expect(mock).toBeCalledWith(schema, override); }); }); @@ -117,7 +88,7 @@ describe("unaccepted type", () => { it("should still be able to override", () => { const schema = Type.BigInt(); - const override: Options = { alias: "aliased" }; + const override = { alias: "aliased" } satisfies Options; const result = component.getOption(schema, override); expect(result).toEqual({ alias: "aliased" }); }); diff --git a/src/transformers/get-option.ts b/src/transformers/get-option.ts index b234ee7..da3df64 100644 --- a/src/transformers/get-option.ts +++ b/src/transformers/get-option.ts @@ -1,6 +1,10 @@ import { + type TArray, + type TBoolean, type TLiteral, + type TNumber, type TSchema, + type TString, type TUnion, TypeGuard, } from "@sinclair/typebox"; @@ -12,19 +16,28 @@ import { getChoiceOption } from "./choice"; import { getNumberOption } from "./number"; import { getStringOption } from "./string"; -export function getOption(schema: TSchema, override: Options = {}) { - const option: 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); +export function getOption( + schema: T, + overwrites?: O, +): T extends TNumber + ? ReturnType> + : T extends TString + ? ReturnType> + : T extends TBoolean + ? ReturnType> + : T extends TUnion + ? ReturnType> + : T extends TArray + ? ReturnType> + : O; - return {}; - })(); +export function getOption(schema: TSchema, overwrites: Options = {}) { + if (TypeGuard.IsNumber(schema)) return getNumberOption(schema, overwrites); + if (TypeGuard.IsString(schema)) return getStringOption(schema, overwrites); + if (TypeGuard.IsBoolean(schema)) return getBooleanOption(schema, overwrites); + if (TypeGuard.IsUnionLiteral(schema) || TypeGuard.IsLiteral(schema)) + return getChoiceOption(schema, overwrites); + if (TypeGuard.IsArray(schema)) return getArrayOption(schema, overwrites); - Object.assign(option, override); - - return option; + return overwrites; } diff --git a/src/transformers/number.spec.ts b/src/transformers/number.spec.ts index 301ed6b..9d6cf24 100644 --- a/src/transformers/number.spec.ts +++ b/src/transformers/number.spec.ts @@ -1,79 +1,44 @@ import { Type } from "@sinclair/typebox"; -import { expect, it } from "vitest"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import type { Options } from "yargs"; -import { getNumberOption } from "./number"; +const transform = vi.fn().mockReturnValue({}); -it("should transform TNumber to yargs option", () => { - const schema = Type.Number(); - - const result = getNumberOption(schema); - - expect(result).toEqual({ - type: "number", - requiresArg: true, - }); -}); - -it("should transform TNumber with truthy default value to yargs option", () => { - const schema = Type.Number({ default: 10 }); +let getNumberOption: typeof import("./number").getNumberOption; - const result = getNumberOption(schema); +beforeAll(async () => { + vi.doMock("./transform", () => ({ transform })); - expect(result).toEqual({ - type: "number", - requiresArg: false, - default: 10, - }); + getNumberOption = await import("./number").then(m => m.getNumberOption); }); -it("should transform TNumber with falsy default value to yargs option", () => { - const schema = Type.Number({ default: 0 }); - - const result = getNumberOption(schema); - - expect(result).toEqual({ - type: "number", - requiresArg: false, - default: 0, - }); +beforeEach(() => { + vi.clearAllMocks(); }); -it("should transform TNumber with description to yargs option", () => { - const schema = Type.Number({ description: "foo" }); +it("should call transform to transform", () => { + const schema = Type.Number(); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); - const result = getNumberOption(schema); + const response = getNumberOption(schema); - expect(result).toEqual({ - type: "number", - requiresArg: true, - description: "foo", - }); + expect(transform).toBeCalledWith("number", schema, {}); + expect(response).toEqual(expectedResponse); }); -it("should transform TNumber with override to yargs option", () => { +it("should call transform to transform with overwrites", () => { const schema = Type.Number(); - const overwrite: Options = { - requiresArg: false, - alias: "aliased", - }; - - const result = getNumberOption(schema, overwrite); - - expect(result).toEqual({ - type: "number", - requiresArg: false, + const overwrites = { alias: "aliased", - }); -}); + } satisfies Options; + const expectedResponse = { mocked: true, ...overwrites }; + transform.mockReturnValue(expectedResponse); -it("should detect if it is optional", () => { - const schema = Type.Optional(Type.Number()); + const response = getNumberOption(schema, overwrites); - const result = getNumberOption(schema); + const expectedOverwrites = overwrites satisfies Options; - expect(result).toEqual({ - type: "number", - requiresArg: false, - }); + expect(transform).toBeCalledWith("number", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); diff --git a/src/transformers/number.ts b/src/transformers/number.ts index 8aa70c0..0b21826 100644 --- a/src/transformers/number.ts +++ b/src/transformers/number.ts @@ -1,22 +1,11 @@ -import { type TNumber, TypeGuard } from "@sinclair/typebox"; +import { type TNumber } from "@sinclair/typebox"; import type { Options } from "yargs"; -export function getNumberOption( - schema: TNumber, - override: Options = {}, -): Options { - const hasDefaultValue = schema.default !== undefined; - const options: Options = { - type: "number" as const, - requiresArg: !TypeGuard.IsOptional(schema) && !hasDefaultValue, - }; +import { transform } from "./transform"; - if (hasDefaultValue) - Object.assign(options, { default: schema.default as number }); - if (schema.description) - Object.assign(options, { description: schema.description }); - - Object.assign(options, override); - - return options; +export function getNumberOption( + schema: S, + overwrites: O = {} as never, +) { + return transform("number", schema, overwrites); } diff --git a/src/transformers/string.spec.ts b/src/transformers/string.spec.ts index bbac65b..c6e12e7 100644 --- a/src/transformers/string.spec.ts +++ b/src/transformers/string.spec.ts @@ -1,79 +1,44 @@ import { Type } from "@sinclair/typebox"; -import { expect, it } from "vitest"; +import { beforeAll, beforeEach, expect, it, vi } from "vitest"; import type { Options } from "yargs"; -import { getStringOption } from "./string"; +const transform = vi.fn().mockReturnValue({}); -it("should transform TString to yargs option", () => { - const schema = Type.String(); - - const result = getStringOption(schema); - - expect(result).toEqual({ - type: "string", - requiresArg: true, - }); -}); - -it("should transform TString with truthy default value to yargs option", () => { - const schema = Type.String({ default: "foo" }); +let getStringOption: typeof import("./string").getStringOption; - const result = getStringOption(schema); +beforeAll(async () => { + vi.doMock("./transform", () => ({ transform })); - expect(result).toEqual({ - type: "string", - requiresArg: false, - default: "foo", - }); + getStringOption = await import("./string").then(m => m.getStringOption); }); -it("should transform TString with falsy default value to yargs option", () => { - const schema = Type.String({ default: "" }); - - const result = getStringOption(schema); - - expect(result).toEqual({ - type: "string", - requiresArg: false, - default: "", - }); +beforeEach(() => { + vi.clearAllMocks(); }); -it("should transform TString with description to yargs option", () => { - const schema = Type.String({ description: "foo" }); +it("should call transform to transform", () => { + const schema = Type.String(); + const expectedResponse = { mocked: true }; + transform.mockReturnValue(expectedResponse); - const result = getStringOption(schema); + const response = getStringOption(schema); - expect(result).toEqual({ - type: "string", - requiresArg: true, - description: "foo", - }); + expect(transform).toBeCalledWith("string", schema, {}); + expect(response).toEqual(expectedResponse); }); -it("should transform TString with override to yargs option", () => { +it("should call transform to transform with overwrites", () => { const schema = Type.String(); - const overwrite: Options = { - requiresArg: false, - alias: "aliased", - }; - - const result = getStringOption(schema, overwrite); - - expect(result).toEqual({ - type: "string", - requiresArg: false, + const overwrites = { alias: "aliased", - }); -}); + } satisfies Options; + const expectedResponse = { mocked: true, ...overwrites }; + transform.mockReturnValue(expectedResponse); -it("should detect if it is optional", () => { - const schema = Type.Optional(Type.String()); + const response = getStringOption(schema, overwrites); - const result = getStringOption(schema); + const expectedOverwrites = overwrites satisfies Options; - expect(result).toEqual({ - type: "string", - requiresArg: false, - }); + expect(transform).toBeCalledWith("string", schema, expectedOverwrites); + expect(response).toEqual(expectedResponse); }); diff --git a/src/transformers/string.ts b/src/transformers/string.ts index 8af6cab..d8d9cad 100644 --- a/src/transformers/string.ts +++ b/src/transformers/string.ts @@ -1,22 +1,11 @@ -import { type TString, TypeGuard } from "@sinclair/typebox"; +import { type TString } from "@sinclair/typebox"; import type { Options } from "yargs"; -export function getStringOption( - schema: TString, - override: Options = {}, -): Options { - const hasDefaultValue = schema.default !== undefined; - const options = { - type: "string" as const, - requiresArg: !TypeGuard.IsOptional(schema) && !hasDefaultValue, - }; +import { transform } from "./transform"; - 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; +export function getStringOption( + schema: S, + overwrites: O = {} as never, +) { + return transform("string", schema, overwrites); } diff --git a/src/transformers/transform.spec.ts b/src/transformers/transform.spec.ts new file mode 100644 index 0000000..73fb563 --- /dev/null +++ b/src/transformers/transform.spec.ts @@ -0,0 +1,95 @@ +import { Type, type UnionToTuple } from "@sinclair/typebox"; +import { expect, it } from "vitest"; +import type { Options } from "yargs"; + +import { type SchemaType, transform } from "./transform"; + +const schemaTypes = [ + "string", + "number", + "boolean", + "array", +] satisfies UnionToTuple; + +it.each(schemaTypes)( + "should transform %s type schema to yargs option", + type => { + const schema = Type.Any(); + const result = transform(type, schema); + const expected = { + type, + requiresArg: true, + demandOption: true, + } satisfies Options; + + expect(result).toEqual(expected); + }, +); + +it("should check if it is marked as optional", () => { + const schema = Type.Optional(Type.Any()); + const overwrites = {} satisfies Options; + const expected = { + type: "string", + requiresArg: false, + demandOption: false, + } satisfies Options; + + const result = transform("string", schema, overwrites); + + expect(result).toEqual(expected); +}); + +it("should take default value if present and made it optional", () => { + const schema = Type.Any({ + default: true, + }); + const overwrites = {} satisfies Options; + const expected = { + type: "string", + requiresArg: false, + demandOption: false, + default: true, + } satisfies Options; + + const result = transform("string", schema, overwrites); + + expect(result).toEqual(expected); +}); + +it("should take description if present", () => { + const schema = Type.Any({ + description: "Something", + }); + const overwrites = {} satisfies Options; + const expected = { + type: "string", + requiresArg: true, + demandOption: true, + description: "Something", + }; + + const result = transform("string", schema, overwrites); + + expect(result).toEqual(expected); +}); + +it("should be able to overwrite given options", () => { + const schema = Type.Any(); + const overwrites = { + requiresArg: false, + description: "Something", + choices: ["foo", "bar"], + } satisfies Options; + const expected = { + type: "string", + requiresArg: false, + demandOption: true, + description: "Something", + choices: ["foo", "bar"], + }; + + const result = transform("string", schema, overwrites); + + expect(result).toEqual(expected); +}); diff --git a/src/transformers/transform.ts b/src/transformers/transform.ts new file mode 100644 index 0000000..6354720 --- /dev/null +++ b/src/transformers/transform.ts @@ -0,0 +1,33 @@ +import { type Static, type TSchema, TypeGuard } from "@sinclair/typebox"; +import type { Options } from "yargs"; + +export type SchemaType = "string" | "number" | "boolean" | "array"; + +/** + * Transform TypeBox schema to yargs options. + * @param type type of the yargs option + * @param schema TypeBox schema to transform + * @returns applicable yargs options + */ +export function transform< + T extends SchemaType, + S extends TSchema, + O extends Options = object, +>(type: T, schema: S, overwrites: O = {} as never) { + const hasDefaultValue = schema.default !== undefined; + const required = !TypeGuard.IsOptional(schema) && !hasDefaultValue; + const defaultValue = hasDefaultValue + ? (schema.default as Static) + : undefined; + + const base = { type } as { type: T }; + + return { + ...base, + requiresArg: required, + demandOption: required, + default: defaultValue, + description: schema.description, + ...overwrites, + } satisfies Options; +} diff --git a/tests/get-option.test.ts b/tests/get-option.test.ts index 1668369..8dd08ba 100644 --- a/tests/get-option.test.ts +++ b/tests/get-option.test.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { type TSchema, Type } from "@sinclair/typebox"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { Options } from "yargs"; @@ -12,103 +12,85 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("array", () => { - it("should pass schema to array transformer", () => { - const schema = Type.Array(Type.String()); - const result = component.getOption(schema); - expect(result).toEqual({ type: "array", requiresArg: true }); - }); +const types = [ + "array", + "boolean", + "number", + "string", + "literal", + "union", +] as const; - it("should pass override to array transformer", () => { - const schema = Type.Array(Type.String()); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(result).toEqual({ - type: "array", - requiresArg: true, - alias: "aliased", - }); - }); -}); +const schemas = { + array: Type.Array( + Type.Union([Type.Literal("id"), Type.Literal("createdAt")]), + ), + boolean: Type.Boolean({ description: "pretty print" }), + number: Type.Number({ description: "page size", default: 10 }), + string: Type.String(), + literal: Type.Literal(true), + union: Type.Union([Type.Literal("asc"), Type.Literal("desc")], { + default: "asc", + }), +} satisfies Record<(typeof types)[number], TSchema>; -describe("boolean", () => { - it("should pass schema to boolean transformer", () => { - const schema = Type.Boolean(); - const result = component.getOption(schema); - expect(result).toEqual({ type: "boolean", requiresArg: true }); - }); +const expectations = { + array: { + type: "array", + requiresArg: true, + demandOption: true, + choices: ["id", "createdAt"], + }, + boolean: { + type: "boolean", + requiresArg: true, + demandOption: true, + description: "pretty print", + }, + number: { + type: "number", + requiresArg: false, + demandOption: false, + default: 10, + description: "page size", + }, + string: { + type: "string", + requiresArg: true, + demandOption: true, + }, + literal: { + type: "string", + requiresArg: true, + demandOption: true, + choices: [true], + }, + union: { + type: "string", + requiresArg: false, + demandOption: false, + choices: ["asc", "desc"], + default: "asc", + }, +} satisfies Record<(typeof types)[number], Options>; - it("should pass override to boolean transformer", () => { - const schema = Type.Boolean(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(result).toEqual({ - type: "boolean", - requiresArg: true, - alias: "aliased", - }); - }); -}); +describe.each(types)("should properly handle %s", type => { + const schema = schemas[type]; + const expected = expectations[type]; -describe("number", () => { - it("should pass schema to number transformer", () => { - const schema = Type.Number(); + it("should transform", () => { const result = component.getOption(schema); - expect(result).toEqual({ type: "number", requiresArg: true }); - }); - - it("should pass override to number transformer", () => { - const schema = Type.Number(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(result).toEqual({ - type: "number", - requiresArg: true, - alias: "aliased", - }); - }); -}); -describe("string", () => { - it("should pass schema to string transformer", () => { - const schema = Type.String(); - const result = component.getOption(schema); - expect(result).toEqual({ type: "string", requiresArg: true }); + expect(result).toEqual(expected); }); - it("should pass override to string transformer", () => { - const schema = Type.String(); - const override: Options = { alias: "aliased" }; - const result = component.getOption(schema, override); - expect(result).toEqual({ - type: "string", - requiresArg: true, + it("should transform with overwrites", () => { + const override = { alias: "aliased", - }); - }); -}); - -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" }; + } satisfies Options; const result = component.getOption(schema, override); - expect(result).toEqual({ - type: "string", - requiresArg: true, - choices: ["foo", "bar"], - alias: "aliased", - }); + + expect(result).toEqual({ ...expected, alias: "aliased" }); }); }); @@ -121,7 +103,7 @@ describe("unaccepted type", () => { it("should still be able to override", () => { const schema = Type.BigInt(); - const override: Options = { alias: "aliased" }; + const override = { alias: "aliased" } satisfies Options; const result = component.getOption(schema, override); expect(result).toEqual({ alias: "aliased" }); }); diff --git a/tests/get-options.test.ts b/tests/get-options.test.ts index 3c851c9..c6749a9 100644 --- a/tests/get-options.test.ts +++ b/tests/get-options.test.ts @@ -26,21 +26,52 @@ it("should transform schemas of props in a TObject to yargs options", () => { }); const result = component.getOptions(schema); expect(result).toEqual({ - page: { type: "number", requiresArg: true, description: "page number" }, + page: { + type: "number", + requiresArg: true, + demandOption: true, + description: "page number", + }, size: { type: "number", requiresArg: false, + demandOption: false, default: 10, description: "page size", }, - query: { type: "string", requiresArg: false }, - sort: { type: "array", requiresArg: false, choices: ["id", "createdAt"] }, + query: { + type: "string", + requiresArg: false, + demandOption: false, + }, + sort: { + type: "array", + requiresArg: false, + demandOption: false, + choices: ["id", "createdAt"], + }, order: { type: "string", requiresArg: false, + demandOption: false, choices: ["asc", "desc"], default: "asc", }, - pretty: { type: "boolean", requiresArg: true, description: "pretty print" }, + pretty: { + type: "boolean", + requiresArg: true, + demandOption: true, + description: "pretty print", + }, + }); +}); + +it("should transform unknown schemas to empty object", () => { + const schema = Type.Object({ + foo: Type.Object({}), + }); + const result = component.getOptions(schema); + expect(result).toEqual({ + foo: {}, }); });