diff --git a/packages/br-utilities/Gemfile b/packages/br-utilities/Gemfile index af4d876..12f5a87 100644 --- a/packages/br-utilities/Gemfile +++ b/packages/br-utilities/Gemfile @@ -8,7 +8,6 @@ gem 'cnpj-fmt', path: '../cnpj-fmt' gem 'cnpj-gen', path: '../cnpj-gen' gem 'cnpj-utilities', path: '../cnpj-utilities' gem 'cnpj-val', path: '../cnpj-val' -gem 'cpf-dv', path: '../cpf-dv' gem 'cpf-fmt', path: '../cpf-fmt' gem 'cpf-gen', path: '../cpf-gen' gem 'cpf-utilities', path: '../cpf-utilities' diff --git a/packages/cnpj-fmt/README.md b/packages/cnpj-fmt/README.md index 400a6dc..27e715f 100644 --- a/packages/cnpj-fmt/README.md +++ b/packages/cnpj-fmt/README.md @@ -112,7 +112,7 @@ Holds all formatter settings, with validation and merge support. Exposes propert ### Functional helper -`CnpjFmt.cnpj_fmt` builds a new `CnpjFmt::CnpjFormatter` from the same constructor parameters and calls `format(cnpj_input)` once. Pass either keyword arguments **or** a `Hash`/`CnpjFmt::CnpjFormatterOptions` instance for options — not both (passing both raises `InvalidArgumentCombinationError`): +`CnpjFmt.cnpj_fmt` builds a new `CnpjFmt::CnpjFormatter` from the same constructor parameters and calls `format(cnpj_input)` once. Pass either keyword arguments **or** a `Hash`/`CnpjFmt::CnpjFormatterOptions` instance for options — not both (passing `options` with any non-`nil` keyword raises `InvalidArgumentCombinationError`): ```ruby require 'cnpj-fmt' @@ -237,13 +237,13 @@ Errors fall into two categories: Every custom error includes the `CnpjFmt::Error` marker module. Domain failures (`InvalidLengthError`, `OutOfRangeError`, `ValidationError`) inherit from `CnpjFmt::DomainError` (`RangeError`). -**Important:** length failures are **constructed as `InvalidLengthError` and passed to `on_fail` as a `DomainError`**, not raised from `format` / `cnpj_fmt`. Passing both an `options` instance/`Hash` and keyword arguments raises `InvalidArgumentCombinationError`. +**Important:** length failures are **constructed as `InvalidLengthError` and passed to `on_fail` as a `DomainError`**, not raised from `format` / `cnpj_fmt`. Passing both an `options` instance/`Hash` and any non-`nil` keyword argument raises `InvalidArgumentCombinationError`. #### Summary | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments are passed at once | +| `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once | | `CnpjFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CNPJ input or option has the wrong data type | | `CnpjFmt::InvalidLengthError` | `CnpjFmt::DomainError` | Domain error | Sanitized length is not exactly 14 (passed to `on_fail` as `DomainError`) | | `CnpjFmt::OutOfRangeError` | `CnpjFmt::DomainError` | Domain error | `hidden_start` / `hidden_end` outside `0`–`13` | diff --git a/packages/cnpj-fmt/README.pt.md b/packages/cnpj-fmt/README.pt.md index c28e0b0..960327f 100644 --- a/packages/cnpj-fmt/README.pt.md +++ b/packages/cnpj-fmt/README.pt.md @@ -99,7 +99,7 @@ Armazena todas as configurações do formatador, com validação e suporte a mes ### Helper funcional -`CnpjFmt.cnpj_fmt` instancia um novo `CnpjFmt::CnpjFormatter` com os mesmos parâmetros do construtor e chama `format(cnpj_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CnpjFmt::CnpjFormatterOptions` para as opções — não ambos (passar ambos lança `InvalidArgumentCombinationError`): +`CnpjFmt.cnpj_fmt` instancia um novo `CnpjFmt::CnpjFormatter` com os mesmos parâmetros do construtor e chama `format(cnpj_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CnpjFmt::CnpjFormatterOptions` para as opções — não ambos (passar `options` com qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError`): ```ruby require 'cnpj-fmt' @@ -224,13 +224,13 @@ Os erros se dividem em duas categorias: Todo erro customizado inclui o módulo marcador `CnpjFmt::Error`. Falhas de domínio (`InvalidLengthError`, `OutOfRangeError`, `ValidationError`) herdam de `CnpjFmt::DomainError` (`RangeError`). -**Importante:** falhas de tamanho são **construídas como `InvalidLengthError` e passadas ao `on_fail` como `DomainError`**, não levantadas por `format` / `cnpj_fmt`. Passar ao mesmo tempo um argumento `options` (instância/`Hash`) e argumentos nomeados lança `InvalidArgumentCombinationError`. +**Importante:** falhas de tamanho são **construídas como `InvalidLengthError` e passadas ao `on_fail` como `DomainError`**, não levantadas por `format` / `cnpj_fmt`. Passar ao mesmo tempo um argumento `options` (instância/`Hash`) e qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError`. #### Resumo | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados passados ao mesmo tempo | +| `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` passados ao mesmo tempo | | `CnpjFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada de CNPJ ou opção com tipo de dado incorreto | | `CnpjFmt::InvalidLengthError` | `CnpjFmt::DomainError` | Erro de domínio | Tamanho após sanitização não é exatamente 14 (passado ao `on_fail` como `DomainError`) | | `CnpjFmt::OutOfRangeError` | `CnpjFmt::DomainError` | Erro de domínio | `hidden_start` / `hidden_end` fora de `0`–`13` | diff --git a/packages/cnpj-gen/README.md b/packages/cnpj-gen/README.md index 8a8bf27..8d4b23d 100644 --- a/packages/cnpj-gen/README.md +++ b/packages/cnpj-gen/README.md @@ -99,7 +99,7 @@ Prefix rules: base ID (first 8 chars) and branch ID (chars 9–12) cannot be all Generates a valid CNPJ string. With no options, returns a 14-character alphanumeric CNPJ. This is a convenience wrapper around `CnpjGen::CnpjGenerator.new(...).generate`. - **`options`** (optional): `CnpjGen::CnpjGeneratorOptions` instance, a `Hash` of option keys, or `nil`. See [Generator options](#generator-options). -- **`format`**, **`prefix`**, **`type`** (keyword arguments): Only used when `options` is omitted (`nil`). Passing `options` **and** any of these keywords at the same time raises `InvalidArgumentCombinationError` — the two ways of passing options are never merged together. +- **`format`**, **`prefix`**, **`type`** (keyword arguments): Only used when `options` is omitted (`nil`). Passing `options` **and** any non-`nil` of these keywords at the same time raises `InvalidArgumentCombinationError` — the two ways of passing options are never merged. ### `CnpjGen::CnpjGenerator` (class) @@ -185,13 +185,13 @@ Errors fall into two categories: Every custom error includes the `CnpjGen::Error` marker module. Domain failures (`ValidationError`) inherit from `CnpjGen::DomainError` (`RangeError`). -**Important:** passing both an `options` instance/`Hash` and keyword arguments raises `InvalidArgumentCombinationError`. +**Important:** passing both an `options` instance/`Hash` and any non-`nil` keyword argument raises `InvalidArgumentCombinationError`. #### Summary | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CnpjGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments are passed at once | +| `CnpjGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once | | `CnpjGen::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | A generator option has the wrong data type | | `CnpjGen::ValidationError` | `CnpjGen::DomainError` | Domain error | `prefix` is ineligible, or `type` is not one of the allowed values | diff --git a/packages/cnpj-gen/README.pt.md b/packages/cnpj-gen/README.pt.md index 53f59a5..d0ed602 100644 --- a/packages/cnpj-gen/README.pt.md +++ b/packages/cnpj-gen/README.pt.md @@ -84,7 +84,7 @@ Regras do prefixo: a base (primeiros 8 caracteres) e a filial (caracteres 9–12 Gera uma string de CNPJ válida. Sem opções, retorna um CNPJ alfanumérico de 14 caracteres. É um atalho para `CnpjGen::CnpjGenerator.new(...).generate`. - **`options`** (opcional): instância de `CnpjGen::CnpjGeneratorOptions`, `Hash` de chaves de opção ou `nil`. Veja [Opções do gerador](#opções-do-gerador). -- **`format`**, **`prefix`**, **`type`** (argumentos nomeados): Usados apenas quando `options` é omitido (`nil`). Passar `options` **e** qualquer um desses argumentos nomeados ao mesmo tempo gera `InvalidArgumentCombinationError` — as duas formas de passar opções nunca são mescladas entre si. +- **`format`**, **`prefix`**, **`type`** (argumentos nomeados): Usados apenas quando `options` é omitido (`nil`). Passar `options` **e** qualquer um desses argumentos nomeados não-`nil` ao mesmo tempo gera `InvalidArgumentCombinationError` — as duas formas de passar opções nunca são mescladas entre si. ### `CnpjGen::CnpjGenerator` (classe) @@ -170,13 +170,13 @@ Os erros se dividem em duas categorias: Todo erro customizado inclui o módulo marcador `CnpjGen::Error`. Falhas de domínio (`ValidationError`) herdam de `CnpjGen::DomainError` (`RangeError`). -**Importante:** passar ao mesmo tempo uma instância/`Hash` de `options` e argumentos nomeados levanta `InvalidArgumentCombinationError`. +**Importante:** passar ao mesmo tempo uma instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` levanta `InvalidArgumentCombinationError`. #### Resumo | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CnpjGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados passados ao mesmo tempo | +| `CnpjGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` passados ao mesmo tempo | | `CnpjGen::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Uma opção do gerador tem o tipo de dado incorreto | | `CnpjGen::ValidationError` | `CnpjGen::DomainError` | Erro de domínio | `prefix` inelegível, ou `type` fora dos valores permitidos | diff --git a/packages/cnpj-val/README.md b/packages/cnpj-val/README.md index 8cf17a0..88fa034 100644 --- a/packages/cnpj-val/README.md +++ b/packages/cnpj-val/README.md @@ -136,7 +136,7 @@ options.all # => frozen snapshot of current options ### Functional helper -`CnpjVal.cnpj_val` builds a new `CnpjVal::CnpjValidator` from the same constructor parameters and calls `is_valid(cnpj_input)` once. Pass either keyword arguments **or** a `Hash`/`CnpjVal::CnpjValidatorOptions` instance for options — not both (passing both raises `InvalidArgumentCombinationError`): +`CnpjVal.cnpj_val` builds a new `CnpjVal::CnpjValidator` from the same constructor parameters and calls `is_valid(cnpj_input)` once. Pass either keyword arguments **or** a `Hash`/`CnpjVal::CnpjValidatorOptions` instance for options — not both (passing `options` with any non-`nil` keyword raises `InvalidArgumentCombinationError`): ```ruby require 'cnpj-val' @@ -195,13 +195,13 @@ Errors fall into two categories: Every custom error includes the `CnpjVal::Error` marker module. Domain failures (`ValidationError`) inherit from `CnpjVal::DomainError` (`RangeError`). Invalid CNPJ data returns `false` (it does not raise). -**Important:** passing both an `options` instance/`Hash` and keyword arguments raises `InvalidArgumentCombinationError`. +**Important:** passing both an `options` instance/`Hash` and any non-`nil` keyword argument raises `InvalidArgumentCombinationError`. #### Summary | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments are passed at once | +| `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once | | `CnpjVal::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CNPJ input or option has the wrong data type | | `CnpjVal::ValidationError` | `CnpjVal::DomainError` | Domain error | `type` is not one of the allowed values | diff --git a/packages/cnpj-val/README.pt.md b/packages/cnpj-val/README.pt.md index f06b1c9..d4de73f 100644 --- a/packages/cnpj-val/README.pt.md +++ b/packages/cnpj-val/README.pt.md @@ -123,7 +123,7 @@ options.all # => snapshot congelado das opções atuais ### Helper funcional -`CnpjVal.cnpj_val` instancia um novo `CnpjVal::CnpjValidator` com os mesmos parâmetros do construtor e chama `is_valid(cnpj_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CnpjVal::CnpjValidatorOptions` para as opções — não ambos (passar ambos lança `InvalidArgumentCombinationError`): +`CnpjVal.cnpj_val` instancia um novo `CnpjVal::CnpjValidator` com os mesmos parâmetros do construtor e chama `is_valid(cnpj_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CnpjVal::CnpjValidatorOptions` para as opções — não ambos (passar `options` com qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError`): ```ruby require 'cnpj-val' @@ -182,13 +182,13 @@ Os erros se dividem em duas categorias: Todo erro customizado inclui o módulo marcador `CnpjVal::Error`. Falhas de domínio (`ValidationError`) herdam de `CnpjVal::DomainError` (`RangeError`). Dados de CNPJ inválidos retornam `false` (não levantam erro). -**Importante:** passar ao mesmo tempo uma instância/`Hash` de `options` e argumentos nomeados levanta `InvalidArgumentCombinationError`. +**Importante:** passar ao mesmo tempo uma instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` levanta `InvalidArgumentCombinationError`. #### Resumo | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados passados ao mesmo tempo | +| `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` passados ao mesmo tempo | | `CnpjVal::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada CNPJ ou opção com tipo de dado incorreto | | `CnpjVal::ValidationError` | `CnpjVal::DomainError` | Erro de domínio | `type` fora dos valores permitidos | diff --git a/packages/cpf-fmt/README.md b/packages/cpf-fmt/README.md index 60ec2de..2a2fd0f 100644 --- a/packages/cpf-fmt/README.md +++ b/packages/cpf-fmt/README.md @@ -108,7 +108,7 @@ Holds all formatter settings, with validation and merge support. Exposes propert ### Functional helper -`CpfFmt.cpf_fmt` builds a new `CpfFmt::CpfFormatter` from the same constructor parameters and calls `format(cpf_input)` once. Pass either keyword arguments **or** a `Hash`/`CpfFmt::CpfFormatterOptions` instance for options — not both (passing both raises `InvalidArgumentCombinationError`): +`CpfFmt.cpf_fmt` builds a new `CpfFmt::CpfFormatter` from the same constructor parameters and calls `format(cpf_input)` once. Pass either keyword arguments **or** a `Hash`/`CpfFmt::CpfFormatterOptions` instance for options — not both (passing `options` with any non-`nil` keyword raises `InvalidArgumentCombinationError`): ```ruby require 'cpf-fmt' @@ -228,13 +228,13 @@ Errors fall into two categories: Every custom error includes the `CpfFmt::Error` marker module. Domain failures (`InvalidLengthError`, `OutOfRangeError`, `ValidationError`) inherit from `CpfFmt::DomainError` (`RangeError`). -**Important:** length failures are **constructed as `InvalidLengthError` and passed to `on_fail` as a `DomainError`**, not raised from `format` / `cpf_fmt`. Passing both an `options` instance/`Hash` and keyword arguments raises `InvalidArgumentCombinationError`. +**Important:** length failures are **constructed as `InvalidLengthError` and passed to `on_fail` as a `DomainError`**, not raised from `format` / `cpf_fmt`. Passing both an `options` instance/`Hash` and any non-`nil` keyword argument raises `InvalidArgumentCombinationError`. #### Summary | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments are passed at once | +| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once | | `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CPF input or option has the wrong data type | | `CpfFmt::InvalidLengthError` | `CpfFmt::DomainError` | Domain error | Sanitized length is not exactly 11 (passed to `on_fail` as `DomainError`) | | `CpfFmt::OutOfRangeError` | `CpfFmt::DomainError` | Domain error | `hidden_start` / `hidden_end` outside `0`–`10` | diff --git a/packages/cpf-fmt/README.pt.md b/packages/cpf-fmt/README.pt.md index f60014c..ec6c3d4 100644 --- a/packages/cpf-fmt/README.pt.md +++ b/packages/cpf-fmt/README.pt.md @@ -95,7 +95,7 @@ Armazena todas as configurações do formatador, com validação e suporte a mes ### Helper funcional -`CpfFmt.cpf_fmt` instancia um novo `CpfFmt::CpfFormatter` com os mesmos parâmetros do construtor e chama `format(cpf_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CpfFmt::CpfFormatterOptions` para as opções — não ambos (passar ambos lança `InvalidArgumentCombinationError`): +`CpfFmt.cpf_fmt` instancia um novo `CpfFmt::CpfFormatter` com os mesmos parâmetros do construtor e chama `format(cpf_input)` uma vez. Passe argumentos nomeados **ou** um `Hash`/instância de `CpfFmt::CpfFormatterOptions` para as opções — não ambos (passar `options` com qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError`): ```ruby require 'cpf-fmt' @@ -215,13 +215,13 @@ Os erros se dividem em duas categorias: Todo erro customizado inclui o módulo marcador `CpfFmt::Error`. Falhas de domínio (`InvalidLengthError`, `OutOfRangeError`, `ValidationError`) herdam de `CpfFmt::DomainError` (`RangeError`). -**Importante:** falhas de tamanho são **construídas como `InvalidLengthError` e passadas ao `on_fail` como `DomainError`**, não levantadas por `format` / `cpf_fmt`. Passar ao mesmo tempo um argumento `options` (instância/`Hash`) e argumentos nomeados lança `InvalidArgumentCombinationError`. +**Importante:** falhas de tamanho são **construídas como `InvalidLengthError` e passadas ao `on_fail` como `DomainError`**, não levantadas por `format` / `cpf_fmt`. Passar ao mesmo tempo um argumento `options` (instância/`Hash`) e qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError`. #### Resumo | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados passados ao mesmo tempo | +| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` passados ao mesmo tempo | | `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada de CPF ou opção com tipo de dado incorreto | | `CpfFmt::InvalidLengthError` | `CpfFmt::DomainError` | Erro de domínio | Tamanho após sanitização não é exatamente 11 (passado ao `on_fail` como `DomainError`) | | `CpfFmt::OutOfRangeError` | `CpfFmt::DomainError` | Erro de domínio | `hidden_start` / `hidden_end` fora de `0`–`10` | diff --git a/packages/cpf-gen/CHANGELOG.md b/packages/cpf-gen/CHANGELOG.md index 147c889..ff400dc 100644 --- a/packages/cpf-gen/CHANGELOG.md +++ b/packages/cpf-gen/CHANGELOG.md @@ -1 +1,17 @@ # cpf-gen + +## 1.0.0 + +### 🚀 Stable Version Released! + +Utility module to generate valid CPF (Brazilian personal ID) strings. Main features: + +- **Multiple interfaces**: supports `CpfGen.cpf_gen` and `CpfGen::CpfGenerator` with shared `CpfGen::CpfGeneratorOptions` defaults; an `options` argument (instance or `Hash`) is never merged with keyword overrides — passing both at once raises `InvalidArgumentCombinationError`. +- **Numeric CPF**: generates 11-digit numeric identifiers; random body is always digits (`0-9`). +- **Prefix support**: partial start strings are sanitized (digits only, truncated to 9) and validated — rejects zeroed base ID and 9 repeated digits via `ValidationError`; only missing digits up to 9 are randomly generated. +- **Optional formatting**: `format: true` returns the standard masked `XXX.XXX.XXX-XX` layout; default output is unformatted. +- **Strict options merging**: `CpfGeneratorOptions.new`/`#set` fold positional `Hash`/instance layers left to right, then apply keyword overrides with the highest precedence, filling any still-unresolved option with its `DEFAULT_*` value; property setters (`format=`, `prefix=`) never accept `nil` directly — pass the matching `DEFAULT_*` constant to reset explicitly. +- **Structured errors**: `CpfGen::Error` marker with misuse leaves (`TypeMismatchError`, `InvalidArgumentCombinationError`) and domain leaf (`ValidationError` under `DomainError`). +- **Check digits**: computed via `cpf-dv`; generator retries when check-digit computation rejects a random body candidate. + +For detailed usage and API reference, see the [README](./README.md). diff --git a/packages/cpf-gen/Gemfile b/packages/cpf-gen/Gemfile index 533428d..c6122eb 100644 --- a/packages/cpf-gen/Gemfile +++ b/packages/cpf-gen/Gemfile @@ -4,8 +4,6 @@ source 'https://rubygems.org' gemspec -gem 'cpf-dv', path: '../cpf-dv' - group :test do gem 'rake', '~> 13.2' gem 'rspec', '~> 3.13' diff --git a/packages/cpf-gen/README.md b/packages/cpf-gen/README.md new file mode 100644 index 0000000..605e810 --- /dev/null +++ b/packages/cpf-gen/README.md @@ -0,0 +1,350 @@ +![cpf-gen for Ruby](https://br-utils.vercel.app/img/cover_cpf-gen.jpg) + +[![Gem Version](https://img.shields.io/gem/v/cpf-gen)](https://rubygems.org/gems/cpf-gen) +[![Gem Downloads](https://img.shields.io/gem/dt/cpf-gen)](https://rubygems.org/gems/cpf-gen) +[![Ruby Version](https://img.shields.io/gem/rv/cpf-gen)](https://www.ruby-lang.org/) +[![Test Status](https://img.shields.io/github/actions/workflow/status/LacusSolutions/br-utils-ruby/ci.yml?label=ci/cd)](https://github.com/LacusSolutions/br-utils-ruby/actions) +[![Last Update Date](https://img.shields.io/github/last-commit/LacusSolutions/br-utils-ruby)](https://github.com/LacusSolutions/br-utils-ruby) +[![Project License](https://img.shields.io/github/license/LacusSolutions/br-utils-ruby)](https://github.com/LacusSolutions/br-utils-ruby/blob/main/LICENSE) + +> 🌎 [Acessar documentação em português](./README.pt.md) + +A Ruby utility to generate valid CPF (Brazilian Individual's Taxpayer ID) values. + +## Ruby Support + +| ![Ruby 3.2](https://img.shields.io/badge/Ruby-3.2-CC342D?logo=ruby&logoColor=white) | ![Ruby 3.3](https://img.shields.io/badge/Ruby-3.3-CC342D?logo=ruby&logoColor=white) | ![Ruby 3.4](https://img.shields.io/badge/Ruby-3.4-CC342D?logo=ruby&logoColor=white) | +| --- | --- | --- | +| Passing ✔ | Passing ✔ | Passing ✔ | + +Requires Ruby **≥ 3.1** (see `required_ruby_version` in the gemspec). + +## Features + +- ✅ **Numeric CPF**: Generates 11-digit numeric CPF values with valid check digits +- ✅ **Optional prefix**: Provide 0–9 digits to fix the start of the CPF and generate the rest with valid check digits +- ✅ **Formatting**: Option to return the standard formatted string (`000.000.000-00`) +- ✅ **Reusable generator**: `CpfGen::CpfGenerator` class with default options and per-call overrides +- ✅ **Keyword overrides**: Pass `format:` and `prefix:` on `cpf_gen`, `CpfGenerator#generate`, and constructors +- ✅ **Minimal dependencies**: Only [`cpf-dv`](https://rubygems.org/gems/cpf-dv) and [`lacus-utils`](https://rubygems.org/gems/lacus-utils) +- ✅ **Error handling**: API misuse vs domain errors with a `CpfGen::Error` marker for library-wide rescue + +## Installation + +Install the gem directly: + +```bash +gem install cpf-gen +``` + +Or add it to your `Gemfile` and run `bundle install`: + +```ruby +gem 'cpf-gen' +``` + +## Require + +```ruby +require 'cpf-gen' +``` + +## Quick Start + +```ruby +require 'cpf-gen' + +CpfGen.cpf_gen # => e.g. "47844241055" (11-digit numeric) + +CpfGen.cpf_gen(format: true) # => e.g. "005.265.352-88" + +CpfGen.cpf_gen(prefix: '528250911') # => e.g. "52825091138" +CpfGen.cpf_gen( # => e.g. "528.250.911-38" + prefix: '528250911', + format: true +) +``` + +Options can also be passed as a `Hash`: + +```ruby +CpfGen.cpf_gen({ format: true, prefix: '528250911' }) +``` + +## Usage + +The main entry points are the module helper `CpfGen.cpf_gen`, the class `CpfGen::CpfGenerator`, and the options class `CpfGen::CpfGeneratorOptions`. + +### Generator options + +All options are optional: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | `Boolean` | `false` | When truthy, return the generated CPF in standard format (`000.000.000-00`). Non-boolean values are coerced (`false`, `''`, and `0` become `false`; other values become truthy). | +| `prefix` | `String` | `''` | Partial start string (0–9 digits). Only digits are kept; missing characters are generated randomly and check digits are computed. Prefixes longer than 9 digits are truncated silently. | + +Prefix rules: the base (first 9 digits) cannot be all zeros; 9 repeated digits (e.g. `999999999`) are not allowed. Prefixes shorter than 9 digits are never rejected by these rules (e.g. `"00000000"` and `"11111111"` are allowed). + +`nil` is accepted as a keyword argument on `cpf_gen`, `CpfGenerator.new`, `CpfGenerator#generate`, and `CpfGeneratorOptions.new`/`#set` — it simply means "no override for this option". It is **not** accepted by the `CpfGeneratorOptions` property setters (`options.format = value`, `options.prefix = value`): calling a setter with `nil` directly raises `CpfGen::TypeMismatchError`. To reset a property to its default value through a setter, pass the literal constant instead, e.g. `options.format = CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT`. + +### `CpfGen.cpf_gen` (helper) + +Generates a valid CPF string. With no options, returns an 11-digit numeric CPF. This is a convenience wrapper around `CpfGen::CpfGenerator.new(...).generate`. + +- **`options`** (optional): `CpfGen::CpfGeneratorOptions` instance, a `Hash` of option keys, or `nil`. See [Generator options](#generator-options). +- **`format`**, **`prefix`** (keyword arguments): Only used when `options` is omitted (`nil`). Passing `options` **and** any non-`nil` of these keywords at the same time raises `InvalidArgumentCombinationError` — the two ways of passing options are never merged. + +### `CpfGen::CpfGenerator` (class) + +For reusable defaults or per-call overrides, use the class: + +```ruby +require 'cpf-gen' + +generator = CpfGen::CpfGenerator.new(format: true) + +generator.generate # => e.g. "005.265.352-88" +generator.generate(prefix: '123456') # override for this call only +generator.options # current default options (CpfGen::CpfGeneratorOptions) +``` + +- **`initialize(options = nil, **keywords)`**: Optional default options. When `options` is given (a `CpfGen::CpfGeneratorOptions` instance or a `Hash`) alone, it determines the default options; a `CpfGen::CpfGeneratorOptions` instance is stored by reference (mutating it later affects future `generate` calls that do not pass per-call options), while a `Hash` builds a new instance. When `options` is omitted (`nil`), the default options are built exclusively from the keyword arguments (`format:`, `prefix:`). Passing `options` together with any non-`nil` keyword raises `InvalidArgumentCombinationError` instead of silently ignoring the keywords. +- **`generate(options = nil, **keywords)`**: Returns a valid CPF. `options` and the keyword arguments are never merged: a given `options` argument alone fully overrides the instance defaults for this call; otherwise, any given keyword overrides the instance defaults for this call. When neither is given, the instance defaults are used as-is. The instance defaults are never mutated by a per-call override. Passing `options` together with any non-`nil` keyword raises `InvalidArgumentCombinationError`. +- **`options`**: Reader returning the default options used when per-call options are not provided (same instance as used internally; mutating it affects future `generate` calls). + +Default options on the instance; per-call overrides: + +```ruby +require 'cpf-gen' + +generator = CpfGen::CpfGenerator.new(format: true) + +generator.generate # formatted CPF +generator.generate(format: false) # this call only: unformatted +generator.generate # formatted again (instance defaults preserved) +``` + +### `CpfGen::CpfGeneratorOptions` (class) + +Holds options (`format`, `prefix`) with validation and merge support: + +```ruby +require 'cpf-gen' + +options = CpfGen::CpfGeneratorOptions.new( + prefix: '123456', + format: true +) +options.prefix # => "123456" +options.format # => true +options.set(format: false) # merge and return self +options.all # => { format: false, prefix: "123456" } + +# Resetting a property to its default value requires the literal constant — +# a bare `nil` on a setter raises TypeMismatchError: +options.format = CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT +``` + +- **`initialize(*options, **keywords)`**: Every positional `options` argument (each a `Hash` or another `CpfGen::CpfGeneratorOptions` instance) is folded left to right — later arguments win — then the keyword arguments (`format:`, `prefix:`) are applied on top with the highest precedence. At every step, a `nil` value for a given key is ignored in favor of whatever was resolved so far. Any option still unresolved after that is set to its `DEFAULT_*` value. +- **`format`**, **`prefix`**: Accessors with setters; `prefix` is validated (zeroed base ID, repeated digits). The setters **never accept `nil`** — pass the matching `DEFAULT_*` constant (e.g. `CpfGeneratorOptions::DEFAULT_PREFIX`) to reset a property explicitly. +- **`set(*options, **keywords)`**: Updates multiple options at once, using the same fold-then-keywords, ignore-`nil` resolution as `initialize`. Any option left unresolved after merging keeps its **current** value on the instance (a partial update, not a re-initialization). Returns `self`. +- **`all`**: Shallow `Hash` copy of current options (`:format`, `:prefix`). + +## API + +### Exports + +After `require 'cpf-gen'`: + +- **`CpfGen.cpf_gen`**: `(options = nil, **keywords) -> String` — convenience helper. +- **`CpfGen::CpfGenerator`**: Class to generate CPF with optional default options and per-call overrides. +- **`CpfGen::CpfGeneratorOptions`**: Class holding options with validation and merge. +- **`CpfGen::CPF_LENGTH`**: `11` (constant). +- **`CpfGen::CPF_PREFIX_MAX_LENGTH`**: `9` (constant). +- **`CpfGen::VERSION`**: gem version string. +- **Errors**: `CpfGen::Error`, `CpfGen::DomainError`, `CpfGen::InvalidArgumentCombinationError`, `CpfGen::TypeMismatchError`, `CpfGen::ValidationError`. + +### Error handling + +Errors fall into two categories: + +| Category | Meaning | +|---|---| +| **API misuse** | The caller invoked the library incorrectly (wrong type for an option, or an invalid argument combination). | +| **Domain error** | The call was structurally correct, but a value violates a business rule (invalid `prefix`). | + +Every custom error includes the `CpfGen::Error` marker module. Domain failures (`ValidationError`) inherit from `CpfGen::DomainError` (`RangeError`). + +**Important:** passing both an `options` instance/`Hash` and any non-`nil` keyword argument raises `InvalidArgumentCombinationError`. + +#### Summary + +| Class | Inherits from | Category | Trigger condition | +|---|---|---|---| +| `CpfGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once | +| `CpfGen::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | A generator option has the wrong data type | +| `CpfGen::ValidationError` | `CpfGen::DomainError` | Domain error | `prefix` is ineligible (zeroed base ID or 9 repeated digits) | + +#### `CpfGen::Error` (marker module) + +- **Inheritance:** module marker mixed into every library error via `include` (not a class). +- **Category:** N/A (rescue target only) — not a failure mode by itself. +- **When it is raised:** Never raised directly; included by every custom error the library raises. +- **Example:** N/A +- **How to rescue it:** + +```ruby +rescue CpfGen::Error + # everything this library raises +``` + +#### `CpfGen::DomainError` + +- **Inheritance:** `CpfGen::DomainError < RangeError` (includes `CpfGen::Error`) +- **Category:** Domain error — ancestor for all domain failures. +- **When it is raised:** Not raised directly; prefer raising a leaf subclass. +- **Example:** Prefer `raise CpfGen::ValidationError` over raising `DomainError` directly. +- **How to rescue it:** + +```ruby +rescue CpfGen::DomainError + # ValidationError and other DomainError subclasses +``` + +#### `CpfGen::TypeMismatchError` + +- **Inheritance:** `CpfGen::TypeMismatchError < TypeError` (includes `CpfGen::Error`) +- **Category:** API misuse — the caller passed a value of the wrong type. +- **When it is raised:** Raised when a generator option (`format` or `prefix`) has the wrong runtime type. +- **Example:** + +```ruby +CpfGen.cpf_gen(prefix: 123) # raises CpfGen::TypeMismatchError +``` + +- **How to rescue it:** + +```ruby +rescue CpfGen::TypeMismatchError + # this library's type-contract violation + +rescue TypeError + # native type errors, including this library's TypeMismatchError +``` + +#### `CpfGen::InvalidArgumentCombinationError` + +- **Inheritance:** `CpfGen::InvalidArgumentCombinationError < ArgumentError` (includes `CpfGen::Error`) +- **Category:** API misuse — the caller mixed mutually exclusive argument patterns. +- **When it is raised:** Raised when `CpfGenerator.new`, `#generate`, or `cpf_gen` receives both an `options` argument (instance or `Hash`) and any non-`nil` keyword argument at the same time. +- **Example:** + +```ruby +begin + CpfGen::CpfGenerator.new({ format: true }, prefix: '123') +rescue CpfGen::InvalidArgumentCombinationError => e + puts e.message + # Pass either an options instance/Hash to `options`, or keyword arguments (format:, prefix:), not both. +end +``` + +- **How to rescue it:** + +```ruby +rescue CpfGen::InvalidArgumentCombinationError + # this library's invalid argument combination + +rescue ArgumentError + # native argument errors, including this library's InvalidArgumentCombinationError +``` + +#### `CpfGen::ValidationError` + +- **Inheritance:** `CpfGen::ValidationError < CpfGen::DomainError < RangeError` (includes `CpfGen::Error`) +- **Category:** Domain error — a value fails a non-numeric, non-length domain rule. +- **When it is raised:** Raised when `prefix` is ineligible (zeroed base ID `"000000000"`, or 9 repeated digits such as `"999999999"`). +- **Example:** + +```ruby +CpfGen.cpf_gen(prefix: '000000000') # raises CpfGen::ValidationError +CpfGen.cpf_gen(prefix: '999999999') # raises CpfGen::ValidationError +``` + +- **How to rescue it:** + +```ruby +rescue CpfGen::ValidationError + # this exact domain validation failure + +rescue CpfGen::DomainError + # RangeError-rooted domain failures from this library +``` + +#### Rescue granularity + +```ruby +# 1) Single native class — catches type misuse from this library (and other TypeErrors). +rescue TypeError + # CpfGen::TypeMismatchError and any other TypeError (library or not) + +# 2) CpfGen::DomainError — catches business-rule violations under DomainError. +rescue CpfGen::DomainError + # CpfGen::ValidationError and other DomainError subclasses + +# 3) CpfGen::Error — catches everything the library raises. +rescue CpfGen::Error + # every custom error that includes CpfGen::Error + +# 4) Specific leaf class — catches only that exact failure mode. +rescue CpfGen::ValidationError + # only CpfGen::ValidationError +``` + +Notable attributes: + +- `TypeMismatchError`: `option_name`, `actual_input`, `actual_type`, `expected_type` +- `ValidationError`: `option_name`, `actual_input`, `reason` (prefix failures); `expected_values` is always `nil` for CPF + +Property setters never accept `nil` directly — pass the matching `DEFAULT_*` constant to reset: + +```ruby +options = CpfGen::CpfGeneratorOptions.new +begin + options.prefix = nil +rescue CpfGen::TypeMismatchError => e + puts e.message + # CPF generator option "prefix" must be of type string. Got nil. +end + +options.prefix = CpfGen::CpfGeneratorOptions::DEFAULT_PREFIX # explicit reset instead +``` + +Check-digit computation failures from `cpf-dv` are handled internally by retrying generation with the same resolved options; they are not raised to callers under normal operation. + +### Other available resources + +- **`CpfGen::CpfGeneratorOptions::CPF_LENGTH`**: `11`. +- **`CpfGen::CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH`**: `9`. +- **`CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT`**, **`DEFAULT_PREFIX`**: Class-level default constants. + +## Contribution & Support + +We welcome contributions! Please see our [Contributing Guidelines](https://github.com/LacusSolutions/br-utils-ruby/blob/main/CONTRIBUTING.md) for details. If you find this project helpful, please consider: + +- ⭐ Starring the repository +- 🤝 Contributing to the codebase +- 💡 [Suggesting new features](https://github.com/LacusSolutions/br-utils-ruby/issues) +- 🐛 [Reporting bugs](https://github.com/LacusSolutions/br-utils-ruby/issues) + +## License + +This project is licensed under the MIT License — see the [LICENSE](https://github.com/LacusSolutions/br-utils-ruby/blob/main/LICENSE) file for details. + +## Changelog + +See [CHANGELOG](./CHANGELOG.md) for a list of changes and version history. + +--- + +Made with ❤️ by [Lacus Solutions](https://github.com/LacusSolutions) diff --git a/packages/cpf-gen/README.pt.md b/packages/cpf-gen/README.pt.md new file mode 100644 index 0000000..7de3618 --- /dev/null +++ b/packages/cpf-gen/README.pt.md @@ -0,0 +1,335 @@ +![cpf-gen para Ruby](https://br-utils.vercel.app/img/cover_cpf-gen.jpg) + +> 🌎 [Access documentation in English](./README.md) + +Utilitário em Ruby para gerar CPFs válidos (Cadastro de Pessoa Física). + +## Recursos + +- ✅ **CPF numérico**: Gera CPF de 11 dígitos numéricos com dígitos verificadores válidos +- ✅ **Prefixo opcional**: Informe de 0 a 9 dígitos para fixar o início do CPF e gerar o restante com dígitos verificadores válidos +- ✅ **Formatação**: Opção de retornar a string no formato padrão (`000.000.000-00`) +- ✅ **Gerador reutilizável**: Classe `CpfGen::CpfGenerator` com opções padrão e sobrescritas por chamada +- ✅ **Sobrescritas por palavra-chave**: Passe `format:` e `prefix:` em `cpf_gen`, `CpfGenerator#generate` e nos construtores +- ✅ **Dependências mínimas**: Apenas [`cpf-dv`](https://rubygems.org/gems/cpf-dv) e [`lacus-utils`](https://rubygems.org/gems/lacus-utils) +- ✅ **Tratamento de erros**: Uso incorreto da API vs erros de domínio, com marcador `CpfGen::Error` para captura em toda a biblioteca + +## Instalação + +Instale a gem diretamente: + +```bash +gem install cpf-gen +``` + +Ou adicione ao seu `Gemfile` e execute `bundle install`: + +```ruby +gem 'cpf-gen' +``` + +## Require + +```ruby +require 'cpf-gen' +``` + +## Início rápido + +```ruby +require 'cpf-gen' + +CpfGen.cpf_gen # => ex.: "47844241055" (11 dígitos numéricos) + +CpfGen.cpf_gen(format: true) # => ex.: "005.265.352-88" + +CpfGen.cpf_gen(prefix: '528250911') # => ex.: "52825091138" +CpfGen.cpf_gen( # => ex.: "528.250.911-38" + prefix: '528250911', + format: true +) +``` + +As opções também podem ser passadas como `Hash`: + +```ruby +CpfGen.cpf_gen({ format: true, prefix: '528250911' }) +``` + +## Utilização + +Os pontos principais são o helper de módulo `CpfGen.cpf_gen`, a classe `CpfGen::CpfGenerator` e a classe de opções `CpfGen::CpfGeneratorOptions`. + +### Opções do gerador + +Todas as opções são opcionais: + +| Opção | Tipo | Padrão | Descrição | +|--------|------|---------|-------------| +| `format` | `Boolean` | `false` | Se truthy, retorna o CPF gerado no formato padrão (`000.000.000-00`). Valores não booleanos são convertidos (`false`, `''` e `0` viram `false`; demais valores viram truthy). | +| `prefix` | `String` | `''` | String inicial parcial (0–9 dígitos). Apenas dígitos são mantidos; os caracteres faltantes são gerados aleatoriamente e os dígitos verificadores são calculados. Prefixos com mais de 9 dígitos são truncados silenciosamente. | + +Regras do prefixo: a base (primeiros 9 dígitos) não pode ser toda zerada; 9 dígitos repetidos (ex.: `999999999`) não são permitidos. Prefixos com menos de 9 dígitos nunca são rejeitados por essas regras (ex.: `"00000000"` e `"11111111"` são permitidos). + +`nil` é aceito como argumento nomeado em `cpf_gen`, `CpfGenerator.new`, `CpfGenerator#generate` e `CpfGeneratorOptions.new`/`#set` — significa apenas "sem sobrescrita para esta opção". **Não** é aceito pelos setters de propriedade de `CpfGeneratorOptions` (`options.format = valor`, `options.prefix = valor`): chamar um setter com `nil` diretamente lança `CpfGen::TypeMismatchError`. Para redefinir uma propriedade ao seu valor padrão via setter, passe a constante literal, ex.: `options.format = CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT`. + +### `CpfGen.cpf_gen` (helper) + +Gera uma string de CPF válida. Sem opções, retorna um CPF numérico de 11 dígitos. É um atalho para `CpfGen::CpfGenerator.new(...).generate`. + +- **`options`** (opcional): instância de `CpfGen::CpfGeneratorOptions`, `Hash` de chaves de opção ou `nil`. Veja [Opções do gerador](#opções-do-gerador). +- **`format`**, **`prefix`** (argumentos nomeados): Usados apenas quando `options` é omitido (`nil`). Passar `options` **e** qualquer um desses argumentos nomeados não-`nil` ao mesmo tempo gera `InvalidArgumentCombinationError` — as duas formas de passar opções nunca são mescladas entre si. + +### `CpfGen::CpfGenerator` (classe) + +Para padrões reutilizáveis ou sobrescritas por chamada, use a classe: + +```ruby +require 'cpf-gen' + +generator = CpfGen::CpfGenerator.new(format: true) + +generator.generate # => ex.: "005.265.352-88" +generator.generate(prefix: '123456') # sobrescrita apenas nesta chamada +generator.options # opções padrão atuais (CpfGen::CpfGeneratorOptions) +``` + +- **`initialize(options = nil, **keywords)`**: Opções padrão opcionais. Quando `options` é fornecido isoladamente (instância de `CpfGen::CpfGeneratorOptions` ou `Hash`), ele determina as opções padrão; uma instância de `CpfGen::CpfGeneratorOptions` é armazenada por referência (mutações posteriores afetam futuras chamadas de `generate` que não passarem opções por chamada), enquanto um `Hash` cria uma nova instância. Quando `options` é omitido (`nil`), as opções padrão são construídas exclusivamente a partir dos argumentos nomeados (`format:`, `prefix:`). Passar `options` junto com qualquer argumento nomeado não `nil` gera `InvalidArgumentCombinationError`, em vez de ignorar os argumentos nomeados silenciosamente. +- **`generate(options = nil, **keywords)`**: Retorna um CPF válido. `options` e os argumentos nomeados nunca são mesclados: um `options` fornecido isoladamente sobrescreve totalmente os padrões da instância nesta chamada; caso contrário, qualquer argumento nomeado fornecido sobrescreve os padrões da instância nesta chamada. Quando nenhum dos dois é fornecido, os padrões da instância são usados como estão. Os padrões da instância nunca são alterados por uma sobrescrita pontual. Passar `options` junto com qualquer argumento nomeado não `nil` gera `InvalidArgumentCombinationError`. +- **`options`**: Reader que retorna as opções padrão usadas quando não há opções por chamada (mesma instância usada internamente; mutá-la afeta futuras chamadas de `generate`). + +Opções padrão na instância; sobrescritas por chamada: + +```ruby +require 'cpf-gen' + +generator = CpfGen::CpfGenerator.new(format: true) + +generator.generate # CPF formatado +generator.generate(format: false) # somente nesta chamada: sem formato +generator.generate # volta ao padrão da instância +``` + +### `CpfGen::CpfGeneratorOptions` (classe) + +Armazena opções (`format`, `prefix`) com validação e suporte a mesclagem: + +```ruby +require 'cpf-gen' + +options = CpfGen::CpfGeneratorOptions.new( + prefix: '123456', + format: true +) +options.prefix # => "123456" +options.format # => true +options.set(format: false) # mescla e retorna self +options.all # => { format: false, prefix: "123456" } + +# Redefinir uma propriedade ao seu valor padrão exige a constante literal — +# um `nil` direto no setter lança TypeMismatchError: +options.format = CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT +``` + +- **`initialize(*options, **keywords)`**: Cada argumento posicional `options` (um `Hash` ou outra instância de `CpfGen::CpfGeneratorOptions`) é combinado da esquerda para a direita — os últimos prevalecem — e então os argumentos nomeados (`format:`, `prefix:`) são aplicados por cima com a maior precedência. Em cada etapa, um valor `nil` para uma dada chave é ignorado em favor do que já foi resolvido. Qualquer opção ainda não resolvida recebe seu valor `DEFAULT_*`. +- **`format`**, **`prefix`**: Acessores com setters; `prefix` é validado (base zerada, dígitos repetidos). Os setters **nunca aceitam `nil`** — passe a constante `DEFAULT_*` correspondente (ex.: `CpfGeneratorOptions::DEFAULT_PREFIX`) para redefinir uma propriedade explicitamente. +- **`set(*options, **keywords)`**: Atualiza várias opções de uma vez, usando a mesma resolução de `initialize` (combinação seguida de argumentos nomeados, ignorando `nil`). Qualquer opção não resolvida após a combinação mantém seu valor **atual** na instância (uma atualização parcial, não uma reinicialização). Retorna `self`. +- **`all`**: Cópia superficial em `Hash` das opções atuais (`:format`, `:prefix`). + +## API + +### Exportações + +Após `require 'cpf-gen'`: + +- **`CpfGen.cpf_gen`**: `(options = nil, **keywords) -> String` — helper de conveniência. +- **`CpfGen::CpfGenerator`**: Classe para gerar CPF com opções padrão e sobrescritas por chamada. +- **`CpfGen::CpfGeneratorOptions`**: Classe que armazena opções com validação e mesclagem. +- **`CpfGen::CPF_LENGTH`**: `11` (constante). +- **`CpfGen::CPF_PREFIX_MAX_LENGTH`**: `9` (constante). +- **`CpfGen::VERSION`**: string da versão da gem. +- **Erros**: `CpfGen::Error`, `CpfGen::DomainError`, `CpfGen::InvalidArgumentCombinationError`, `CpfGen::TypeMismatchError`, `CpfGen::ValidationError`. + +### Tratamento de erros + +Os erros se dividem em duas categorias: + +| Categoria | Significado | +|---|---| +| **Uso incorreto da API** | O chamador usou a biblioteca de forma incorreta (tipo errado para uma opção, ou combinação inválida de argumentos). | +| **Erro de domínio** | A chamada estava estruturalmente correta, mas um valor viola uma regra de negócio (`prefix` inválido). | + +Todo erro customizado inclui o módulo marcador `CpfGen::Error`. Falhas de domínio (`ValidationError`) herdam de `CpfGen::DomainError` (`RangeError`). + +**Importante:** passar ao mesmo tempo uma instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` levanta `InvalidArgumentCombinationError`. + +#### Resumo + +| Classe | Herda de | Categoria | Condição de disparo | +|---|---|---|---| +| `CpfGen::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e qualquer argumento nomeado não-`nil` passados ao mesmo tempo | +| `CpfGen::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Uma opção do gerador tem o tipo de dado incorreto | +| `CpfGen::ValidationError` | `CpfGen::DomainError` | Erro de domínio | `prefix` inelegível (base zerada ou 9 dígitos repetidos) | + +#### `CpfGen::Error` (módulo marcador) + +- **Herança:** módulo marcador misturado em todo erro da biblioteca via `include` (não é uma classe). +- **Categoria:** N/A (apenas alvo de `rescue`) — não é um modo de falha por si só. +- **Quando é levantado:** Nunca diretamente; incluído por todo erro customizado que a biblioteca levanta. +- **Exemplo:** N/A +- **Como resgatar:** + +```ruby +rescue CpfGen::Error + # tudo o que esta biblioteca levanta +``` + +#### `CpfGen::DomainError` + +- **Herança:** `CpfGen::DomainError < RangeError` (inclui `CpfGen::Error`) +- **Categoria:** Erro de domínio — ancestral de todas as falhas de domínio. +- **Quando é levantado:** Não é levantado diretamente; prefira uma subclasse folha. +- **Exemplo:** Prefira `raise CpfGen::ValidationError` a levantar `DomainError` diretamente. +- **Como resgatar:** + +```ruby +rescue CpfGen::DomainError + # ValidationError e outras subclasses de DomainError +``` + +#### `CpfGen::TypeMismatchError` + +- **Herança:** `CpfGen::TypeMismatchError < TypeError` (inclui `CpfGen::Error`) +- **Categoria:** Uso incorreto da API — o chamador passou um valor do tipo errado. +- **Quando é levantado:** Levantado quando uma opção do gerador (`format` ou `prefix`) tem o tipo de runtime incorreto. +- **Exemplo:** + +```ruby +CpfGen.cpf_gen(prefix: 123) # levanta CpfGen::TypeMismatchError +``` + +- **Como resgatar:** + +```ruby +rescue CpfGen::TypeMismatchError + # violação de contrato de tipo desta biblioteca + +rescue TypeError + # erros nativos de tipo, incluindo TypeMismatchError desta biblioteca +``` + +#### `CpfGen::InvalidArgumentCombinationError` + +- **Herança:** `CpfGen::InvalidArgumentCombinationError < ArgumentError` (inclui `CpfGen::Error`) +- **Categoria:** Uso incorreto da API — o chamador misturou padrões de argumentos mutuamente exclusivos. +- **Quando é levantado:** Levantado quando `CpfGenerator.new`, `#generate` ou `cpf_gen` recebe ao mesmo tempo um argumento `options` (instância ou `Hash`) e qualquer argumento nomeado não-`nil`. +- **Exemplo:** + +```ruby +begin + CpfGen::CpfGenerator.new({ format: true }, prefix: '123') +rescue CpfGen::InvalidArgumentCombinationError => e + puts e.message + # Pass either an options instance/Hash to `options`, or keyword arguments (format:, prefix:), not both. +end +``` + +- **Como resgatar:** + +```ruby +rescue CpfGen::InvalidArgumentCombinationError + # combinação inválida de argumentos desta biblioteca + +rescue ArgumentError + # erros nativos de argumento, incluindo InvalidArgumentCombinationError desta biblioteca +``` + +#### `CpfGen::ValidationError` + +- **Herança:** `CpfGen::ValidationError < CpfGen::DomainError < RangeError` (inclui `CpfGen::Error`) +- **Categoria:** Erro de domínio — um valor falha uma regra de domínio que não é numérica nem de tamanho. +- **Quando é levantado:** Levantado quando `prefix` é inelegível (base zerada `"000000000"`, ou 9 dígitos repetidos como `"999999999"`). +- **Exemplo:** + +```ruby +CpfGen.cpf_gen(prefix: '000000000') # levanta CpfGen::ValidationError +CpfGen.cpf_gen(prefix: '999999999') # levanta CpfGen::ValidationError +``` + +- **Como resgatar:** + +```ruby +rescue CpfGen::ValidationError + # esta falha exata de validação de domínio + +rescue CpfGen::DomainError + # falhas de domínio com raiz em RangeError desta biblioteca +``` + +#### Granularidade de rescue + +```ruby +# 1) Classe nativa — captura uso incorreto de tipo desta biblioteca (e outros TypeErrors). +rescue TypeError + # CpfGen::TypeMismatchError e qualquer outro TypeError (da biblioteca ou não) + +# 2) CpfGen::DomainError — captura violações de regra de negócio sob DomainError. +rescue CpfGen::DomainError + # CpfGen::ValidationError e outras subclasses de DomainError + +# 3) CpfGen::Error — captura tudo o que a biblioteca levanta. +rescue CpfGen::Error + # todo erro customizado que inclui CpfGen::Error + +# 4) Classe folha específica — captura apenas aquele modo de falha. +rescue CpfGen::ValidationError + # apenas CpfGen::ValidationError +``` + +Atributos relevantes: + +- `TypeMismatchError`: `option_name`, `actual_input`, `actual_type`, `expected_type` +- `ValidationError`: `option_name`, `actual_input`, `reason` (falhas de prefixo); `expected_values` é sempre `nil` para CPF + +Os setters de propriedade nunca aceitam `nil` diretamente — passe a constante `DEFAULT_*` correspondente para redefinir: + +```ruby +options = CpfGen::CpfGeneratorOptions.new +begin + options.prefix = nil +rescue CpfGen::TypeMismatchError => e + puts e.message + # CPF generator option "prefix" must be of type string. Got nil. +end + +options.prefix = CpfGen::CpfGeneratorOptions::DEFAULT_PREFIX # redefinição explícita +``` + +Falhas de cálculo de dígitos verificadores pelo `cpf-dv` são tratadas internamente com nova tentativa de geração usando as mesmas opções resolvidas; em operação normal não são propagadas ao chamador. + +### Outros recursos disponíveis + +- **`CpfGen::CpfGeneratorOptions::CPF_LENGTH`**: `11`. +- **`CpfGen::CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH`**: `9`. +- **`CpfGen::CpfGeneratorOptions::DEFAULT_FORMAT`**, **`DEFAULT_PREFIX`**: Constantes de padrão no nível da classe. + +## Contribuição e suporte + +Contribuições são bem-vindas! Consulte as [Diretrizes de contribuição](https://github.com/LacusSolutions/br-utils-ruby/blob/main/CONTRIBUTING.md). Se este projeto for útil para você, considere: + +- ⭐ Dar uma estrela no repositório +- 🤝 Contribuir com o código +- 💡 [Sugerir novos recursos](https://github.com/LacusSolutions/br-utils-ruby/issues) +- 🐛 [Reportar bugs](https://github.com/LacusSolutions/br-utils-ruby/issues) + +## Licença + +Este projeto está licenciado sob a MIT License — consulte o arquivo [LICENSE](https://github.com/LacusSolutions/br-utils-ruby/blob/main/LICENSE). + +## Changelog + +Veja o [CHANGELOG](./CHANGELOG.md) para histórico de versões e alterações. + +--- + +Feito com ❤️ por [Lacus Solutions](https://github.com/LacusSolutions) diff --git a/packages/cpf-gen/cpf-gen.gemspec b/packages/cpf-gen/cpf-gen.gemspec index 85c6f08..54130b9 100644 --- a/packages/cpf-gen/cpf-gen.gemspec +++ b/packages/cpf-gen/cpf-gen.gemspec @@ -6,13 +6,16 @@ Gem::Specification.new do |spec| spec.name = 'cpf-gen' spec.version = CpfGen::VERSION spec.authors = ['Julio L. Muller'] - spec.summary = 'Generate random valid CPF numbers (Brazilian personal ID)' + spec.email = ['juliolmuller@outlook.com'] + spec.summary = "Generate CPF (Brazilian Individual's Taxpayer ID)" + spec.description = "Utility to generate CPF (Brazilian Individual's Taxpayer ID)" spec.homepage = 'https://github.com/LacusSolutions/br-utils-ruby' spec.license = 'MIT' spec.required_ruby_version = '>= 3.1' + spec.metadata['source_code_uri'] = spec.homepage spec.metadata['rubygems_mfa_required'] = 'true' spec.files = Dir['src/**/*'] + ['LICENSE', 'README.md'].select { |f| File.file?(f) } spec.require_paths = ['src'] - spec.add_dependency 'cpf-dv', '>= 0' + spec.add_dependency 'cpf-dv', '>= 1.0.0', '< 1.1.0' spec.add_dependency 'lacus-utils', '>= 1.1.0', '< 2.0.0' end diff --git a/packages/cpf-gen/src/cpf-gen.rb b/packages/cpf-gen/src/cpf-gen.rb index 3fee331..6b1c63b 100644 --- a/packages/cpf-gen/src/cpf-gen.rb +++ b/packages/cpf-gen/src/cpf-gen.rb @@ -1,10 +1,46 @@ # frozen_string_literal: true -require 'cpf-dv' require_relative 'cpf-gen/version' +require_relative 'cpf-gen/errors' +require_relative 'cpf-gen/types' +require_relative 'cpf-gen/cpf_generator_options' +require_relative 'cpf-gen/utils' +require_relative 'cpf-gen/cpf_generator' +require_relative 'cpf-gen/cpf_gen' +# Generates valid CPF (Cadastro de Pessoa Física) identifiers. +# +# Errors fall into two categories: +# +# - *API misuse* — the caller supplied a wrong type or an incompatible argument +# combination. Raised as {CpfGen::TypeMismatchError} or +# {CpfGen::InvalidArgumentCombinationError}. +# - *Domain errors* — the call shape was valid, but a value violates a business +# rule. Invalid +prefix+ values raise {CpfGen::ValidationError} under +# {CpfGen::DomainError}. +# +# Every custom error includes the {CpfGen::Error} marker module so consumers can +# +rescue CpfGen::Error+ for a library-wide catch. +# +# Public API: +# +# - {CpfGen.cpf_gen} +# - {CpfGen::CpfGenerator}, {CpfGen::CpfGeneratorOptions} +# - {CpfGen::CPF_LENGTH}, {CpfGen::CPF_PREFIX_MAX_LENGTH} +# - Error marker {CpfGen::Error}; domain ancestor {CpfGen::DomainError}; +# misuse errors {CpfGen::TypeMismatchError} and +# {CpfGen::InvalidArgumentCombinationError}; domain leaf +# {CpfGen::ValidationError} +# +# @example +# require 'cpf-gen' +# +# CpfGen.cpf_gen # => e.g. "47844241055" module CpfGen - def self.hello - 'cpf-gen' - end + # The standard length of a CPF (Cadastro de Pessoa Física) identifier (11 + # digits). + CPF_LENGTH = CpfGeneratorOptions::CPF_LENGTH + + # Maximum length of the +prefix+ of a CPF. + CPF_PREFIX_MAX_LENGTH = CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH end diff --git a/packages/cpf-gen/src/cpf-gen/cpf_gen.rb b/packages/cpf-gen/src/cpf-gen/cpf_gen.rb new file mode 100644 index 0000000..c326607 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/cpf_gen.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'cpf_generator' + +module CpfGen + module_function + + # Helper function to simplify the usage of the {CpfGenerator} class. + # + # If no options are provided, it generates an 11-digit unformatted numeric CPF + # (e.g. +"47844241055"+) using default settings. If options are provided, they + # control +prefix+ and whether the result is formatted. + # + # Generates a valid 11-digit CPF (+prefix+, random numeric body, and computed + # check digits). With default options the result is unformatted numeric; pass + # +format: true+ for +000.000.000-00+ style output. + # + # @param options [CpfGeneratorOptions, Hash, nil] generator options + # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+; + # see {CpfGeneratorOptions}) + # @return [String] generated CPF + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [ValidationError] if +prefix+ is invalid + # @see CpfGenerator for detailed option descriptions + # + # @example + # CpfGen.cpf_gen # => "47844241055" + def cpf_gen(options = nil, **keywords) + CpfGenerator.new(options, **keywords).generate + end +end diff --git a/packages/cpf-gen/src/cpf-gen/cpf_generator.rb b/packages/cpf-gen/src/cpf-gen/cpf_generator.rb new file mode 100644 index 0000000..4de89b1 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/cpf_generator.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'cpf-dv' +require 'lacus-utils' + +require_relative 'cpf_generator_options' + +module CpfGen + # Generator for CPF (Cadastro de Pessoa Física) identifiers. Builds valid + # 11-digit CPF values by combining an optional +prefix+ with a randomly + # generated sequence and computed check digits. Options control +prefix+ and + # whether the result is formatted (+000.000.000-00+). + class CpfGenerator + # Returns the default options used by this generator when per-call options + # are not provided. + # + # The returned object is the same instance used internally; mutating it (e.g. + # via setters on {CpfGeneratorOptions}) affects future {#generate} calls that + # do not pass +options+. + # + # @return [CpfGeneratorOptions] the instance default options + attr_reader :options + + # Creates a new {CpfGenerator} with optional default options. + # + # Default options apply to every call to {#generate} unless overridden by the + # per-call +options+ argument or keyword overrides. Options control +prefix+ + # and whether the generated CPF is formatted. + # + # +options+ and the keyword arguments are never merged with each other: when + # +options+ is given (a {CpfGeneratorOptions} instance or a {Hash}), it alone + # determines the default options; otherwise, the default options are built + # exclusively from the keyword arguments, with {CpfGeneratorOptions} filling + # in its own defaults for every keyword left as +nil+. Passing +options+ + # together with any non-+nil+ keyword argument raises + # {InvalidArgumentCombinationError} instead of silently ignoring the keywords. + # + # When +options+ is a {CpfGeneratorOptions} instance, that instance is used + # directly (no copy is created). Mutating it later (e.g. via the {#options} + # reader or the original reference) affects future {#generate} calls that do + # not pass per-call options. When a plain {Hash} is passed instead, a new + # {CpfGeneratorOptions} instance is created from it. + # + # @param options [CpfGeneratorOptions, Hash, nil] default options + # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+; + # see {CpfGeneratorOptions}) + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [ValidationError] if +prefix+ is invalid + def initialize(options = nil, **keywords) + @options = resolve_default_options(options, keywords) + end + + # Generates a valid CPF value. + # + # Builds an 11-digit CPF from the configured +prefix+ (if any), a random + # numeric sequence, and two computed check digits. If formatting is enabled, + # the result is returned as +000.000.000-00+. + # + # +options+ and the keyword arguments are never merged with each other: when + # +options+ is given (a {CpfGeneratorOptions} instance or a {Hash}), it alone + # overrides the instance default options for this call; otherwise, any + # non-+nil+ keyword argument overrides the instance default options for this + # call. When neither +options+ nor any keyword argument is given, the + # instance default options are used as-is. In every case, the instance + # default options themselves are left unchanged. Passing +options+ together + # with any non-+nil+ keyword argument raises {InvalidArgumentCombinationError} + # instead of silently ignoring the keywords. + # + # @param options [CpfGeneratorOptions, Hash, nil] per-call option overrides + # @param keywords [Hash] per-call option keyword overrides (mutually exclusive + # with +options+; see {CpfGeneratorOptions}) + # @return [String] generated CPF + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [ValidationError] if +prefix+ is invalid + def generate(options = nil, **keywords) + actual_options = resolve_call_options(options, keywords) + generated_cpf = build_base_cpf(actual_options) + + with_check_digits(generated_cpf, options, keywords) do |cpf_with_digits| + actual_options.format ? Utils.format_cpf(cpf_with_digits) : cpf_with_digits + end + end + + private + + def resolve_default_options(options, keywords) + keyword_overrides = compact_keyword_overrides(keywords) + raise_ambiguous_options! if options && !keyword_overrides.empty? + return options if options.is_a?(CpfGeneratorOptions) + return CpfGeneratorOptions.new(options) if options + + CpfGeneratorOptions.new(**keywords) + end + + def resolve_call_options(options, keywords) + keyword_overrides = compact_keyword_overrides(keywords) + raise_ambiguous_options! if options && !keyword_overrides.empty? + return @options.copy.set(options) if options + return @options if keyword_overrides.empty? + + @options.copy.set(keyword_overrides) + end + + def compact_keyword_overrides(keywords) + CpfGeneratorOptions::OPTION_KEYS.each_with_object({}) do |key, overrides| + value = keywords[key] + overrides[key] = value unless value.nil? + end + end + + def raise_ambiguous_options! + option_keywords = CpfGeneratorOptions::OPTION_KEYS.map { |key| "#{key}:" }.join(', ') + + raise InvalidArgumentCombinationError, + "Pass either an options instance/Hash to `options`, or keyword arguments (#{option_keywords}), " \ + 'not both.' + end + + def build_base_cpf(actual_options) + digits_to_generate = CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH - actual_options.prefix.length + + actual_options.prefix + LacusUtils.generate_random_sequence( + digits_to_generate, + :numeric + ) + end + + def with_check_digits(generated_cpf, options, keywords) + cpf_with_digits = CpfDV::CpfCheckDigits.new(generated_cpf).cpf + + yield cpf_with_digits + rescue CpfDV::DomainError + generate(options, **keywords) + end + end +end diff --git a/packages/cpf-gen/src/cpf-gen/cpf_generator_options.rb b/packages/cpf-gen/src/cpf-gen/cpf_generator_options.rb new file mode 100644 index 0000000..da976c1 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/cpf_generator_options.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require_relative 'types' + +module CpfGen + # Layered option-resolution helpers for {CpfGeneratorOptions}. + module GeneratorOptionsResolution + private + + def fold_layers(layers) + resolved = {} + + layers.each do |layer| + source = layer_source(layer) + next if source.nil? + + CpfGeneratorOptions::OPTION_KEYS.each do |key| + value = Utils.fetch_option(source, key) + resolved[key] = value unless value.nil? + end + end + + resolved + end + + def layer_source(layer) + return layer.all if layer.is_a?(CpfGeneratorOptions) + return layer if layer.is_a?(Hash) + + nil + end + + def apply_keyword_overrides(resolved, keywords) + CpfGeneratorOptions::OPTION_KEYS.each do |key| + value = keywords[key] + resolved[key] = value unless value.nil? + end + end + + def assign_resolved_or_default(resolved) + CpfGeneratorOptions::OPTION_KEYS.each do |key| + value = resolved.key?(key) ? resolved[key] : CpfGeneratorOptions::DEFAULTS[key] + public_send("#{key}=", value) + end + end + end + + # Property accessors for {CpfGeneratorOptions}. Kept as a sibling module in this + # file (not a separate public API) so the options class stays under RuboCop's + # +Metrics/ClassLength+ budget. + module CpfGeneratorOptionProperties + # Returns a shallow copy of all current options. + # + # Exposes resolved +format+ and +prefix+ values. This is useful for creating + # snapshots of the current configuration. + # + # @return [Hash{Symbol => Object}] shallow copy of option values + def all + @options.dup + end + + # Returns whether the generated CPF string will have the standard formatting + # (+000.000.000-00+). + # + # @return [Boolean] + def format + @options[:format] + end + + # Sets whether the generated CPF string will have the standard formatting + # (+000.000.000-00+). The value is converted to a boolean, so truthy/falsy + # values are handled appropriately. + # + # +nil+ is not accepted: pass {CpfGeneratorOptions::DEFAULT_FORMAT} explicitly + # to reset this option to its default value. + # + # @param value [Boolean] enable formatting when truthy + # @raise [TypeMismatchError] if the value is +nil+ + def format=(value) + raise TypeMismatchError.new(value, 'boolean', option_name: 'format') if value.nil? + + @options[:format] = Utils.normalize_boolean(value) + end + + # Returns the string used as the initial string of the generated CPF. + # + # Note: If the evaluated +prefix+ (after stripping non-digit characters) is + # longer than 9 digits, the extra digits are ignored, because a CPF has 9 + # base digits followed by 2 calculated check digits. + # + # @return [String] + def prefix + @options[:prefix] + end + + # Sets the string used as the initial string of the generated CPF. Only + # digits are kept and the rest is stripped. If provided, only the missing + # digits are generated randomly. For example, if the +prefix+ +"123456"+ (6 + # digits) is given, only the next 3 digits are randomly generated and + # concatenated to the +prefix+. + # + # Note: If the evaluated +prefix+ (after stripping non-digit characters) is + # longer than 9 digits, the extra digits are ignored, because a CPF has 9 + # base digits followed by 2 calculated check digits. + # + # +nil+ is not accepted: pass {CpfGeneratorOptions::DEFAULT_PREFIX} explicitly + # to reset this option to its default value. + # + # @param value [String] partial start string + # @raise [TypeMismatchError] if the value is not a +String+ + # @raise [ValidationError] if +prefix+ is invalid + def prefix=(value) + actual_prefix = Utils.sanitize_prefix(value) + + Utils.validate_prefix!(actual_prefix) + + @options[:prefix] = actual_prefix + end + end + + # Stores configuration for the CPF generator. + # + # Provides a centralized way to configure how CPF digits are generated, + # including partial start string (+prefix+) and formatting (+format+). + class CpfGeneratorOptions + include GeneratorOptionsResolution + include CpfGeneratorOptionProperties + + # The standard length of a CPF (Cadastro de Pessoa Física) identifier (11 + # digits). + CPF_LENGTH = 11 + + # Maximum length of the +prefix+ of a CPF. + CPF_PREFIX_MAX_LENGTH = CPF_LENGTH - 2 + + # Default value for the +format+ option. When +true+, the generated CPF + # string will have the standard formatting (+000.000.000-00+). + DEFAULT_FORMAT = false + + # Default string used as the initial string of the generated CPF. + DEFAULT_PREFIX = '' + + CPF_BASE_ID_LENGTH = 9 + CPF_BASE_ID_LAST_INDEX = CPF_BASE_ID_LENGTH - 1 + ZEROED_CPF_BASE_ID = '0' * CPF_BASE_ID_LENGTH + + PREFIX_SANITIZE_PATTERN = /\D/ + + # Option keys managed by this class, in assignment order. + OPTION_KEYS = %i[format prefix].freeze + + # Default value for each key in {OPTION_KEYS}, used to fill any option that + # is still unresolved once {#initialize} finishes merging its arguments. + DEFAULTS = { + format: DEFAULT_FORMAT, + prefix: DEFAULT_PREFIX + }.freeze + + # Creates a new {CpfGeneratorOptions} instance. + # + # Options are resolved in three steps. Each step only overrides a key when it + # is given a non-+nil+ value; a +nil+ is always ignored in favor of whatever + # was resolved by a previous step. + # + # 1. Every positional +options+ layer (each either a {Hash} or another + # {CpfGeneratorOptions} instance) is folded left to right, so later + # layers take precedence over earlier ones. + # 2. The +format+ and +prefix+ keyword arguments are then applied on top of + # the folded layers. Keywords always have the highest precedence, + # overriding every positional layer. + # 3. Any option that is still unresolved after steps 1 and 2 is assigned its + # +DEFAULT_*+ value (see {DEFAULTS}). + # + # Because every option is fully resolved to a concrete, non-+nil+ value + # before assignment, the individual property setters (e.g. {#format=}) never + # receive +nil+ from this method — they always raise if given +nil+ directly. + # + # @param options [Array] option layers merged in + # order (later layers win); a missing or +nil+ value for a key inside a + # layer is ignored and the previously resolved value is kept + # @param keywords [Hash] highest-precedence option overrides (see {OPTION_KEYS}) + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [ValidationError] if +prefix+ is invalid + def initialize(*options, **keywords) + @options = {} + + resolved = fold_layers(options) + apply_keyword_overrides(resolved, keywords) + assign_resolved_or_default(resolved) + end + + # Sets multiple options at once, following the same layered-override + # semantics as {#initialize} (positional layers folded left to right, then + # keyword arguments applied with the highest precedence; +nil+ is always + # ignored). Unlike {#initialize}, any option that is still unresolved after + # merging keeps its **current** value on this instance instead of falling + # back to its default — this method performs a partial update, not a + # re-initialization. + # + # @param options [Array] option layers merged in + # order (later layers win) + # @param keywords [Hash] highest-precedence option overrides (see {OPTION_KEYS}) + # @return [CpfGeneratorOptions] +self+ + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [ValidationError] if +prefix+ is invalid + def set(*options, **keywords) + resolved = fold_layers(options) + apply_keyword_overrides(resolved, keywords) + resolved.each { |key, value| public_send("#{key}=", value) } + self + end + + # Returns a shallow copy of this options instance. + # + # @return [CpfGeneratorOptions] duplicated options for per-call merging + def copy + duplicate = self.class.allocate + duplicate.instance_variable_set(:@options, @options.dup) + duplicate + end + end +end diff --git a/packages/cpf-gen/src/cpf-gen/errors.rb b/packages/cpf-gen/src/cpf-gen/errors.rb new file mode 100644 index 0000000..9ca4fb9 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/errors.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'lacus-utils' + +module CpfGen + # Marker module mixed into every custom error raised by this library. + # + # Use +rescue CpfGen::Error+ to catch every library error regardless of native + # ancestry. + module Error; end + + # API misuse error raised when an argument's runtime type does not match the + # type required by the API contract (a generator option). + class TypeMismatchError < TypeError + include Error + + # @return [Object] the offending input value + attr_reader :actual_input + + # @return [String] human-readable type of {#actual_input} + attr_reader :actual_type + + # @return [String] description of the expected type + attr_reader :expected_type + + # @return [String] the offending option key + attr_reader :option_name + + # @param actual_input [Object] the offending option value + # @param expected_type [String] description of the expected type + # @param option_name [String] option key when the failure is option-related + def initialize(actual_input, expected_type, option_name:) + actual_type = LacusUtils.describe_type(actual_input) + super( + %(CPF generator option "#{option_name}" must be of type #{expected_type}. Got #{actual_type}.) + ) + @actual_input = actual_input + @actual_type = actual_type + @expected_type = expected_type + @option_name = option_name + end + end + + # API misuse error raised when the combination of provided arguments does not + # match any valid overload-style signature. + class InvalidArgumentCombinationError < ArgumentError + include Error + end + + # Domain error ancestor for business-rule failures (validation and other domain + # leaves). Prefer raising a leaf subclass. + class DomainError < RangeError + include Error + end + + # Domain error raised when a generator option has a valid type but violates a + # domain rule (invalid +prefix+). Structurally mirrors {CnpjGen::ValidationError} + # (including +expected_values+) even though CPF has no +type+ option that would + # populate that field. + class ValidationError < DomainError + # @return [String] the offending option name + attr_reader :option_name + + # @return [Object] the offending option value + attr_reader :actual_input + + # @return [String, nil] human-readable reason when the failure is prefix-related + attr_reader :reason + + # @return [Array, nil] reserved for structural parity with CNPJ; always + # +nil+ for CPF prefix validation + attr_reader :expected_values + + # @param option_name [String] the offending option key + # @param actual_input [Object] the offending option value + # @param reason [String, nil] human-readable reason for a prefix failure + # @param expected_values [Array, nil] unused for CPF (always +nil+) + def initialize(option_name, actual_input, reason: nil, expected_values: nil) + super(build_message(option_name, actual_input, reason, expected_values)) + @option_name = option_name + @actual_input = actual_input + @reason = reason + @expected_values = expected_values&.dup&.freeze + end + + private + + def build_message(option_name, actual_input, reason, expected_values) + if expected_values + quoted = expected_values.map { |value| %("#{value}") }.join(', ') + "CPF generator option \"#{option_name}\" accepts only the following values: #{quoted}. " \ + "Got \"#{actual_input}\"." + else + "CPF generator option \"#{option_name}\" with value \"#{actual_input}\" is invalid. #{reason}" + end + end + end +end diff --git a/packages/cpf-gen/src/cpf-gen/types.rb b/packages/cpf-gen/src/cpf-gen/types.rb new file mode 100644 index 0000000..7055364 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/types.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module CpfGen + # Options input accepted by constructors and merge helpers. + # + # May be a {CpfGeneratorOptions} instance, a {Hash} of option keys, or +nil+. + # + # Resolved options contain: + # + # - +format+ [Boolean] — whether to format the generated CPF string as + # +000.000.000-00+ (default: +false+). + # - +prefix+ [String] — a partial string containing 0 to 9 digits to use as the + # start of the generated CPF. Only digits are kept; the rest is stripped. If + # provided, only the missing digits are generated randomly. For example, if + # the +prefix+ +"123456"+ (6 digits) is given, only the next 3 digits are + # randomly generated and concatenated to the +prefix+. + # + # Note: If the evaluated +prefix+ (after stripping non-digit characters) is + # longer than 9 digits, the extra digits are ignored, because a CPF has 9 + # base digits followed by 2 calculated check digits. + # + # @see CpfGeneratorOptions + CpfGeneratorOptionsInput = Object +end diff --git a/packages/cpf-gen/src/cpf-gen/utils.rb b/packages/cpf-gen/src/cpf-gen/utils.rb new file mode 100644 index 0000000..47a9310 --- /dev/null +++ b/packages/cpf-gen/src/cpf-gen/utils.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module CpfGen + # Low-level helpers used by {CpfGenerator} and {CpfGeneratorOptions}. + # + # @api private + module Utils + module_function + + # rubocop:disable Naming/PredicateMethod -- coercion helper, not a predicate query + def normalize_boolean(value) + return false if [false, '', 0].include?(value) + + !!value + end + # rubocop:enable Naming/PredicateMethod + + def assert_string_option!(option_name, value) + return if value.is_a?(String) + + raise TypeMismatchError.new(value, 'string', option_name: option_name) + end + + def fetch_option(source, key) + return source[key] if source.key?(key) + return source[key.to_s] if source.key?(key.to_s) + + nil + end + + def sanitize_prefix(value) + assert_string_option!('prefix', value) + + sanitized = value.gsub(CpfGeneratorOptions::PREFIX_SANITIZE_PATTERN, '') + sanitized[0, CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH] + end + + def validate_prefix!(partial_cpf) + validate_prefix_base_id!(partial_cpf) + validate_prefix_non_repeated_digits!(partial_cpf) + end + + def validate_prefix_base_id!(partial_cpf) + return if partial_cpf.length < CpfGeneratorOptions::CPF_BASE_ID_LENGTH + + cpf_base_id = partial_cpf[0, CpfGeneratorOptions::CPF_BASE_ID_LAST_INDEX + 1] + + return unless cpf_base_id == CpfGeneratorOptions::ZEROED_CPF_BASE_ID + + raise ValidationError.new( + 'prefix', + partial_cpf, + reason: 'Zeroed base ID is not eligible.' + ) + end + + def validate_prefix_non_repeated_digits!(partial_cpf) + return if partial_cpf.length < CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH + + first_character = partial_cpf[0] + + return unless partial_cpf == first_character * CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH + + raise ValidationError.new( + 'prefix', + partial_cpf, + reason: 'Repeated digits are not considered valid.' + ) + end + + # Formats a raw 11-digit CPF into the standard masked representation. + def format_cpf(raw) + "#{raw[0, 3]}.#{raw[3, 3]}.#{raw[6, 3]}-#{raw[9, 2]}" + end + end +end diff --git a/packages/cpf-gen/tests/cpf_gen.spec.rb b/packages/cpf-gen/tests/cpf_gen.spec.rb index a2f4839..633e2a4 100644 --- a/packages/cpf-gen/tests/cpf_gen.spec.rb +++ b/packages/cpf-gen/tests/cpf_gen.spec.rb @@ -3,9 +3,26 @@ require 'spec_helper' RSpec.describe CpfGen do - describe '.hello' do - it 'returns cpf-gen' do - expect(CpfGen.hello).to eq('cpf-gen') + describe '.cpf_gen' do + context 'when called with no arguments' do + it 'returns an 11-digit numeric CPF' do + expect(described_class.cpf_gen).to match(/\A\d{11}\z/) + end + end + + context 'when called with options' do + it 'forwards format and prefix' do + result = described_class.cpf_gen(format: true, prefix: '12345') + + expect(result).to match(/\A123\.45\d\.\d{3}-\d{2}\z/) + end + end + + context 'when called with keyword options' do + it 'raises InvalidArgumentCombinationError when options and keywords are both given' do + expect { described_class.cpf_gen({ format: true }, prefix: '12') } + .to raise_error(CpfGen::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end end end end diff --git a/packages/cpf-gen/tests/cpf_generator.spec.rb b/packages/cpf-gen/tests/cpf_generator.spec.rb new file mode 100644 index 0000000..3d4a3dd --- /dev/null +++ b/packages/cpf-gen/tests/cpf_generator.spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require 'spec_helper' + +CPF_GENERATOR_PREFIX_CASES = %w[ + 1 12 123 1234 12345 123456 1234567 12345678 123456789 +].freeze + +CPF_GENERATOR_FACTORIES = { + constructor_literal: lambda { |options| + generator = CpfGen::CpfGenerator.new(options) + ->(override = nil) { generator.generate(override) } + }, + constructor_options: lambda { |options| + generator_options = CpfGen::CpfGeneratorOptions.new(options) + generator = CpfGen::CpfGenerator.new(generator_options) + ->(override = nil) { generator.generate(override) } + }, + method_literal: lambda { |options| + generator = CpfGen::CpfGenerator.new + lambda do |override = nil| + if override.nil? + generator.generate(options) + elsif override.is_a?(CpfGen::CpfGeneratorOptions) + generator.generate(CpfGen::CpfGeneratorOptions.new(options, override)) + else + generator.generate(options.merge(override)) + end + end + }, + method_options: lambda { |options| + generator = CpfGen::CpfGenerator.new + lambda do |override = nil| + generator_options = CpfGen::CpfGeneratorOptions.new(options, override || {}) + generator.generate(generator_options) + end + } +}.freeze + +class CpfGeneratorCallsSpy < CpfGen::CpfGenerator + attr_reader :calls_count, :calls_arguments + + def initialize(*args, **kwargs) + super + @calls_count = 0 + @calls_arguments = [] + end + + def generate(options = nil, **kwargs) + @calls_count += 1 + @calls_arguments << [options, kwargs] + super + end +end + +RSpec.describe CpfGen::CpfGenerator do + def default_options_snapshot + CpfGen::CpfGeneratorOptions.new.all + end + + def unique_result_count(generate, count: 100, **kwargs) + Array.new(count) { generate.call(**kwargs) }.uniq.size + end + + describe '#initialize' do + context 'when called with no arguments' do + it 'creates an instance with default options' do + generator = described_class.new + + expect(generator.options.all).to eq(default_options_snapshot) + end + end + + context 'when called with an empty hash' do + it 'creates an instance with default options' do + generator = described_class.new({}) + + expect(generator.options.all).to eq(default_options_snapshot) + end + end + + context 'when called with a CpfGeneratorOptions instance' do + let(:options) do + CpfGen::CpfGeneratorOptions.new(format: true, prefix: '123456') + end + + it 'uses that instance directly without copying' do + generator = described_class.new(options) + + aggregate_failures do + expect(generator.options).to equal(options) + expect(generator.options.all).to eq(options.all) + end + end + + it 'reflects mutations on future generate calls' do + generator = described_class.new( + CpfGen::CpfGeneratorOptions.new(prefix: '123456', format: true) + ) + + generator.options.prefix = '112233' + generator.options.format = false + + result = generator.generate + + aggregate_failures do + expect(result.length).to eq(11) + expect(result).to match(/\A112233\d{5}\z/) + end + end + end + + context 'when called with a literal options hash' do + it 'creates a new CpfGeneratorOptions instance' do + input = { format: true, prefix: '123456' } + generator = described_class.new(input) + + aggregate_failures do + expect(generator.options).to be_a(CpfGen::CpfGeneratorOptions) + expect(generator.options.format).to be(true) + expect(generator.options.prefix).to eq('123456') + end + end + end + + context 'when called with invalid options' do + it 'raises ValidationError' do + expect { described_class.new(prefix: '000000000') } + .to raise_error(CpfGen::ValidationError) + end + + it 'raises TypeMismatchError' do + expect { described_class.new(prefix: 123) } + .to raise_error(CpfGen::TypeMismatchError) + end + end + + context 'when called with both an options instance and keyword arguments' do + it 'raises InvalidArgumentCombinationError' do + options = CpfGen::CpfGeneratorOptions.new(format: true, prefix: '123456') + + expect { described_class.new(options, format: false) } + .to raise_error(CpfGen::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + end + + context 'when called with both an options Hash and keyword arguments' do + it 'raises InvalidArgumentCombinationError' do + expect { described_class.new({ format: true }, prefix: '12') } + .to raise_error(CpfGen::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + end + end + + describe '#generate' do + { + 'constructor literal' => :constructor_literal, + 'constructor CpfGeneratorOptions' => :constructor_options, + 'method literal' => :method_literal, + 'method CpfGeneratorOptions' => :method_options + }.each do |label, factory_key| + context "when options are passed via #{label}" do + let(:factory) { CPF_GENERATOR_FACTORIES.fetch(factory_key) } + + context 'when no options are passed' do + let(:generate) { factory.call({}) } + + it 'returns an 11-digit numeric string' do + 100.times do + result = generate.call + + aggregate_failures do + expect(result.length).to eq(11) + expect(result).not_to match(/[a-z]/i) + expect(result).not_to match(%r{[./-]}) + expect(result).to match(/\A\d+\z/) + end + end + end + + it 'returns mostly unique values' do + expect(unique_result_count(generate)).to be >= 99 + end + end + + context 'when format is true' do + let(:generate) { factory.call(format: true) } + + it 'returns a 14-character formatted string' do + 100.times do + result = generate.call + + aggregate_failures do + expect(result.length).to eq(14) + expect(result).not_to match(/[a-z]/i) + expect(result).to match(%r{[./-]}) + expect(result).to match(/\d{2,3}/) + end + end + end + + it 'matches the standard CPF mask' do + 100.times do + expect(generate.call).to match(/\A\d{3}\.\d{3}\.\d{3}-\d{2}\z/) + end + end + + it 'returns mostly unique values' do + expect(unique_result_count(generate)).to be >= 99 + end + end + + context 'when prefix is passed' do + CPF_GENERATOR_PREFIX_CASES.each do |prefix| + it "returns an 11-digit string starting with #{prefix}" do + generate = factory.call(prefix: prefix) + + 100.times do + result = generate.call + + aggregate_failures do + expect(result.length).to eq(11) + expect(result).to match(/\A\d+\z/) + expect(result).to start_with(prefix) + end + end + end + end + + it 'drops characters after the 9th position' do + generate = factory.call(prefix: '12345678910') + result = generate.call + + aggregate_failures do + expect(result.length).to eq(11) + expect(result).not_to end_with('10') + expect(result).to match(/\A123456789\d{2}\z/) + end + end + + it 'returns a deterministic CPF for a 9-digit prefix' do + generate = factory.call(prefix: '987654321') + results = Array.new(100) { generate.call }.uniq + + expect(results.size).to eq(1) + end + + it 'strips non-digit characters from prefix' do + generate = factory.call(prefix: 'ABC.123.DEF.456.GHI.789', format: false) + + expect(generate.call).to start_with('123456789') + end + end + + context 'when different options are combined' do + it 'returns a 14-character CPF with format and prefix' do + generate = factory.call(format: true, prefix: '12345678') + result = generate.call + + aggregate_failures do + expect(result.length).to eq(14) + expect(result).not_to match(/[a-z]/i) + expect(result).to match(/\A123\.456\.78\d-\d{2}\z/) + end + end + end + end + end + + context 'when called with both an options instance and keyword arguments' do + it 'raises InvalidArgumentCombinationError' do + generator = described_class.new(format: false, prefix: '12') + per_call_options = CpfGen::CpfGeneratorOptions.new(format: true, prefix: '123456') + + expect { generator.generate(per_call_options, format: false) } + .to raise_error(CpfGen::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + end + + context 'when called with both an options Hash and keyword arguments' do + it 'raises InvalidArgumentCombinationError' do + generator = described_class.new + + expect { generator.generate({ format: true }, prefix: '12') } + .to raise_error(CpfGen::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + end + + context 'when CpfCheckDigits raises a DomainError' do + before do + allow(LacusUtils).to receive(:generate_random_sequence) + .and_return('111111111', '123456789') + end + + it 'retries generation and returns a valid CPF' do + result = described_class.new.generate + + aggregate_failures do + expect(result.length).to eq(11) + expect(result).to start_with('123456789') + expect(LacusUtils).to have_received(:generate_random_sequence).twice + end + end + + it 'uses the same options on retry' do + allow(LacusUtils).to receive(:generate_random_sequence).and_return('111111', '222333') + + result = described_class.new(prefix: '111', format: true).generate + + aggregate_failures do + expect(result.length).to eq(14) + expect(result).to start_with('111.222.333-') + expect(LacusUtils).to have_received(:generate_random_sequence).with(6, :numeric).twice + end + end + + it 'retries with the same per-call options' do + allow(LacusUtils).to receive(:generate_random_sequence).and_return('111111', '222333') + + generator = CpfGeneratorCallsSpy.new + result = generator.generate(format: true, prefix: '111') + + aggregate_failures do + expect(result.length).to eq(14) + expect(result).to start_with('111.222.333-') + expect(generator.calls_count).to eq(2) + expect(generator.calls_arguments).to eq( + [ + [nil, { format: true, prefix: '111' }], + [nil, { format: true, prefix: '111' }] + ] + ) + end + end + end + end +end diff --git a/packages/cpf-gen/tests/cpf_generator_options.spec.rb b/packages/cpf-gen/tests/cpf_generator_options.spec.rb new file mode 100644 index 0000000..db6e4f1 --- /dev/null +++ b/packages/cpf-gen/tests/cpf_generator_options.spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require 'spec_helper' + +CPF_GENERATOR_OPTIONS_REPEATED_DIGIT_PREFIXES = %w[ + 111111111 + 222222222 + 333333333 + 444444444 + 555555555 + 666666666 + 777777777 + 888888888 + 999999999 +].freeze + +RSpec.describe CpfGen::CpfGeneratorOptions do + def expect_options_match(actual, expected) + expected.each do |key, value| + expect(actual[key]).to eq(value) + end + end + + let(:default_parameters) do + { + format: described_class::DEFAULT_FORMAT, + prefix: described_class::DEFAULT_PREFIX + } + end + + describe '#initialize' do + context 'when called with no parameters' do + it 'sets all options to default values' do + expect_options_match(described_class.new.all, default_parameters) + end + end + + context 'when called with all parameters set to nil' do + it 'sets all options to default values' do + options = described_class.new(format: nil, prefix: nil) + + expect_options_match(options.all, default_parameters) + end + end + + context 'when called with all parameters' do + it 'sets all options to the provided values' do + parameters = { format: true, prefix: '12345' } + + expect_options_match(described_class.new(parameters).all, parameters) + end + end + + context 'when called with some parameters' do + it 'sets only the provided non-nil values' do + options = described_class.new(format: true) + + expect_options_match( + options.all, + default_parameters.merge(format: true) + ) + end + end + + context 'when called with a CpfGeneratorOptions instance' do + it 'creates a new instance with the same values' do + original_options = described_class.new(format: true, prefix: '12345') + options = described_class.new(original_options) + + aggregate_failures do + expect(options).not_to equal(original_options) + expect_options_match(options.all, original_options.all) + end + end + end + + context 'when called with override parameters' do + it 'uses the last option with two params' do + options = described_class.new({ prefix: '12345' }, { prefix: '11222333' }) + + expect(options.prefix).to eq('11222333') + end + + it 'uses the last option with one hash and one instance' do + options = described_class.new( + { prefix: '12345' }, + described_class.new(prefix: '11222333') + ) + + expect(options.prefix).to eq('11222333') + end + + it 'uses the last option with five params' do + options = described_class.new( + { prefix: '123456780009' }, + { prefix: '11' }, + { prefix: '22333' }, + { prefix: '44555666' }, + { prefix: '77888999' } + ) + + expect(options.prefix).to eq('77888999') + end + + it 'ignores a nil value inside a later layer' do + options = described_class.new({ prefix: '11222333' }, { prefix: nil }) + + expect(options.prefix).to eq('11222333') + end + + it 'gives keyword arguments precedence over every positional layer' do + options = described_class.new({ prefix: '12345' }, { prefix: '11222333' }, prefix: '99999999') + + expect(options.prefix).to eq('99999999') + end + + it 'ignores a nil keyword argument in favor of the positional layers' do + options = described_class.new({ prefix: '11222333' }, prefix: nil) + + expect(options.prefix).to eq('11222333') + end + end + end + + describe '#set' do + context 'when called with partial overrides' do + it 'updates only the provided keys and keeps current values' do + options = described_class.new(format: true, prefix: '12345') + options.set(prefix: '11222333') + + expect_options_match(options.all, format: true, prefix: '11222333') + end + + it 'folds positional layers left to right then applies keywords' do + options = described_class.new(format: false, prefix: '12345') + options.set({ prefix: '11' }, { prefix: '22333' }, format: true) + + expect_options_match(options.all, format: true, prefix: '22333') + end + + it 'ignores nil values and keeps the previously resolved value' do + options = described_class.new(format: true, prefix: '12345') + options.set(format: nil, prefix: '11222333') + + expect_options_match(options.all, format: true, prefix: '11222333') + end + + it 'returns self' do + options = described_class.new + + expect(options.set(format: true)).to equal(options) + end + end + end + + describe '#format=' do + context 'when setting to a boolean value' do + it 'sets format to true' do + options = described_class.new(format: false) + options.format = true + + expect(options.format).to be(true) + end + + it 'sets format to false' do + options = described_class.new(format: true) + options.format = false + + expect(options.format).to be(false) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(format: !described_class::DEFAULT_FORMAT) + + expect { options.format = nil } + .to raise_error( + CpfGen::TypeMismatchError, + 'CPF generator option "format" must be of type boolean. Got nil.' + ) + end + end + + context 'when setting to a non-boolean value' do + it 'coerces an object to true' do + options = described_class.new(format: false) + options.format = { not: 'a boolean' } + + expect(options.format).to be(true) + end + + it 'coerces a truthy string to true' do + options = described_class.new(format: false) + options.format = 'not a boolean' + + expect(options.format).to be(true) + end + + it 'coerces a truthy number to true' do + options = described_class.new(format: false) + options.format = 123 + + expect(options.format).to be(true) + end + + it 'coerces an empty string to false' do + options = described_class.new(format: false) + options.format = '' + + expect(options.format).to be(false) + end + + it 'coerces zero to false' do + options = described_class.new(format: false) + options.format = 0 + + expect(options.format).to be(false) + end + end + end + + describe '#prefix=' do + context 'when setting to a valid string value' do + it 'sets prefix to the provided value' do + options = described_class.new(prefix: '12345') + options.prefix = '11222333' + + expect(options.prefix).to eq('11222333') + end + + it 'strips non-digit characters' do + options = described_class.new + options.prefix = '123.ABC.def' + + expect(options.prefix).to eq('123') + end + + it 'truncates extra characters beyond 9' do + options = described_class.new + options.prefix = '12345678910' + + expect(options.prefix).to eq('123456789') + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(prefix: '12345') + + expect { options.prefix = nil } + .to raise_error( + CpfGen::TypeMismatchError, + 'CPF generator option "prefix" must be of type string. Got nil.' + ) + end + end + + context 'when setting to a non-string value' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.prefix = { not: 'a string' } } + .to raise_error( + CpfGen::TypeMismatchError, + 'CPF generator option "prefix" must be of type string. Got hash.' + ) + end + + it 'raises TypeMismatchError for a number' do + options = described_class.new + + expect { options.prefix = 123 } + .to raise_error( + CpfGen::TypeMismatchError, + 'CPF generator option "prefix" must be of type string. Got integer number.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.prefix = true } + .to raise_error( + CpfGen::TypeMismatchError, + 'CPF generator option "prefix" must be of type string. Got boolean.' + ) + end + end + + context 'when setting to an invalid string' do + it 'raises for a zeroed base ID' do + options = described_class.new + + expect { options.prefix = '000000000' } + .to raise_error( + CpfGen::ValidationError, + 'CPF generator option "prefix" with value "000000000" is invalid. ' \ + 'Zeroed base ID is not eligible.' + ) + end + + CPF_GENERATOR_OPTIONS_REPEATED_DIGIT_PREFIXES.each do |prefix| + it "raises for repeated digits in #{prefix}" do + options = described_class.new + + expect { options.prefix = prefix } + .to raise_error( + CpfGen::ValidationError, + %(CPF generator option "prefix" with value "#{prefix}" is invalid. ) \ + 'Repeated digits are not considered valid.' + ) + end + end + end + end + + describe '#all' do + it 'returns all properties with expected types' do + snapshot = described_class.new.all + + aggregate_failures do + expect([true, false]).to include(snapshot[:format]) + expect(snapshot[:prefix]).to be_a(String) + end + end + end +end diff --git a/packages/cpf-gen/tests/errors.spec.rb b/packages/cpf-gen/tests/errors.spec.rb new file mode 100644 index 0000000..5116f17 --- /dev/null +++ b/packages/cpf-gen/tests/errors.spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CpfGen::Error do + it 'is a module' do + expect(described_class).to be_a(Module) + expect(described_class).not_to be_a(Class) + end +end + +RSpec.describe CpfGen::TypeMismatchError do + subject(:error) { described_class.new(123, 'string', option_name: 'format') } + + context 'when instantiated' do + it 'is a TypeError' do + expect(error).to be_a(TypeError) + end + + it 'includes CpfGen::Error' do + expect(error).to be_a(CpfGen::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfGen::TypeMismatchError') + end + + it 'sets option_name' do + expect(described_class.new(123, 'string', option_name: 'prefix').option_name).to eq('prefix') + end + + it 'sets actual_input' do + expect(error.actual_input).to eq(123) + end + + it 'sets actual_type' do + expect(error.actual_type).to eq('integer number') + end + + it 'sets expected_type' do + expect(error.expected_type).to eq('string') + end + + it 'builds a descriptive message' do + expect(error.message).to eq( + 'CPF generator option "format" must be of type string. Got integer number.' + ) + end + end +end + +RSpec.describe CpfGen::InvalidArgumentCombinationError do + subject(:error) { described_class.new('invalid combination') } + + it 'is an ArgumentError' do + expect(error).to be_a(ArgumentError) + end + + it 'includes CpfGen::Error' do + expect(error).to be_a(CpfGen::Error) + end + + it 'is not a DomainError' do + expect(error).not_to be_a(CpfGen::DomainError) + end +end + +RSpec.describe CpfGen::DomainError do + before do + stub_const('CpfGen::TestDomainError', Class.new(described_class)) + end + + subject(:error) { CpfGen::TestDomainError.new('some error') } + + context 'when instantiated through a subclass' do + it 'is a RangeError' do + expect(error).to be_a(RangeError) + end + + it 'is a DomainError' do + expect(error).to be_a(described_class) + end + + it 'includes CpfGen::Error' do + expect(error).to be_a(CpfGen::Error) + end + + it 'exposes the subclass name' do + expect(error.class.name).to eq('CpfGen::TestDomainError') + end + + it 'exposes the message' do + expect(error.message).to eq('some error') + end + end +end + +RSpec.describe CpfGen::ValidationError do + context 'when instantiated for an invalid prefix' do + subject(:error) { described_class.new('prefix', '1.2.3.4.5', reason: 'repeated digits') } + + it 'is a RangeError' do + expect(error).to be_a(RangeError) + end + + it 'is a DomainError' do + expect(error).to be_a(CpfGen::DomainError) + end + + it 'includes CpfGen::Error' do + expect(error).to be_a(CpfGen::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfGen::ValidationError') + end + + it 'sets option_name' do + expect(error.option_name).to eq('prefix') + end + + it 'sets actual_input' do + expect(described_class.new('prefix', '77777777', reason: 'repeated digits').actual_input) + .to eq('77777777') + end + + it 'sets reason' do + expect(error.reason).to eq('repeated digits') + end + + it 'leaves expected_values nil' do + expect(error.expected_values).to be_nil + end + + it 'builds a descriptive message' do + expect(error.message).to eq( + 'CPF generator option "prefix" with value "1.2.3.4.5" is invalid. repeated digits' + ) + end + end +end diff --git a/packages/cpf-gen/tests/utils.spec.rb b/packages/cpf-gen/tests/utils.spec.rb new file mode 100644 index 0000000..015059f --- /dev/null +++ b/packages/cpf-gen/tests/utils.spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CpfGen::Utils do + describe '.normalize_boolean' do + it 'returns false for false, empty string, and zero' do + aggregate_failures do + expect(described_class.normalize_boolean(false)).to be(false) + expect(described_class.normalize_boolean('')).to be(false) + expect(described_class.normalize_boolean(0)).to be(false) + end + end + + it 'returns true for other truthy values' do + aggregate_failures do + expect(described_class.normalize_boolean(true)).to be(true) + expect(described_class.normalize_boolean('yes')).to be(true) + expect(described_class.normalize_boolean(1)).to be(true) + end + end + end + + describe '.assert_string_option!' do + it 'accepts a string' do + expect { described_class.assert_string_option!('prefix', '123') }.not_to raise_error + end + + it 'raises TypeMismatchError for a non-string' do + expect { described_class.assert_string_option!('prefix', 1) } + .to raise_error(CpfGen::TypeMismatchError) + end + end + + describe '.fetch_option' do + it 'reads a symbol key' do + expect(described_class.fetch_option({ format: true }, :format)).to be(true) + end + + it 'reads a string key' do + expect(described_class.fetch_option({ 'format' => true }, :format)).to be(true) + end + + it 'returns nil when the key is absent' do + expect(described_class.fetch_option({}, :format)).to be_nil + end + end + + describe '.sanitize_prefix' do + it 'strips non-digit characters' do + expect(described_class.sanitize_prefix('ABC.123.DEF.456')).to eq('123456') + end + + it 'truncates to the maximum prefix length' do + long_prefix = '1' * 20 + + expect(described_class.sanitize_prefix(long_prefix).length) + .to eq(CpfGen::CpfGeneratorOptions::CPF_PREFIX_MAX_LENGTH) + end + + it 'raises TypeMismatchError for a non-string' do + expect { described_class.sanitize_prefix(123) } + .to raise_error(CpfGen::TypeMismatchError) + end + end + + describe '.validate_prefix!' do + it 'accepts a valid partial prefix' do + expect { described_class.validate_prefix!('123456') }.not_to raise_error + end + + it 'accepts eight zeros as a partial prefix' do + expect { described_class.validate_prefix!('00000000') }.not_to raise_error + end + + it 'raises when the base ID is zeroed' do + expect { described_class.validate_prefix!('000000000') } + .to raise_error(CpfGen::ValidationError, /Zeroed base ID/) + end + + it 'raises when the full prefix is repeated digits' do + expect { described_class.validate_prefix!('111111111') } + .to raise_error(CpfGen::ValidationError, /Repeated digits/) + end + end + + describe '.format_cpf' do + it 'inserts the standard CPF delimiters' do + expect(described_class.format_cpf('47844241055')).to eq('478.442.410-55') + end + end +end diff --git a/packages/cpf-utilities/Gemfile b/packages/cpf-utilities/Gemfile index f2be271..2295579 100644 --- a/packages/cpf-utilities/Gemfile +++ b/packages/cpf-utilities/Gemfile @@ -4,7 +4,6 @@ source 'https://rubygems.org' gemspec -gem 'cpf-dv', path: '../cpf-dv' gem 'cpf-fmt', path: '../cpf-fmt' gem 'cpf-gen', path: '../cpf-gen' gem 'cpf-val', path: '../cpf-val' diff --git a/packages/cpf-val/Gemfile b/packages/cpf-val/Gemfile index 533428d..c6122eb 100644 --- a/packages/cpf-val/Gemfile +++ b/packages/cpf-val/Gemfile @@ -4,8 +4,6 @@ source 'https://rubygems.org' gemspec -gem 'cpf-dv', path: '../cpf-dv' - group :test do gem 'rake', '~> 13.2' gem 'rspec', '~> 3.13' diff --git a/packages/cpf-val/cpf-val.gemspec b/packages/cpf-val/cpf-val.gemspec index 31f94a7..685b92e 100644 --- a/packages/cpf-val/cpf-val.gemspec +++ b/packages/cpf-val/cpf-val.gemspec @@ -13,6 +13,6 @@ Gem::Specification.new do |spec| spec.metadata['rubygems_mfa_required'] = 'true' spec.files = Dir['src/**/*'] + ['LICENSE', 'README.md'].select { |f| File.file?(f) } spec.require_paths = ['src'] - spec.add_dependency 'cpf-dv', '>= 0' + spec.add_dependency 'cpf-dv', '>= 1.0.0', '< 1.1.0' spec.add_dependency 'lacus-utils', '>= 1.1.0', '< 2.0.0' end