diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23444fa36..78b5624ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,18 @@ but the few rules below hold everywhere: ViaCEP and the alternatives listed in their docs) over HTTPS through `src/_internals/fetch-with-retry`, treat every response as untrusted input and never send anything but the CEP or address being looked up. +- `src/_cli/` is the `brazilian-utils` command (the `bin` of `package.json`), laid out like + `src/_internals/`: one function per folder. It is a generic dispatcher over `src/index.ts`, so a + new utility is a new command with no wiring. `run-cli` is the dispatcher as a pure function + (arguments and stdin in, output and exit code out), `run-bin` connects it to a process, and + `bin/bin.ts` is the three-line Node.js entry, built into `dist/cli.js` by its own pack config in + `vite.config.ts` with the library left external. No library entry point imports it, so it never + reaches a consumer's bundle. Arguments are strings unless `src/_cli/constants.ts` says otherwise: + `CLI_OPTION_KINDS` and `CLI_POSITIONAL_KINDS` list the options and positional arguments that are + a number, a boolean, a list or a date, and `CLI_PARAMS_UTILITIES` the utilities whose first + argument is an options object, which therefore never read stdin on their own. All three types + are derived from the public signatures, so `npm run check` fails when a new non-string option or + argument, or a new params-only utility, is missing from them. - There are no runtime dependencies (see [Zero runtime dependencies](#zero-runtime-dependencies)), so the trust boundary of the published package is this repository, its build toolchain and the npm registry; [MAINTAINERS.md](MAINTAINERS.md) lists who can change what, and diff --git a/README.md b/README.md index 59853d24e..0d3f8450e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,19 @@ The [utilities reference](https://brazilian-utils.com.br/utilities) lists every - Using an AI coding assistant? The docs are indexed on Context7 as [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript), and [llms.txt](https://brazilian-utils.com.br/llms.txt) lists every util for other tools. See [AI assistants](https://brazilian-utils.com.br/getting-started?id=ai-assistants). - The package is tree-shakeable. Every util is also available as its own subpath (e.g. `@brazilian-utils/brazilian-utils/get-cities`) so you can lazy-load the few heavy ones. See [Bundle size](https://brazilian-utils.com.br/getting-started?id=bundle-size). +## Command line + +Every utility also runs from a terminal or a shell script, with no install needed: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true (exit code 0; 1 for a negative answer) +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", ... } as JSON +echo 01001000 | npx @brazilian-utils/brazilian-utils getAddressInfoByCep # reads stdin, awaits the lookup +``` + +The first argument is the name of a utility, the positional values are its arguments and `--key value`, `--flag` or `--json ''` become its options object. `list` prints every utility and `--help` the full usage. See [Command line](https://brazilian-utils.com.br/getting-started?id=command-line). + ## Development This repository uses Vite+ as the local toolchain; it is installed as a dependency, so nothing has diff --git a/docs/getting-started.md b/docs/getting-started.md index 3dbe79b89..29701cb8d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,6 +48,49 @@ isValidCpf('1232454233345'); // false The [utilities reference](utilities.md) lists every function, grouped by family, with its options and examples. The [guides](guides/document-field.md) show a CPF field in React, Angular, Vue and plain JavaScript. +## Command line + +The package ships a `brazilian-utils` command that runs any utility from a terminal or a shell script, with no install needed: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true +npx @brazilian-utils/brazilian-utils generateCpf # 45654643304 +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } +``` + +`bunx @brazilian-utils/brazilian-utils` runs the same command, and Deno runs it as `deno run npm:@brazilian-utils/brazilian-utils`, asking for the permissions it needs (only the two CEP lookups reach the network). Once the package is installed in a project it is `npx brazilian-utils`, or plain `brazilian-utils` in a `package.json` script. + +The command is a generic dispatcher over the public API: the first argument is the name of a utility, exactly as it is exported, and the rest maps to its arguments. + +| You write | The utility receives | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isValidIe SP 110042490114` | Positional values, in order: `isValidIe("SP", "110042490114")` | +| `--key value`, `--key=value` | An entry of the options (or params) object; `--state-code` and `--stateCode` are the same key | +| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --symbol`, `isBusinessDay 2026-02-17 --no-include-optional` | +| `--json ''` | The options (or params) object as JSON; options given next to it win | +| `-`, or no value in a pipe | The value is read from stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | +| `--` | Ends the options, so that a value starting with `--` is read as a value | + +A utility whose first argument is an options object, such as `isHoliday`, `getHolidays` or `isValidBankAccount`, never takes a value from stdin on its own: it reads stdin only where you write `-`. So `brazilian-utils isHoliday --target-date 2026-09-07` answers the same inside a script whose own stdin is a file or a pipe. + +Values stay strings (so `001` keeps its zeros), except where the utility expects a number, a list (comma separated: `--accept cpf,cnpj`) or a date. Dates are written `YYYY-MM-DD`, mean that local calendar day, and are printed the same way: + +```bash +brazilian-utils addBusinessDays 2026-09-04 1 # 2026-09-08 +brazilian-utils getHolidays --year 2026 --state-code SP # [{ "name": "Ano novo", "date": "2026-01-01", ... }] +brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' +brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address +``` + +Strings and numbers are printed as they are, a date as its `YYYY-MM-DD` local day (inside JSON too), anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: + +```bash +if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi +``` + +`brazilian-utils list` prints the name of every utility, `--help` the usage and `--version` the version of the package. The command is a separate file that no entry point of the library imports, so it adds nothing to your bundle. + ## AI assistants The documentation is indexed on Context7 as [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript). In a coding agent connected to the Context7 MCP server, name the library in the prompt and the agent skips the library search: diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 86e5f018f..b05c68936 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -48,6 +48,49 @@ isValidCpf('1232454233345'); // false A [referência de utilitários](pt-br/utilities.md) lista todas as funções, agrupadas por família, com opções e exemplos. Os [guias](pt-br/guides/document-field.md) mostram um campo de CPF em React, Angular, Vue e JavaScript puro. +## Linha de comando + +O pacote traz o comando `brazilian-utils`, que executa qualquer utilitário a partir do terminal ou de um shell script, sem precisar instalar nada: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true +npx @brazilian-utils/brazilian-utils generateCpf # 45654643304 +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } +``` + +`bunx @brazilian-utils/brazilian-utils` executa o mesmo comando, e no Deno ele é `deno run npm:@brazilian-utils/brazilian-utils`, que pede as permissões necessárias (só as duas consultas de CEP acessam a rede). Com o pacote instalado no projeto ele vira `npx brazilian-utils`, ou só `brazilian-utils` em um script do `package.json`. + +O comando é um despachante genérico sobre a API pública: o primeiro argumento é o nome de um utilitário, exatamente como ele é exportado, e o restante vira os argumentos dele. + +| Você escreve | O utilitário recebe | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isValidIe SP 110042490114` | Valores posicionais, em ordem: `isValidIe("SP", "110042490114")` | +| `--key value`, `--key=value` | Uma entrada do objeto de opções (ou de parâmetros); `--state-code` e `--stateCode` são a mesma chave | +| `--flag`, `--no-flag` | Uma opção booleana com valor `true` ou `false`: `formatCurrency 10 --symbol`, `isBusinessDay 2026-02-17 --no-include-optional` | +| `--json ''` | O objeto de opções (ou de parâmetros) em JSON; as opções passadas junto com ele têm prioridade | +| `-`, ou nenhum valor em pipe | O valor é lido da stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | +| `--` | Encerra as opções, para que um valor que começa com `--` seja lido como valor | + +Um utilitário cujo primeiro argumento é um objeto de opções, como `isHoliday`, `getHolidays` ou `isValidBankAccount`, nunca pega um valor da stdin por conta própria: ele só lê a stdin onde você escreve `-`. Assim `brazilian-utils isHoliday --target-date 2026-09-07` responde a mesma coisa dentro de um script cuja stdin é um arquivo ou um pipe. + +Os valores continuam sendo strings (assim `001` mantém os zeros), exceto onde o utilitário espera um número, uma lista (separada por vírgulas: `--accept cpf,cnpj`) ou uma data. Datas são escritas como `YYYY-MM-DD`, representam esse dia do calendário local e são impressas do mesmo jeito: + +```bash +brazilian-utils addBusinessDays 2026-09-04 1 # 2026-09-08 +brazilian-utils getHolidays --year 2026 --state-code SP # [{ "name": "Ano novo", "date": "2026-01-01", ... }] +brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' +brazilian-utils getAddressInfoByCep 01001000 # aguarda a consulta e imprime o endereço +``` + +Strings e números são impressos como estão, uma data como o seu dia local `YYYY-MM-DD` (também dentro do JSON), e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é negativa (`false`, `null` ou a string vazia que um formatador devolve quando não consegue ler o valor) ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: + +```bash +if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi +``` + +`brazilian-utils list` imprime o nome de todos os utilitários, `--help` o modo de uso e `--version` a versão do pacote. O comando é um arquivo separado que nenhum ponto de entrada da biblioteca importa, então não acrescenta nada ao seu bundle. + ## Assistentes de IA A documentação está indexada no Context7 como [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript). Em um agente de código conectado ao servidor MCP do Context7, cite a biblioteca no prompt e o agente pula a busca pela biblioteca: diff --git a/knip.json b/knip.json index ee3124cf8..7225c7e01 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,12 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/index.ts", "src/*/*.ts!", "scripts/*.ts", "stryker.config.json"], + "entry": [ + "src/index.ts", + "src/*/*.ts!", + "src/_cli/bin/bin.ts!", + "scripts/*.ts", + "stryker.config.json" + ], "project": ["src/**/*.ts", "scripts/**/*.ts"], "ignore": ["src/_internals/test/runtime-deno.ts"], "ignoreDependencies": ["@arethetypeswrong/core", "publint"] diff --git a/package.json b/package.json index 7026d4835..3534bf37f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "type": "git", "url": "git+https://github.com/brazilian-utils/javascript.git" }, + "bin": { + "brazilian-utils": "./dist/cli.js" + }, "files": [ "./CHANGELOG.md", "./brazilian-utils.cdx.json", @@ -84,6 +87,7 @@ "default": "./dist/brazilian-utils.umd.cjs" } }, + "./cli": null, "./*": { "import": { "types": "./dist/*.d.ts", diff --git a/src/_cli/bin/bin.ts b/src/_cli/bin/bin.ts new file mode 100644 index 000000000..7dbe12dcc --- /dev/null +++ b/src/_cli/bin/bin.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { fstatSync } from "node:fs"; +import process from "node:process"; + +import packageJson from "../../../package.json" with { type: "json" }; +import * as api from "../../index"; +import { runBin } from "../run-bin/run-bin"; + +await runBin({ + api, + version: packageJson.version, + process, + isStdinPiped: () => { + const stats = fstatSync(process.stdin.fd); + + return stats.isFIFO() || stats.isFile(); + }, +}); diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.test.ts b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts new file mode 100644 index 000000000..a18ae56cf --- /dev/null +++ b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts @@ -0,0 +1,157 @@ +import * as fc from "fast-check"; + +import { anyText, anyValue } from "../../_internals/test/arbitraries"; +import { expectNeverThrows } from "../../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type CliValueKind } from "../constants"; +import { coerceCliValue } from "./coerce-cli-value"; + +const KINDS: (CliValueKind | "text" | undefined)[] = [ + "boolean", + "date", + "list", + "number", + "text", + undefined, +]; + +describe("coerceCliValue", () => { + test("should keep text with no kind as it is, leading zeros included", () => { + expect(coerceCliValue("001")).toBe("001"); + expect(coerceCliValue("001", "text")).toBe("001"); + expect(coerceCliValue("")).toBe(""); + expect(coerceCliValue("true")).toBe("true"); + }); + + test("should turn the text of a number into that number", () => { + expect(coerceCliValue("2", "number")).toBe(2); + expect(coerceCliValue("-5", "number")).toBe(-5); + expect(coerceCliValue("1234.56", "number")).toBe(1234.56); + expect(coerceCliValue(" 7 ", "number")).toBe(7); + }); + + test("should turn text that is not a number into NaN, a blank text included", () => { + expect(Number.isNaN(coerceCliValue("abc", "number"))).toBe(true); + expect(Number.isNaN(coerceCliValue("", "number"))).toBe(true); + expect(Number.isNaN(coerceCliValue(" ", "number"))).toBe(true); + }); + + test("should read a YYYY-MM-DD date as that local calendar day", () => { + const date = coerceCliValue("2024-12-25", "date") as Date; + + expect(date instanceof Date).toBe(true); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(11); + expect(date.getDate()).toBe(25); + expect(date.getHours()).toBe(0); + }); + + test("should read a year below 100 as written, not as 19xx", () => { + const date = coerceCliValue("0050-03-04", "date") as Date; + + expect(date.getFullYear()).toBe(50); + expect(date.getMonth()).toBe(2); + expect(date.getDate()).toBe(4); + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(0); + expect(date.getSeconds()).toBe(0); + expect(date.getMilliseconds()).toBe(0); + }); + + test("should read the last day of a month and a leap day", () => { + const leap = coerceCliValue("2024-02-29", "date") as Date; + const last = coerceCliValue("2024-12-31", "date") as Date; + + expect([leap.getFullYear(), leap.getMonth(), leap.getDate()]).toEqual([2024, 1, 29]); + expect([last.getFullYear(), last.getMonth(), last.getDate()]).toEqual([2024, 11, 31]); + }); + + test("should read a leap day of a year below 100 that 19xx does not have", () => { + const date = coerceCliValue("0000-02-29", "date") as Date; + + expect([date.getFullYear(), date.getMonth(), date.getDate()]).toEqual([0, 1, 29]); + expect(date.getHours()).toBe(0); + expect(Number.isNaN((coerceCliValue("0001-02-29", "date") as Date).getTime())).toBe(true); + }); + + test("should turn anything that is not a real YYYY-MM-DD day into an invalid date", () => { + for (const text of [ + "2024-02-30", + "2024-13-01", + "2024-00-10", + "2024-01-00", + "2024-12-32", + "2023-02-29", + "25/12/2024", + "2024-12-25T10:00:00", + " 2024-12-25", + "2024-12-25 ", + "today", + "", + ]) { + const date = coerceCliValue(text, "date") as Date; + + expect(date instanceof Date).toBe(true); + expect(Number.isNaN(date.getTime())).toBe(true); + } + }); + + test("should split a list on commas and trim every item", () => { + expect(coerceCliValue("cpf", "list")).toEqual(["cpf"]); + expect(coerceCliValue("cpf,cnpj", "list")).toEqual(["cpf", "cnpj"]); + expect(coerceCliValue(" de , da ,do", "list")).toEqual(["de", "da", "do"]); + }); + + test("should read true and false as booleans and keep any other text", () => { + expect(coerceCliValue("true", "boolean")).toBe(true); + expect(coerceCliValue("false", "boolean")).toBe(false); + expect(coerceCliValue("yes", "boolean")).toBe("yes"); + expect(coerceCliValue("", "boolean")).toBe(""); + }); + + test("should hand back a value that is not text untouched, whatever the kind", () => { + const list = ["cpf"]; + const date = new Date(2024, 0, 1); + + expect(coerceCliValue(true, "boolean")).toBe(true); + expect(coerceCliValue(false, "number")).toBe(false); + expect(coerceCliValue(3, "number")).toBe(3); + expect(coerceCliValue(3, "date")).toBe(3); + expect(coerceCliValue(list, "list")).toBe(list); + expect(coerceCliValue(date, "date")).toBe(date); + expect(coerceCliValue(null, "list")).toBeNull(); + expect((coerceCliValue as unknown as () => unknown)()).toBeUndefined(); + }); + + describe("properties", () => { + test("should never throw, whatever the value and the kind", () => { + for (const kind of KINDS) { + expectNeverThrows((value: unknown) => coerceCliValue(value, kind), anyValue); + } + }); + + test("should keep any text with no kind unchanged", () => { + fc.assert( + fc.property(anyText, (text) => { + expect(coerceCliValue(text)).toBe(text); + }), + ); + }); + + test("should read back any integer written as text", () => { + fc.assert( + fc.property(fc.integer(), (value) => { + expect(coerceCliValue(String(value), "number")).toBe(value); + }), + ); + }); + }); +}); + +describe("coerceCliValue types", () => { + test("should take any value and a kind, and return an unknown value", () => { + expectTypeOf(coerceCliValue).parameter(0).toEqualTypeOf(); + expectTypeOf(coerceCliValue).parameter(1).toEqualTypeOf(); + expectTypeOf(coerceCliValue).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.ts b/src/_cli/coerce-cli-value/coerce-cli-value.ts new file mode 100644 index 000000000..64c3116ad --- /dev/null +++ b/src/_cli/coerce-cli-value/coerce-cli-value.ts @@ -0,0 +1,66 @@ +import { type CliValueKind } from "../constants"; + +const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; + +const toDate = (value: string): Date => { + const match = ISO_DATE_REGEX.exec(value); + if (match === null) return new Date(Number.NaN); + + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(year, month, day); + + date.setFullYear(year, month, day); + + return date.getMonth() === month ? date : new Date(Number.NaN); +}; + +const toBoolean = (value: string): boolean | string => { + if (value === "true") return true; + if (value === "false") return false; + return value; +}; + +/** + * Turns the text of a command line argument into the value a utility expects. + * + * Only text is converted: a value that already has a type (a bare `--flag`, anything that came + * from `--json`) is handed back untouched, and so is text with no kind, which stays a string so + * that codes such as `"001"` keep their leading zeros. Text that does not spell its kind becomes + * the invalid value of that kind (`NaN`, an invalid `Date`), which every utility already answers + * with `false`, `null` or `""` instead of throwing. A boolean has no invalid value, so text that + * spells neither `"true"` nor `"false"` is left as it is; `parseCliArguments` rejects that + * spelling before it gets here, and only a `--json` entry can still carry it. + * + * A date is written `YYYY-MM-DD` and means that local calendar day, the way the date utilities of + * this library read a `Date`. A day that does not exist (`2024-02-30`, month `13`) rolls over into + * another month when it is built, which is how it is told apart and made an invalid `Date`. The + * whole day is set again afterwards because the `Date` constructor reads a year below 100 as 19xx, + * where a day such as `0000-02-29` would already have rolled over. + * + * @param {unknown} value - The argument, as written on the command line or read from `--json`. + * @param {CliValueKind | "text"} [kind] - The kind the utility expects. Text stays text without one. + * @returns {unknown} The converted value. + * + * @example + * ```typescript + * coerceCliValue("001"); // "001" + * coerceCliValue("2", "number"); // 2 + * coerceCliValue("abc", "number"); // NaN + * coerceCliValue("2024-12-25", "date"); // new Date(2024, 11, 25) + * coerceCliValue("cpf, cnpj", "list"); // ["cpf", "cnpj"] + * coerceCliValue("false", "boolean"); // false + * coerceCliValue(3, "number"); // 3 + * ``` + */ +export const coerceCliValue = (value: unknown, kind?: CliValueKind | "text"): unknown => { + if (typeof value !== "string") return value; + + if (kind === "boolean") return toBoolean(value); + if (kind === "date") return toDate(value); + if (kind === "list") return value.split(",").map((item) => item.trim()); + if (kind === "number") return value.trim() === "" ? Number.NaN : Number(value); + + return value; +}; diff --git a/src/_cli/constants.ts b/src/_cli/constants.ts new file mode 100644 index 000000000..d5ba99f18 --- /dev/null +++ b/src/_cli/constants.ts @@ -0,0 +1,196 @@ +import type * as api from "../index"; + +/** How the command line turns the text of an argument into the value a utility expects. */ +export type CliValueKind = "boolean" | "date" | "list" | "number"; + +/** The kind of a positional argument: `"text"` holds the place of one that stays a string. */ +export type CliPositionalKind = CliValueKind | "text"; + +type Api = typeof api; + +type Callable = (...args: never[]) => unknown; + +type ObjectOf = Value extends Date | readonly unknown[] | Callable + ? never + : Value extends object + ? Value + : never; + +type KindOf = [Value] extends [never] + ? never + : [Value] extends [boolean] + ? "boolean" + : [Value] extends [number] + ? "number" + : [Value] extends [Date] + ? "date" + : [Value] extends [readonly unknown[]] + ? "list" + : never; + +type ArgumentsOf = Name extends unknown + ? Api[Name] extends Callable + ? Required> + : never + : never; + +type ObjectArgument = ObjectOf[number]>; + +type KeysOf = Value extends unknown ? keyof Value : never; + +type ValueOf = Value extends unknown + ? Key extends keyof Value + ? NonNullable + : never + : never; + +type PositionalKindsOf = Arguments extends [infer Head, ...infer Rest] + ? [Exclude>] extends [never] + ? [] + : [ + [KindOf>>] extends [never] + ? "text" + : KindOf>>, + ...PositionalKindsOf, + ] + : []; + +type HasKind = [Exclude] extends [never] + ? false + : true; + +type CliOptionKinds = { + [ + Key in KeysOf as [KindOf>] extends [never] + ? never + : Key + ]: KindOf>; +}; + +type CliPositionalKinds = { + [ + Name in keyof Api as HasKind>> extends true ? Name : never + ]: PositionalKindsOf>; +}; + +type CliParamsUtilities = { + [ + Name in keyof Api as Name extends Capitalize + ? never + : ArgumentsOf extends [unknown, ...unknown[]] + ? PositionalKindsOf> extends [] + ? Name + : never + : never + ]: true; +}; + +/** + * Exit codes of the command line: a negative answer (`false`, `null` or a utility that throws) is + * told apart from a call that could not be made at all. + */ +export const CLI_EXIT_CODES = { + success: 0, + failure: 1, + usage: 2, +} as const; + +/** + * Every option key of the public API whose value is not a string. The type is derived from the + * signatures exported by `src/index.ts`, so an option added to a utility without its entry here + * fails `npm run check`. + */ +export const CLI_OPTION_KINDS: Readonly> = { + accept: "list", + amount: "number", + branch: "number", + court: "number", + includeLegacy: "boolean", + includeOptional: "boolean", + lowerCaseWords: "list", + obfuscate: "boolean", + pad: "boolean", + precision: "number", + providers: "list", + referenceDate: "date", + symbol: "boolean", + targetDate: "date", + upperCaseWords: "list", + version: "number", + weekday: "boolean", + year: "number", +} satisfies CliOptionKinds; + +/** + * Every utility with a positional argument that is not a string, checked against the signatures + * the same way. `getHolidays` is listed by hand: its `year` overload is hidden from the derived + * type by the params overload declared after it. + */ +export const CLI_POSITIONAL_KINDS: Readonly> = { + addBusinessDays: ["date", "number"], + convertCurrencyToWords: ["number"], + convertNumberToWords: ["number"], + differenceInBusinessDays: ["date", "date"], + generateCNPJ: ["number"], + generateCnpj: ["number"], + getHolidays: ["number"], + isBusinessDay: ["date"], + subBusinessDays: ["date", "number"], +} satisfies CliPositionalKinds & Record; + +/** + * Every utility whose first argument is an options or params object, derived from the signatures + * the same way. Their values are written as options, so stdin is only read for them when a `-` + * asks for it: a command run with stdin attached to a file or a pipe, as a shell script is, must + * not turn that text into a first argument. `getHolidays` is here through its params overload, + * and still takes a year as a written positional value. + */ +export const CLI_PARAMS_UTILITIES: Readonly> = { + generateBoleto: true, + generatePixPayload: true, + generateProcessoJuridico: true, + getCepInfoByAddress: true, + getHolidays: true, + getLegalNatures: true, + getMunicipality: true, + isHoliday: true, + isValidBankAccount: true, + isValidRegistroProfissional: true, +} satisfies CliParamsUtilities; + +/** The text printed by `--help`. */ +export const CLI_USAGE = `Usage: brazilian-utils [value...] [--option value] [--flag] [--json ''] + +Runs any utility exported by @brazilian-utils/brazilian-utils. + + brazilian-utils isValidCpf 12345678909 + brazilian-utils formatCnpj 12345678000195 --obfuscate + brazilian-utils getBankByCode 001 + brazilian-utils isValidIe SP 110042490114 + brazilian-utils getHolidays --year 2026 --state-code SP + brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' + echo 01001000 | brazilian-utils getAddressInfoByCep + +Arguments: + value Positional arguments of the utility, in order. "-" reads the value from + stdin, and so does leaving the value out while stdin is a pipe or a file, + unless the first argument of the utility is an options object. + --key value An entry of the options (or params) object. --key=value and --kebab-case + are accepted too. + --flag, --no-flag A boolean option set to true or to false. + --json '' The options (or params) object as JSON. Options given next to it win. + -- Ends the options: everything after it is a positional value. + +Dates are written YYYY-MM-DD and mean that local calendar day. Lists are comma separated. + +Commands: + list Prints the name of every utility. + --help, -h Prints this help. + --version, -v Prints the version of the package. + +Output: strings and numbers as they are, anything else as JSON. +Exit codes: 0 on success, 1 when the answer is negative (false, null or the empty string a +formatter answers with) or the utility throws, 2 when the command line itself is wrong. + +Documentation: https://brazilian-utils.com.br +`; diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts new file mode 100644 index 000000000..7d78cd897 --- /dev/null +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts @@ -0,0 +1,206 @@ +import * as fc from "fast-check"; + +import { PROTOTYPE_KEYS, anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type ParsedCliArguments, parseCliArguments } from "./parse-cli-arguments"; + +const plainText = anyText.filter((text) => !text.startsWith("--")); + +describe("parseCliArguments", () => { + test("should return nothing for an empty command line", () => { + expect(parseCliArguments([])).toEqual({ positionals: [], options: {}, error: null }); + }); + + test("should keep the positional values in order", () => { + expect(parseCliArguments(["SP", "110042490114"])).toEqual({ + positionals: ["SP", "110042490114"], + options: {}, + error: null, + }); + }); + + test("should read a negative number and the stdin marker as positional values", () => { + expect(parseCliArguments(["-5", "-"])).toEqual({ + positionals: ["-5", "-"], + options: {}, + error: null, + }); + }); + + test("should read --key value as a text option", () => { + expect(parseCliArguments(["--year", "2026", "--mask", "international"])).toEqual({ + positionals: [], + options: { year: "2026", mask: "international" }, + error: null, + }); + }); + + test("should read --key=value as a text option, keeping every later equals sign", () => { + expect(parseCliArguments(["--year=2026", "--description=a=b", "--txid="])).toEqual({ + positionals: [], + options: { year: "2026", description: "a=b", txid: "" }, + error: null, + }); + }); + + test("should take a value that starts with a single dash", () => { + expect(parseCliArguments(["--amount", "-5"])).toEqual({ + positionals: [], + options: { amount: "-5" }, + error: null, + }); + }); + + test("should read --kebab-case names as camelCase", () => { + expect(parseCliArguments(["--state-code", "SP", "--merchant-name=Loja"])).toEqual({ + positionals: [], + options: { stateCode: "SP", merchantName: "Loja" }, + error: null, + }); + expect(parseCliArguments(["--stateCode", "SP"]).options).toEqual({ stateCode: "SP" }); + }); + + test("should set a boolean option with a bare flag, without taking the next value", () => { + expect(parseCliArguments(["--obfuscate", "12345678000195", "--pad"])).toEqual({ + positionals: ["12345678000195"], + options: { obfuscate: true, pad: true }, + error: null, + }); + expect(parseCliArguments(["--include-optional"]).options).toEqual({ includeOptional: true }); + }); + + test("should unset a boolean option with --no-flag", () => { + expect(parseCliArguments(["--no-symbol", "10", "--no-include-optional"])).toEqual({ + positionals: ["10"], + options: { symbol: false, includeOptional: false }, + error: null, + }); + }); + + test("should read the value given to a boolean option with an equals sign as the boolean", () => { + expect(parseCliArguments(["--pad=false", "--symbol=true"]).options).toEqual({ + pad: false, + symbol: true, + }); + }); + + test("should reject a boolean option whose value spells neither true nor false", () => { + expect(parseCliArguments(["--pad=x"])).toEqual({ + positionals: [], + options: {}, + error: "Option --pad needs true or false.", + }); + expect(parseCliArguments(["1", "--include-optional=yes", "--pad"])).toEqual({ + positionals: ["1"], + options: {}, + error: "Option --include-optional needs true or false.", + }); + }); + + test("should reject a value given to --no-flag", () => { + expect(parseCliArguments(["--no-pad=x"])).toEqual({ + positionals: [], + options: {}, + error: "Option --no-pad does not take a value.", + }); + expect(parseCliArguments(["--no-symbol=false"]).error).toBe( + "Option --no-symbol does not take a value.", + ); + }); + + test("should read --no-key as an ordinary text option when key is not a boolean option", () => { + expect(parseCliArguments(["--no-year", "5", "--no-", "x", "--no", "y"])).toEqual({ + positionals: [], + options: { noYear: "5", "no-": "x", no: "y" }, + error: null, + }); + }); + + test("should treat --help as a flag", () => { + expect(parseCliArguments(["--help", "123"])).toEqual({ + positionals: ["123"], + options: { help: true }, + error: null, + }); + expect(parseCliArguments(["--help=true"]).options).toEqual({ help: true }); + expect(parseCliArguments(["--help=x"]).error).toBe("Option --help needs true or false."); + }); + + test("should let the last occurrence of an option win", () => { + expect(parseCliArguments(["--year", "2025", "--year", "2026"]).options).toEqual({ + year: "2026", + }); + expect(parseCliArguments(["--pad", "--no-pad"]).options).toEqual({ pad: false }); + }); + + test("should read everything after a bare -- as positional values", () => { + expect(parseCliArguments(["--pad", "--", "--obfuscate", "--", "-x", "--year=1"])).toEqual({ + positionals: ["--obfuscate", "--", "-x", "--year=1"], + options: { pad: true }, + error: null, + }); + }); + + test("should report a text option left without a value", () => { + expect(parseCliArguments(["123", "--year"])).toEqual({ + positionals: ["123"], + options: {}, + error: "Option --year needs a value.", + }); + expect(parseCliArguments(["--state-code", "--pad", "1"])).toEqual({ + positionals: [], + options: {}, + error: "Option --stateCode needs a value.", + }); + expect(parseCliArguments(["--year", "--"]).error).toBe("Option --year needs a value."); + }); + + test("should store prototype keys as plain own options", () => { + for (const key of PROTOTYPE_KEYS) { + const { options, error } = parseCliArguments([`--${key}`, "x"]); + + expect(error).toBeNull(); + expect(Object.hasOwn(options, key)).toBe(true); + expect(Object.getPrototypeOf(options)).toBe(Object.prototype); + } + expect(parseCliArguments(["--__proto__=x"]).options["__proto__"]).toBe("x"); + expect(parseCliArguments(["--toString"]).error).toBe("Option --toString needs a value."); + }); + + describe("properties", () => { + test("should never throw and always return the three keys", () => { + fc.assert( + fc.property(fc.array(anyText), (argv) => { + const parsed = parseCliArguments(argv); + + expect(Object.keys(parsed)).toEqual(["positionals", "options", "error"]); + }), + ); + }); + + test("should hand back every value that does not start with two dashes as positional", () => { + fc.assert( + fc.property(fc.array(plainText), (argv) => { + expect(parseCliArguments(argv)).toEqual({ positionals: argv, options: {}, error: null }); + }), + ); + }); + + test("should hand back anything after a bare -- as positional", () => { + fc.assert( + fc.property(fc.array(anyText), (argv) => { + expect(parseCliArguments(["--", ...argv]).positionals).toEqual(argv); + }), + ); + }); + }); +}); + +describe("parseCliArguments types", () => { + test("should take the arguments and return the parsed command line", () => { + expectTypeOf(parseCliArguments).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCliArguments).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts new file mode 100644 index 000000000..453f7b8e4 --- /dev/null +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts @@ -0,0 +1,110 @@ +import { CLI_OPTION_KINDS } from "../constants"; + +/** The command line of a utility, split into its positional values and its options. */ +export type ParsedCliArguments = { + /** The positional values, in the order they were written. */ + positionals: string[]; + /** The options, keyed in camelCase: text for `--key value`, a boolean for a flag. */ + options: Record; + /** Why the command line cannot be read, or `null` when it can. */ + error: string | null; +}; + +const OPTION_PREFIX = "--"; +const NEGATION_PREFIX = "no-"; +const KEBAB_REGEX = /-([a-z])/g; + +const toCamelCase = (name: string): string => + name.replaceAll(KEBAB_REGEX, (_match, letter: string) => letter.toUpperCase()); + +const isFlag = (name: string): boolean => + name === "help" || + (Object.hasOwn(CLI_OPTION_KINDS, name) && CLI_OPTION_KINDS[name] === "boolean"); + +type Token = { option: [string, string | boolean] } | { pending: string } | { error: string }; + +const toToken = (body: string): Token => { + const separator = body.indexOf("="); + const written = separator === -1 ? body : body.slice(0, separator); + const name = toCamelCase(written); + const negated = toCamelCase(written.slice(NEGATION_PREFIX.length)); + const isNegated = written.startsWith(NEGATION_PREFIX) && isFlag(negated); + + if (separator === -1) { + if (isFlag(name)) return { option: [name, true] }; + if (isNegated) return { option: [negated, false] }; + return { pending: name }; + } + + const value = body.slice(separator + 1); + + if (isNegated) return { error: `Option --${written} does not take a value.` }; + if (!isFlag(name)) return { option: [name, value] }; + if (value === "true" || value === "false") return { option: [name, value === "true"] }; + return { error: `Option --${written} needs true or false.` }; +}; + +/** + * Splits the arguments that follow the utility name into positional values and options. + * + * `--key value` and `--key=value` are text options, `--flag` and `--no-flag` set a boolean option + * (the keys listed as `"boolean"` in `CLI_OPTION_KINDS`, plus `help`) to `true` or `false`, and + * `--kebab-case` names are read as `camelCase`. Everything after a bare `--` is positional, and + * so is anything that does not start with `--`, which covers negative numbers and the `-` that + * stands for stdin. Apart from the booleans, nothing is converted here: values stay text. + * + * A boolean option spelled `--flag=value` takes only `true` or `false`, and `--no-flag` takes no + * value at all. Anything else is a usage error rather than an option that looks set and holds + * text, which any non-empty value would make true. + * + * @param {readonly string[]} argv - The arguments after the utility name. + * @returns {ParsedCliArguments} The positional values and the options, or the reason the command line cannot be read. + * + * @example + * ```typescript + * parseCliArguments(["12345678000195", "--obfuscate"]); + * // { positionals: ["12345678000195"], options: { obfuscate: true }, error: null } + * parseCliArguments(["--year", "2026", "--state-code=SP", "--no-pad"]); + * // { positionals: [], options: { year: "2026", stateCode: "SP", pad: false }, error: null } + * parseCliArguments(["--year"]); + * // { positionals: [], options: {}, error: "Option --year needs a value." } + * parseCliArguments(["--pad=yes"]); + * // { positionals: [], options: {}, error: "Option --pad needs true or false." } + * ``` + */ +export const parseCliArguments = (argv: readonly string[]): ParsedCliArguments => { + const positionals: string[] = []; + const entries: [string, string | boolean][] = []; + let pending: string | null = null; + let rejected: string | null = null; + let isLiteral = false; + + for (const token of argv) { + const isOption = !isLiteral && token.startsWith(OPTION_PREFIX); + + if (pending !== null) { + if (isOption) break; + entries.push([pending, token]); + pending = null; + } else if (isOption && token === OPTION_PREFIX) { + isLiteral = true; + } else if (isOption) { + const parsed = toToken(token.slice(OPTION_PREFIX.length)); + + if ("error" in parsed) { + rejected = parsed.error; + break; + } + if ("pending" in parsed) pending = parsed.pending; + else entries.push(parsed.option); + } else { + positionals.push(token); + } + } + + return { + positionals, + options: Object.fromEntries(entries), + error: rejected ?? (pending === null ? null : `Option --${pending} needs a value.`), + }; +}; diff --git a/src/_cli/run-bin/run-bin.test.ts b/src/_cli/run-bin/run-bin.test.ts new file mode 100644 index 000000000..4a5fd3aa2 --- /dev/null +++ b/src/_cli/run-bin/run-bin.test.ts @@ -0,0 +1,153 @@ +import * as fc from "fast-check"; + +import { anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type BinProcess, type RunBinParams, runBin } from "./run-bin"; + +const createProcess = (args: string[], chunks: unknown[] = []) => { + const written = { stdout: [] as string[], stderr: [] as string[] }; + const stdin = { encodings: [] as string[], reads: 0 }; + const process: BinProcess = { + argv: ["/usr/bin/node", "/usr/bin/brazilian-utils", ...args], + stdin: { + setEncoding: (encoding) => stdin.encodings.push(encoding), + [Symbol.asyncIterator]() { + const iterator = chunks[Symbol.iterator](); + + stdin.reads += 1; + return { next: () => Promise.resolve(iterator.next()) }; + }, + }, + stdout: { write: (text) => written.stdout.push(text) }, + stderr: { write: (text) => written.stderr.push(text) }, + }; + + return { process, written, stdin }; +}; + +const api = { + echo: (value: unknown) => value, + isValidThing: (value: unknown) => value === "yes", + generateThing: () => "generated", +}; + +const runWith = async ( + args: string[], + { chunks = [] as unknown[], isStdinPiped = (): boolean => false } = {}, +) => { + const context = createProcess(args, chunks); + + await runBin({ api, version: "9.8.7", process: context.process, isStdinPiped }); + + return context; +}; + +describe("runBin", () => { + test("should skip the runtime and the script, write the result to stdout and exit with 0", async () => { + const { process, written, stdin } = await runWith(["echo", "12345678909"]); + + expect(written).toEqual({ stdout: ["12345678909\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + expect(stdin).toEqual({ encodings: [], reads: 0 }); + }); + + test("should print the version it was given", async () => { + const { process, written } = await runWith(["--version"]); + + expect(written).toEqual({ stdout: ["9.8.7\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + }); + + test("should exit with 1 for a negative answer", async () => { + const { process, written } = await runWith(["isValidThing", "no"]); + + expect(written).toEqual({ stdout: ["false\n"], stderr: [] }); + expect(process.exitCode).toBe(1); + }); + + test("should write a usage error to stderr only and exit with 2", async () => { + const { process, written } = await runWith(["nope"]); + + expect(written).toEqual({ + stdout: [], + stderr: [ + 'Unknown utility "nope". Run "brazilian-utils list" to see every utility.\nRun "brazilian-utils --help" for usage.\n', + ], + }); + expect(process.exitCode).toBe(2); + }); + + test("should read a piped stdin as UTF-8 text, joining its chunks", async () => { + const { process, written, stdin } = await runWith(["echo"], { + chunks: ["São ", "Paulo\n"], + isStdinPiped: () => true, + }); + + expect(written).toEqual({ stdout: ["São Paulo\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + expect(stdin).toEqual({ encodings: ["utf8"], reads: 1 }); + }); + + test("should turn a chunk that is not text into text", async () => { + const { written } = await runWith(["echo", "-"], { chunks: [12, 34] }); + + expect(written.stdout).toEqual(["1234\n"]); + }); + + test("should read stdin for - even when it is not piped", async () => { + const { written, stdin } = await runWith(["echo", "-"], { chunks: ["typed\n"] }); + + expect(written.stdout).toEqual(["typed\n"]); + expect(stdin).toEqual({ encodings: ["utf8"], reads: 1 }); + }); + + test("should not read a stdin that is not piped", async () => { + const { written, stdin } = await runWith(["echo"], { chunks: ["ignored"] }); + + expect(written.stdout).toEqual(["null\n"]); + expect(stdin.reads).toBe(0); + }); + + test("should count a stdin probe that throws as not piped", async () => { + const { process, written, stdin } = await runWith(["echo"], { + chunks: ["ignored"], + isStdinPiped: () => { + throw new Error("EBADF: bad file descriptor, fstat"); + }, + }); + + expect(written).toEqual({ stdout: ["null\n"], stderr: [] }); + expect(process.exitCode).toBe(1); + expect(stdin.reads).toBe(0); + }); + + test("should not touch a piped stdin for a utility that takes no argument", async () => { + const { written, stdin } = await runWith(["generateThing"], { + chunks: ["ignored"], + isStdinPiped: () => true, + }); + + expect(written.stdout).toEqual(["generated\n"]); + expect(stdin.reads).toBe(0); + }); + + describe("properties", () => { + test("should never reject and always set a known exit code", async () => { + await fc.assert( + fc.asyncProperty(fc.array(anyText), fc.array(anyText), async (args, chunks) => { + const { process } = await runWith(args, { chunks, isStdinPiped: () => true }); + + expect([0, 1, 2].includes(process.exitCode as number)).toBe(true); + }), + ); + }); + }); +}); + +describe("runBin types", () => { + test("should take the params and resolve to nothing", () => { + expectTypeOf(runBin).parameter(0).toEqualTypeOf(); + expectTypeOf(runBin).returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<() => boolean>(); + }); +}); diff --git a/src/_cli/run-bin/run-bin.ts b/src/_cli/run-bin/run-bin.ts new file mode 100644 index 000000000..65e5b460d --- /dev/null +++ b/src/_cli/run-bin/run-bin.ts @@ -0,0 +1,96 @@ +import { runCli } from "../run-cli/run-cli"; + +/** A stream the command line writes text to. */ +type BinOutput = { + /** Writes the text. */ + write: (text: string) => unknown; +}; + +/** The standard input stream, read as UTF-8 text. */ +type BinInput = AsyncIterable & { + /** Makes the stream yield decoded text instead of bytes. */ + setEncoding: (encoding: "utf8") => unknown; +}; + +/** The part of the Node.js `process` the command line uses. */ +export type BinProcess = { + /** The runtime, the script and then the command line arguments. */ + argv: readonly string[]; + /** The standard input. */ + stdin: BinInput; + /** The standard output. */ + stdout: BinOutput; + /** The standard error. */ + stderr: BinOutput; + /** The exit code of the process, set instead of exiting so that pending output is flushed. */ + exitCode?: number | string | null; +}; + +/** Params of `runBin`. */ +export type RunBinParams = { + /** The exports the command line dispatches to: the public API of the package. */ + api: Readonly>; + /** The version printed by `--version`. */ + version: string; + /** The process to read the arguments and stdin from and to write the result to. */ + process: BinProcess; + /** Tells whether stdin is a pipe or a file. May throw (a closed stdin): that counts as no. */ + isStdinPiped: () => boolean; +}; + +// Stryker disable BlockStatement: with an empty catch block a probe that throws answers `undefined`, which `runCli` reads as not piped exactly like `false`. +const isPiped = (isStdinPiped: () => boolean): boolean => { + try { + return isStdinPiped(); + } catch { + return false; + } +}; +// Stryker restore BlockStatement + +const readAll = async (stdin: BinInput): Promise => { + let text = ""; + + stdin.setEncoding("utf8"); + for await (const chunk of stdin) text += String(chunk); + + return text; +}; + +/** + * Connects `runCli` to a process: reads its arguments and stdin, writes the result to its stdout + * and stderr and sets its exit code. + * + * The exit code is set on the process instead of calling `process.exit()`, so that a long result + * (`getMunicipalities`) is flushed before the process ends. Stdin is only touched when `runCli` + * asks for it. + * + * @param {RunBinParams} params - The API, the version, the process and the stdin probe. + * @returns {Promise} Settles once the result has been written. + * + * @example + * ```typescript + * import process from "node:process"; + * + * import * as api from "@brazilian-utils/brazilian-utils"; + * + * await runBin({ api, version: "2.4.0", process, isStdinPiped: () => false }); + * ``` + */ +export const runBin = async ({ + api, + version, + process, + isStdinPiped, +}: RunBinParams): Promise => { + const result = await runCli({ + argv: process.argv.slice(2), + api, + version, + stdin: { isPiped: isPiped(isStdinPiped), read: () => readAll(process.stdin) }, + }); + + if (result.stdout !== "") process.stdout.write(result.stdout); + if (result.stderr !== "") process.stderr.write(result.stderr); + process.exitCode = result.exitCode; +}; diff --git a/src/_cli/run-cli/run-cli.test.ts b/src/_cli/run-cli/run-cli.test.ts new file mode 100644 index 000000000..af83a4c88 --- /dev/null +++ b/src/_cli/run-cli/run-cli.test.ts @@ -0,0 +1,694 @@ +import * as fc from "fast-check"; + +import { PROTOTYPE_KEYS, anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { addBusinessDays } from "../../add-business-days/add-business-days"; +import { convertNumberToWords } from "../../convert-number-to-words/convert-number-to-words"; +import { formatCnpj } from "../../format-cnpj/format-cnpj"; +import { formatCpf } from "../../format-cpf/format-cpf"; +import { formatCurrency } from "../../format-currency/format-currency"; +import { generateCnpj } from "../../generate-cnpj/generate-cnpj"; +import { generateCpf } from "../../generate-cpf/generate-cpf"; +import { + GetAddressInfoByCepError, + getAddressInfoByCep, +} from "../../get-address-info-by-cep/get-address-info-by-cep"; +import { getBankByCode } from "../../get-bank-by-code/get-bank-by-code"; +import { getCepInfoByAddress } from "../../get-cep-info-by-address/get-cep-info-by-address"; +import { getHolidays } from "../../get-holidays/get-holidays"; +import type * as brazilianUtils from "../../index"; +import { isBusinessDay } from "../../is-business-day/is-business-day"; +import { isHoliday } from "../../is-holiday/is-holiday"; +import { isValidCnpj } from "../../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../../is-valid-cpf/is-valid-cpf"; +import { isValidIe } from "../../is-valid-ie/is-valid-ie"; +import { CLI_USAGE } from "../constants"; +import { type CliResult, type CliStdin, type RunCliParams, runCli } from "./run-cli"; + +const USAGE_HINT = 'Run "brazilian-utils --help" for usage.\n'; + +const HOLIDAYS_2026_HEAD = + '[\n {\n "name": "Ano novo",\n "date": "2026-01-01",\n "type": "national"\n },'; + +const terminal: CliStdin = { + isPiped: false, + read: () => Promise.reject(new Error("stdin must not be read")), +}; + +const pipe = (text: string): CliStdin => ({ isPiped: true, read: () => Promise.resolve(text) }); + +const typed = (text: string): CliStdin => ({ isPiped: false, read: () => Promise.resolve(text) }); + +const createApi = () => { + const calls: unknown[][] = []; + const record = (produce: () => unknown, length = 1) => + Object.defineProperty( + (...args: unknown[]) => { + calls.push(args); + return produce(); + }, + "length", + { value: length }, + ); + + return { + calls, + api: { + formatThing: record(() => "formatted", 2), + isValidThing: record(() => false), + getThing: record(() => null), + getThings: record(() => [{ code: "001" }], 0), + generateThing: record(() => "generated", 0), + addBusinessDays: record(() => new Date(2026, 8, 8), 3), + fetchThing: record(() => Promise.resolve({ cep: "01001000" })), + rejectThing: record(() => Promise.reject(new RangeError("service down"))), + throwThing: record(() => { + throw new TypeError("boom"); + }), + throwText: record(() => { + // eslint-disable-next-line typescript/only-throw-error, sonarjs/no-throw-literal -- a hostile export + throw "plain text"; + }), + ThingError: class ThingError extends Error {}, + VERSION: "1.0.0", + settings: { pad: true }, + } as Record, + }; +}; + +const stdoutOf = async (result: Promise) => { + const { stdout } = await result; + + return stdout; +}; + +const exitCodeOf = async (result: Promise) => { + const { exitCode } = await result; + + return exitCode; +}; + +const publicApi = { + GetAddressInfoByCepError, + addBusinessDays, + convertNumberToWords, + formatCnpj, + formatCpf, + formatCurrency, + generateCnpj, + generateCpf, + getAddressInfoByCep, + getBankByCode, + getCepInfoByAddress, + getHolidays, + isBusinessDay, + isHoliday, + isValidCnpj, + isValidCpf, + isValidIe, +}; + +const runApi = (argv: string[], stdin: CliStdin = terminal) => + runCli({ argv, api: publicApi, version: "9.8.7", stdin }); + +const apiStdout = (argv: string[], stdin: CliStdin = terminal) => stdoutOf(runApi(argv, stdin)); + +const run = (argv: string[], stdin: CliStdin = terminal, api = createApi().api) => + runCli({ argv, api, version: "9.8.7", stdin }); + +describe("runCli", () => { + describe("built-in commands", () => { + test("should print the usage on stderr and exit with 2 when no utility is given", async () => { + expect(await run([])).toEqual({ stdout: "", stderr: CLI_USAGE, exitCode: 2 }); + expect(await run([""])).toEqual({ stdout: "", stderr: CLI_USAGE, exitCode: 2 }); + }); + + test("should print the usage for --help and -h", async () => { + expect(await run(["--help"])).toEqual({ stdout: CLI_USAGE, stderr: "", exitCode: 0 }); + expect(await run(["-h"])).toEqual({ stdout: CLI_USAGE, stderr: "", exitCode: 0 }); + }); + + test("should print the usage for --help after a utility, without calling it", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "1", "--help"], terminal, api)).toEqual({ + stdout: CLI_USAGE, + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([]); + }); + + test("should describe the command line in the usage", () => { + expect(CLI_USAGE.startsWith("Usage: brazilian-utils [value...]")).toBe(true); + expect(CLI_USAGE.endsWith("Documentation: https://brazilian-utils.com.br\n")).toBe(true); + for (const term of ["--json", "--no-flag", "--version", "list", "stdin", "YYYY-MM-DD"]) { + expect(CLI_USAGE.includes(term)).toBe(true); + } + }); + + test("should print the version for --version and -v", async () => { + expect(await run(["--version"])).toEqual({ stdout: "9.8.7\n", stderr: "", exitCode: 0 }); + expect(await run(["-v"])).toEqual({ stdout: "9.8.7\n", stderr: "", exitCode: 0 }); + }); + + test("should keep --version after a utility as the version option of that utility", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "1", "--version", "2"], terminal, api); + + expect(calls).toEqual([["1", { version: 2 }]]); + }); + + test("should list every callable lower-camel-case export, sorted, one per line", async () => { + expect(await run(["list"])).toEqual({ + stdout: [ + "addBusinessDays", + "fetchThing", + "formatThing", + "generateThing", + "getThing", + "getThings", + "isValidThing", + "rejectThing", + "throwText", + "throwThing", + "", + ].join("\n"), + stderr: "", + exitCode: 0, + }); + }); + }); + + describe("unknown utilities", () => { + test("should reject a name that is not exported", async () => { + expect(await run(["nope"])).toEqual({ + stdout: "", + stderr: `Unknown utility "nope". Run "brazilian-utils list" to see every utility.\n${USAGE_HINT}`, + exitCode: 2, + }); + }); + + test("should reject an export that is not a utility", async () => { + const names = ["ThingError", "VERSION", "settings", "help", "--nope", "-x"]; + const results = await Promise.all(names.map((name) => run([name]))); + + expect(results.map(({ exitCode }) => exitCode)).toEqual([2, 2, 2, 2, 2, 2]); + expect(results.map(({ stdout }) => stdout)).toEqual(["", "", "", "", "", ""]); + expect(results.map(({ stderr }) => stderr.slice(0, 27))).toEqual([ + 'Unknown utility "ThingError', + 'Unknown utility "VERSION". ', + 'Unknown utility "settings".', + 'Unknown utility "help". Run', + 'Unknown utility "--nope". R', + 'Unknown utility "-x". Run "', + ]); + }); + + test("should reject every prototype key", async () => { + const results = await Promise.all(PROTOTYPE_KEYS.map((name) => run([name]))); + + expect(PROTOTYPE_KEYS.includes("constructor")).toBe(true); + for (const [index, result] of results.entries()) { + expect(result.exitCode).toBe(2); + expect(result.stderr.startsWith(`Unknown utility "${PROTOTYPE_KEYS[index]}".`)).toBe(true); + } + }); + + test("should reject an inherited function", async () => { + const api = Object.create({ inheritedThing: () => "inherited" }) as Record; + + expect(await exitCodeOf(run(["inheritedThing"], terminal, api))).toBe(2); + expect(await stdoutOf(run(["list"], terminal, api))).toBe("\n"); + }); + }); + + describe("arguments", () => { + test("should call the utility with no argument at all when none is given", async () => { + const { api, calls } = createApi(); + + expect(await run(["generateThing"], terminal, api)).toEqual({ + stdout: "generated\n", + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([[]]); + }); + + test("should pass the positional values in order, as text", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "001", "0002"], terminal, api); + + expect(calls).toEqual([["001", "0002"]]); + }); + + test("should pass the options as the last argument", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "123", "--obfuscate", "--state-code", "SP"], terminal, api); + + expect(calls).toEqual([["123", { obfuscate: true, stateCode: "SP" }]]); + }); + + test("should pass the options as the only argument of a utility that takes params", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--bank-code", "001", "--agency=1234"], terminal, api); + + expect(calls).toEqual([[{ bankCode: "001", agency: "1234" }]]); + }); + + test("should convert the options that have a kind", async () => { + const { api, calls } = createApi(); + + await run( + [ + "formatThing", + "--year=2026", + "--precision", + "3", + "--accept", + "cpf, cnpj", + "--pad=false", + "--no-symbol", + "--target-date", + "2026-09-07", + "--code", + "0012", + ], + terminal, + api, + ); + + expect(calls).toEqual([ + [ + { + year: 2026, + precision: 3, + accept: ["cpf", "cnpj"], + pad: false, + symbol: false, + targetDate: new Date(2026, 8, 7), + code: "0012", + }, + ], + ]); + }); + + test("should convert the positional values of the utilities that have kinds", async () => { + const { api, calls } = createApi(); + + expect( + await run( + ["addBusinessDays", "2026-09-04", "1", "SP", "--state-code", "SP"], + terminal, + api, + ), + ).toEqual({ stdout: "2026-09-08\n", stderr: "", exitCode: 0 }); + expect(calls).toEqual([[new Date(2026, 8, 4), 1, "SP", { stateCode: "SP" }]]); + }); + + test("should report a text option left without a value", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "1", "--mask"], terminal, api)).toEqual({ + stdout: "", + stderr: `Option --mask needs a value.\n${USAGE_HINT}`, + exitCode: 2, + }); + expect(calls).toEqual([]); + }); + }); + + describe("--json", () => { + test("should pass the JSON object as the options", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--json", '{"bankCode":"001","digit":"9"}'], terminal, api); + + expect(calls).toEqual([[{ bankCode: "001", digit: "9" }]]); + }); + + test("should convert the text of the entries that have a kind and keep typed values", async () => { + const { api, calls } = createApi(); + + await run( + ["formatThing", "1", "--json", '{"targetDate":"2026-09-07","year":2026,"pad":"true"}'], + terminal, + api, + ); + + expect(calls).toEqual([["1", { targetDate: new Date(2026, 8, 7), year: 2026, pad: true }]]); + }); + + test("should let the options given next to it win", async () => { + const { api, calls } = createApi(); + + await run( + ["formatThing", "--pad", "--json", '{"pad":false,"mask":"a"}', "--mask=b"], + terminal, + api, + ); + + expect(calls).toEqual([[{ pad: true, mask: "b" }]]); + }); + + test("should pass no options for an empty JSON object", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "1", "--json", "{}"], terminal, api); + + expect(calls).toEqual([["1"]]); + }); + + test("should keep a __proto__ entry as plain data", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--json", '{"__proto__":{"pad":true}}'], terminal, api); + + const [[options]] = calls as [[Record]]; + + expect(Object.getPrototypeOf(options)).toBe(Object.prototype); + expect(Object.hasOwn(options, "__proto__")).toBe(true); + expect(options["pad"]).toBeUndefined(); + }); + + test("should reject anything that is not a JSON object", async () => { + const { api, calls } = createApi(); + const values = ["nope", "[1]", "null", "1", '"text"', "true", "", "{"]; + const results = await Promise.all( + values.map((json) => run(["formatThing", `--json=${json}`], terminal, api)), + ); + + expect(results).toHaveLength(8); + for (const result of results) { + expect(result).toEqual({ + stdout: "", + stderr: `Option --json needs a JSON object.\n${USAGE_HINT}`, + exitCode: 2, + }); + } + expect(calls).toEqual([]); + }); + }); + + describe("stdin", () => { + test("should read the value left out from a piped stdin, trimmed", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--obfuscate"], pipe(" 12345678909\n"), api); + + expect(calls).toEqual([["12345678909", { obfuscate: true }]]); + }); + + test("should convert the value read from stdin like a written one", async () => { + const { api, calls } = createApi(); + + await run(["addBusinessDays"], pipe("2026-09-04\n"), api); + + expect(calls).toEqual([[new Date(2026, 8, 4)]]); + }); + + test("should pass no value when the piped stdin is blank", async () => { + const { api, calls } = createApi(); + + await run(["formatThing"], pipe(" \n"), api); + + expect(calls).toEqual([[]]); + }); + + test("should not read stdin when it is not piped", async () => { + const { api, calls } = createApi(); + + expect(await exitCodeOf(run(["formatThing"], terminal, api))).toBe(0); + expect(calls).toEqual([[]]); + }); + + test("should not read a piped stdin when a value is given", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["formatThing", "1"], stdin, api))).toBe(0); + expect(calls).toEqual([["1"]]); + }); + + test("should not read a piped stdin when --json is given", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["formatThing", "--json", '{"a":"b"}'], stdin, api))).toBe(0); + expect(calls).toEqual([[{ a: "b" }]]); + }); + + test("should not read a piped stdin for a utility that takes no argument", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["generateThing"], stdin, api))).toBe(0); + expect(calls).toEqual([[]]); + }); + + test("should not read a piped stdin for a utility whose first argument is an options object", async () => { + const script = pipe("hello world\n"); + const holidays = await apiStdout(["getHolidays", "--year", "2026"], script); + + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-07"], script)).toBe("true\n"); + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-08"], script)).toBe("false\n"); + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); + expect(await apiStdout(["isHoliday"], script)).toBe("false\n"); + }); + + test("should still read a - from stdin for a utility that takes an options object", async () => { + const holidays = await apiStdout(["getHolidays", "-"], pipe("2026\n")); + + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); + }); + + test("should read every - from stdin, piped or not", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "SP", "-"], typed("110042490114\n"), api); + await run(["formatThing", "-", "--json", "{}"], pipe("1"), api); + await run(["generateThing", "-"], typed(""), api); + + expect(calls).toEqual([["SP", "110042490114"], ["1"], [""]]); + }); + + test("should report a stdin that cannot be read and not call the utility", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "-"], terminal, api)).toEqual({ + stdout: "", + stderr: "Error: stdin must not be read\n", + exitCode: 1, + }); + expect(calls).toEqual([]); + }); + }); + + describe("results", () => { + test("should exit with 1 when a validator returns false", async () => { + expect(await run(["isValidThing", "1"])).toEqual({ + stdout: "false\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should exit with 1 when a lookup returns null", async () => { + expect(await run(["getThing", "1"])).toEqual({ stdout: "null\n", stderr: "", exitCode: 1 }); + }); + + test("should print an object as JSON", async () => { + expect(await run(["getThings"])).toEqual({ + stdout: '[\n {\n "code": "001"\n }\n]\n', + stderr: "", + exitCode: 0, + }); + }); + + test("should await a utility that returns a promise", async () => { + const { api, calls } = createApi(); + + expect(await run(["fetchThing", "01001000"], terminal, api)).toEqual({ + stdout: '{\n "cep": "01001000"\n}\n', + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([["01001000"]]); + }); + + test("should report a rejected promise on stderr and exit with 1", async () => { + expect(await run(["rejectThing", "1"])).toEqual({ + stdout: "", + stderr: "RangeError: service down\n", + exitCode: 1, + }); + }); + + test("should report a utility that throws on stderr and exit with 1", async () => { + expect(await run(["throwThing", "1"])).toEqual({ + stdout: "", + stderr: "TypeError: boom\n", + exitCode: 1, + }); + expect(await run(["throwText", "1"])).toEqual({ + stdout: "", + stderr: "plain text\n", + exitCode: 1, + }); + }); + }); + + describe("public API", () => { + test("should validate", async () => { + expect(await runApi(["isValidCpf", "12345678909"])).toEqual({ + stdout: "true\n", + stderr: "", + exitCode: 0, + }); + expect(await exitCodeOf(runApi(["isValidCpf", "11111111111"]))).toBe(1); + expect(await apiStdout(["isValidIe", "SP", "110042490114"])).toBe("true\n"); + expect(await apiStdout(["isValidCnpj", "12ABC34501DE35", "--version", "1"])).toBe("false\n"); + expect(await apiStdout(["isValidCnpj", "12ABC34501DE35", "--version", "2"])).toBe("true\n"); + }); + + test("should format, with options", async () => { + expect(await apiStdout(["formatCnpj", "12345678000195"])).toBe("12.345.678/0001-95\n"); + expect(await apiStdout(["formatCnpj", "12345678000195", "--obfuscate"])).toBe( + "**.345.678/0001-**\n", + ); + expect(await apiStdout(["formatCurrency", "1234.5"])).toBe("1.234,50\n"); + expect(await apiStdout(["formatCurrency", "1234.5", "--symbol"])).toBe("R$ 1.234,50\n"); + expect(await apiStdout(["formatCurrency", "1234.5", "--symbol=false"])).toBe("1.234,50\n"); + expect(await apiStdout(["formatCpf", "--obfuscate"], pipe("12345678909\n"))).toBe( + "***.456.789-**\n", + ); + }); + + test("should exit with 1 when a formatter cannot read its value", async () => { + expect(await runApi(["formatCpf", "abc"])).toEqual({ + stdout: "\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should reject a boolean option that spells neither true nor false", async () => { + expect(await runApi(["formatCurrency", "1234.5", "--symbol=no"])).toEqual({ + stdout: "", + stderr: `Option --symbol needs true or false.\n${USAGE_HINT}`, + exitCode: 2, + }); + expect(await runApi(["formatCurrency", "1234.5", "--no-symbol=true"])).toEqual({ + stdout: "", + stderr: `Option --no-symbol does not take a value.\n${USAGE_HINT}`, + exitCode: 2, + }); + }); + + test("should look up, printing JSON and exiting with 1 for a miss", async () => { + expect(await runApi(["getBankByCode", "001"])).toEqual({ + stdout: '{\n "code": "001",\n "ispb": "00000000",\n "name": "Banco do Brasil S.A."\n}\n', + stderr: "", + exitCode: 0, + }); + expect(await runApi(["getBankByCode", "99999"])).toEqual({ + stdout: "null\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should generate", async () => { + const { stdout, exitCode } = await runApi(["generateCpf"]); + + expect(/^\d{11}\n$/.test(stdout)).toBe(true); + expect(exitCode).toBe(0); + expect(await apiStdout(["generateCnpj", "2"])).toMatch(/^[\dA-Z]{12}\d{2}\n$/); + expect(await apiStdout(["generateCnpj", "--version=1"])).toMatch(/^\d{14}\n$/); + }); + + test("should take numbers and dates", async () => { + expect(await apiStdout(["convertNumberToWords", "-5"])).toBe("menos cinco\n"); + expect(await apiStdout(["addBusinessDays", "2026-09-04", "1"])).toBe("2026-09-08\n"); + expect(await apiStdout(["isBusinessDay", "2026-09-07"])).toBe("false\n"); + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-07"])).toBe("true\n"); + expect(await apiStdout(["isHoliday", "--json", '{"targetDate":"2026-09-08"}'])).toBe( + "false\n", + ); + const holidays = await apiStdout(["getHolidays", "2026"]); + + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); + }); + + test("should report the rejection of a network utility without touching the network", async () => { + const result = await runApi(["getAddressInfoByCep", "123"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.startsWith("GetAddressInfoByCepValidationError: ")).toBe(true); + }); + + test("should list every utility and no error class", async () => { + const stdout = await apiStdout(["list"]); + const names = stdout.split("\n"); + + expect(names.includes("isValidCpf")).toBe(true); + expect(names.includes("getCepInfoByAddress")).toBe(true); + expect(names.includes("GetAddressInfoByCepError")).toBe(false); + expect(names.at(-1)).toBe(""); + }); + }); + + describe("properties", () => { + test("should never reject and always return text and a known exit code", async () => { + await fc.assert( + fc.asyncProperty(fc.array(anyText), async (argv) => { + const result = await run(argv, pipe("")); + + expect(typeof result.stdout).toBe("string"); + expect(typeof result.stderr).toBe("string"); + expect([0, 1, 2].includes(result.exitCode)).toBe(true); + }), + ); + }); + + test("should never reject for any arguments handed to a real utility", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom( + "isValidCpf", + "formatCnpj", + "getBankByCode", + "isHoliday", + "addBusinessDays", + ), + fc.array(anyText), + async (name, argv) => { + const result = await runCli({ + argv: [name, ...argv], + api: publicApi, + version: "9.8.7", + stdin: pipe(""), + }); + + expect([0, 1, 2].includes(result.exitCode)).toBe(true); + }, + ), + ); + }); + }); +}); + +describe("runCli types", () => { + test("should take the params and resolve to the result", () => { + expectTypeOf(runCli).parameter(0).toEqualTypeOf(); + expectTypeOf(runCli).returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<0 | 1 | 2>(); + expectTypeOf().toEqualTypeOf>>(); + expectTypeOf().toExtend(); + }); +}); diff --git a/src/_cli/run-cli/run-cli.ts b/src/_cli/run-cli/run-cli.ts new file mode 100644 index 000000000..187629027 --- /dev/null +++ b/src/_cli/run-cli/run-cli.ts @@ -0,0 +1,219 @@ +import { coerceCliValue } from "../coerce-cli-value/coerce-cli-value"; +import { + CLI_EXIT_CODES, + CLI_OPTION_KINDS, + CLI_PARAMS_UTILITIES, + CLI_POSITIONAL_KINDS, + CLI_USAGE, +} from "../constants"; +import { parseCliArguments } from "../parse-cli-arguments/parse-cli-arguments"; +import { toCliOutput } from "../to-cli-output/to-cli-output"; + +/** The standard input of the command line, read only when a value has to come from it. */ +export type CliStdin = { + /** Whether stdin is a pipe or a file, so that a value left out may be read from it. */ + isPiped: boolean; + /** Reads stdin to its end. */ + read: () => Promise; +}; + +/** Params of `runCli`. */ +export type RunCliParams = { + /** The command line arguments, without the runtime and the script (`process.argv.slice(2)`). */ + argv: readonly string[]; + /** The exports the command line dispatches to: the public API of the package. */ + api: Readonly>; + /** The version printed by `--version`. */ + version: string; + /** The standard input. */ + stdin: CliStdin; +}; + +/** What a run of the command line writes and how it exits. */ +export type CliResult = { + /** The text for stdout, empty when there is none. */ + stdout: string; + /** The text for stderr, empty when there is none. */ + stderr: string; + /** `0` on success, `1` for `false`, `null` or a utility that throws, `2` for a usage error. */ + exitCode: 0 | 1 | 2; +}; + +type Utility = (...args: unknown[]) => unknown; + +const UTILITY_NAME_REGEX = /^[a-z]/; +const STDIN_MARKER = "-"; + +const isUtility = (value: unknown): value is Utility => typeof value === "function"; + +const isPlainObject = (value: unknown): value is Record => + Object.prototype.toString.call(value) === "[object Object]"; + +const getUtility = (api: Readonly>, name: string): Utility | null => { + const value = Object.hasOwn(api, name) && UTILITY_NAME_REGEX.test(name) ? api[name] : null; + + return isUtility(value) ? value : null; +}; + +const succeed = (stdout: string): CliResult => ({ + stdout, + stderr: "", + exitCode: CLI_EXIT_CODES.success, +}); + +const fail = (message: string): CliResult => ({ + stdout: "", + stderr: `${message}\n`, + exitCode: CLI_EXIT_CODES.failure, +}); + +const rejectUsage = (message: string): CliResult => ({ + stdout: "", + stderr: `${message}\nRun "brazilian-utils --help" for usage.\n`, + exitCode: CLI_EXIT_CODES.usage, +}); + +const parseJsonObject = (text: string | boolean): Record | null => { + try { + const value: unknown = JSON.parse(String(text)); + + return isPlainObject(value) ? value : null; + } catch { + return null; + } +}; + +const describeError = (error: unknown): string => + error instanceof Error ? `${error.name}: ${error.message}` : String(error); + +const call = async (utility: Utility, read: () => Promise): Promise => { + try { + const args = await read(); + const { text, exitCode } = toCliOutput(await utility(...args)); + + return { stdout: `${text}\n`, stderr: "", exitCode }; + } catch (error) { + return fail(describeError(error)); + } +}; + +const readValues = async ( + positionals: readonly string[], + isImplicit: boolean, + stdin: CliStdin, +): Promise => { + if (!positionals.includes(STDIN_MARKER) && !isImplicit) return [...positionals]; + + const text = await stdin.read(); + const input = text.trim(); + + if (positionals.length > 0) { + return positionals.map((value) => (value === STDIN_MARKER ? input : value)); + } + + return input === "" ? [] : [input]; +}; + +const coerceOptions = (options: Record): Record[] => { + const entries = Object.entries(options).map(([key, value]): [string, unknown] => [ + key, + coerceCliValue(value, Object.hasOwn(CLI_OPTION_KINDS, key) ? CLI_OPTION_KINDS[key] : undefined), + ]); + + return entries.length === 0 ? [] : [Object.fromEntries(entries)]; +}; + +const dispatch = ( + command: string, + utility: Utility, + argv: readonly string[], + stdin: CliStdin, +): Promise => { + const { positionals, options, error } = parseCliArguments(argv); + if (error !== null) return Promise.resolve(rejectUsage(error)); + if (options["help"] === true) return Promise.resolve(succeed(CLI_USAGE)); + + const { json, ...flags } = options; + const base = json === undefined ? {} : parseJsonObject(json); + if (base === null) return Promise.resolve(rejectUsage("Option --json needs a JSON object.")); + + const isImplicit = + positionals.length === 0 && + json === undefined && + stdin.isPiped && + utility.length > 0 && + !Object.hasOwn(CLI_PARAMS_UTILITIES, command); + const kinds = Object.hasOwn(CLI_POSITIONAL_KINDS, command) + ? CLI_POSITIONAL_KINDS[command] + : undefined; + + return call(utility, async () => { + const values = await readValues(positionals, isImplicit, stdin); + + return [ + ...values.map((value, index) => coerceCliValue(value, kinds?.[index])), + ...coerceOptions({ ...base, ...flags }), + ]; + }); +}; + +const resolveCommand = ( + command: string, + api: Readonly>, + version: string, +): CliResult | Utility => { + if (command === "") return { stdout: "", stderr: CLI_USAGE, exitCode: CLI_EXIT_CODES.usage }; + if (command === "--help" || command === "-h") return succeed(CLI_USAGE); + if (command === "--version" || command === "-v") return succeed(`${version}\n`); + + if (command === "list") { + const names = Object.keys(api).filter((name) => getUtility(api, name) !== null); + + return succeed(`${names.toSorted().join("\n")}\n`); + } + + return ( + getUtility(api, command) ?? + rejectUsage(`Unknown utility "${command}". Run "brazilian-utils list" to see every utility.`) + ); +}; + +/** + * Runs the command line as a pure function: arguments and stdin in, output and exit code out. + * + * The command line is a generic dispatcher over the public API. The first argument names an + * exported utility (an own, callable, lower-camel-case export, so error classes and prototype + * keys such as `constructor` are not commands); the positional values after it become the + * utility's arguments, in order, and the `--key value` options, merged over the `--json` object, + * become its last argument. Values stay text unless `CLI_POSITIONAL_KINDS` or `CLI_OPTION_KINDS` + * give them a kind. A value written as `-` is read from stdin, and so is a value left out when + * stdin is piped, the utility takes a value as its first argument (so not one of + * `CLI_PARAMS_UTILITIES`) and no `--json` was given. A promise returned by the utility + * (`getAddressInfoByCep`, `getCepInfoByAddress`) is awaited. + * + * It never throws: a utility that throws or rejects becomes exit code `1` with the error on + * stderr, and a command line that cannot be read becomes exit code `2`. + * + * @param {RunCliParams} params - The arguments, the API to dispatch to, the version and stdin. + * @returns {Promise} The text for stdout and stderr, and the exit code. + * + * @example + * ```typescript + * import * as api from "@brazilian-utils/brazilian-utils"; + * + * const stdin = { isPiped: false, read: async () => "" }; + * + * await runCli({ argv: ["formatCpf", "12345678909"], api, version: "2.4.0", stdin }); + * // { stdout: "123.456.789-09\n", stderr: "", exitCode: 0 } + * await runCli({ argv: ["isValidCpf", "11111111111"], api, version: "2.4.0", stdin }); + * // { stdout: "false\n", stderr: "", exitCode: 1 } + * await runCli({ argv: ["nope"], api, version: "2.4.0", stdin }); + * // { stdout: "", stderr: 'Unknown utility "nope". ...', exitCode: 2 } + * ``` + */ +export const runCli = ({ argv, api, version, stdin }: RunCliParams): Promise => { + const [command = "", ...rest] = argv; + const resolved = resolveCommand(command, api, version); + + return isUtility(resolved) ? dispatch(command, resolved, rest, stdin) : Promise.resolve(resolved); +}; diff --git a/src/_cli/to-cli-output/to-cli-output.test.ts b/src/_cli/to-cli-output/to-cli-output.test.ts new file mode 100644 index 000000000..d98c2de41 --- /dev/null +++ b/src/_cli/to-cli-output/to-cli-output.test.ts @@ -0,0 +1,156 @@ +import * as fc from "fast-check"; + +import { anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type CliOutput, toCliOutput } from "./to-cli-output"; + +describe("toCliOutput", () => { + test("should print a string as it is", () => { + expect(toCliOutput("123.456.789-09")).toEqual({ text: "123.456.789-09", exitCode: 0 }); + expect(toCliOutput('say "oi"')).toEqual({ text: 'say "oi"', exitCode: 0 }); + }); + + test("should exit with 1 for the empty string a formatter answers with", () => { + expect(toCliOutput("")).toEqual({ text: "", exitCode: 1 }); + }); + + test("should print a number as it is", () => { + expect(toCliOutput(1234.56)).toEqual({ text: "1234.56", exitCode: 0 }); + expect(toCliOutput(0)).toEqual({ text: "0", exitCode: 0 }); + expect(toCliOutput(-3)).toEqual({ text: "-3", exitCode: 0 }); + }); + + test("should print true and exit with 0", () => { + expect(toCliOutput(true)).toEqual({ text: "true", exitCode: 0 }); + }); + + test("should print false and exit with 1", () => { + expect(toCliOutput(false)).toEqual({ text: "false", exitCode: 1 }); + }); + + test("should print null for null and undefined and exit with 1", () => { + expect(toCliOutput(null)).toEqual({ text: "null", exitCode: 1 }); + expect((toCliOutput as unknown as () => unknown)()).toEqual({ text: "null", exitCode: 1 }); + }); + + test("should print an object as indented JSON", () => { + expect(toCliOutput({ code: "001", ispb: "00000000" })).toEqual({ + text: '{\n "code": "001",\n "ispb": "00000000"\n}', + exitCode: 0, + }); + expect(toCliOutput({})).toEqual({ text: "{}", exitCode: 0 }); + }); + + test("should print an array as indented JSON", () => { + expect(toCliOutput(["Acrelândia", "Assis Brasil"])).toEqual({ + text: '[\n "Acrelândia",\n "Assis Brasil"\n]', + exitCode: 0, + }); + expect(toCliOutput([])).toEqual({ text: "[]", exitCode: 0 }); + expect(toCliOutput([11, 12])).toEqual({ text: "[\n 11,\n 12\n]", exitCode: 0 }); + }); + + test("should print a date as its local calendar day", () => { + expect(toCliOutput(new Date(2024, 11, 25))).toEqual({ text: "2024-12-25", exitCode: 0 }); + expect(toCliOutput(new Date(2024, 0, 2, 23, 59, 59))).toEqual({ + text: "2024-01-02", + exitCode: 0, + }); + expect(toCliOutput(new Date(2024, 0, 2, 0, 0, 0))).toEqual({ + text: "2024-01-02", + exitCode: 0, + }); + }); + + test("should pad the year of a date to four digits", () => { + const date = new Date(2024, 4, 9); + date.setFullYear(987); + + expect(toCliOutput(date).text).toBe("0987-05-09"); + }); + + test("should print the dates inside a result as local calendar days too", () => { + const holidays = [ + { name: "Ano novo", date: new Date(2026, 0, 1), type: "national" }, + { name: "Natal", date: new Date(2026, 11, 25, 12), type: "national" }, + ]; + + expect(toCliOutput(holidays).text).toBe( + [ + "[", + " {", + ' "name": "Ano novo",', + ' "date": "2026-01-01",', + ' "type": "national"', + " },", + " {", + ' "name": "Natal",', + ' "date": "2026-12-25",', + ' "type": "national"', + " }", + "]", + ].join("\n"), + ); + expect(toCliOutput({ range: { from: new Date(2026, 5, 4) } }).text).toBe( + '{\n "range": {\n "from": "2026-06-04"\n }\n}', + ); + }); + + test("should keep null, booleans and numbers inside a result", () => { + expect(toCliOutput({ a: null, b: false, c: 0, d: [null] }).text).toBe( + '{\n "a": null,\n "b": false,\n "c": 0,\n "d": [\n null\n ]\n}', + ); + }); + + test("should print the values JSON cannot hold as their text instead of throwing", () => { + expect(toCliOutput(10n)).toEqual({ text: "10", exitCode: 0 }); + expect(toCliOutput(Symbol("pix"))).toEqual({ text: "Symbol(pix)", exitCode: 0 }); + expect(toCliOutput({ total: 10n, key: Symbol("pix") }).text).toBe( + '{\n "total": "10",\n "key": "Symbol(pix)"\n}', + ); + expect(toCliOutput({ run: String }).text).toBe( + JSON.stringify({ run: String(String) }, null, 2), + ); + }); + + describe("properties", () => { + test("should print any string unchanged", () => { + fc.assert( + fc.property(anyText, (text) => { + expect(toCliOutput(text)).toEqual({ text, exitCode: text === "" ? 1 : 0 }); + }), + ); + }); + + test("should print any JSON value as text that parses back to it", () => { + fc.assert( + fc.property(fc.jsonValue(), (value) => { + const { text } = toCliOutput(value); + + expect(text).toBe(typeof value === "string" ? value : JSON.stringify(value, null, 2)); + }), + ); + }); + + test("should exit with 1 only for false, null, undefined and the empty string", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const { text, exitCode } = toCliOutput(value); + const isNegative = + value === false || value === null || value === undefined || value === ""; + + expect(typeof text).toBe("string"); + expect(exitCode).toBe(isNegative ? 1 : 0); + }), + ); + }); + }); +}); + +describe("toCliOutput types", () => { + test("should take any value and return the text and the exit code", () => { + expectTypeOf(toCliOutput).parameter(0).toEqualTypeOf(); + expectTypeOf(toCliOutput).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<0 | 1>(); + }); +}); diff --git a/src/_cli/to-cli-output/to-cli-output.ts b/src/_cli/to-cli-output/to-cli-output.ts new file mode 100644 index 000000000..9fc58247f --- /dev/null +++ b/src/_cli/to-cli-output/to-cli-output.ts @@ -0,0 +1,64 @@ +import { CLI_EXIT_CODES } from "../constants"; + +/** What the command line prints for the value a utility returned. */ +export type CliOutput = { + /** The text written to stdout, without the trailing line break. */ + text: string; + /** `1` when the value is `false`, `null`, `undefined` or the empty string, `0` otherwise. */ + exitCode: 0 | 1; +}; + +const toLocalDate = (date: Date): string => { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +const toPrintable = (value: unknown): unknown => { + if (value instanceof Date) return toLocalDate(value); + if (Array.isArray(value)) return value.map((item) => toPrintable(item)); + if (typeof value === "bigint" || typeof value === "symbol") return String(value); + if (typeof value === "function") return String(value); + if (typeof value !== "object" || value === null) return value; + + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toPrintable(item)])); +}; + +/** + * Renders the value a utility returned for the command line. + * + * A string is printed as it is, anything else as indented JSON (which prints a number or `true` + * as they are too). A `Date`, alone or inside a result, is printed as its local calendar day + * (`YYYY-MM-DD`), the way the date utilities of this library read one, instead of the UTC instant + * `JSON.stringify` would write. The exit code is `1` for the three negative answers of the + * library: `false` from a validator, `null` from a lookup and `""` from a formatter or a parser + * that was given something it cannot read. The values JSON cannot hold (a bigint, a symbol, a + * function) are printed as their text, so rendering never throws. + * + * @param {unknown} value - The value returned by the utility. + * @returns {CliOutput} The text to print and the exit code. + * + * @example + * ```typescript + * toCliOutput("123.456.789-09"); // { text: "123.456.789-09", exitCode: 0 } + * toCliOutput(false); // { text: "false", exitCode: 1 } + * toCliOutput(null); // { text: "null", exitCode: 1 } + * toCliOutput(""); // { text: "", exitCode: 1 } + * toCliOutput(new Date(2024, 11, 25)); // { text: "2024-12-25", exitCode: 0 } + * toCliOutput({ code: "001" }); // { text: '{\n "code": "001"\n}', exitCode: 0 } + * ``` + */ +export const toCliOutput = (value: unknown): CliOutput => { + if (value === undefined || value === null || value === false) { + return { text: value === false ? "false" : "null", exitCode: CLI_EXIT_CODES.failure }; + } + + if (value === "") return { text: "", exitCode: CLI_EXIT_CODES.failure }; + + const printable = toPrintable(value); + const text = typeof printable === "string" ? printable : JSON.stringify(printable, null, 2); + + return { text, exitCode: CLI_EXIT_CODES.success }; +}; diff --git a/stryker.config.json b/stryker.config.json index a6f745b68..175bc0043 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -11,7 +11,8 @@ "!src/_internals/test/**", "!src/**/constants.ts", "!src/_internals/constants/**", - "!src/index.ts" + "!src/index.ts", + "!src/_cli/bin/bin.ts" ], "coverageAnalysis": "perTest", "reporters": ["clear-text", "progress", "html", "json"], diff --git a/vite.config.ts b/vite.config.ts index f5c52e475..8fa713b98 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { transform } from "esbuild"; import { rules as sonarjsPluginRules } from "eslint-plugin-sonarjs"; @@ -55,6 +55,21 @@ const minifyUmdChunk = (): PackPlugin => ({ }, }); +/** + * The command line (`src/_cli/bin/bin.ts`, published as the `bin` of the package) dispatches to + * the whole public API. Bundling `src/index.ts` into it would ship every dataset a second time, + * so its import of the barrel is rewritten to the root ESM bundle that sits next to it in `dist/`. + * @returns {PackPlugin} The pack plugin that points the command line at `dist/brazilian-utils.js`. + */ +const externalizeLibrary = (): PackPlugin => ({ + name: "brazilian-utils:externalize-library", + resolveId(source, importer) { + if (importer === undefined) return null; + if (resolve(dirname(importer), source) !== resolve(srcDir, "index")) return null; + return { id: "./brazilian-utils.js", external: true }; + }, +}); + /** * Every util directory under `src/` (skipping the `_`-prefixed internal ones) that has a * same-named entry file (`src/get-cities/get-cities.ts`, `src/is-valid-cpf/is-valid-cpf.ts`, @@ -468,10 +483,12 @@ export default defineConfig({ }, // The barrel re-exports every deprecated alias, and a deprecated util's own tests (plus // the `getMunicipalities` property that cross-checks it against `getCities`) have to - // keep calling it for as long as it is still supported. + // keep calling it for as long as it is still supported. The command line lists the + // deprecated `generateCNPJ` next to `generateCnpj` because both are commands. { files: [ "src/index.ts", + "src/_cli/constants.ts", "src/index.test.ts", "src/get-cities/get-cities.test.ts", "src/get-municipalities/get-municipalities.test.ts", @@ -503,6 +520,15 @@ export default defineConfig({ "jsdoc/require-returns": "off", }, }, + // The command line dispatches to the public API, so its test drives a sample of real + // utilities. They are imported one by one, never through the barrel: a second copy of + // the whole module graph is more than a browser test page can instantiate. + { + files: ["src/_cli/run-cli/run-cli.test.ts"], + rules: { + "import/max-dependencies": "off", + }, + }, { env: { node: true, @@ -551,6 +577,7 @@ export default defineConfig({ "src/**/constants.ts", "src/_internals/constants/**", "src/index.ts", + "src/_cli/bin/bin.ts", ], thresholds: { statements: 100, @@ -574,5 +601,17 @@ export default defineConfig({ entry: utilEntries, format: ["es", "cjs"], }, + // The command line: one ESM file with a shebang, no declarations (it exports nothing) and + // the library left external (see `externalizeLibrary`), so it costs library consumers + // nothing: no entry point imports it and `exports` maps "./cli" to null, so the only way + // to reach it is the `bin`. + { + ...sharedPack, + sourcemap: false, + dts: false, + entry: { cli: resolve(rootDir, "src/_cli/bin/bin.ts") }, + format: ["es"], + plugins: [externalizeLibrary()], + }, ], });