cpf-fmt 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a057b7092cf818f4d92bc0ff8ff1e881eee618dd3d50defb96575ba7aa5327a
4
- data.tar.gz: 12c7bfd662807da3c40f8c29c71c22a6f233fa4ead749867f473b595d65b4a55
3
+ metadata.gz: 1f276500fe73287c24b46c728a6b9cfd965fdfb038f38b84ffe6df340c6db3de
4
+ data.tar.gz: 80ed01045da22c14c08725718a91f947c2a03fd0e4980e2cb974d476a5fa4933
5
5
  SHA512:
6
- metadata.gz: 6cec01e15df4631b2816dc957d82ce54d90be6c0b6af72523c4afb3ece5035734d3d995c3e415bf9574c8499751a92226cc7c9fff16566bcdd2eebe2b61914a8
7
- data.tar.gz: 3c25dd9c3c10bd1e75402b31b749d8db63807235490a9cc2e9fc1cb63856fdf88bb6635b7ab8e6d506b89ecc9a1e072a7ed50f62ceb4f4d04b5b92e18b9bb521
6
+ metadata.gz: 3e102bda4c2471563b7d3447b1d9307ad90a16ec4f870c8ebc0f3c2b14582e354fca389286fe9fa4066990957ab859390ee42844b1034f1ace6c34857bd86974
7
+ data.tar.gz: bf97c65dff5fe57dde1de3126403ab20fa5dc2ba88f0a0a0337026281c282aac5a33097c2a313d1a717a865237cdd8526476698e170d6920bbcc2d6bf5bd1f5f
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julio L. Muller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,454 @@
1
+ ![cpf-fmt for Ruby](https://br-utils.vercel.app/img/cover_cpf-fmt.jpg)
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/cpf-fmt)](https://rubygems.org/gems/cpf-fmt)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/cpf-fmt)](https://rubygems.org/gems/cpf-fmt)
5
+ [![Ruby Version](https://img.shields.io/gem/rv/cpf-fmt)](https://www.ruby-lang.org/)
6
+ [![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)
7
+ [![Last Update Date](https://img.shields.io/github/last-commit/LacusSolutions/br-utils-ruby)](https://github.com/LacusSolutions/br-utils-ruby)
8
+ [![Project License](https://img.shields.io/github/license/LacusSolutions/br-utils-ruby)](https://github.com/LacusSolutions/br-utils-ruby/blob/main/LICENSE)
9
+
10
+ > 🌎 [Acessar documentação em português](./README.pt.md)
11
+
12
+ A Ruby utility to format CPF (Brazilian Individual's Taxpayer ID).
13
+
14
+ ## Ruby Support
15
+
16
+ | ![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) |
17
+ | --- | --- | --- |
18
+ | Passing ✔ | Passing ✔ | Passing ✔ |
19
+
20
+ ## Features
21
+
22
+ - ✅ **Flexible input**: Accepts `String` or `Array` of strings; array elements are concatenated in order
23
+ - ✅ **Format agnostic**: Strips non-digit characters before formatting (letters and punctuation are discarded)
24
+ - ✅ **Custom delimiters**: `dot_key` and `dash_key` may be empty, single-, or multi-character strings
25
+ - ✅ **Masking**: Optional hiding of a digit range with a configurable replacement string (`hidden`, `hidden_key`, `hidden_start`, `hidden_end`)
26
+ - ✅ **HTML & URL output**: Optional `escape` (HTML entities) and `encode` (URI component encoding, similar to JavaScript `encodeURIComponent`)
27
+ - ✅ **Length errors without throwing**: Invalid length after sanitization is handled via `on_fail` (default returns an empty string)
28
+ - ✅ **Minimal dependencies**: Only [`lacus-utils`](https://rubygems.org/gems/lacus-utils)
29
+ - ✅ **Error handling**: API misuse vs domain errors with a `CpfFmt::Error` marker for library-wide rescue
30
+
31
+ ## Installation
32
+
33
+ Install the gem directly:
34
+
35
+ ```bash
36
+ gem install cpf-fmt
37
+ ```
38
+
39
+ Or add it to your `Gemfile` and run `bundle install`:
40
+
41
+ ```ruby
42
+ gem 'cpf-fmt'
43
+ ```
44
+
45
+ ## Require
46
+
47
+ ```ruby
48
+ require 'cpf-fmt'
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ```ruby
54
+ require 'cpf-fmt'
55
+
56
+ formatter = CpfFmt::CpfFormatter.new
57
+
58
+ formatter.format('03603568195') # => "036.035.681-95"
59
+ formatter.format('123.456.789-10') # => "123.456.789-10"
60
+ formatter.format('12345678910') # => "123.456.789-10"
61
+ ```
62
+
63
+ Basic helper usage:
64
+
65
+ ```ruby
66
+ require 'cpf-fmt'
67
+
68
+ cpf = '03603568195'
69
+
70
+ CpfFmt.cpf_fmt(cpf) # => "036.035.681-95"
71
+ CpfFmt.cpf_fmt(cpf, hidden: true) # => "036.***.***-**"
72
+ CpfFmt.cpf_fmt( # => "036035681_95"
73
+ cpf,
74
+ dot_key: '',
75
+ dash_key: '_'
76
+ )
77
+ ```
78
+
79
+ ## Usage
80
+
81
+ The main entry points are the class `CpfFmt::CpfFormatter`, the options class `CpfFmt::CpfFormatterOptions`, and the helper `CpfFmt.cpf_fmt`.
82
+
83
+ ### `CpfFmt::CpfFormatter`
84
+
85
+ - **`initialize(options = nil, **keywords)`**: Optional default formatting options. When `options` is given (a `CpfFmt::CpfFormatterOptions` instance or a `Hash`) alone, it determines the default options; a `CpfFmt::CpfFormatterOptions` instance is stored by reference (mutating it later affects future `format` 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 (`hidden:`, `hidden_key:`, `dot_key:`, …). Passing `options` together with any non-`nil` keyword raises `InvalidArgumentCombinationError` instead of silently ignoring the keywords. Example: `CpfFmt::CpfFormatter.new(hidden: true, dash_key: '_')`.
86
+ - **`options`**: Returns the instance’s `CpfFmt::CpfFormatterOptions` (same object used internally).
87
+ - **`format(cpf_input, options = nil, **keywords)`**: Formats a CPF value.
88
+
89
+ Input is normalized by removing non-digit characters. If the sanitized length is not exactly **11**, the **`on_fail`** callback is invoked with the original input and a `CpfFmt::DomainError` (`InvalidLengthError`); its return value is the result (nothing is thrown for length).
90
+
91
+ If the input is not a `String` or an `Array` of strings, **`CpfFmt::TypeMismatchError`** is raised.
92
+
93
+ Per-call `options` and 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`.
94
+
95
+ ### `CpfFmt::CpfFormatterOptions`
96
+
97
+ Holds all formatter settings, with validation and merge support. Exposes properties: `hidden`, `hidden_key`, `hidden_start`, `hidden_end`, `dot_key`, `dash_key`, `escape`, `encode`, `on_fail`.
98
+
99
+ - **`initialize(options = nil, *extra_overrides, **keywords)`**: Optional default options (plain `Hash`, `CpfFmt::CpfFormatterOptions` instance, or keyword arguments), plus extra override objects merged in order (later overrides win).
100
+ - **`all`**: Returns a shallow `Hash` copy of all current options.
101
+ - **`copy`**: Returns a shallow copy of this options instance.
102
+ - **`set(options)`**: Updates multiple fields at once; returns `self`. Accepts a `Hash` or another `CpfFmt::CpfFormatterOptions` instance. Explicit `nil` values in the update keep the current value.
103
+ - **`set_hidden_range(hidden_start, hidden_end)`**: Validates indices in **`[0, 10]`** (inclusive); if `hidden_start > hidden_end`, values are swapped. `nil` arguments fall back to defaults (`DEFAULT_HIDDEN_START` / `DEFAULT_HIDDEN_END`).
104
+
105
+ **`hidden_start` / `hidden_end`**: Indices refer to the **11-digit normalized CPF string** (before inserting punctuation). The inclusive range is replaced internally by placeholders, then `hidden_key` is substituted (supports multi-character keys and empty string).
106
+
107
+ **Key options** (`hidden_key`, `dot_key`, `dash_key`): Must be strings and must not contain any character in `CpfFmt::CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS` (reserved for internal formatting).
108
+
109
+ ### Functional helper
110
+
111
+ `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`):
112
+
113
+ ```ruby
114
+ require 'cpf-fmt'
115
+
116
+ cpf = '03603568195'
117
+
118
+ CpfFmt.cpf_fmt(cpf) # => "036.035.681-95"
119
+ CpfFmt.cpf_fmt(cpf, hidden: true) # masked with defaults
120
+ CpfFmt.cpf_fmt( # => "036035681_95"
121
+ cpf,
122
+ dot_key: '',
123
+ dash_key: '_'
124
+ )
125
+ CpfFmt.cpf_fmt(cpf, { # Hash form
126
+ hidden: true,
127
+ hidden_key: '#'
128
+ })
129
+ ```
130
+
131
+ ### Object-oriented examples
132
+
133
+ ```ruby
134
+ require 'cpf-fmt'
135
+
136
+ formatter = CpfFmt::CpfFormatter.new
137
+ cpf = '12345678910'
138
+
139
+ formatter.format(cpf) # => "123.456.789-10"
140
+ formatter.format( # => "123.###.###-##"
141
+ cpf,
142
+ hidden: true,
143
+ hidden_key: '#',
144
+ hidden_start: 3,
145
+ hidden_end: 10
146
+ )
147
+ ```
148
+
149
+ Default options on the instance; per-call overrides:
150
+
151
+ ```ruby
152
+ require 'cpf-fmt'
153
+
154
+ formatter = CpfFmt::CpfFormatter.new(hidden: true)
155
+ cpf = '12345678910'
156
+
157
+ formatter.format(cpf) # uses instance masking
158
+ formatter.format(cpf, hidden: false) # this call only: unmasked
159
+ formatter.format(cpf) # back to instance defaults
160
+ ```
161
+
162
+ Array input:
163
+
164
+ ```ruby
165
+ require 'cpf-fmt'
166
+
167
+ formatter = CpfFmt::CpfFormatter.new
168
+
169
+ formatter.format([ # => "123.456.789-10"
170
+ '123',
171
+ '456',
172
+ '789',
173
+ '10'
174
+ ])
175
+ ```
176
+
177
+ ### Input formats
178
+
179
+ **String:** Raw digits, or already formatted CPF (e.g. `123.456.789-10`, `123 456 789 10`). Non-digit characters are removed; leading zeros are preserved.
180
+
181
+ **Array of strings:** Each element must be a `String`; values are concatenated (e.g. per digit, grouped segments, or mixed with punctuation — all non-digits are stripped during normalization). Non-string elements are not allowed.
182
+
183
+ ### Formatting options
184
+
185
+ | Parameter | Type | Default | Description |
186
+ |-----------|------|---------|-------------|
187
+ | `hidden` | `Boolean`, `nil` | `false` | When truthy, replaces the inclusive index range `[hidden_start, hidden_end]` on the normalized 11-digit string before punctuation is applied |
188
+ | `hidden_key` | `String`, `nil` | `'*'` | Replacement for each hidden position (may be multi-character or empty); must not use disallowed key characters |
189
+ | `hidden_start` | `Integer`, `nil` | `3` | Start index `0`–`10` (inclusive) |
190
+ | `hidden_end` | `Integer`, `nil` | `10` | End index `0`–`10` (inclusive); if `hidden_start > hidden_end`, they are swapped |
191
+ | `dot_key` | `String`, `nil` | `'.'` | Separator after the 3rd and 6th digits |
192
+ | `dash_key` | `String`, `nil` | `'-'` | Separator after the 9th digit |
193
+ | `escape` | `Boolean`, `nil` | `false` | When truthy, HTML-escapes the final string |
194
+ | `encode` | `Boolean`, `nil` | `false` | When truthy, URL-encodes the final string (similar to `encodeURIComponent`) |
195
+ | `on_fail` | `Proc`, `nil` | see below | `(value, error) -> String` — used when sanitized length ≠ 11 |
196
+
197
+ Default **`on_fail`** returns an empty string. Signature: `(original_input, error) -> String`, where `error` is a **`CpfFmt::DomainError`** (currently an `InvalidLengthError` with `actual_input`, `evaluated_input`, `expected_length`). The callback return value must be a `String`; otherwise **`CpfFmt::TypeMismatchError`** is raised.
198
+
199
+ Example with all options:
200
+
201
+ ```ruby
202
+ require 'cpf-fmt'
203
+
204
+ cpf = '12345678910'
205
+
206
+ CpfFmt.cpf_fmt(
207
+ cpf,
208
+ hidden: true,
209
+ hidden_key: '#',
210
+ hidden_start: 3,
211
+ hidden_end: 9,
212
+ dot_key: ' ',
213
+ dash_key: '_-_',
214
+ escape: true,
215
+ encode: true,
216
+ on_fail: ->(value, _error) { value.to_s }
217
+ )
218
+ ```
219
+
220
+ ### Error handling
221
+
222
+ Errors fall into two categories:
223
+
224
+ | Category | Meaning |
225
+ |---|---|
226
+ | **API misuse** | The caller invoked the library incorrectly (wrong type for input or options, or an invalid argument combination). |
227
+ | **Domain error** | The call was structurally correct, but a value violates a business rule (length, range, forbidden characters). |
228
+
229
+ Every custom error includes the `CpfFmt::Error` marker module. Domain failures (`InvalidLengthError`, `OutOfRangeError`, `ValidationError`) inherit from `CpfFmt::DomainError` (`RangeError`).
230
+
231
+ **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`.
232
+
233
+ #### Summary
234
+
235
+ | Class | Inherits from | Category | Trigger condition |
236
+ |---|---|---|---|
237
+ | `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and any non-`nil` keyword argument are passed at once |
238
+ | `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CPF input or option has the wrong data type |
239
+ | `CpfFmt::InvalidLengthError` | `CpfFmt::DomainError` | Domain error | Sanitized length is not exactly 11 (passed to `on_fail` as `DomainError`) |
240
+ | `CpfFmt::OutOfRangeError` | `CpfFmt::DomainError` | Domain error | `hidden_start` / `hidden_end` outside `0`–`10` |
241
+ | `CpfFmt::ValidationError` | `CpfFmt::DomainError` | Domain error | Key option contains a disallowed character |
242
+
243
+ #### `CpfFmt::Error` (marker module)
244
+
245
+ - **Inheritance:** module marker mixed into every library error via `include` (not a class).
246
+ - **Category:** N/A (rescue target only) — not a failure mode by itself.
247
+ - **When it is raised:** Never raised directly; included by every custom error the library raises or constructs for `on_fail`.
248
+ - **Example:** N/A
249
+ - **How to rescue it:**
250
+
251
+ ```ruby
252
+ rescue CpfFmt::Error
253
+ # everything this library raises
254
+ ```
255
+
256
+ #### `CpfFmt::DomainError`
257
+
258
+ - **Inheritance:** `CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`)
259
+ - **Category:** Domain error — ancestor for numeric/length domain failures.
260
+ - **When it is raised:** Not raised directly; prefer raising a leaf subclass.
261
+ - **Example:** Prefer `raise CpfFmt::OutOfRangeError` / construct `InvalidLengthError` over raising `DomainError` directly.
262
+ - **How to rescue it:**
263
+
264
+ ```ruby
265
+ rescue CpfFmt::DomainError
266
+ # OutOfRangeError, InvalidLengthError, ValidationError, and other DomainError subclasses
267
+ ```
268
+
269
+ #### `CpfFmt::TypeMismatchError`
270
+
271
+ - **Inheritance:** `CpfFmt::TypeMismatchError < TypeError` (includes `CpfFmt::Error`)
272
+ - **Category:** API misuse — the caller passed a value of the wrong type.
273
+ - **When it is raised:** Raised when the CPF input is not a `String` or an `Array` of strings, when an option has the wrong type, or when `on_fail` does not return a `String`.
274
+ - **Example:**
275
+
276
+ ```ruby
277
+ CpfFmt::CpfFormatter.new.format(12_345) # raises CpfFmt::TypeMismatchError
278
+ ```
279
+
280
+ - **How to rescue it:**
281
+
282
+ ```ruby
283
+ rescue CpfFmt::TypeMismatchError
284
+ # this library's type-contract violation
285
+
286
+ rescue TypeError
287
+ # native type errors, including this library's TypeMismatchError
288
+ ```
289
+
290
+ #### `CpfFmt::InvalidLengthError`
291
+
292
+ - **Inheritance:** `CpfFmt::InvalidLengthError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`)
293
+ - **Category:** Domain error — a collection or string length violates a business rule.
294
+ - **When it is raised:** Not raised from `format`; constructed and passed as the `DomainError` second argument to `on_fail` when the sanitized CPF does not contain exactly 11 digits.
295
+ - **Example:**
296
+
297
+ ```ruby
298
+ CpfFmt::CpfFormatter.new.format(
299
+ 'short',
300
+ on_fail: ->(_value, error) {
301
+ error # => #<CpfFmt::InvalidLengthError ...> (a DomainError)
302
+ 'invalid'
303
+ }
304
+ ) # => "invalid"
305
+
306
+ ```
307
+
308
+ - **How to rescue it:** Handle inside `on_fail` (typical), or rescue if you re-raise:
309
+
310
+ ```ruby
311
+ rescue CpfFmt::InvalidLengthError
312
+ # this exact length violation
313
+
314
+ rescue CpfFmt::DomainError
315
+ # RangeError-rooted domain failures from this library
316
+ ```
317
+
318
+ #### `CpfFmt::InvalidArgumentCombinationError`
319
+
320
+ - **Inheritance:** `CpfFmt::InvalidArgumentCombinationError < ArgumentError` (includes `CpfFmt::Error`)
321
+ - **Category:** API misuse — the caller mixed mutually exclusive argument patterns.
322
+ - **When it is raised:** Raised when `CpfFormatter.new`, `#format`, or `cpf_fmt` receives both an `options` argument (instance or `Hash`) and any non-`nil` keyword argument at the same time.
323
+ - **Example:**
324
+
325
+ ```ruby
326
+ begin
327
+ CpfFmt::CpfFormatter.new({ dash_key: '_' }, hidden: true)
328
+ rescue CpfFmt::InvalidArgumentCombinationError => e
329
+ puts e.message
330
+ # Pass either an options instance/Hash to `options`, or keyword arguments (hidden:, ...), not both.
331
+ end
332
+ ```
333
+
334
+ - **How to rescue it:**
335
+
336
+ ```ruby
337
+ rescue CpfFmt::InvalidArgumentCombinationError
338
+ # this library's invalid argument combination
339
+
340
+ rescue ArgumentError
341
+ # native argument errors, including this library's InvalidArgumentCombinationError
342
+ ```
343
+
344
+ #### `CpfFmt::OutOfRangeError`
345
+
346
+ - **Inheritance:** `CpfFmt::OutOfRangeError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`)
347
+ - **Category:** Domain error — a numeric value violates a business range rule.
348
+ - **When it is raised:** Raised when `hidden_start` or `hidden_end` is outside the inclusive range `0`–`10`.
349
+ - **Example:**
350
+
351
+ ```ruby
352
+ CpfFmt::CpfFormatterOptions.new(hidden_start: 11) # raises CpfFmt::OutOfRangeError
353
+ ```
354
+
355
+ - **How to rescue it:**
356
+
357
+ ```ruby
358
+ rescue CpfFmt::OutOfRangeError
359
+ # this exact range violation
360
+
361
+ rescue CpfFmt::DomainError
362
+ # RangeError-rooted domain failures from this library
363
+ ```
364
+
365
+ #### `CpfFmt::ValidationError`
366
+
367
+ - **Inheritance:** `CpfFmt::ValidationError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`)
368
+ - **Category:** Domain error — a value fails a non-numeric, non-length domain rule.
369
+ - **When it is raised:** Raised when a key option (`hidden_key`, `dot_key`, `dash_key`) contains a disallowed character.
370
+ - **Example:**
371
+
372
+ ```ruby
373
+ CpfFmt::CpfFormatterOptions.new(dot_key: 'å') # raises CpfFmt::ValidationError
374
+ ```
375
+
376
+ - **How to rescue it:**
377
+
378
+ ```ruby
379
+ rescue CpfFmt::ValidationError
380
+ # this exact domain validation failure
381
+
382
+ rescue CpfFmt::DomainError
383
+ # RangeError-rooted domain failures from this library
384
+ ```
385
+
386
+ #### Rescue granularity
387
+
388
+ ```ruby
389
+ # 1) Single native class — catches type misuse from this library (and other TypeErrors).
390
+ rescue TypeError
391
+ # CpfFmt::TypeMismatchError and any other TypeError (library or not)
392
+
393
+ # 2) CpfFmt::DomainError — catches business-rule violations under DomainError.
394
+ rescue CpfFmt::DomainError
395
+ # CpfFmt::OutOfRangeError, CpfFmt::InvalidLengthError, CpfFmt::ValidationError,
396
+ # and other DomainError subclasses
397
+
398
+ # 3) CpfFmt::Error — catches everything the library raises.
399
+ rescue CpfFmt::Error
400
+ # every custom error that includes CpfFmt::Error
401
+
402
+ # 4) Specific leaf class — catches only that exact failure mode.
403
+ rescue CpfFmt::OutOfRangeError
404
+ # only CpfFmt::OutOfRangeError
405
+ ```
406
+
407
+ Notable attributes:
408
+
409
+ - `TypeMismatchError`: `actual_input`, `actual_type`, `expected_type`, `option_name` (nil for CPF input)
410
+ - `InvalidLengthError`: `actual_input`, `evaluated_input`, `expected_length`
411
+ - `OutOfRangeError`: `option_name`, `actual_input`, `min_expected_value`, `max_expected_value`
412
+ - `ValidationError`: `option_name`, `actual_input`, `forbidden_characters`
413
+
414
+ ## API
415
+
416
+ ### Exports
417
+
418
+ After `require 'cpf-fmt'`:
419
+
420
+ - **`CpfFmt.cpf_fmt`**: `(cpf_input, options = nil, **keywords) -> String` — convenience helper.
421
+ - **`CpfFmt::CpfFormatter`**: Class to format CPF with optional default options; accepts `String` or `Array<String>` in `format`.
422
+ - **`CpfFmt::CpfFormatterOptions`**: Class holding options; supports merge via constructor, `set`, and keyword arguments.
423
+ - **`CpfFmt::CPF_LENGTH`**: `11` (constant).
424
+ - **`CpfFmt::VERSION`**: gem version string.
425
+ - **Type predicate**: `CpfFmt::CpfInput` — `CpfFmt::CpfInput.accept?(value)` / `CpfFmt::CpfInput === value` is true only for `String` or `Array<String>`.
426
+ - **Errors**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`.
427
+
428
+ ### Other available resources
429
+
430
+ - **`CpfFmt::CpfFormatterOptions::CPF_LENGTH`**: `11`.
431
+ - **`CpfFmt::CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Characters forbidden in `hidden_key`, `dot_key`, `dash_key`.
432
+ - **`CpfFmt::CpfFormatterOptions::DEFAULT_*`**: Default values for each option.
433
+ - **`CpfFmt::CpfFormatterOptions.default_on_fail`**: Shared default failure callback.
434
+
435
+ ## Contribution & Support
436
+
437
+ 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:
438
+
439
+ - ⭐ Starring the repository
440
+ - 🤝 Contributing to the codebase
441
+ - 💡 [Suggesting new features](https://github.com/LacusSolutions/br-utils-ruby/issues)
442
+ - 🐛 [Reporting bugs](https://github.com/LacusSolutions/br-utils-ruby/issues)
443
+
444
+ ## License
445
+
446
+ This project is licensed under the MIT License — see the [LICENSE](https://github.com/LacusSolutions/br-utils-ruby/blob/main/LICENSE) file for details.
447
+
448
+ ## Changelog
449
+
450
+ See [CHANGELOG](./CHANGELOG.md) for a list of changes and version history.
451
+
452
+ ---
453
+
454
+ Made with ❤️ by [Lacus Solutions](https://github.com/LacusSolutions)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CpfFmt
4
+ # Helper function to simplify the usage of the {CpfFormatter} class.
5
+ #
6
+ # Formats a CPF string according to the given options. With no options,
7
+ # returns the traditional CPF format (e.g. +123.456.789-10+). Invalid input
8
+ # length is handled by the configured +on_fail+ callback instead of throwing.
9
+ #
10
+ # @param cpf_input [String, Array<String>] CPF value as a string or array of
11
+ # strings
12
+ # @param options [CpfFormatterOptions, Hash, nil] default formatter options
13
+ # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+;
14
+ # see {CpfFormatterOptions})
15
+ # @return [String] formatted CPF string, or the +on_fail+ callback result
16
+ # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given
17
+ # @raise [TypeMismatchError] if +cpf_input+ is not a +String+ or +Array<String>+
18
+ # @raise [TypeMismatchError] if any option has an invalid type
19
+ # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range
20
+ # @raise [ValidationError] if any key option contains a disallowed character
21
+ # @see CpfFormatter#format for detailed option descriptions
22
+ # @see CpfFormatter
23
+ #
24
+ # @example
25
+ # CpfFmt.cpf_fmt('12345678910') # => "123.456.789-10"
26
+ def self.cpf_fmt(cpf_input, options = nil, **keywords)
27
+ CpfFormatter.new(options, **keywords).format(cpf_input)
28
+ end
29
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'erb'
5
+
6
+ module CpfFmt
7
+ # Formatter for CPF (Cadastro de Pessoas Físicas) identifiers.
8
+ #
9
+ # Normalizes and optionally masks, HTML-escapes, or URL-encodes 11-digit CPF
10
+ # input. Accepts a string or array of strings; non-digit characters are
11
+ # stripped. Invalid input type is handled by throwing; invalid length is
12
+ # handled via the configured +on_fail+ callback instead of throwing.
13
+ class CpfFormatter
14
+ # Returns the default options used by this formatter when per-call options
15
+ # are not provided.
16
+ #
17
+ # The returned object is the same instance used internally; mutating it (e.g.
18
+ # via setters on {CpfFormatterOptions}) affects future {#format} calls that
19
+ # do not pass +options+.
20
+ #
21
+ # @return [CpfFormatterOptions] the instance default options
22
+ attr_reader :options
23
+
24
+ # Creates a new formatter with optional default options.
25
+ #
26
+ # Default options apply to every call to {#format} unless overridden by the
27
+ # per-call +options+ argument or keyword overrides. Options control masking,
28
+ # HTML escaping, URL encoding, and the callback used when formatting fails.
29
+ #
30
+ # +options+ and the keyword arguments are never merged with each other: when
31
+ # +options+ is given (a {CpfFormatterOptions} instance or a {Hash}), it alone
32
+ # determines the default options; otherwise, the default options are built
33
+ # exclusively from the keyword arguments, with {CpfFormatterOptions} filling
34
+ # in its own defaults for every keyword left as +nil+. Passing +options+
35
+ # together with any non-+nil+ keyword argument raises
36
+ # {InvalidArgumentCombinationError} instead of silently ignoring the keywords.
37
+ #
38
+ # When +options+ is a {CpfFormatterOptions} instance, that instance is used
39
+ # directly (no copy is created). Mutating it later (e.g. via the {#options}
40
+ # reader or the original reference) affects future {#format} calls that do
41
+ # not pass per-call options. When a plain {Hash} is passed instead, a new
42
+ # {CpfFormatterOptions} instance is created from it.
43
+ #
44
+ # @param options [CpfFormatterOptions, Hash, nil] default formatter options
45
+ # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+;
46
+ # see {CpfFormatterOptions})
47
+ # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given
48
+ # @raise [TypeMismatchError] if any option has an invalid type
49
+ # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range
50
+ # @raise [ValidationError] if any key option contains a disallowed character
51
+ def initialize(options = nil, **keywords)
52
+ @options = resolve_default_options(options, keywords)
53
+ end
54
+
55
+ # Formats a CPF value into a human-readable string.
56
+ #
57
+ # Input is normalized by stripping non-digit characters. If the result
58
+ # length is not exactly 11, the configured +on_fail+ callback is invoked
59
+ # with the original value and a {DomainError}; its return value is used as
60
+ # the result.
61
+ #
62
+ # When valid, the result may be further transformed according to options:
63
+ #
64
+ # - If +hidden+ is +true+, digits between +hidden_start+ and +hidden_end+
65
+ # (inclusive) are replaced with +hidden_key+.
66
+ # - If +escape+ is +true+, HTML special characters are escaped.
67
+ # - If +encode+ is +true+, the string is URL-encoded (similar to JavaScript's
68
+ # +encodeURIComponent+).
69
+ #
70
+ # +options+ and the keyword arguments are never merged with each other: when
71
+ # +options+ is given (a {CpfFormatterOptions} instance or a {Hash}), it alone
72
+ # overrides the instance default options for this call; otherwise, any
73
+ # non-+nil+ keyword argument overrides the instance default options for this
74
+ # call. When neither +options+ nor any keyword argument is given, the
75
+ # instance default options are used as-is. In every case, the instance
76
+ # default options themselves are left unchanged. Passing +options+ together
77
+ # with any non-+nil+ keyword argument raises {InvalidArgumentCombinationError}
78
+ # instead of silently ignoring the keywords.
79
+ #
80
+ # @param cpf_input [String, Array<String>] CPF value as a string or array of
81
+ # strings
82
+ # @param options [CpfFormatterOptions, Hash, nil] per-call option overrides
83
+ # @param keywords [Hash] per-call option keyword overrides (mutually exclusive
84
+ # with +options+; see {CpfFormatterOptions})
85
+ # @return [String] formatted CPF string, or the +on_fail+ callback result
86
+ # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given
87
+ # @raise [TypeMismatchError] if the input is not a +String+ or +Array<String>+
88
+ # @raise [TypeMismatchError] if any option has an invalid type
89
+ # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range
90
+ # @raise [ValidationError] if any key option contains a disallowed character
91
+ #
92
+ # @example
93
+ # formatter = CpfFmt::CpfFormatter.new
94
+ # formatter.format('12345678910') # => "123.456.789-10"
95
+ def format(cpf_input, options = nil, **keywords)
96
+ actual_input = Utils.to_string_input(cpf_input)
97
+ actual_options = resolve_call_options(options, keywords)
98
+ formatted_cpf = Utils.sanitize_cpf_input(actual_input)
99
+
100
+ return handle_invalid_length(cpf_input, formatted_cpf, actual_options) unless valid_length?(formatted_cpf)
101
+
102
+ format_valid_cpf(formatted_cpf, actual_options)
103
+ end
104
+
105
+ private
106
+
107
+ def valid_length?(formatted_cpf)
108
+ formatted_cpf.length == CpfFormatterOptions::CPF_LENGTH
109
+ end
110
+
111
+ def handle_invalid_length(cpf_input, formatted_cpf, actual_options)
112
+ error = InvalidLengthError.new(
113
+ cpf_input,
114
+ formatted_cpf,
115
+ CpfFormatterOptions::CPF_LENGTH
116
+ )
117
+
118
+ Utils.invoke_on_fail(actual_options.on_fail, cpf_input, error)
119
+ end
120
+
121
+ def format_valid_cpf(formatted_cpf, actual_options)
122
+ formatted_cpf = Utils.apply_hidden_mask(formatted_cpf, actual_options) if actual_options.hidden
123
+ formatted_cpf = Utils.insert_delimiters(formatted_cpf, actual_options)
124
+
125
+ if actual_options.hidden
126
+ formatted_cpf = Utils.replace_hidden_placeholders(
127
+ formatted_cpf,
128
+ actual_options.hidden_key
129
+ )
130
+ end
131
+
132
+ Utils.apply_post_processing(formatted_cpf, actual_options)
133
+ end
134
+
135
+ def resolve_default_options(options, keywords)
136
+ keyword_overrides = compact_keyword_overrides(keywords)
137
+ raise_ambiguous_options! if options && !keyword_overrides.empty?
138
+ return options if options.is_a?(CpfFormatterOptions)
139
+ return CpfFormatterOptions.new(options) if options
140
+
141
+ CpfFormatterOptions.new(**keywords)
142
+ end
143
+
144
+ def resolve_call_options(options, keywords)
145
+ keyword_overrides = compact_keyword_overrides(keywords)
146
+ raise_ambiguous_options! if options && !keyword_overrides.empty?
147
+ return @options.copy.set(options) if options
148
+ return @options if keyword_overrides.empty?
149
+
150
+ @options.copy.set(keyword_overrides)
151
+ end
152
+
153
+ def compact_keyword_overrides(keywords)
154
+ CpfFormatterOptions::OPTION_KEYS.each_with_object({}) do |key, overrides|
155
+ value = keywords[key]
156
+ overrides[key] = value unless value.nil?
157
+ end
158
+ end
159
+
160
+ def raise_ambiguous_options!
161
+ option_keywords = CpfFormatterOptions::OPTION_KEYS.map { |key| "#{key}:" }.join(', ')
162
+
163
+ raise InvalidArgumentCombinationError,
164
+ "Pass either an options instance/Hash to `options`, or keyword arguments (#{option_keywords}), " \
165
+ 'not both.'
166
+ end
167
+ end
168
+ end