From c495e18f4b5cc708690946b9610db220396b8e35 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:18:28 -0300 Subject: [PATCH 01/10] chore(cpf-fmt): update package metadata - Revised summary and description to clarify the purpose of the gem as a formatter of CPF (Brazilian Individual's Taxpayer ID). - Removed changelog URI from metadata for simplification. Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/cpf-fmt.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cpf-fmt/cpf-fmt.gemspec b/packages/cpf-fmt/cpf-fmt.gemspec index 5f99b7b..27db211 100644 --- a/packages/cpf-fmt/cpf-fmt.gemspec +++ b/packages/cpf-fmt/cpf-fmt.gemspec @@ -7,8 +7,8 @@ Gem::Specification.new do |spec| spec.version = CpfFmt::VERSION spec.authors = ['Julio L. Muller'] spec.email = ['juliolmuller@outlook.com'] - spec.summary = 'Format and parse CPF strings (Brazilian personal ID)' - spec.description = 'Format CPF with or without punctuation; strip to digits.' + spec.summary = "Format CPF (Brazilian Individual's Taxpayer ID)" + spec.description = "Utility to format CPF (Brazilian Individual's Taxpayer ID)" spec.homepage = 'https://github.com/LacusSolutions/br-utils-ruby' spec.license = 'MIT' spec.required_ruby_version = '>= 3.1' From c13a31f63130d3d92d4ede7e53e6ad19e26703d0 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:21:05 -0300 Subject: [PATCH 02/10] feat(cpf-fmt): implement formatter for CPF Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/src/cpf-fmt.rb | 44 +- packages/cpf-fmt/src/cpf-fmt/cpf_fmt.rb | 29 ++ packages/cpf-fmt/src/cpf-fmt/cpf_formatter.rb | 168 +++++++ .../src/cpf-fmt/cpf_formatter_options.rb | 423 ++++++++++++++++++ packages/cpf-fmt/src/cpf-fmt/errors.rb | 175 ++++++++ packages/cpf-fmt/src/cpf-fmt/types.rb | 42 ++ packages/cpf-fmt/src/cpf-fmt/utils.rb | 137 ++++++ 7 files changed, 1015 insertions(+), 3 deletions(-) create mode 100644 packages/cpf-fmt/src/cpf-fmt/cpf_fmt.rb create mode 100644 packages/cpf-fmt/src/cpf-fmt/cpf_formatter.rb create mode 100644 packages/cpf-fmt/src/cpf-fmt/cpf_formatter_options.rb create mode 100644 packages/cpf-fmt/src/cpf-fmt/errors.rb create mode 100644 packages/cpf-fmt/src/cpf-fmt/types.rb create mode 100644 packages/cpf-fmt/src/cpf-fmt/utils.rb diff --git a/packages/cpf-fmt/src/cpf-fmt.rb b/packages/cpf-fmt/src/cpf-fmt.rb index fef9f30..924a58d 100644 --- a/packages/cpf-fmt/src/cpf-fmt.rb +++ b/packages/cpf-fmt/src/cpf-fmt.rb @@ -1,9 +1,47 @@ # frozen_string_literal: true require_relative 'cpf-fmt/version' +require_relative 'cpf-fmt/errors' +require_relative 'cpf-fmt/types' +require_relative 'cpf-fmt/cpf_formatter_options' +require_relative 'cpf-fmt/utils' +require_relative 'cpf-fmt/cpf_formatter' +require_relative 'cpf-fmt/cpf_fmt' +# Formats a CPF (Cadastro de Pessoas Físicas) identifier into a human-readable +# string. Supports the 11-digit CPF format (digits only after sanitization). +# +# Errors fall into two categories: +# +# - *API misuse* — the caller supplied a wrong type or an incompatible argument +# combination. Raised as {CpfFmt::TypeMismatchError} or +# {CpfFmt::InvalidArgumentCombinationError}. +# - *Domain errors* — the call shape was valid, but a value violates a business +# rule. Length failures construct {CpfFmt::InvalidLengthError} and pass it to +# +on_fail+ as a {CpfFmt::DomainError}. Hidden-range failures raise +# {CpfFmt::OutOfRangeError}; forbidden key characters raise +# {CpfFmt::ValidationError}. +# +# Every custom error includes the {CpfFmt::Error} marker module so consumers can +# +rescue CpfFmt::Error+ for a library-wide catch. +# +# Public API: +# +# - {CpfFmt.cpf_fmt} +# - {CpfFormatter}, {CpfFormatterOptions} +# - {CPF_LENGTH}, {VERSION} +# - Error marker {CpfFmt::Error}; domain ancestor {CpfFmt::DomainError}; +# misuse errors {CpfFmt::TypeMismatchError} and +# {CpfFmt::InvalidArgumentCombinationError}; domain leaves +# {CpfFmt::InvalidLengthError}, {CpfFmt::OutOfRangeError}, and +# {CpfFmt::ValidationError} +# +# @example +# require 'cpf-fmt' +# +# CpfFmt.cpf_fmt('12345678910') # => "123.456.789-10" module CpfFmt - def self.hello - 'cpf-fmt' - end + # The standard length of a CPF (Cadastro de Pessoas Físicas) identifier + # (11 digits). + CPF_LENGTH = CpfFormatterOptions::CPF_LENGTH end diff --git a/packages/cpf-fmt/src/cpf-fmt/cpf_fmt.rb b/packages/cpf-fmt/src/cpf-fmt/cpf_fmt.rb new file mode 100644 index 0000000..b184f76 --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/cpf_fmt.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CpfFmt + # Helper function to simplify the usage of the {CpfFormatter} class. + # + # Formats a CPF string according to the given options. With no options, + # returns the traditional CPF format (e.g. +123.456.789-10+). Invalid input + # length is handled by the configured +on_fail+ callback instead of throwing. + # + # @param cpf_input [String, Array] CPF value as a string or array of + # strings + # @param options [CpfFormatterOptions, Hash, nil] default formatter options + # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+; + # see {CpfFormatterOptions}) + # @return [String] formatted CPF string, or the +on_fail+ callback result + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if +cpf_input+ is not a +String+ or +Array+ + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range + # @raise [ValidationError] if any key option contains a disallowed character + # @see CpfFormatter#format for detailed option descriptions + # @see CpfFormatter + # + # @example + # CpfFmt.cpf_fmt('12345678910') # => "123.456.789-10" + def self.cpf_fmt(cpf_input, options = nil, **keywords) + CpfFormatter.new(options, **keywords).format(cpf_input) + end +end diff --git a/packages/cpf-fmt/src/cpf-fmt/cpf_formatter.rb b/packages/cpf-fmt/src/cpf-fmt/cpf_formatter.rb new file mode 100644 index 0000000..f6dd880 --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/cpf_formatter.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'cgi' +require 'erb' + +module CpfFmt + # Formatter for CPF (Cadastro de Pessoas Físicas) identifiers. + # + # Normalizes and optionally masks, HTML-escapes, or URL-encodes 11-digit CPF + # input. Accepts a string or array of strings; non-digit characters are + # stripped. Invalid input type is handled by throwing; invalid length is + # handled via the configured +on_fail+ callback instead of throwing. + class CpfFormatter + # Returns the default options used by this formatter when per-call options + # are not provided. + # + # The returned object is the same instance used internally; mutating it (e.g. + # via setters on {CpfFormatterOptions}) affects future {#format} calls that + # do not pass +options+. + # + # @return [CpfFormatterOptions] the instance default options + attr_reader :options + + # Creates a new formatter with optional default options. + # + # Default options apply to every call to {#format} unless overridden by the + # per-call +options+ argument or keyword overrides. Options control masking, + # HTML escaping, URL encoding, and the callback used when formatting fails. + # + # +options+ and the keyword arguments are never merged with each other: when + # +options+ is given (a {CpfFormatterOptions} instance or a {Hash}), it alone + # determines the default options; otherwise, the default options are built + # exclusively from the keyword arguments, with {CpfFormatterOptions} 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 {CpfFormatterOptions} 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 {#format} calls that do + # not pass per-call options. When a plain {Hash} is passed instead, a new + # {CpfFormatterOptions} instance is created from it. + # + # @param options [CpfFormatterOptions, Hash, nil] default formatter options + # @param keywords [Hash] option keyword overrides (mutually exclusive with +options+; + # see {CpfFormatterOptions}) + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range + # @raise [ValidationError] if any key option contains a disallowed character + def initialize(options = nil, **keywords) + @options = resolve_default_options(options, keywords) + end + + # Formats a CPF value into a human-readable string. + # + # Input is normalized by stripping non-digit characters. If the result + # length is not exactly 11, the configured +on_fail+ callback is invoked + # with the original value and a {DomainError}; its return value is used as + # the result. + # + # When valid, the result may be further transformed according to options: + # + # - If +hidden+ is +true+, digits between +hidden_start+ and +hidden_end+ + # (inclusive) are replaced with +hidden_key+. + # - If +escape+ is +true+, HTML special characters are escaped. + # - If +encode+ is +true+, the string is URL-encoded (similar to JavaScript's + # +encodeURIComponent+). + # + # +options+ and the keyword arguments are never merged with each other: when + # +options+ is given (a {CpfFormatterOptions} 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 cpf_input [String, Array] CPF value as a string or array of + # strings + # @param options [CpfFormatterOptions, Hash, nil] per-call option overrides + # @param keywords [Hash] per-call option keyword overrides (mutually exclusive + # with +options+; see {CpfFormatterOptions}) + # @return [String] formatted CPF string, or the +on_fail+ callback result + # @raise [InvalidArgumentCombinationError] if +options+ and a keyword argument are both given + # @raise [TypeMismatchError] if the input is not a +String+ or +Array+ + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range + # @raise [ValidationError] if any key option contains a disallowed character + # + # @example + # formatter = CpfFmt::CpfFormatter.new + # formatter.format('12345678910') # => "123.456.789-10" + def format(cpf_input, options = nil, **keywords) + actual_input = Utils.to_string_input(cpf_input) + actual_options = resolve_call_options(options, keywords) + formatted_cpf = Utils.sanitize_cpf_input(actual_input) + + return handle_invalid_length(cpf_input, formatted_cpf, actual_options) unless valid_length?(formatted_cpf) + + format_valid_cpf(formatted_cpf, actual_options) + end + + private + + def valid_length?(formatted_cpf) + formatted_cpf.length == CpfFormatterOptions::CPF_LENGTH + end + + def handle_invalid_length(cpf_input, formatted_cpf, actual_options) + error = InvalidLengthError.new( + cpf_input, + formatted_cpf, + CpfFormatterOptions::CPF_LENGTH + ) + + Utils.invoke_on_fail(actual_options.on_fail, cpf_input, error) + end + + def format_valid_cpf(formatted_cpf, actual_options) + formatted_cpf = Utils.apply_hidden_mask(formatted_cpf, actual_options) if actual_options.hidden + formatted_cpf = Utils.insert_delimiters(formatted_cpf, actual_options) + + if actual_options.hidden + formatted_cpf = Utils.replace_hidden_placeholders( + formatted_cpf, + actual_options.hidden_key + ) + end + + Utils.apply_post_processing(formatted_cpf, actual_options) + end + + 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?(CpfFormatterOptions) + return CpfFormatterOptions.new(options) if options + + CpfFormatterOptions.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) + CpfFormatterOptions::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 = CpfFormatterOptions::OPTION_KEYS.map { |key| "#{key}:" }.join(', ') + + raise InvalidArgumentCombinationError, + "Pass either an options instance/Hash to `options`, or keyword arguments (#{option_keywords}), " \ + 'not both.' + end + end +end diff --git a/packages/cpf-fmt/src/cpf-fmt/cpf_formatter_options.rb b/packages/cpf-fmt/src/cpf-fmt/cpf_formatter_options.rb new file mode 100644 index 0000000..752c5e3 --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/cpf_formatter_options.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +require_relative 'types' + +module CpfFmt + # Layered option-resolution helpers for {CpfFormatterOptions}. + module FormatterOptionsResolution + private + + def fold_layers(layers) + resolved = {} + + layers.each do |layer| + source = layer_source(layer) + next if source.nil? + + CpfFormatterOptions::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?(CpfFormatterOptions) + return layer if layer.is_a?(Hash) + + nil + end + + def apply_keyword_overrides(resolved, keywords) + CpfFormatterOptions::OPTION_KEYS.each do |key| + value = keywords[key] + resolved[key] = value unless value.nil? + end + end + + def assign_resolved_or_default(resolved) + CpfFormatterOptions::SIMPLE_OPTION_KEYS.each do |key| + value = resolved.key?(key) ? resolved[key] : CpfFormatterOptions::DEFAULTS[key] + public_send("#{key}=", value) + end + + set_hidden_range( + resolved.key?(:hidden_start) ? resolved[:hidden_start] : CpfFormatterOptions::DEFAULTS[:hidden_start], + resolved.key?(:hidden_end) ? resolved[:hidden_end] : CpfFormatterOptions::DEFAULTS[:hidden_end] + ) + end + + def assign_resolved_keeping_current(resolved) + CpfFormatterOptions::SIMPLE_OPTION_KEYS.each do |key| + next unless resolved.key?(key) + + public_send("#{key}=", resolved[key]) + end + + return unless resolved.key?(:hidden_start) || resolved.key?(:hidden_end) + + set_hidden_range( + resolved.key?(:hidden_start) ? resolved[:hidden_start] : hidden_start, + resolved.key?(:hidden_end) ? resolved[:hidden_end] : hidden_end + ) + end + + def assign_string_key_option(option_name, value) + Utils.assert_string_option!(option_name, value) + Utils.assert_no_disallowed_key_characters!( + option_name, + value, + CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS + ) + @options[option_name.to_sym] = value + end + end + + # Property accessors for {CpfFormatterOptions}. 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 CpfFormatterOptionProperties + # @return [Boolean] + def hidden + @options[:hidden] + end + + # Sets whether hidden digit replacement is enabled. +nil+ is not accepted: + # pass {CpfFormatterOptions::DEFAULT_HIDDEN} to reset explicitly. + # + # @param value [Boolean] enable masking when truthy + # @raise [TypeMismatchError] if the value is +nil+ + def hidden=(value) + raise TypeMismatchError.new(value, 'boolean', option_name: 'hidden') if value.nil? + + @options[:hidden] = Utils.normalize_boolean(value) + end + + # @return [String] + def hidden_key + @options[:hidden_key] + end + + # Sets the string used to replace hidden CPF digits. +nil+ is not accepted: + # pass {CpfFormatterOptions::DEFAULT_HIDDEN_KEY} to reset explicitly. + # + # @param value [String] replacement string + # @raise [TypeMismatchError] if the value is not a +String+ + # @raise [ValidationError] if the value contains any disallowed key character + def hidden_key=(value) + assign_string_key_option('hidden_key', value) + end + + # @return [Integer] + def hidden_start + @options[:hidden_start] + end + + # Sets the start index for hiding CPF digits. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_HIDDEN_START} to reset explicitly. + # + # @param value [Integer] start index + # @raise [TypeMismatchError] if the value is not an integer + # @raise [OutOfRangeError] if the value is out of valid range + def hidden_start=(value) + set_hidden_range(value, @options[:hidden_end]) + end + + # @return [Integer] + def hidden_end + @options[:hidden_end] + end + + # Sets the end index for hiding CPF digits. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_HIDDEN_END} to reset explicitly. + # + # @param value [Integer] end index + # @raise [TypeMismatchError] if the value is not an integer + # @raise [OutOfRangeError] if the value is out of valid range + def hidden_end=(value) + set_hidden_range(@options[:hidden_start], value) + end + + # @return [String] + def dot_key + @options[:dot_key] + end + + # Sets the dot delimiter. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_DOT_KEY} to reset explicitly. + # + # @param value [String] delimiter string + # @raise [TypeMismatchError] if the value is not a +String+ + # @raise [ValidationError] if the value contains any disallowed key character + def dot_key=(value) + assign_string_key_option('dot_key', value) + end + + # @return [String] + def dash_key + @options[:dash_key] + end + + # Sets the dash delimiter. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_DASH_KEY} to reset explicitly. + # + # @param value [String] delimiter string + # @raise [TypeMismatchError] if the value is not a +String+ + # @raise [ValidationError] if the value contains any disallowed key character + def dash_key=(value) + assign_string_key_option('dash_key', value) + end + + # @return [Boolean] + def escape + @options[:escape] + end + + # Sets whether HTML escaping is enabled. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_ESCAPE} to reset explicitly. + # + # @param value [Boolean] enable escaping when truthy + # @raise [TypeMismatchError] if the value is +nil+ + def escape=(value) + raise TypeMismatchError.new(value, 'boolean', option_name: 'escape') if value.nil? + + @options[:escape] = Utils.normalize_boolean(value) + end + + # @return [Boolean] + def encode + @options[:encode] + end + + # Sets whether URL encoding is enabled. +nil+ is not accepted: pass + # {CpfFormatterOptions::DEFAULT_ENCODE} to reset explicitly. + # + # @param value [Boolean] enable encoding when truthy + # @raise [TypeMismatchError] if the value is +nil+ + def encode=(value) + raise TypeMismatchError.new(value, 'boolean', option_name: 'encode') if value.nil? + + @options[:encode] = Utils.normalize_boolean(value) + end + + # @return [Proc] failure callback + def on_fail + @options[:on_fail] + end + + # Sets the callback executed when formatting fails. +nil+ is not accepted: + # pass {CpfFormatterOptions::DEFAULT_ON_FAIL} to reset explicitly. + # + # @param value [Proc] callback + # @raise [TypeMismatchError] if the value is not callable + def on_fail=(value) + raise TypeMismatchError.new(value, 'function', option_name: 'on_fail') unless value.respond_to?(:call) + + @options[:on_fail] = value + end + end + + # Stores configuration for the CPF formatter. + # + # Provides a centralized way to configure how CPF numbers are formatted, + # including delimiters, hidden digit ranges, HTML escaping, URL encoding, + # and error handling callbacks. + class CpfFormatterOptions + include FormatterOptionsResolution + include CpfFormatterOptionProperties + + # The standard length of a CPF (Cadastro de Pessoas Físicas) identifier + # (11 digits). + CPF_LENGTH = 11 + + # Minimum valid index for the hidden range (inclusive). Must be between 0 and + # {CPF_LENGTH} - 1. + MIN_HIDDEN_RANGE = 0 + + # Maximum valid index for the hidden range (inclusive). Must be between 0 and + # {CPF_LENGTH} - 1. + MAX_HIDDEN_RANGE = CPF_LENGTH - 1 + + # Default value for the +hidden+ option. When +false+, all CPF digits are + # displayed. + DEFAULT_HIDDEN = false + + # Default string used to replace hidden CPF digits. + DEFAULT_HIDDEN_KEY = '*' + + # Default start index (inclusive) for hiding CPF digits. Digits from this + # index onwards will be replaced with the +hidden_key+ value. + DEFAULT_HIDDEN_START = 3 + + # Default end index (inclusive) for hiding CPF digits. Digits up to and + # including this index will be replaced with the +hidden_key+ value. + DEFAULT_HIDDEN_END = 10 + + # Default string used as the dot delimiter in formatted CPF. Used to separate + # the first groups of digits (+XXX.XXX.XXX+). + DEFAULT_DOT_KEY = '.' + + # Default string used as the dash delimiter in formatted CPF. Used to + # separate the first group of digits from the check digits at the end + # (+XXXX-XX+). + DEFAULT_DASH_KEY = '-' + + # Default value for the +escape+ option. When +false+, HTML special characters + # are not escaped. + DEFAULT_ESCAPE = false + + # Default value for the +encode+ option. When +false+, the CPF string is not + # URL-encoded. + DEFAULT_ENCODE = false + + # Characters that are not allowed in key options (+hidden_key+, +dot_key+, + # +dash_key+). They are reserved for internal formatting logic. + # + # For now, the first character is only used to replace the hidden key + # placeholder in {CpfFormatter}. However, this set of characters is reserved + # for future use already. + DISALLOWED_KEY_CHARACTERS = %w[å ë ï ö].freeze + + # Option keys managed by this class, in assignment order. + OPTION_KEYS = FORMATTER_OPTION_KEYS + + # The +hidden_start+/+hidden_end+ pair is resolved and assigned together (via + # {#set_hidden_range}) because their validation (range + swap) is coupled. + RANGE_OPTION_KEYS = %i[hidden_start hidden_end].freeze + + # Every option key except {RANGE_OPTION_KEYS}, each assignable independently + # through its own property setter. + SIMPLE_OPTION_KEYS = (OPTION_KEYS - RANGE_OPTION_KEYS).freeze + + class << self + # Returns the shared default +on_fail+ callback. + # + # Returns an empty string by default. The callback is created lazily on first + # use. + # + # @return [Proc] default failure callback + def default_on_fail + @default_on_fail ||= proc { |_value, _error| '' } + end + end + + # Default callback function executed when formatting fails. Returns an empty + # string by default. + DEFAULT_ON_FAIL = default_on_fail + + # Default value for each key in {OPTION_KEYS}, used to fill any option that is + # still unresolved once {#initialize} finishes merging its arguments. + DEFAULTS = { + hidden: DEFAULT_HIDDEN, + hidden_key: DEFAULT_HIDDEN_KEY, + hidden_start: DEFAULT_HIDDEN_START, + hidden_end: DEFAULT_HIDDEN_END, + dot_key: DEFAULT_DOT_KEY, + dash_key: DEFAULT_DASH_KEY, + escape: DEFAULT_ESCAPE, + encode: DEFAULT_ENCODE, + on_fail: DEFAULT_ON_FAIL + }.freeze + + # Creates a new {CpfFormatterOptions} 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 + # {CpfFormatterOptions} instance) is folded left to right, so later layers + # take precedence over earlier ones. + # 2. The 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. {#hidden=}) 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 [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range + # @raise [ValidationError] if any key option contains a disallowed character + def initialize(*options, **keywords) + @options = {} + + resolved = fold_layers(options) + apply_keyword_overrides(resolved, keywords) + assign_resolved_or_default(resolved) + end + + # Sets +hidden_start+ and +hidden_end+ with validation. + # + # Validates that both indices are integers within the valid range + # +[0, CPF_LENGTH - 1]+. If +hidden_start > hidden_end+, the values are + # automatically swapped to ensure a valid range. This method is used internally + # to keep both bounds consistent whenever either one changes. + # + # Neither argument accepts +nil+: pass the current or default value explicitly + # if only the other bound is changing. + # + # @param hidden_start [Integer] inclusive start index (0–10) + # @param hidden_end [Integer] inclusive end index (0–10) + # @return [CpfFormatterOptions] +self+ + # @raise [TypeMismatchError] if either value is not an integer + # @raise [OutOfRangeError] if either value is out of valid range +[0, CPF_LENGTH - 1]+ + def set_hidden_range(hidden_start, hidden_end) + start_index, end_index = Utils.normalize_hidden_range( + hidden_start, + hidden_end, + MIN_HIDDEN_RANGE, + MAX_HIDDEN_RANGE + ) + @options[:hidden_start] = start_index + @options[:hidden_end] = end_index + self + end + + # Returns a shallow copy of this options instance. + # + # @return [CpfFormatterOptions] duplicated options for per-call merging + def copy + duplicate = self.class.allocate + duplicate.instance_variable_set(:@options, @options.dup) + duplicate + 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 [CpfFormatterOptions] +self+ + # @raise [TypeMismatchError] if any option has an invalid type + # @raise [OutOfRangeError] if +hidden_start+ or +hidden_end+ are out of valid range + # @raise [ValidationError] if any key option contains a disallowed character + def set(*options, **keywords) + resolved = fold_layers(options) + apply_keyword_overrides(resolved, keywords) + assign_resolved_keeping_current(resolved) + self + end + + # Returns a shallow copy of all current options. + # + # @return [Hash{Symbol => Object}] shallow copy of option values + def all + @options.dup + end + end +end diff --git a/packages/cpf-fmt/src/cpf-fmt/errors.rb b/packages/cpf-fmt/src/cpf-fmt/errors.rb new file mode 100644 index 0000000..4f3efed --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/errors.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'lacus-utils' + +module CpfFmt + # Formats the original input for inclusion in a length error message. + module FormatLengthExceptionInput + module_function + + def call(actual_input) + return %("#{actual_input}") if actual_input.is_a?(String) + + return "sequence[#{actual_input.length}]" if actual_input.is_a?(Array) + + actual_input.inspect + end + end + private_constant :FormatLengthExceptionInput + + # Marker module mixed into every custom error raised by this library. + # + # Use +rescue CpfFmt::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 (CPF input or a formatting 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, nil] the offending option key, or +nil+ for CPF input + attr_reader :option_name + + # @param actual_input [Object] the offending input or option value + # @param expected_type [String] description of the expected type + # @param option_name [String, nil] option key when the failure is option-related + def initialize(actual_input, expected_type, option_name: nil) + actual_type = LacusUtils.describe_type(actual_input) + super(build_message(actual_type, expected_type, option_name)) + @actual_input = actual_input + @actual_type = actual_type + @expected_type = expected_type + @option_name = option_name + end + + private + + def build_message(actual_type, expected_type, option_name) + if option_name + %(CPF formatting option "#{option_name}" must be of type #{expected_type}. Got #{actual_type}.) + else + "CPF input must be of type #{expected_type}. Got #{actual_type}." + end + 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 (length, range, validation, + # and other domain leaves). Prefer raising or constructing a leaf subclass. + class DomainError < RangeError + include Error + end + + # Domain error raised when +hidden_start+ or +hidden_end+ falls outside the + # valid index range for CPF formatting. + class OutOfRangeError < DomainError + # @return [String] the offending option name + attr_reader :option_name + + # @return [Integer] the offending value + attr_reader :actual_input + + # @return [Integer] minimum valid index + attr_reader :min_expected_value + + # @return [Integer] maximum valid index + attr_reader :max_expected_value + + # @param option_name [String] the offending option key + # @param actual_input [Integer] the offending value + # @param min_expected_value [Integer] minimum valid index + # @param max_expected_value [Integer] maximum valid index + def initialize(option_name, actual_input, min_expected_value, max_expected_value) + super( + %(CPF formatting option "#{option_name}" must be an integer between ) \ + "#{min_expected_value} and #{max_expected_value}. Got #{actual_input}." + ) + @option_name = option_name + @actual_input = actual_input + @min_expected_value = min_expected_value + @max_expected_value = max_expected_value + end + end + + # Domain error constructed when the sanitized CPF input does not have the + # required length. + # + # Passed to the +on_fail+ callback as a {DomainError}; not raised from + # {CpfFormatter#format}. + class InvalidLengthError < DomainError + # @return [String, Array] the original input + attr_reader :actual_input + + # @return [String] the sanitized digit string + attr_reader :evaluated_input + + # @return [Integer] expected length ({CPF_LENGTH}) + attr_reader :expected_length + + # @param actual_input [String, Array] the original input + # @param evaluated_input [String] the sanitized digit string + # @param expected_length [Integer] expected length (11) + def initialize(actual_input, evaluated_input, expected_length) + super(build_message(actual_input, evaluated_input, expected_length)) + @actual_input = actual_input + @evaluated_input = evaluated_input + @expected_length = expected_length + end + + private + + def build_message(actual_input, evaluated_input, expected_length) + fmt_actual_input = FormatLengthExceptionInput.call(actual_input) + fmt_evaluated_input = + if actual_input == evaluated_input + evaluated_input.length.to_s + else + %(#{evaluated_input.length} in "#{evaluated_input}") + end + + "CPF input #{fmt_actual_input} does not contain #{expected_length} digits. " \ + "Got #{fmt_evaluated_input}." + end + end + + # Domain error raised when a key option contains a disallowed character. + class ValidationError < DomainError + # @return [String] the offending option name + attr_reader :option_name + + # @return [String] the offending option value + attr_reader :actual_input + + # @return [Array] disallowed characters found in the value + attr_reader :forbidden_characters + + # @param option_name [String] the offending option key + # @param actual_input [String] the offending option value + # @param forbidden_characters [Array] disallowed characters + def initialize(option_name, actual_input, forbidden_characters) + quoted = forbidden_characters.map { |character| %("#{character}") }.join(', ') + super( + %(Value "#{actual_input}" for CPF formatting option "#{option_name}" contains ) \ + "disallowed characters (#{quoted})." + ) + @option_name = option_name + @actual_input = actual_input + @forbidden_characters = forbidden_characters.dup.freeze + end + end +end diff --git a/packages/cpf-fmt/src/cpf-fmt/types.rb b/packages/cpf-fmt/src/cpf-fmt/types.rb new file mode 100644 index 0000000..8fe1392 --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/types.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module CpfFmt + # Shared keyword option names for formatter entry points. + # + # @see CpfFormatter#initialize + # @see CpfFormatter#format + # @see CpfFmt.cpf_fmt + FORMATTER_OPTION_KEYS = %i[ + hidden hidden_key hidden_start hidden_end dot_key dash_key escape encode on_fail + ].freeze + + # Represents valid input types for CPF formatting. + # + # A CPF can be provided as: + # + # - A string containing digits (with or without formatting) + # - An array of strings, where each string represents a digit or group of digits + # + # @see CpfFormatter#format + # @see CpfFmt.cpf_fmt + CpfInput = Object + + # Callback function type for handling formatting failures. + # + # This function is invoked when the CPF formatter encounters an error during + # formatting, such as invalid input length or other formatting issues. The + # callback receives the original input value and a {DomainError}, and should + # return a string to use as the fallback output. + # + # @yieldparam original_input [String, Array] the raw input value + # @yieldparam error [DomainError] the domain failure (currently {InvalidLengthError}) + # @yieldreturn [String] fallback output + OnFailCallback = Object + + # Options input accepted by formatter constructors and {#format} calls. + # + # May be a {CpfFormatterOptions} instance, a {Hash} of option keys, or +nil+. + # + # @see CpfFormatterOptions + CpfFormatterOptionsInput = Object +end diff --git a/packages/cpf-fmt/src/cpf-fmt/utils.rb b/packages/cpf-fmt/src/cpf-fmt/utils.rb new file mode 100644 index 0000000..27da09a --- /dev/null +++ b/packages/cpf-fmt/src/cpf-fmt/utils.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module CpfFmt + # Low-level helpers used by {CpfFormatter} and {CpfFormatterOptions}. + # + # @api private + module Utils + # A rarely-used 1-length character that is replaced with +hidden_key+ when + # +hidden+ is +true+. + HIDDEN_KEY_PLACEHOLDER = CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS[0] + NON_DIGIT_PATTERN = /\D/ + + 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 assert_no_disallowed_key_characters!(option_name, value, forbidden_characters) + return unless value.chars.intersect?(forbidden_characters) + + raise ValidationError.new(option_name, value, forbidden_characters) + end + + def assert_hidden_index_type!(option_name, value) + return if value.is_a?(Integer) + + raise TypeMismatchError.new(value, 'integer', option_name: option_name) + end + + def assert_hidden_index!(option_name, value, min_value, max_value) + return if value.between?(min_value, max_value) + + raise OutOfRangeError.new(option_name, value, min_value, max_value) + 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 normalize_hidden_range(hidden_start, hidden_end, min_value, max_value) + assert_hidden_index_type!('hidden_start', hidden_start) + assert_hidden_index_type!('hidden_end', hidden_end) + assert_hidden_index!('hidden_start', hidden_start, min_value, max_value) + assert_hidden_index!('hidden_end', hidden_end, min_value, max_value) + + return [hidden_end, hidden_start] if hidden_start > hidden_end + + [hidden_start, hidden_end] + end + + def sanitize_cpf_input(value) + if value.length == CpfFormatterOptions::CPF_LENGTH && + value.ascii_only? && + value.match?(/\A[0-9]+\z/) + return value + end + + value.gsub(NON_DIGIT_PATTERN, '') + end + + def apply_hidden_mask(formatted_cpf, options) + starting_part = formatted_cpf[0...options.hidden_start] + ending_part = formatted_cpf[(options.hidden_end + 1)..] + hidden_part_length = options.hidden_end - options.hidden_start + 1 + hidden_part = HIDDEN_KEY_PLACEHOLDER * hidden_part_length + + starting_part + hidden_part + ending_part + end + + def insert_delimiters(formatted_cpf, options) + formatted_cpf[0, 3] + + options.dot_key + + formatted_cpf[3, 3] + + options.dot_key + + formatted_cpf[6, 3] + + options.dash_key + + formatted_cpf[9, 2] + end + + def replace_hidden_placeholders(formatted_cpf, hidden_key) + formatted_cpf.gsub(HIDDEN_KEY_PLACEHOLDER, hidden_key) + end + + def apply_post_processing(formatted_cpf, options) + formatted_cpf = CGI.escapeHTML(formatted_cpf) if options.escape + formatted_cpf = ERB::Util.url_encode(formatted_cpf) if options.encode + formatted_cpf + end + + # Normalizes the input to a string. + # + # @param cpf_input [Object] candidate CPF input + # @return [String] joined string input + # @raise [TypeMismatchError] if the input is not a +String+ or +Array+ + def to_string_input(cpf_input) + return cpf_input if cpf_input.is_a?(String) + + if cpf_input.is_a?(Array) + cpf_input.each do |item| + raise TypeMismatchError.new(cpf_input, 'string or string[]') unless item.is_a?(String) + end + + return cpf_input.join + end + + raise TypeMismatchError.new(cpf_input, 'string or string[]') + end + + # Invokes the +on_fail+ callback and validates its return type. + # + # @param on_fail [Proc] failure callback + # @param cpf_input [String, Array] original input + # @param error [DomainError] domain failure passed to the callback + # @return [String] callback result + # @raise [TypeMismatchError] if the callback does not return a +String+ + def invoke_on_fail(on_fail, cpf_input, error) + result = on_fail.call(cpf_input, error) + raise TypeMismatchError.new(result, 'string', option_name: 'on_fail') unless result.is_a?(String) + + result + end + end +end From 2c4c51cb4c5cee07df73e52592b2917d80b06db7 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:22:53 -0300 Subject: [PATCH 03/10] test(cpf-fmt): create unit tests Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/tests/cpf_fmt.spec.rb | 58 +- packages/cpf-fmt/tests/cpf_formatter.spec.rb | 481 +++++++ .../tests/cpf_formatter_options.spec.rb | 1113 +++++++++++++++++ packages/cpf-fmt/tests/errors.spec.rb | 260 ++++ packages/cpf-fmt/tests/utils.spec.rb | 229 ++++ 5 files changed, 2138 insertions(+), 3 deletions(-) create mode 100644 packages/cpf-fmt/tests/cpf_formatter.spec.rb create mode 100644 packages/cpf-fmt/tests/cpf_formatter_options.spec.rb create mode 100644 packages/cpf-fmt/tests/errors.spec.rb create mode 100644 packages/cpf-fmt/tests/utils.spec.rb diff --git a/packages/cpf-fmt/tests/cpf_fmt.spec.rb b/packages/cpf-fmt/tests/cpf_fmt.spec.rb index 8d3d999..4f6acf0 100644 --- a/packages/cpf-fmt/tests/cpf_fmt.spec.rb +++ b/packages/cpf-fmt/tests/cpf_fmt.spec.rb @@ -3,9 +3,61 @@ require 'spec_helper' RSpec.describe CpfFmt do - describe '.hello' do - it 'returns cpf-fmt' do - expect(CpfFmt.hello).to eq('cpf-fmt') + describe '.cpf_fmt' do + context 'when called' do + it 'matches CpfFormatter#format behavior' do + input = '12345678910' + formatter = CpfFmt::CpfFormatter.new + + expect(described_class.cpf_fmt(input)).to eq(formatter.format(input)) + end + + it 'accepts options and forwards formatting' do + input = '12345678910' + options = { dot_key: ' ', dash_key: '_' } + + expect(described_class.cpf_fmt(input, options)).to eq('123 456 789_10') + end + end + + context 'when called with keyword options' do + let(:input) { '12345678910' } + let(:default_hidden_length) do + described_class::CpfFormatterOptions::DEFAULT_HIDDEN_END - + described_class::CpfFormatterOptions::DEFAULT_HIDDEN_START + 1 + end + + it 'forwards hidden to the formatter' do + formatter = described_class::CpfFormatter.new(hidden: true) + + aggregate_failures do + expect(described_class.cpf_fmt(input, hidden: true)).to eq(formatter.format(input)) + expect(described_class.cpf_fmt(input, hidden: true).count('*')).to eq(default_hidden_length) + end + end + + it 'forwards encode to the formatter' do + formatter = described_class::CpfFormatter.new(encode: true, dash_key: '/') + + aggregate_failures do + expect(described_class.cpf_fmt(input, encode: true, dash_key: '/')) + .to eq(formatter.format(input)) + expect(described_class.cpf_fmt(input, encode: true, dash_key: '/')) + .to eq('123.456.789%2F10') + end + end + + it 'forwards on_fail to the formatter' do + on_fail = ->(_value, _error) { 'fallback' } + formatter = described_class::CpfFormatter.new(on_fail: on_fail) + + expect(described_class.cpf_fmt('short', on_fail: on_fail)).to eq(formatter.format('short')) + end + + it 'raises InvalidArgumentCombinationError when options and keywords are both given' do + expect { described_class.cpf_fmt(input, { dot_key: ' ' }, hidden: true) } + .to raise_error(CpfFmt::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end end end end diff --git a/packages/cpf-fmt/tests/cpf_formatter.spec.rb b/packages/cpf-fmt/tests/cpf_formatter.spec.rb new file mode 100644 index 0000000..f718a54 --- /dev/null +++ b/packages/cpf-fmt/tests/cpf_formatter.spec.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +require 'spec_helper' + +INVALID_LENGTH_CASES = [ + ['1', 1], + ['12', 2], + ['123', 3], + ['1234', 4], + ['12345', 5], + ['123456', 6], + ['1234567', 7], + ['12345678', 8], + ['123456789', 9], + ['1234567890', 10], + ['123456789012', 12], + ['1234567890123', 13] +].freeze + +INVALID_TYPE_CASES = [ + [nil, 'nil'], + [42, 'integer number'], + [3.14, 'float number'], + [false, 'boolean'], + [true, 'boolean'], + [{}, 'hash'] +].freeze + +ON_FAIL_INVALID_RETURN_CASES = [ + [42, 'integer number'], + [true, 'boolean'], + [nil, 'nil'], + [{}, 'hash'] +].freeze + +RSpec.describe CpfFmt::CpfFormatter do + let(:formatter) { described_class.new } + subject(:format) { formatter.method(:format) } + + describe '#initialize' do + context 'when called with no arguments' do + it 'creates an instance with default options' do + default_options = CpfFmt::CpfFormatterOptions.new + + expect(described_class.new.options.all).to eq(default_options.all) + end + end + + context 'when called with arguments' do + it 'sets default options with empty hash' do + default_options = CpfFmt::CpfFormatterOptions.new + + expect(described_class.new({}).options.all).to eq(default_options.all) + end + + it 'uses the provided options instance' do + options = CpfFmt::CpfFormatterOptions.new + + expect(described_class.new(options).options).to be(options) + end + + it 'overrides defaults with a literal hash' do + options = { + hidden: true, + dash_key: '_', + dot_key: ' ', + encode: true + } + + formatter = described_class.new(options) + + options.each do |key, value| + expect(formatter.options.all[key]).to eq(value) + end + end + + it 'overrides defaults with an options instance' do + options = CpfFmt::CpfFormatterOptions.new( + hidden: true, + dash_key: '_', + dot_key: ' ', + encode: true + ) + + expect(described_class.new(options).options.all).to eq(options.all) + end + end + + context 'when called with both an options instance and keyword arguments' do + it 'raises InvalidArgumentCombinationError' do + options = CpfFmt::CpfFormatterOptions.new(dot_key: ' ') + + expect { described_class.new(options, hidden: true) } + .to raise_error(CpfFmt::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({ dot_key: ' ' }, hidden: true) } + .to raise_error(CpfFmt::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + end + end + + describe '#format' do + context 'when input is a string' do + it 'handles unformatted input' do + expect(format.call('12345678910')).to eq('123.456.789-10') + end + + it 'handles standard formatting' do + expect(format.call('123.456.789-10')).to eq('123.456.789-10') + end + + it 'handles custom formatting' do + expect(format.call('123 456 789 _ 10')).to eq('123.456.789-10') + end + + it 'handles input with dashes' do + expect(format.call('809-765-110-61')).to eq('809.765.110-61') + end + + it 'handles input with spaces' do + expect(format.call('809 765 110 61')).to eq('809.765.110-61') + end + + it 'handles input with trailing space' do + expect(format.call('80976511061 ')).to eq('809.765.110-61') + end + + it 'handles input with leading space' do + expect(format.call(' 80976511061')).to eq('809.765.110-61') + end + + it 'handles input with individual dots' do + expect(format.call('8.0.9.7.6.5.1.1.0.6.1')).to eq('809.765.110-61') + end + + it 'handles input with individual dashes' do + expect(format.call('8-0-9-7-6-5-1-1-0-6-1')).to eq('809.765.110-61') + end + + it 'handles input with individual spaces' do + expect(format.call('8 0 9 7 6 5 1 1 0 6 1')).to eq('809.765.110-61') + end + + it 'strips non-digit characters' do + expect(format.call('80976511061abc')).to eq('809.765.110-61') + end + + it 'strips mixed non-digit separators' do + expect(format.call('809765110 dv 61')).to eq('809.765.110-61') + end + + it 'preserves leading zeros' do + expect(format.call('03603568195')).to eq('036.035.681-95') + end + end + + context 'when input is an array of strings' do + it 'handles array of only digits' do + result = format.call( + %w[1 2 3 4 5 6 7 8 9 1 0] + ) + + expect(result).to eq('123.456.789-10') + end + + it 'handles single-item digit array' do + expect(format.call(['12345678910'])).to eq('123.456.789-10') + end + + it 'handles grouped digits' do + expect(format.call(%w[123 456 789 10])).to eq('123.456.789-10') + end + + it 'handles grouped digits and punctuation' do + expect(format.call(%w[123 . 456 . 789 - 10])).to eq('123.456.789-10') + end + end + + context 'when input is not a string or string array' do + INVALID_TYPE_CASES.each do |input_value, actual_type| + it "raises TypeMismatchError for #{actual_type}" do + expect { format.call(input_value) } + .to raise_error(CpfFmt::TypeMismatchError) do |error| + aggregate_failures do + expect(error.expected_type).to eq('string or string[]') + expect(error.actual_input).to equal(input_value) + expect(error.actual_type).to eq(actual_type) + end + end + end + end + + it 'raises TypeMismatchError for arrays with non-strings' do + input_value = ['123', 45, '6789010'] + + expect { format.call(input_value) } + .to raise_error(CpfFmt::TypeMismatchError) do |error| + aggregate_failures do + expect(error.expected_type).to eq('string or string[]') + expect(error.actual_input).to eq(input_value) + end + end + end + end + + context 'when sanitized input length is not 11' do + INVALID_LENGTH_CASES.each do |input_value, length| + it "invokes on_fail for #{length}-digit input" do + on_fail = lambda do |value, error| + aggregate_failures do + expect(error).to be_a(CpfFmt::DomainError) + expect(error).to be_a(CpfFmt::InvalidLengthError) + expect(error.evaluated_input.length).to eq(length) + expect(error.actual_input).to eq(value) + end + + %(ERROR: "#{value}") + end + + expect(format.call(input_value, { on_fail: on_fail })).to eq(%(ERROR: "#{input_value}")) + end + end + + it 'returns the string from on_fail' do + on_fail = ->(value, _error) { value.upcase } + + expect(format.call('abc', { on_fail: on_fail })).to eq('ABC') + end + + it 'returns an empty string from the default on_fail' do + expect(format.call('abc')).to eq('') + end + end + + context 'when on_fail does not return a string' do + ON_FAIL_INVALID_RETURN_CASES.each do |return_value, actual_type| + it "raises TypeMismatchError for #{actual_type}" do + on_fail = ->(_value, _error) { return_value } + + expect { format.call('short', { on_fail: on_fail }) } + .to raise_error(CpfFmt::TypeMismatchError) do |error| + aggregate_failures do + expect(error.option_name).to eq('on_fail') + expect(error.actual_input).to equal(return_value) + expect(error.actual_type).to eq(actual_type) + expect(error.expected_type).to eq('string') + expect(error.message).to eq( + %(CPF formatting option "on_fail" must be of type string. Got #{actual_type}.) + ) + end + end + end + end + end + + context 'when using per-call keyword overrides' do + it 'applies overrides without mutating defaults' do + formatter = described_class.new({ dot_key: ' ' }) + + aggregate_failures do + expect(formatter.format('12345678910', hidden: true).count('*')).to be_positive + expect(formatter.options.hidden).to be(false) + expect(formatter.options.dot_key).to eq(' ') + end + end + + it 'raises InvalidArgumentCombinationError when options and keywords are both given' do + expect { format.call('12345678910', { hidden: false }, hidden: true) } + .to raise_error(CpfFmt::InvalidArgumentCombinationError, /options.*keyword arguments.*not both/) + end + + it 'applies encode via keyword override' do + expect(format.call('12345678910', encode: true, dash_key: '/')).to eq('123.456.789%2F10') + end + + it 'applies on_fail via keyword override' do + on_fail = ->(_value, _error) { 'fallback' } + + expect(format.call('short', on_fail: on_fail)).to eq('fallback') + end + end + + context 'when using hidden option' do + let(:default_hidden_length) do + CpfFmt::CpfFormatterOptions::DEFAULT_HIDDEN_END - + CpfFmt::CpfFormatterOptions::DEFAULT_HIDDEN_START + 1 + end + let(:standard_cpf_format_length) { '000.000.000-00'.length } + + it 'masks with asterisks when true' do + result = format.call('12345678910', { hidden: true }) + hidden_chars = result.chars.select { |char| char == '*' } + + aggregate_failures do + expect(hidden_chars.length).to eq(default_hidden_length) + expect(result.length).to eq(standard_cpf_format_length) + end + end + + it 'masks a given range with asterisks' do + result = format.call( + '12345678910', + { hidden: true, hidden_start: 3, hidden_end: 7 } + ) + + aggregate_failures do + expect(result).to eq('123.***.**9-10') + expect(result.length).to eq(standard_cpf_format_length) + end + end + + it 'masks with a custom key' do + result = format.call('12345678910', { hidden: true, hidden_key: '#' }) + hidden_chars = result.chars.select { |char| char == '#' } + + aggregate_failures do + expect(result).not_to include('*') + expect(hidden_chars.length).to eq(default_hidden_length) + expect(result.length).to eq(standard_cpf_format_length) + end + end + + it 'masks with a zero-width key' do + result = format.call('12345678910', { hidden: true, hidden_key: '' }) + + aggregate_failures do + expect(result).not_to include('*') + expect(result.length).to eq(standard_cpf_format_length - default_hidden_length) + end + end + + it 'masks with a multi-character key' do + result = format.call('12345678910', { hidden: true, hidden_key: '[]' }) + bracket_chars = result.chars.select { |char| ['[', ']'].include?(char) }.join + + aggregate_failures do + expect(result).not_to include('*') + expect(bracket_chars).to match(/\A(\[\]){#{default_hidden_length}}\z/) + expect(result.length).to eq(standard_cpf_format_length + default_hidden_length) + end + end + + it 'masks from a custom start index' do + expect(format.call('80976511061', { hidden: true, hidden_start: 6 })) + .to eq('809.765.***-**') + end + + it 'masks up to a custom end index' do + expect(format.call('80976511061', { hidden: true, hidden_end: 8 })) + .to eq('809.***.***-61') + end + + it 'masks a full custom range' do + expect(format.call('80976511061', { hidden: true, hidden_start: 0, hidden_end: 8 })) + .to eq('***.***.***-61') + end + + it 'swaps reversed hidden range values' do + expect(format.call('80976511061', { hidden: true, hidden_start: 9, hidden_end: 3 })) + .to eq('809.***.***-*1') + end + + it 'masks with a custom key and start index' do + expect( + format.call('80976511061', { hidden: true, hidden_key: '#', hidden_start: 6 }) + ).to eq('809.765.###-##') + end + + it 'raises OutOfRangeError for start below zero' do + expect { format.call('12345678910', { hidden: true, hidden_start: -1 }) } + .to raise_error(CpfFmt::OutOfRangeError) + end + + it 'raises OutOfRangeError for start above 10' do + expect { format.call('12345678910', { hidden: true, hidden_start: 11 }) } + .to raise_error(CpfFmt::OutOfRangeError) + end + + it 'raises OutOfRangeError for end below zero' do + expect { format.call('12345678910', { hidden: true, hidden_end: -1 }) } + .to raise_error(CpfFmt::OutOfRangeError) + end + + it 'raises OutOfRangeError for end above 10' do + expect { format.call('12345678910', { hidden: true, hidden_end: 11 }) } + .to raise_error(CpfFmt::OutOfRangeError) + end + end + + context 'when customizing punctuation' do + it 'replaces dots with a custom key' do + expect(format.call('12345678910', { dot_key: ' ' })).to eq('123 456 789-10') + end + + it 'replaces dots with a zero-width key' do + expect(format.call('12345678910', { dot_key: '' })).to eq('123456789-10') + end + + it 'replaces dots with a multi-character key' do + expect(format.call('12345678910', { dot_key: '[]' })).to eq('123[]456[]789-10') + end + + it 'replaces dash with a custom key' do + expect(format.call('12345678910', { dash_key: '_' })).to eq('123.456.789_10') + end + + it 'replaces dash with a zero-width key' do + expect(format.call('12345678910', { dash_key: '' })).to eq('123.456.78910') + end + + it 'replaces dash with a multi-character key' do + expect(format.call('12345678910', { dash_key: ' dv ' })).to eq('123.456.789 dv 10') + end + + it 'uses dash_key as a trailing delimiter with a dot character' do + expect(format.call('80976511061', { dash_key: '.' })).to eq('809.765.110.61') + end + + it 'removes all delimiters' do + expect(format.call('809.765.110-61', { dot_key: '', dash_key: '' })).to eq('80976511061') + end + end + + context 'when using escape option' do + it 'escapes HTML special characters' do + result = format.call( + '12345678910', + { + dot_key: '&', + dash_key: '<>', + escape: true + } + ) + + expect(result).to eq('123&456&789<>10') + end + + it 'escapes angle-bracket delimiters' do + result = format.call( + '80976511061', + { + dot_key: '<', + dash_key: '>', + escape: true + } + ) + + expect(result).to eq('809<765<110>61') + end + end + + context 'when using encode option' do + it 'URL-encodes the result' do + expect(format.call('12345678910', { dash_key: '/', encode: true })) + .to eq('123.456.789%2F10') + end + end + + context 'with multi-character delimiter edge case' do + it 'combines hidden and custom delimiter keys' do + result = format.call( + '12345678910', + { + hidden: true, + hidden_start: 3, + hidden_end: 7, + hidden_key: '[*]', + dot_key: '[.]', + dash_key: '[-]' + } + ) + + expect(result).to eq('123[.][*][*][*][.][*][*]9[-]10') + end + end + end +end diff --git a/packages/cpf-fmt/tests/cpf_formatter_options.spec.rb b/packages/cpf-fmt/tests/cpf_formatter_options.spec.rb new file mode 100644 index 0000000..f673e29 --- /dev/null +++ b/packages/cpf-fmt/tests/cpf_formatter_options.spec.rb @@ -0,0 +1,1113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Deliberately independent of CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS so +# production changes fail loudly. +DISALLOWED_KEY_CHARACTERS = %w[å ë ï ö].freeze + +RSpec.describe CpfFmt::CpfFormatterOptions do + def expect_options_match(actual, expected) + expected.each do |key, value| + if key == :on_fail + expect(actual[key]).to equal(value) + else + expect(actual[key]).to eq(value) + end + end + end + + def forbidden_key_message(option_name, value) + quoted = DISALLOWED_KEY_CHARACTERS.map { |char| %("#{char}") }.join(', ') + + %(Value "#{value}" for CPF formatting option "#{option_name}" contains disallowed characters (#{quoted}).) + end + + let(:default_parameters) do + { + hidden: described_class::DEFAULT_HIDDEN, + hidden_key: described_class::DEFAULT_HIDDEN_KEY, + hidden_start: described_class::DEFAULT_HIDDEN_START, + hidden_end: described_class::DEFAULT_HIDDEN_END, + dot_key: described_class::DEFAULT_DOT_KEY, + dash_key: described_class::DEFAULT_DASH_KEY, + escape: described_class::DEFAULT_ESCAPE, + encode: described_class::DEFAULT_ENCODE, + on_fail: described_class::DEFAULT_ON_FAIL + } + 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( + { + hidden: nil, + hidden_key: nil, + hidden_start: nil, + hidden_end: nil, + dot_key: nil, + dash_key: nil, + escape: nil, + encode: nil, + on_fail: 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 + on_fail = ->(value, _error) { "ERROR: #{value}" } + parameters = { + hidden: true, + hidden_key: '#', + hidden_start: 1, + hidden_end: 8, + dot_key: '|', + dash_key: '~', + escape: true, + encode: true, + on_fail: on_fail + } + + 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( + hidden: true, + hidden_key: '#', + hidden_start: nil, + hidden_end: nil, + escape: true, + encode: false, + on_fail: nil + ) + + expect_options_match( + options.all, + default_parameters.merge( + hidden: true, + hidden_key: '#', + escape: true, + encode: false + ) + ) + end + + it 'preserves defaults for mixed nil and valid values' do + on_fail = ->(value, _error) { "CUSTOM: #{value}" } + options = described_class.new( + { + escape: true, + hidden: nil, + hidden_key: nil, + hidden_start: 5, + hidden_end: nil, + dot_key: nil, + dash_key: '~', + on_fail: on_fail + } + ) + + aggregate_failures do + expect(options.escape).to be(true) + expect(options.hidden).to be(false) + expect(options.hidden_key).to eq('*') + expect(options.hidden_start).to eq(5) + expect(options.hidden_end).to eq(10) + expect(options.dot_key).to eq('.') + expect(options.dash_key).to eq('~') + expect(options.on_fail).to equal(on_fail) + end + end + end + + context 'when called with a CpfFormatterOptions instance' do + it 'creates a new instance with the same values' do + original_options = described_class.new( + hidden: true, + hidden_start: 1, + hidden_end: 8, + dash_key: '_', + escape: true, + on_fail: ->(value, _error) { "ERROR: #{value}" } + ) + + 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({ hidden_key: '#' }, { hidden_key: 'X' }) + + expect(options.hidden_key).to eq('X') + end + + it 'uses the last option with one hash and one instance' do + options = described_class.new( + { hidden_key: '#' }, + described_class.new(hidden_key: 'X') + ) + + expect(options.hidden_key).to eq('X') + end + + it 'uses the last option with five params' do + options = described_class.new( + { hidden_key: '.' }, + { hidden_key: '_' }, + { hidden_key: '#' }, + { hidden_key: 'X' }, + { hidden_key: '@' } + ) + + expect(options.hidden_key).to eq('@') + end + + it 'lets keyword arguments override positional layers' do + options = described_class.new({ hidden_key: '#' }, hidden_key: 'X') + + expect(options.hidden_key).to eq('X') + end + end + end + + describe '#set' do + context 'when updating with a partial hash' do + it 'keeps current values for unresolved keys' do + options = described_class.new(hidden: true, hidden_key: '#', dash_key: '_') + options.set(dot_key: ' ') + + aggregate_failures do + expect(options.hidden).to be(true) + expect(options.hidden_key).to eq('#') + expect(options.dash_key).to eq('_') + expect(options.dot_key).to eq(' ') + end + end + end + + context 'when updating with nil values' do + it 'ignores nil and keeps the current value' do + options = described_class.new(hidden_key: '#') + options.set(hidden_key: nil) + + expect(options.hidden_key).to eq('#') + end + end + + context 'when updating with layered overrides' do + it 'folds left-to-right then applies keywords' do + options = described_class.new(hidden_key: '*') + options.set({ hidden_key: '#' }, { hidden_key: 'X' }, hidden_key: '@') + + expect(options.hidden_key).to eq('@') + end + end + end + + describe '#hidden=' do + context 'when setting to a boolean value' do + it 'sets hidden to true' do + options = described_class.new(hidden: false) + options.hidden = true + + expect(options.hidden).to be(true) + end + + it 'sets hidden to false' do + options = described_class.new(hidden: true) + options.hidden = false + + expect(options.hidden).to be(false) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden: !default_parameters[:hidden]) + + expect { options.hidden = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden" 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(hidden: false) + options.hidden = { not: 'a boolean' } + + expect(options.hidden).to be(true) + end + + it 'coerces a truthy string to true' do + options = described_class.new(hidden: false) + options.hidden = 'not a boolean' + + expect(options.hidden).to be(true) + end + + it 'coerces a truthy number to true' do + options = described_class.new(hidden: false) + options.hidden = 123 + + expect(options.hidden).to be(true) + end + + it 'coerces an empty string to false' do + options = described_class.new(hidden: false) + options.hidden = '' + + expect(options.hidden).to be(false) + end + + it 'coerces zero to false' do + options = described_class.new(hidden: false) + options.hidden = 0 + + expect(options.hidden).to be(false) + end + end + end + + describe '#hidden_key=' do + context 'when setting to a string value' do + it 'sets hidden_key to the provided value' do + options = described_class.new(hidden_key: '*') + options.hidden_key = 'X' + + expect(options.hidden_key).to eq('X') + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden_key: '#') + + expect { options.hidden_key = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_key" 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.hidden_key = { not: 'a string' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_key" must be of type string. Got hash.' + ) + end + + it 'raises TypeMismatchError for a number' do + options = described_class.new + + expect { options.hidden_key = 123 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_key" must be of type string. Got integer number.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.hidden_key = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_key" must be of type string. Got boolean.' + ) + end + end + + context 'when setting to a forbidden key character' do + DISALLOWED_KEY_CHARACTERS.each do |forbidden_char| + it "raises ValidationError for #{forbidden_char}" do + options = described_class.new + + expect { options.hidden_key = forbidden_char } + .to raise_error( + CpfFmt::ValidationError, + forbidden_key_message('hidden_key', forbidden_char) + ) + end + end + end + end + + describe '#hidden_start=' do + context 'when setting to a number value' do + it 'sets hidden_start to the provided value' do + options = described_class.new(hidden_start: 0) + options.hidden_start = 1 + + expect(options.hidden_start).to eq(1) + end + end + + context 'when setting to an invalid range' do + it 'raises OutOfRangeError for -1' do + options = described_class.new + + expect { options.hidden_start = -1 } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_start" must be an integer between 0 and 10. Got -1.' + ) + end + + it 'raises OutOfRangeError for 11' do + options = described_class.new + + expect { options.hidden_start = 11 } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_start" must be an integer between 0 and 10. Got 11.' + ) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden_start: 0) + + expect { options.hidden_start = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got nil.' + ) + end + end + + context 'when setting to a non-integer value' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.hidden_start = { not: 'a number' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got hash.' + ) + end + + it 'raises TypeMismatchError for a string' do + options = described_class.new + + expect { options.hidden_start = 'not a number' } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got string.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.hidden_start = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got boolean.' + ) + end + + it 'raises TypeMismatchError for a float' do + options = described_class.new + + expect { options.hidden_start = 1.5 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got float number.' + ) + end + end + end + + describe '#hidden_end=' do + context 'when setting to a number value' do + it 'sets hidden_end to the provided value' do + options = described_class.new(hidden_end: 10) + options.hidden_end = 9 + + expect(options.hidden_end).to eq(9) + end + end + + context 'when setting to an invalid range' do + it 'raises OutOfRangeError for -1' do + options = described_class.new + + expect { options.hidden_end = -1 } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_end" must be an integer between 0 and 10. Got -1.' + ) + end + + it 'raises OutOfRangeError for 11' do + options = described_class.new + + expect { options.hidden_end = 11 } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_end" must be an integer between 0 and 10. Got 11.' + ) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden_end: 0) + + expect { options.hidden_end = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got nil.' + ) + end + end + + context 'when setting to a non-integer value' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.hidden_end = { not: 'a number' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got hash.' + ) + end + + it 'raises TypeMismatchError for a string' do + options = described_class.new + + expect { options.hidden_end = 'not a number' } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got string.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.hidden_end = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got boolean.' + ) + end + + it 'raises TypeMismatchError for a float' do + options = described_class.new + + expect { options.hidden_end = 1.5 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got float number.' + ) + end + end + end + + describe '#dot_key=' do + context 'when setting to a string value' do + it 'sets dot_key to the provided value' do + options = described_class.new(dot_key: '.') + options.dot_key = '_' + + expect(options.dot_key).to eq('_') + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(dot_key: '_') + + expect { options.dot_key = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dot_key" 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.dot_key = { not: 'a string' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dot_key" must be of type string. Got hash.' + ) + end + + it 'raises TypeMismatchError for a number' do + options = described_class.new + + expect { options.dot_key = 123 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dot_key" must be of type string. Got integer number.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.dot_key = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dot_key" must be of type string. Got boolean.' + ) + end + end + + context 'when setting to a forbidden key character' do + DISALLOWED_KEY_CHARACTERS.each do |forbidden_char| + it "raises ValidationError for #{forbidden_char}" do + options = described_class.new + + expect { options.dot_key = forbidden_char } + .to raise_error( + CpfFmt::ValidationError, + forbidden_key_message('dot_key', forbidden_char) + ) + end + end + end + end + + describe '#dash_key=' do + context 'when setting to a string value' do + it 'sets dash_key to the provided value' do + options = described_class.new(dash_key: '.') + options.dash_key = '_' + + expect(options.dash_key).to eq('_') + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(dash_key: '_') + + expect { options.dash_key = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dash_key" 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.dash_key = { not: 'a string' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dash_key" must be of type string. Got hash.' + ) + end + + it 'raises TypeMismatchError for a number' do + options = described_class.new + + expect { options.dash_key = 123 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dash_key" must be of type string. Got integer number.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.dash_key = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "dash_key" must be of type string. Got boolean.' + ) + end + end + + context 'when setting to a forbidden key character' do + DISALLOWED_KEY_CHARACTERS.each do |forbidden_char| + it "raises ValidationError for #{forbidden_char}" do + options = described_class.new + + expect { options.dash_key = forbidden_char } + .to raise_error( + CpfFmt::ValidationError, + forbidden_key_message('dash_key', forbidden_char) + ) + end + end + end + end + + describe '#escape=' do + context 'when setting to a boolean value' do + it 'sets escape to true' do + options = described_class.new(escape: false) + options.escape = true + + expect(options.escape).to be(true) + end + + it 'sets escape to false' do + options = described_class.new(escape: true) + options.escape = false + + expect(options.escape).to be(false) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(escape: !default_parameters[:escape]) + + expect { options.escape = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "escape" 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(escape: false) + options.escape = { not: 'a boolean' } + + expect(options.escape).to be(true) + end + + it 'coerces a truthy string to true' do + options = described_class.new(escape: false) + options.escape = 'not a boolean' + + expect(options.escape).to be(true) + end + + it 'coerces a truthy number to true' do + options = described_class.new(escape: false) + options.escape = 123 + + expect(options.escape).to be(true) + end + + it 'coerces an empty string to false' do + options = described_class.new(escape: false) + options.escape = '' + + expect(options.escape).to be(false) + end + + it 'coerces zero to false' do + options = described_class.new(escape: false) + options.escape = 0 + + expect(options.escape).to be(false) + end + end + end + + describe '#encode=' do + context 'when setting to a boolean value' do + it 'sets encode to true' do + options = described_class.new(encode: false) + options.encode = true + + expect(options.encode).to be(true) + end + + it 'sets encode to false' do + options = described_class.new(encode: true) + options.encode = false + + expect(options.encode).to be(false) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + options = described_class.new(encode: !default_parameters[:encode]) + + expect { options.encode = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "encode" 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(encode: false) + options.encode = { not: 'a boolean' } + + expect(options.encode).to be(true) + end + + it 'coerces a truthy string to true' do + options = described_class.new(encode: false) + options.encode = 'not a boolean' + + expect(options.encode).to be(true) + end + + it 'coerces a truthy number to true' do + options = described_class.new(encode: false) + options.encode = 123 + + expect(options.encode).to be(true) + end + + it 'coerces an empty string to false' do + options = described_class.new(encode: false) + options.encode = '' + + expect(options.encode).to be(false) + end + + it 'coerces zero to false' do + options = described_class.new(encode: false) + options.encode = 0 + + expect(options.encode).to be(false) + end + end + end + + describe '#on_fail=' do + context 'when using the default callback value' do + it 'returns an empty string' do + expect(described_class::DEFAULT_ON_FAIL.call('some value')).to eq('') + end + end + + context 'when setting to a callable value' do + it 'sets on_fail to the provided callback' do + callback = ->(value, _error) { "ERROR: #{value}" } + options = described_class.new + options.on_fail = callback + + expect(options.on_fail).to equal(callback) + end + end + + context 'when setting to a nil value' do + it 'raises TypeMismatchError' do + callback = ->(value, _error) { "ERROR: #{value}" } + options = described_class.new(on_fail: callback) + + expect { options.on_fail = nil } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "on_fail" must be of type function. Got nil.' + ) + end + end + + context 'when setting to a non-callable value' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.on_fail = { not: 'a function' } } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "on_fail" must be of type function. Got hash.' + ) + end + + it 'raises TypeMismatchError for a string' do + options = described_class.new + + expect { options.on_fail = 'not a function' } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "on_fail" must be of type function. Got string.' + ) + end + + it 'raises TypeMismatchError for a number' do + options = described_class.new + + expect { options.on_fail = 123 } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "on_fail" must be of type function. Got integer number.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.on_fail = true } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "on_fail" must be of type function. Got boolean.' + ) + end + end + end + + describe '#all' do + it 'returns all properties with expected types' do + all_options = described_class.new.all + + aggregate_failures do + expect([true, false]).to include(all_options[:hidden]) + expect(all_options[:hidden_key]).to be_a(String) + expect(all_options[:hidden_start]).to be_a(Integer) + expect(all_options[:hidden_end]).to be_a(Integer) + expect(all_options[:dot_key]).to be_a(String) + expect(all_options[:dash_key]).to be_a(String) + expect([true, false]).to include(all_options[:escape]) + expect([true, false]).to include(all_options[:encode]) + expect(all_options[:on_fail]).to be_a(Proc) + end + end + end + + describe '#set_hidden_range' do + context 'when called with valid values' do + it 'sets hidden_start and hidden_end' do + options = described_class.new + options.set_hidden_range(0, 10) + + aggregate_failures do + expect(options.hidden_start).to eq(0) + expect(options.hidden_end).to eq(10) + end + end + + context 'when hidden_start equals hidden_end' do + it 'accepts 0 for both ends' do + options = described_class.new + options.set_hidden_range(0, 0) + + aggregate_failures do + expect(options.hidden_start).to eq(0) + expect(options.hidden_end).to eq(0) + end + end + + it 'accepts 10 for both ends' do + options = described_class.new + options.set_hidden_range(10, 10) + + aggregate_failures do + expect(options.hidden_start).to eq(10) + expect(options.hidden_end).to eq(10) + end + end + end + + context 'when hidden_start is greater than hidden_end' do + it 'swaps start and end values' do + options = described_class.new + options.set_hidden_range(8, 2) + + aggregate_failures do + expect(options.hidden_start).to eq(2) + expect(options.hidden_end).to eq(8) + end + end + end + end + + context 'when called with nil values' do + it 'raises TypeMismatchError for hidden_start' do + options = described_class.new + + expect { options.set_hidden_range(nil, nil) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got nil.' + ) + end + + context 'when hidden_start is nil' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden_start: 0) + + expect { options.set_hidden_range(nil, 10) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got nil.' + ) + end + end + + context 'when hidden_end is nil' do + it 'raises TypeMismatchError' do + options = described_class.new(hidden_end: 10) + + expect { options.set_hidden_range(0, nil) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got nil.' + ) + end + end + end + + context 'when called with invalid values' do + context 'when hidden_start is out of range' do + it 'raises OutOfRangeError for -1' do + options = described_class.new + + expect { options.set_hidden_range(-1, 10) } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_start" must be an integer between 0 and 10. Got -1.' + ) + end + + it 'raises OutOfRangeError for 11' do + options = described_class.new + + expect { options.set_hidden_range(11, 10) } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_start" must be an integer between 0 and 10. Got 11.' + ) + end + end + + context 'when hidden_end is out of range' do + it 'raises OutOfRangeError for -1' do + options = described_class.new + + expect { options.set_hidden_range(0, -1) } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_end" must be an integer between 0 and 10. Got -1.' + ) + end + + it 'raises OutOfRangeError for 11' do + options = described_class.new + + expect { options.set_hidden_range(0, 11) } + .to raise_error( + CpfFmt::OutOfRangeError, + 'CPF formatting option "hidden_end" must be an integer between 0 and 10. Got 11.' + ) + end + end + + context 'when hidden_start is not an integer' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.set_hidden_range({ not: 'a number' }, 10) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got hash.' + ) + end + + it 'raises TypeMismatchError for a string' do + options = described_class.new + + expect { options.set_hidden_range('not a number', 10) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got string.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.set_hidden_range(true, 10) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got boolean.' + ) + end + + it 'raises TypeMismatchError for a float' do + options = described_class.new + + expect { options.set_hidden_range(1.5, 10) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_start" must be of type integer. Got float number.' + ) + end + end + + context 'when hidden_end is not an integer' do + it 'raises TypeMismatchError for an object' do + options = described_class.new + + expect { options.set_hidden_range(0, { not: 'a number' }) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got hash.' + ) + end + + it 'raises TypeMismatchError for a string' do + options = described_class.new + + expect { options.set_hidden_range(0, 'not a number') } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got string.' + ) + end + + it 'raises TypeMismatchError for a boolean' do + options = described_class.new + + expect { options.set_hidden_range(0, true) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got boolean.' + ) + end + + it 'raises TypeMismatchError for a float' do + options = described_class.new + + expect { options.set_hidden_range(0, 1.5) } + .to raise_error( + CpfFmt::TypeMismatchError, + 'CPF formatting option "hidden_end" must be of type integer. Got float number.' + ) + end + end + end + end +end diff --git a/packages/cpf-fmt/tests/errors.spec.rb b/packages/cpf-fmt/tests/errors.spec.rb new file mode 100644 index 0000000..fb9f2a1 --- /dev/null +++ b/packages/cpf-fmt/tests/errors.spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CpfFmt::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 CpfFmt::TypeMismatchError do + context 'when instantiated for CPF input' do + subject(:error) { described_class.new(123, 'string') } + + it 'is a TypeError' do + expect(error).to be_a(TypeError) + end + + it 'includes CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfFmt::TypeMismatchError') + 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(described_class.new(123, 'string or string[]').expected_type) + .to eq('string or string[]') + end + + it 'leaves option_name nil' do + expect(error.option_name).to be_nil + end + + it 'builds a descriptive message' do + expect(described_class.new(123, 'string[]').message) + .to eq('CPF input must be of type string[]. Got integer number.') + end + end + + context 'when instantiated for an option' do + subject(:error) { described_class.new(123, 'string', option_name: 'hidden_key') } + + it 'sets option_name' do + expect(error.option_name).to eq('hidden_key') + 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 formatting option "hidden_key" must be of type string. Got integer number.' + ) + end + end +end + +RSpec.describe CpfFmt::InvalidArgumentCombinationError do + subject(:error) { described_class.new('invalid combination') } + + it 'is an ArgumentError' do + expect(error).to be_a(ArgumentError) + end + + it 'includes CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'is not a DomainError' do + expect(error).not_to be_a(CpfFmt::DomainError) + end +end + +RSpec.describe CpfFmt::DomainError do + before do + stub_const('CpfFmt::TestDomainError', Class.new(described_class)) + end + + subject(:error) { CpfFmt::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 CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'exposes the subclass name' do + expect(error.class.name).to eq('CpfFmt::TestDomainError') + end + + it 'exposes the message' do + expect(error.message).to eq('some error') + end + end +end + +RSpec.describe CpfFmt::OutOfRangeError do + subject(:error) { described_class.new('hidden_start', 20, 0, 10) } + + context 'when instantiated' do + it 'is a RangeError' do + expect(error).to be_a(RangeError) + end + + it 'is a DomainError' do + expect(error).to be_a(CpfFmt::DomainError) + end + + it 'includes CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfFmt::OutOfRangeError') + end + + it 'sets option_name' do + expect(error.option_name).to eq('hidden_start') + end + + it 'sets actual_input' do + expect(error.actual_input).to eq(20) + end + + it 'sets min_expected_value' do + expect(error.min_expected_value).to eq(0) + end + + it 'sets max_expected_value' do + expect(error.max_expected_value).to eq(10) + end + + it 'builds a descriptive message' do + expect(error.message).to eq( + 'CPF formatting option "hidden_start" must be an integer between 0 and 10. Got 20.' + ) + end + end +end + +RSpec.describe CpfFmt::InvalidLengthError do + subject(:error) { described_class.new('1.2.3.4.5', '12345', 11) } + + context 'when instantiated' do + it 'is a RangeError' do + expect(error).to be_a(RangeError) + end + + it 'is a DomainError' do + expect(error).to be_a(CpfFmt::DomainError) + end + + it 'includes CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfFmt::InvalidLengthError') + end + + it 'sets actual_input' do + expect(error.actual_input).to eq('1.2.3.4.5') + end + + it 'sets evaluated_input' do + expect(error.evaluated_input).to eq('12345') + end + + it 'sets expected_length' do + expect(error.expected_length).to eq(11) + end + + it 'builds a descriptive message' do + expect(error.message).to eq( + 'CPF input "1.2.3.4.5" does not contain 11 digits. Got 5 in "12345".' + ) + end + + it 'summarizes sequence input without serializing contents' do + sequence_error = described_class.new(%w[123 45], '12345', 11) + + aggregate_failures do + expect(sequence_error.actual_input).to eq(%w[123 45]) + expect(sequence_error.message).to eq( + 'CPF input sequence[2] does not contain 11 digits. Got 5 in "12345".' + ) + end + end + end +end + +RSpec.describe CpfFmt::ValidationError do + subject(:error) do + described_class.new('dot_key', 'å', %w[å ë ï ö]) + end + + context 'when instantiated' do + it 'is a RangeError' do + expect(error).to be_a(RangeError) + end + + it 'is a DomainError' do + expect(error).to be_a(CpfFmt::DomainError) + end + + it 'includes CpfFmt::Error' do + expect(error).to be_a(CpfFmt::Error) + end + + it 'exposes the class name' do + expect(error.class.name).to eq('CpfFmt::ValidationError') + end + + it 'sets option_name' do + expect(described_class.new('hidden_key', 'x', ['x']).option_name).to eq('hidden_key') + end + + it 'sets actual_input' do + expect(described_class.new('dash_key', '/', ['/']).actual_input).to eq('/') + end + + it 'sets forbidden_characters' do + expect(described_class.new('dash_key', 'å', %w[å ë ï ö]).forbidden_characters) + .to eq(%w[å ë ï ö]) + end + + it 'builds a descriptive message' do + expect(error.message).to eq( + 'Value "å" for CPF formatting option "dot_key" contains disallowed characters ("å", "ë", "ï", "ö").' + ) + end + end +end diff --git a/packages/cpf-fmt/tests/utils.spec.rb b/packages/cpf-fmt/tests/utils.spec.rb new file mode 100644 index 0000000..7f26814 --- /dev/null +++ b/packages/cpf-fmt/tests/utils.spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'cgi' +require 'erb' + +RSpec.describe CpfFmt::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!('dot_key', '.') }.not_to raise_error + end + + it 'raises TypeMismatchError for a non-string' do + expect { described_class.assert_string_option!('dot_key', 1) } + .to raise_error(CpfFmt::TypeMismatchError) { |error| + expect(error.option_name).to eq('dot_key') + expect(error.expected_type).to eq('string') + } + end + end + + describe '.assert_no_disallowed_key_characters!' do + # Deliberately independent of CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS. + let(:forbidden) { %w[å ë ï ö].freeze } + + it 'accepts a value without disallowed characters' do + expect do + described_class.assert_no_disallowed_key_characters!('dot_key', '.', forbidden) + end.not_to raise_error + end + + it 'raises ValidationError when a disallowed character is present' do + expect do + described_class.assert_no_disallowed_key_characters!('dot_key', forbidden.first, forbidden) + end.to raise_error(CpfFmt::ValidationError) { |error| + expect(error.option_name).to eq('dot_key') + expect(error.forbidden_characters).to include(forbidden.first) + } + end + end + + describe '.fetch_option' do + it 'reads a symbol key' do + expect(described_class.fetch_option({ hidden: true }, :hidden)).to be(true) + end + + it 'reads a string key' do + expect(described_class.fetch_option({ 'hidden' => true }, :hidden)).to be(true) + end + + it 'returns nil when the key is absent' do + expect(described_class.fetch_option({}, :hidden)).to be_nil + end + end + + describe '.normalize_hidden_range' do + it 'returns the range unchanged when start is less than or equal to end' do + expect(described_class.normalize_hidden_range(2, 8, 0, 10)).to eq([2, 8]) + end + + it 'swaps the bounds when start is greater than end' do + expect(described_class.normalize_hidden_range(8, 2, 0, 10)).to eq([2, 8]) + end + + it 'raises TypeMismatchError for a non-integer bound' do + expect { described_class.normalize_hidden_range('a', 8, 0, 10) } + .to raise_error(CpfFmt::TypeMismatchError) + end + + it 'raises OutOfRangeError for an out-of-range bound' do + expect { described_class.normalize_hidden_range(-1, 8, 0, 10) } + .to raise_error(CpfFmt::OutOfRangeError) + end + end + + describe '.sanitize_cpf_input' do + it 'returns an already clean digit string unchanged' do + expect(described_class.sanitize_cpf_input('12345678910')).to eq('12345678910') + end + + it 'preserves leading zeros' do + expect(described_class.sanitize_cpf_input('03603568195')).to eq('03603568195') + end + + it 'strips punctuation and letters' do + expect(described_class.sanitize_cpf_input('123.456.789-10abc')).to eq('12345678910') + end + end + + describe '.to_string_input' do + it 'returns a string unchanged' do + expect(described_class.to_string_input('12345678910')).to eq('12345678910') + end + + it 'joins an array of strings' do + expect(described_class.to_string_input(%w[123 456 789 10])).to eq('12345678910') + end + + it 'raises TypeMismatchError for a non-string, non-array input' do + expect { described_class.to_string_input(12_345) } + .to raise_error(CpfFmt::TypeMismatchError) { |error| + expect(error.option_name).to be_nil + expect(error.expected_type).to eq('string or string[]') + expect(error.actual_input).to eq(12_345) + } + end + + it 'raises TypeMismatchError when an array contains a non-string' do + input = ['123', 45, '678'] + + expect { described_class.to_string_input(input) } + .to raise_error(CpfFmt::TypeMismatchError) { |error| + expect(error.expected_type).to eq('string or string[]') + expect(error.actual_input).to eq(input) + } + end + end + + describe '.insert_delimiters' do + it 'inserts default delimiter keys' do + options = CpfFmt::CpfFormatterOptions.new + + expect(described_class.insert_delimiters('12345678910', options)) + .to eq('123.456.789-10') + end + + it 'uses custom delimiter keys' do + options = CpfFmt::CpfFormatterOptions.new(dot_key: ' ', dash_key: '_') + + expect(described_class.insert_delimiters('12345678910', options)) + .to eq('123 456 789_10') + end + end + + describe '.apply_hidden_mask' do + it 'replaces the inclusive hidden range with the placeholder character' do + options = CpfFmt::CpfFormatterOptions.new(hidden_start: 3, hidden_end: 7) + placeholder = described_class::HIDDEN_KEY_PLACEHOLDER + + expect(described_class.apply_hidden_mask('12345678910', options)) + .to eq("123#{placeholder * 5}910") + end + end + + describe '.replace_hidden_placeholders' do + it 'substitutes each placeholder with the hidden key' do + placeholder = described_class::HIDDEN_KEY_PLACEHOLDER + masked = "123#{placeholder * 3}78910" + + expect(described_class.replace_hidden_placeholders(masked, '#')) + .to eq('123###78910') + end + end + + describe '.apply_post_processing' do + it 'returns the string unchanged when escape and encode are false' do + options = CpfFmt::CpfFormatterOptions.new(escape: false, encode: false) + + expect(described_class.apply_post_processing('123.456.789-10', options)) + .to eq('123.456.789-10') + end + + it 'HTML-escapes when escape is true' do + options = CpfFmt::CpfFormatterOptions.new(escape: true, encode: false) + + expect(described_class.apply_post_processing('123&456<>10', options)) + .to eq(CGI.escapeHTML('123&456<>10')) + end + + it 'URL-encodes when encode is true' do + options = CpfFmt::CpfFormatterOptions.new(escape: false, encode: true) + + expect(described_class.apply_post_processing('123.456.789/10', options)) + .to eq(ERB::Util.url_encode('123.456.789/10')) + end + + it 'applies escape before encode when both are true' do + options = CpfFmt::CpfFormatterOptions.new(escape: true, encode: true) + escaped = CGI.escapeHTML('123&456<>10') + + expect(described_class.apply_post_processing('123&456<>10', options)) + .to eq(ERB::Util.url_encode(escaped)) + end + end + + describe '.invoke_on_fail' do + let(:error) { CpfFmt::InvalidLengthError.new('short', 'short', 11) } + + it 'returns the callback string result' do + on_fail = lambda { |value, raised| + expect(value).to eq('short') + expect(raised).to equal(error) + expect(raised).to be_a(CpfFmt::DomainError) + 'fallback' + } + + expect(described_class.invoke_on_fail(on_fail, 'short', error)).to eq('fallback') + end + + it 'raises TypeMismatchError when the callback does not return a string' do + on_fail = ->(_value, _raised) { 123 } + + expect { described_class.invoke_on_fail(on_fail, 'short', error) } + .to raise_error(CpfFmt::TypeMismatchError) { |raised| + expect(raised.option_name).to eq('on_fail') + expect(raised.actual_input).to eq(123) + expect(raised.expected_type).to eq('string') + } + end + end +end From 06c4ea8c26fa9663e2527272ff1cb234c2133f33 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:24:01 -0300 Subject: [PATCH 04/10] docs(cpf-dv): create changelogs file Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/cpf-fmt/CHANGELOG.md b/packages/cpf-fmt/CHANGELOG.md index ec35ed5..e007c18 100644 --- a/packages/cpf-fmt/CHANGELOG.md +++ b/packages/cpf-fmt/CHANGELOG.md @@ -1 +1,18 @@ # cpf-fmt + +## 1.0.0 + +### 🚀 Stable Version Released! + +Utility module to format CPF (Brazilian individual taxpayer ID) strings. Main features: + +- **Multiple interfaces**: supports `CpfFmt.cpf_fmt` and `CpfFmt::CpfFormatter` with shared `CpfFmt::CpfFormatterOptions` defaults; an `options` argument (instance or `Hash`) is never merged with keyword overrides — passing both at once raises `InvalidArgumentCombinationError`. +- **Numeric CPF**: formats 11-digit input; strips non-digits before delimiter insertion (`XXX.XXX.XXX-XX`). +- **Flexible input**: accepts a `String` or `Array` and concatenates sequence items before sanitization. +- **Customizable output**: configurable `dot_key` and `dash_key` delimiters for flexible layouts. +- **Privacy masking**: `hidden`, `hidden_key`, `hidden_start`, and `hidden_end` mask sensitive digit ranges in the formatted string. +- **Post-format transforms**: optional `escape` (HTML) and `encode` (URL) applied after successful formatting. +- **Structured errors**: `CpfFmt::Error` marker with misuse leaves (`TypeMismatchError`, `InvalidArgumentCombinationError`), domain leaves (`InvalidLengthError`, `OutOfRangeError`, `ValidationError` under `DomainError`); `on_fail` receives `(original_input, DomainError)` (length failures pass `InvalidLengthError`). +- **Strict options merging**: `CpfFormatterOptions.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 never accept `nil` directly — pass the matching `DEFAULT_*` constant (or use `#set_hidden_range`) to reset explicitly. + +For detailed usage and API reference, see the [README](./README.md). From 9f2d2d728762d15d41a24c5745187c5e48a77ffc Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:24:40 -0300 Subject: [PATCH 05/10] docs(cpf-fmt): create README file Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/README.md | 453 +++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 packages/cpf-fmt/README.md diff --git a/packages/cpf-fmt/README.md b/packages/cpf-fmt/README.md new file mode 100644 index 0000000..d7c00d0 --- /dev/null +++ b/packages/cpf-fmt/README.md @@ -0,0 +1,453 @@ +![cpf-fmt for Ruby](https://br-utils.vercel.app/img/cover_cpf-fmt.jpg) + +[![Gem Version](https://img.shields.io/gem/v/cpf-fmt)](https://rubygems.org/gems/cpf-fmt) +[![Gem Downloads](https://img.shields.io/gem/dt/cpf-fmt)](https://rubygems.org/gems/cpf-fmt) +[![Ruby Version](https://img.shields.io/gem/rv/cpf-fmt)](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 format CPF (Brazilian Individual's Taxpayer ID). + +## 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 ✔ | + +## Features + +- ✅ **Flexible input**: Accepts `String` or `Array` of strings; array elements are concatenated in order +- ✅ **Format agnostic**: Strips non-digit characters before formatting (letters and punctuation are discarded) +- ✅ **Custom delimiters**: `dot_key` and `dash_key` may be empty, single-, or multi-character strings +- ✅ **Masking**: Optional hiding of a digit range with a configurable replacement string (`hidden`, `hidden_key`, `hidden_start`, `hidden_end`) +- ✅ **HTML & URL output**: Optional `escape` (HTML entities) and `encode` (URI component encoding, similar to JavaScript `encodeURIComponent`) +- ✅ **Length errors without throwing**: Invalid length after sanitization is handled via `on_fail` (default returns an empty string) +- ✅ **Minimal dependencies**: Only [`lacus-utils`](https://rubygems.org/gems/lacus-utils) +- ✅ **Error handling**: API misuse vs domain errors with a `CpfFmt::Error` marker for library-wide rescue + +## Installation + +Install the gem directly: + +```bash +gem install cpf-fmt +``` + +Or add it to your `Gemfile` and run `bundle install`: + +```ruby +gem 'cpf-fmt' +``` + +## Require + +```ruby +require 'cpf-fmt' +``` + +## Quick Start + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new + +formatter.format('03603568195') # => "036.035.681-95" +formatter.format('123.456.789-10') # => "123.456.789-10" +formatter.format('12345678910') # => "123.456.789-10" +``` + +Basic helper usage: + +```ruby +require 'cpf-fmt' + +cpf = '03603568195' + +CpfFmt.cpf_fmt(cpf) # => "036.035.681-95" +CpfFmt.cpf_fmt(cpf, hidden: true) # => "036.***.***-**" +CpfFmt.cpf_fmt( # => "036035681_95" + cpf, + dot_key: '', + dash_key: '_' +) +``` + +## Usage + +The main entry points are the class `CpfFmt::CpfFormatter`, the options class `CpfFmt::CpfFormatterOptions`, and the helper `CpfFmt.cpf_fmt`. + +### `CpfFmt::CpfFormatter` + +- **`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: '_')`. +- **`options`**: Returns the instance’s `CpfFmt::CpfFormatterOptions` (same object used internally). +- **`format(cpf_input, options = nil, **keywords)`**: Formats a CPF value. + + 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). + + If the input is not a `String` or an `Array` of strings, **`CpfFmt::TypeMismatchError`** is raised. + + 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`. + +### `CpfFmt::CpfFormatterOptions` + +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`. + +- **`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). +- **`all`**: Returns a shallow `Hash` copy of all current options. +- **`copy`**: Returns a shallow copy of this options instance. +- **`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. +- **`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`). + +**`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). + +**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). + +### 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`): + +```ruby +require 'cpf-fmt' + +cpf = '03603568195' + +CpfFmt.cpf_fmt(cpf) # => "036.035.681-95" +CpfFmt.cpf_fmt(cpf, hidden: true) # masked with defaults +CpfFmt.cpf_fmt( # => "036035681_95" + cpf, + dot_key: '', + dash_key: '_' +) +CpfFmt.cpf_fmt(cpf, { # Hash form + hidden: true, + hidden_key: '#' +}) +``` + +### Object-oriented examples + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new +cpf = '12345678910' + +formatter.format(cpf) # => "123.456.789-10" +formatter.format( # => "123.###.###-##" + cpf, + hidden: true, + hidden_key: '#', + hidden_start: 3, + hidden_end: 10 +) +``` + +Default options on the instance; per-call overrides: + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new(hidden: true) +cpf = '12345678910' + +formatter.format(cpf) # uses instance masking +formatter.format(cpf, hidden: false) # this call only: unmasked +formatter.format(cpf) # back to instance defaults +``` + +Array input: + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new + +formatter.format([ # => "123.456.789-10" + '123', + '456', + '789', + '10' +]) +``` + +### Input formats + +**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. + +**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. + +### Formatting options + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `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 | +| `hidden_key` | `String`, `nil` | `'*'` | Replacement for each hidden position (may be multi-character or empty); must not use disallowed key characters | +| `hidden_start` | `Integer`, `nil` | `3` | Start index `0`–`10` (inclusive) | +| `hidden_end` | `Integer`, `nil` | `10` | End index `0`–`10` (inclusive); if `hidden_start > hidden_end`, they are swapped | +| `dot_key` | `String`, `nil` | `'.'` | Separator after the 3rd and 6th digits | +| `dash_key` | `String`, `nil` | `'-'` | Separator after the 9th digit | +| `escape` | `Boolean`, `nil` | `false` | When truthy, HTML-escapes the final string | +| `encode` | `Boolean`, `nil` | `false` | When truthy, URL-encodes the final string (similar to `encodeURIComponent`) | +| `on_fail` | `Proc`, `nil` | see below | `(value, error) -> String` — used when sanitized length ≠ 11 | + +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. + +Example with all options: + +```ruby +require 'cpf-fmt' + +cpf = '12345678910' + +CpfFmt.cpf_fmt( + cpf, + hidden: true, + hidden_key: '#', + hidden_start: 3, + hidden_end: 9, + dot_key: ' ', + dash_key: '_-_', + escape: true, + encode: true, + on_fail: ->(value, _error) { value.to_s } +) +``` + +### Error handling + +Errors fall into two categories: + +| Category | Meaning | +|---|---| +| **API misuse** | The caller invoked the library incorrectly (wrong type for input or options, or an invalid argument combination). | +| **Domain error** | The call was structurally correct, but a value violates a business rule (length, range, forbidden characters). | + +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`. + +#### Summary + +| Class | Inherits from | Category | Trigger condition | +|---|---|---|---| +| `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CPF input or option has the wrong data type | +| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments are passed at once | +| `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` | +| `CpfFmt::ValidationError` | `CpfFmt::DomainError` | Domain error | Key option contains a disallowed character | + +#### `CpfFmt::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 or constructs for `on_fail`. +- **Example:** N/A +- **How to rescue it:** + +```ruby +rescue CpfFmt::Error + # everything this library raises +``` + +#### `CpfFmt::DomainError` + +- **Inheritance:** `CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`) +- **Category:** Domain error — ancestor for numeric/length domain failures. +- **When it is raised:** Not raised directly; prefer raising a leaf subclass. +- **Example:** Prefer `raise CpfFmt::OutOfRangeError` / construct `InvalidLengthError` over raising `DomainError` directly. +- **How to rescue it:** + +```ruby +rescue CpfFmt::DomainError + # OutOfRangeError, InvalidLengthError, ValidationError, and other DomainError subclasses +``` + +#### `CpfFmt::TypeMismatchError` + +- **Inheritance:** `CpfFmt::TypeMismatchError < TypeError` (includes `CpfFmt::Error`) +- **Category:** API misuse — the caller passed a value of the wrong type. +- **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`. +- **Example:** + +```ruby +CpfFmt::CpfFormatter.new.format(12_345) # raises CpfFmt::TypeMismatchError +``` + +- **How to rescue it:** + +```ruby +rescue CpfFmt::TypeMismatchError + # this library's type-contract violation + +rescue TypeError + # native type errors, including this library's TypeMismatchError +``` + +#### `CpfFmt::InvalidLengthError` + +- **Inheritance:** `CpfFmt::InvalidLengthError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`) +- **Category:** Domain error — a collection or string length violates a business rule. +- **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. +- **Example:** + +```ruby +CpfFmt::CpfFormatter.new.format( + 'short', + on_fail: ->(_value, error) { + error # => # (a DomainError) + 'invalid' + } +) # => "invalid" + +``` + +- **How to rescue it:** Handle inside `on_fail` (typical), or rescue if you re-raise: + +```ruby +rescue CpfFmt::InvalidLengthError + # this exact length violation + +rescue CpfFmt::DomainError + # RangeError-rooted domain failures from this library +``` + +#### `CpfFmt::InvalidArgumentCombinationError` + +- **Inheritance:** `CpfFmt::InvalidArgumentCombinationError < ArgumentError` (includes `CpfFmt::Error`) +- **Category:** API misuse — the caller mixed mutually exclusive argument patterns. +- **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. +- **Example:** + +```ruby +begin + CpfFmt::CpfFormatter.new({ dash_key: '_' }, hidden: true) +rescue CpfFmt::InvalidArgumentCombinationError => e + puts e.message + # Pass either an options instance/Hash to `options`, or keyword arguments (hidden:, ...), not both. +end +``` + +- **How to rescue it:** + +```ruby +rescue CpfFmt::InvalidArgumentCombinationError + # this library's invalid argument combination + +rescue ArgumentError + # native argument errors, including this library's InvalidArgumentCombinationError +``` + +#### `CpfFmt::OutOfRangeError` + +- **Inheritance:** `CpfFmt::OutOfRangeError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`) +- **Category:** Domain error — a numeric value violates a business range rule. +- **When it is raised:** Raised when `hidden_start` or `hidden_end` is outside the inclusive range `0`–`10`. +- **Example:** + +```ruby +CpfFmt::CpfFormatterOptions.new(hidden_start: 11) # raises CpfFmt::OutOfRangeError +``` + +- **How to rescue it:** + +```ruby +rescue CpfFmt::OutOfRangeError + # this exact range violation + +rescue CpfFmt::DomainError + # RangeError-rooted domain failures from this library +``` + +#### `CpfFmt::ValidationError` + +- **Inheritance:** `CpfFmt::ValidationError < CpfFmt::DomainError < RangeError` (includes `CpfFmt::Error`) +- **Category:** Domain error — a value fails a non-numeric, non-length domain rule. +- **When it is raised:** Raised when a key option (`hidden_key`, `dot_key`, `dash_key`) contains a disallowed character. +- **Example:** + +```ruby +CpfFmt::CpfFormatterOptions.new(dot_key: 'å') # raises CpfFmt::ValidationError +``` + +- **How to rescue it:** + +```ruby +rescue CpfFmt::ValidationError + # this exact domain validation failure + +rescue CpfFmt::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 + # CpfFmt::TypeMismatchError and any other TypeError (library or not) + +# 2) CpfFmt::DomainError — catches business-rule violations under DomainError. +rescue CpfFmt::DomainError + # CpfFmt::OutOfRangeError, CpfFmt::InvalidLengthError, CpfFmt::ValidationError, + # and other DomainError subclasses + +# 3) CpfFmt::Error — catches everything the library raises. +rescue CpfFmt::Error + # every custom error that includes CpfFmt::Error + +# 4) Specific leaf class — catches only that exact failure mode. +rescue CpfFmt::OutOfRangeError + # only CpfFmt::OutOfRangeError +``` + +Notable attributes: + +- `TypeMismatchError`: `actual_input`, `actual_type`, `expected_type`, `option_name` (nil for CPF input) +- `InvalidLengthError`: `actual_input`, `evaluated_input`, `expected_length` +- `OutOfRangeError`: `option_name`, `actual_input`, `min_expected_value`, `max_expected_value` +- `ValidationError`: `option_name`, `actual_input`, `forbidden_characters` + +## API + +### Exports + +After `require 'cpf-fmt'`: + +- **`CpfFmt.cpf_fmt`**: `(cpf_input, options = nil, **keywords) -> String` — convenience helper. +- **`CpfFmt::CpfFormatter`**: Class to format CPF with optional default options; accepts `String` or `Array` in `format`. +- **`CpfFmt::CpfFormatterOptions`**: Class holding options; supports merge via constructor, `set`, and keyword arguments. +- **`CpfFmt::CPF_LENGTH`**: `11` (constant). +- **`CpfFmt::VERSION`**: gem version string. +- **Errors**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. + +### Other available resources + +- **`CpfFmt::CpfFormatterOptions::CPF_LENGTH`**: `11`. +- **`CpfFmt::CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Characters forbidden in `hidden_key`, `dot_key`, `dash_key`. +- **`CpfFmt::CpfFormatterOptions::DEFAULT_*`**: Default values for each option. +- **`CpfFmt::CpfFormatterOptions.default_on_fail`**: Shared default failure callback. + +## 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) From 0d25e891d1661f01529b9caaf84ea86d0d07cf1e Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 13:25:10 -0300 Subject: [PATCH 06/10] docs(cpf-fmt): create Portuguese version of README file Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/README.pt.md | 439 ++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 packages/cpf-fmt/README.pt.md diff --git a/packages/cpf-fmt/README.pt.md b/packages/cpf-fmt/README.pt.md new file mode 100644 index 0000000..96020d2 --- /dev/null +++ b/packages/cpf-fmt/README.pt.md @@ -0,0 +1,439 @@ +![cpf-fmt para Ruby](https://br-utils.vercel.app/img/cover_cpf-fmt.jpg) + +> 🌎 [Access documentation in English](./README.md) + +Utilitário em Ruby para formatar CPF (Cadastro de Pessoas Físicas) como valor numérico de 11 dígitos, com opções de máscara, escape HTML e codificação para URL. + +## Recursos + +- ✅ **Entrada flexível**: Aceita `String` ou `Array` de strings; elementos do array são concatenados na ordem +- ✅ **Agnóstico ao formato**: Remove caracteres não numéricos antes de formatar (letras e pontuação são descartados) +- ✅ **Delimitadores personalizáveis**: `dot_key` e `dash_key` podem ser vazios ou strings de um ou vários caracteres +- ✅ **Mascaramento**: Ocultação opcional de um intervalo de índices com string de substituição configurável (`hidden`, `hidden_key`, `hidden_start`, `hidden_end`) +- ✅ **Saída HTML e URL**: `escape` opcional (entidades HTML) e `encode` opcional (codificação tipo componente de URI, semelhante ao `encodeURIComponent` do JavaScript) +- ✅ **Erro de tamanho sem exceção**: Comprimento inválido após sanitização é tratado via `on_fail` (o padrão retorna string vazia) +- ✅ **Dependências mínimas**: Apenas [`lacus-utils`](https://rubygems.org/gems/lacus-utils) +- ✅ **Tratamento de erros**: Erros de uso da API vs erros de domínio, com o marcador `CpfFmt::Error` para resgate em nível de biblioteca + +## Instalação + +Instale a gem diretamente: + +```bash +gem install cpf-fmt +``` + +Ou adicione ao seu `Gemfile` e execute `bundle install`: + +```ruby +gem 'cpf-fmt' +``` + +## Require + +```ruby +require 'cpf-fmt' +``` + +## Início rápido + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new + +formatter.format('03603568195') # => "036.035.681-95" +formatter.format('123.456.789-10') # => "123.456.789-10" +formatter.format('12345678910') # => "123.456.789-10" +``` + +Uso básico com o helper: + +```ruby +require 'cpf-fmt' + +cpf = '03603568195' + +CpfFmt.cpf_fmt(cpf) # => "036.035.681-95" +CpfFmt.cpf_fmt(cpf, hidden: true) # => "036.***.***-**" +CpfFmt.cpf_fmt( # => "036035681_95" + cpf, + dot_key: '', + dash_key: '_' +) +``` + +## Utilização + +Os pontos principais são a classe `CpfFmt::CpfFormatter`, a classe de opções `CpfFmt::CpfFormatterOptions` e o helper `CpfFmt.cpf_fmt`. + +### `CpfFmt::CpfFormatter` + +- **`initialize(options = nil, **keywords)`**: Opções padrão de formatação. Quando `options` é informado (uma instância de `CpfFmt::CpfFormatterOptions` ou um `Hash`) sozinho, ele determina as opções padrão; uma instância de `CpfFmt::CpfFormatterOptions` é armazenada por referência (alterações posteriores afetam chamadas futuras a `format` 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 montadas exclusivamente a partir dos argumentos nomeados (`hidden:`, `hidden_key:`, `dot_key:`, …). Passar `options` junto com qualquer argumento nomeado não-`nil` lança `InvalidArgumentCombinationError` em vez de ignorar os keywords silenciosamente. Exemplo: `CpfFmt::CpfFormatter.new(hidden: true, dash_key: '_')`. +- **`options`**: Retorna o `CpfFmt::CpfFormatterOptions` da instância (o mesmo objeto usado internamente). +- **`format(cpf_input, options = nil, **keywords)`**: Formata um valor CPF. + + A entrada é normalizada removendo caracteres não numéricos. Se o comprimento após sanitização não for exatamente **11**, o callback **`on_fail`** é chamado com a entrada original e um `CpfFmt::DomainError` (`InvalidLengthError`); o valor de retorno do callback é o resultado (nada é lançado por comprimento). + + Se a entrada não for `String` nem `Array` de strings, é lançado **`CpfFmt::TypeMismatchError`**. + + `options` por chamada e argumentos nomeados nunca são mesclados: um argumento `options` informado sozinho sobrescreve totalmente os padrões da instância nesta chamada; caso contrário, qualquer keyword informado sobrescreve os padrões da instância nesta chamada. Quando nenhum dos dois é informado, os padrões da instância são usados como estão. Os padrões da instância nunca são mutados por uma sobrescrita por chamada. Passar `options` junto com qualquer keyword não-`nil` lança `InvalidArgumentCombinationError`. + +### `CpfFmt::CpfFormatterOptions` + +Armazena todas as configurações do formatador, com validação e suporte a mesclagem. Expõe propriedades: `hidden`, `hidden_key`, `hidden_start`, `hidden_end`, `dot_key`, `dash_key`, `escape`, `encode`, `on_fail`. + +- **`initialize(options = nil, *extra_overrides, **keywords)`**: Opções padrão opcionais (`Hash` simples, instância de `CpfFmt::CpfFormatterOptions` ou argumentos nomeados), além de objetos extras de sobrescrita mesclados em ordem (as últimas sobrescritas prevalecem). +- **`all`**: Retorna uma cópia superficial em `Hash` de todas as opções atuais. +- **`copy`**: Retorna uma cópia superficial desta instância de opções. +- **`set(options)`**: Atualiza vários campos de uma vez; retorna `self`. Aceita um `Hash` ou outra instância de `CpfFmt::CpfFormatterOptions`. Valores `nil` explícitos na atualização mantêm o valor atual. +- **`set_hidden_range(hidden_start, hidden_end)`**: Valida índices em **`[0, 10]`** (inclusivos); se `hidden_start > hidden_end`, os valores são trocados. Argumentos `nil` usam os padrões (`DEFAULT_HIDDEN_START` / `DEFAULT_HIDDEN_END`). + +**`hidden_start` / `hidden_end`**: Os índices referem-se à **string CPF normalizada de 11 dígitos** (antes de inserir pontuação). O intervalo inclusivo é substituído internamente por placeholders e depois por `hidden_key` (permite chaves com vários caracteres ou string vazia). + +**Opções de chave** (`hidden_key`, `dot_key`, `dash_key`): Devem ser strings e não podem conter caracteres em `CpfFmt::CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS` (reservados para a lógica interna). + +### 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`): + +```ruby +require 'cpf-fmt' + +cpf = '03603568195' + +CpfFmt.cpf_fmt(cpf) # => "036.035.681-95" +CpfFmt.cpf_fmt(cpf, hidden: true) # mascarado com padrões +CpfFmt.cpf_fmt( # => "036035681_95" + cpf, + dot_key: '', + dash_key: '_' +) +CpfFmt.cpf_fmt(cpf, { # forma com Hash + hidden: true, + hidden_key: '#' +}) +``` + +### Exemplos orientados a objeto + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new +cpf = '12345678910' + +formatter.format(cpf) # => "123.456.789-10" +formatter.format( # => "123.###.###-##" + cpf, + hidden: true, + hidden_key: '#', + hidden_start: 3, + hidden_end: 10 +) +``` + +Padrões na instância; sobrescritas por chamada: + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new(hidden: true) +cpf = '12345678910' + +formatter.format(cpf) # usa mascaramento da instância +formatter.format(cpf, hidden: false) # só nesta chamada: sem máscara +formatter.format(cpf) # volta aos padrões da instância +``` + +Entrada em array: + +```ruby +require 'cpf-fmt' + +formatter = CpfFmt::CpfFormatter.new + +formatter.format([ # => "123.456.789-10" + '123', + '456', + '789', + '10' +]) +``` + +### Formatos de entrada + +**String:** Dígitos brutos ou CPF já formatado (ex.: `123.456.789-10`, `123 456 789 10`). Caracteres não numéricos são removidos; zeros à esquerda são preservados. + +**Array de strings:** Cada elemento deve ser `String`; os valores são concatenados (ex.: por dígito, segmentos agrupados ou misturados com pontuação — tudo que não for dígito é removido na normalização). Elementos que não sejam string não são permitidos. + +### Opções de formatação + +| Parâmetro | Tipo | Padrão | Descrição | +|-----------|------|---------|-------------| +| `hidden` | `Boolean`, `nil` | `false` | Se truthy, substitui o intervalo inclusivo `[hidden_start, hidden_end]` na string normalizada de 11 dígitos antes de aplicar pontuação | +| `hidden_key` | `String`, `nil` | `'*'` | Substituição de cada posição oculta (pode ter vários caracteres ou ser vazia); não pode usar caracteres proibidos nas chaves | +| `hidden_start` | `Integer`, `nil` | `3` | Índice inicial `0`–`10` (inclusivo) | +| `hidden_end` | `Integer`, `nil` | `10` | Índice final `0`–`10` (inclusivo); se `hidden_start > hidden_end`, são trocados | +| `dot_key` | `String`, `nil` | `'.'` | Separador após o 3º e o 6º dígitos | +| `dash_key` | `String`, `nil` | `'-'` | Separador após o 9º dígito | +| `escape` | `Boolean`, `nil` | `false` | Se truthy, escapa HTML na string final | +| `encode` | `Boolean`, `nil` | `false` | Se truthy, codifica a string final para URL (semelhante a `encodeURIComponent`) | +| `on_fail` | `Proc`, `nil` | veja abaixo | `(value, error) -> String` — usado quando o comprimento sanitizado ≠ 11 | + +O **`on_fail`** padrão retorna string vazia. Assinatura: `(original_input, error) -> String`, onde `error` é um **`CpfFmt::DomainError`** (atualmente um `InvalidLengthError` com `actual_input`, `evaluated_input`, `expected_length`). O valor de retorno do callback deve ser `String`; caso contrário, é lançado **`CpfFmt::TypeMismatchError`**. + +Exemplo com todas as opções: + +```ruby +require 'cpf-fmt' + +cpf = '12345678910' + +CpfFmt.cpf_fmt( + cpf, + hidden: true, + hidden_key: '#', + hidden_start: 3, + hidden_end: 9, + dot_key: ' ', + dash_key: '_-_', + escape: true, + encode: true, + on_fail: ->(value, _error) { value.to_s } +) +``` + +### 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 entrada ou opções, 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 (tamanho, intervalo, caracteres proibidos). | + +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`. + +#### Resumo + +| Classe | Herda de | Categoria | Condição de disparo | +|---|---|---|---| +| `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada de CPF ou opção com tipo de dado incorreto | +| `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados passados ao mesmo tempo | +| `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` | +| `CpfFmt::ValidationError` | `CpfFmt::DomainError` | Erro de domínio | Opção de chave contém caractere proibido | + +#### `CpfFmt::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 ou constrói para o `on_fail`. +- **Exemplo:** N/A +- **Como resgatar:** + +```ruby +rescue CpfFmt::Error + # tudo o que esta biblioteca levanta +``` + +#### `CpfFmt::DomainError` + +- **Herança:** `CpfFmt::DomainError < RangeError` (inclui `CpfFmt::Error`) +- **Categoria:** Erro de domínio — ancestral das falhas numéricas/de tamanho. +- **Quando é levantado:** Não é levantado diretamente; prefira uma subclasse folha. +- **Exemplo:** Prefira `raise CpfFmt::OutOfRangeError` / construir `InvalidLengthError` a levantar `DomainError` diretamente. +- **Como resgatar:** + +```ruby +rescue CpfFmt::DomainError + # OutOfRangeError, InvalidLengthError, ValidationError e outras subclasses de DomainError +``` + +#### `CpfFmt::TypeMismatchError` + +- **Herança:** `CpfFmt::TypeMismatchError < TypeError` (inclui `CpfFmt::Error`) +- **Categoria:** Uso incorreto da API — o chamador passou um valor do tipo errado. +- **Quando é levantado:** Levantado quando a entrada de CPF não é `String` nem `Array` de strings, quando uma opção tem tipo errado, ou quando `on_fail` não retorna `String`. +- **Exemplo:** + +```ruby +CpfFmt::CpfFormatter.new.format(12_345) # levanta CpfFmt::TypeMismatchError +``` + +- **Como resgatar:** + +```ruby +rescue CpfFmt::TypeMismatchError + # violação de contrato de tipo desta biblioteca + +rescue TypeError + # erros nativos de tipo, incluindo TypeMismatchError desta biblioteca +``` + +#### `CpfFmt::InvalidLengthError` + +- **Herança:** `CpfFmt::InvalidLengthError < CpfFmt::DomainError < RangeError` (inclui `CpfFmt::Error`) +- **Categoria:** Erro de domínio — o tamanho de uma coleção ou string viola uma regra de negócio. +- **Quando é levantado:** Não é levantado por `format`; é construído e passado como segundo argumento `DomainError` ao `on_fail` quando o CPF sanitizado não contém exatamente 11 dígitos. +- **Exemplo:** + +```ruby +CpfFmt::CpfFormatter.new.format( + 'short', + on_fail: ->(_value, error) { + error # => # (um DomainError) + 'invalid' + } +) # => "invalid" +``` + +- **Como resgatar:** Trate dentro do `on_fail` (caso típico), ou resgate se você o reerguer: + +```ruby +rescue CpfFmt::InvalidLengthError + # esta violação exata de tamanho + +rescue CpfFmt::DomainError + # falhas de domínio enraizadas em RangeError desta biblioteca +``` + +#### `CpfFmt::InvalidArgumentCombinationError` + +- **Herança:** `CpfFmt::InvalidArgumentCombinationError < ArgumentError` (inclui `CpfFmt::Error`) +- **Categoria:** Uso incorreto da API — o chamador misturou padrões de argumentos mutuamente exclusivos. +- **Quando é levantado:** Levantado quando `CpfFormatter.new`, `#format` ou `cpf_fmt` recebe ao mesmo tempo um argumento `options` (instância ou `Hash`) e qualquer argumento nomeado não-`nil`. +- **Exemplo:** + +```ruby +begin + CpfFmt::CpfFormatter.new({ dash_key: '_' }, hidden: true) +rescue CpfFmt::InvalidArgumentCombinationError => e + puts e.message + # Pass either an options instance/Hash to `options`, or keyword arguments (hidden:, ...), not both. +end +``` + +- **Como resgatar:** + +```ruby +rescue CpfFmt::InvalidArgumentCombinationError + # combinação inválida de argumentos desta biblioteca + +rescue ArgumentError + # erros nativos de argumento, incluindo InvalidArgumentCombinationError desta biblioteca +``` + +#### `CpfFmt::OutOfRangeError` + +- **Herança:** `CpfFmt::OutOfRangeError < CpfFmt::DomainError < RangeError` (inclui `CpfFmt::Error`) +- **Categoria:** Erro de domínio — um valor numérico viola uma regra de intervalo. +- **Quando é levantado:** Levantado quando `hidden_start` ou `hidden_end` está fora do intervalo inclusivo `0`–`10`. +- **Exemplo:** + +```ruby +CpfFmt::CpfFormatterOptions.new(hidden_start: 11) # levanta CpfFmt::OutOfRangeError +``` + +- **Como resgatar:** + +```ruby +rescue CpfFmt::OutOfRangeError + # esta violação exata de intervalo + +rescue CpfFmt::DomainError + # falhas de domínio enraizadas em RangeError desta biblioteca +``` + +#### `CpfFmt::ValidationError` + +- **Herança:** `CpfFmt::ValidationError < CpfFmt::DomainError < RangeError` (inclui `CpfFmt::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 uma opção de chave (`hidden_key`, `dot_key`, `dash_key`) contém um caractere proibido. +- **Exemplo:** + +```ruby +CpfFmt::CpfFormatterOptions.new(dot_key: 'å') # levanta CpfFmt::ValidationError +``` + +- **Como resgatar:** + +```ruby +rescue CpfFmt::ValidationError + # esta falha exata de validação de domínio + +rescue CpfFmt::DomainError + # falhas de domínio enraizadas em RangeError desta biblioteca +``` + +#### Granularidade de rescue + +```ruby +# 1) Uma classe nativa — captura uso incorreto de tipo desta biblioteca (e outros TypeError). +rescue TypeError + # CpfFmt::TypeMismatchError e qualquer outro TypeError (da biblioteca ou não) + +# 2) CpfFmt::DomainError — captura violações de regra de negócio sob DomainError. +rescue CpfFmt::DomainError + # CpfFmt::OutOfRangeError, CpfFmt::InvalidLengthError, CpfFmt::ValidationError + # e outras subclasses de DomainError + +# 3) CpfFmt::Error — captura tudo o que a biblioteca levanta. +rescue CpfFmt::Error + # todo erro customizado que inclui CpfFmt::Error + +# 4) Classe folha específica — captura apenas aquele modo de falha. +rescue CpfFmt::OutOfRangeError + # apenas CpfFmt::OutOfRangeError +``` + +Atributos relevantes: + +- `TypeMismatchError`: `actual_input`, `actual_type`, `expected_type`, `option_name` (nil para entrada de CPF) +- `InvalidLengthError`: `actual_input`, `evaluated_input`, `expected_length` +- `OutOfRangeError`: `option_name`, `actual_input`, `min_expected_value`, `max_expected_value` +- `ValidationError`: `option_name`, `actual_input`, `forbidden_characters` + +## API + +### Exportações + +Após `require 'cpf-fmt'`: + +- **`CpfFmt.cpf_fmt`**: `(cpf_input, options = nil, **keywords) -> String` — helper de conveniência. +- **`CpfFmt::CpfFormatter`**: Classe para formatar CPF com opções padrão opcionais; aceita `String` ou `Array` em `format`. +- **`CpfFmt::CpfFormatterOptions`**: Classe que armazena opções; suporta mesclagem via construtor, `set` e argumentos nomeados. +- **`CpfFmt::CPF_LENGTH`**: `11` (constante). +- **`CpfFmt::VERSION`**: string de versão da gem. +- **Erros**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. + +### Outros recursos disponíveis + +- **`CpfFmt::CpfFormatterOptions::CPF_LENGTH`**: `11`. +- **`CpfFmt::CpfFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Caracteres proibidos em `hidden_key`, `dot_key`, `dash_key`. +- **`CpfFmt::CpfFormatterOptions::DEFAULT_*`**: Valores padrão de cada opção. +- **`CpfFmt::CpfFormatterOptions.default_on_fail`**: Callback padrão compartilhado para falhas. + +## 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 ao 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 + +Consulte o [CHANGELOG](./CHANGELOG.md) para histórico de versões e alterações. + +--- + +Feito com ❤️ por [Lacus Solutions](https://github.com/LacusSolutions) From 90b9b2eca4917881f611845ecb2e2111cb274d2a Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 15:56:18 -0300 Subject: [PATCH 07/10] docs(cpf-fmt): sort list of errors alphabetically Adjustment as per @coderabbitai review comment at https://github.com/LacusSolutions/br-utils-ruby/pull/22#discussion_r3624510967. Co-authored-by: CodeRabbit AI <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cpf-fmt/README.md | 4 ++-- packages/cpf-fmt/README.pt.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cpf-fmt/README.md b/packages/cpf-fmt/README.md index d7c00d0..60ec2de 100644 --- a/packages/cpf-fmt/README.md +++ b/packages/cpf-fmt/README.md @@ -234,8 +234,8 @@ Every custom error includes the `CpfFmt::Error` marker module. Domain failures ( | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CPF input or option has the wrong data type | | `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments 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` | | `CpfFmt::ValidationError` | `CpfFmt::DomainError` | Domain error | Key option contains a disallowed character | @@ -422,7 +422,7 @@ After `require 'cpf-fmt'`: - **`CpfFmt::CpfFormatterOptions`**: Class holding options; supports merge via constructor, `set`, and keyword arguments. - **`CpfFmt::CPF_LENGTH`**: `11` (constant). - **`CpfFmt::VERSION`**: gem version string. -- **Errors**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. +- **Errors**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. ### Other available resources diff --git a/packages/cpf-fmt/README.pt.md b/packages/cpf-fmt/README.pt.md index 96020d2..f60014c 100644 --- a/packages/cpf-fmt/README.pt.md +++ b/packages/cpf-fmt/README.pt.md @@ -221,8 +221,8 @@ Todo erro customizado inclui o módulo marcador `CpfFmt::Error`. Falhas de domí | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CpfFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada de CPF ou opção com tipo de dado incorreto | | `CpfFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados 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` | | `CpfFmt::ValidationError` | `CpfFmt::DomainError` | Erro de domínio | Opção de chave contém caractere proibido | @@ -408,7 +408,7 @@ Após `require 'cpf-fmt'`: - **`CpfFmt::CpfFormatterOptions`**: Classe que armazena opções; suporta mesclagem via construtor, `set` e argumentos nomeados. - **`CpfFmt::CPF_LENGTH`**: `11` (constante). - **`CpfFmt::VERSION`**: string de versão da gem. -- **Erros**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. +- **Erros**: `CpfFmt::Error`, `CpfFmt::DomainError`, `CpfFmt::InvalidArgumentCombinationError`, `CpfFmt::TypeMismatchError`, `CpfFmt::InvalidLengthError`, `CpfFmt::OutOfRangeError`, `CpfFmt::ValidationError`. ### Outros recursos disponíveis From e31f051550b8062ee1581fe2b0536ce15543a020 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 15:56:18 -0300 Subject: [PATCH 08/10] docs(cnpj-fmt): sort list of errors alphabetically Adjustment as per @coderabbitai review comment at https://github.com/LacusSolutions/br-utils-ruby/pull/22#discussion_r3624510967. Co-authored-by: CodeRabbit AI <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cnpj-fmt/README.md | 4 ++-- packages/cnpj-fmt/README.pt.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cnpj-fmt/README.md b/packages/cnpj-fmt/README.md index 79beea4..400a6dc 100644 --- a/packages/cnpj-fmt/README.md +++ b/packages/cnpj-fmt/README.md @@ -243,8 +243,8 @@ Every custom error includes the `CnpjFmt::Error` marker module. Domain failures | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CnpjFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CNPJ input or option has the wrong data type | | `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments 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` | | `CnpjFmt::ValidationError` | `CnpjFmt::DomainError` | Domain error | Key option contains a disallowed character | @@ -431,7 +431,7 @@ After `require 'cnpj-fmt'`: - **`CnpjFmt::CnpjFormatterOptions`**: Class holding options; supports merge via constructor, `set`, and keyword arguments. - **`CnpjFmt::CNPJ_LENGTH`**: `14` (constant). - **`CnpjFmt::VERSION`**: gem version string. -- **Errors**: `CnpjFmt::Error`, `CnpjFmt::DomainError`, `CnpjFmt::TypeMismatchError`, `CnpjFmt::InvalidArgumentCombinationError`, `CnpjFmt::InvalidLengthError`, `CnpjFmt::OutOfRangeError`, `CnpjFmt::ValidationError`. +- **Errors**: `CnpjFmt::Error`, `CnpjFmt::DomainError`, `CnpjFmt::InvalidArgumentCombinationError`, `CnpjFmt::TypeMismatchError`, `CnpjFmt::InvalidLengthError`, `CnpjFmt::OutOfRangeError`, `CnpjFmt::ValidationError`. ### Other available resources diff --git a/packages/cnpj-fmt/README.pt.md b/packages/cnpj-fmt/README.pt.md index a814966..c28e0b0 100644 --- a/packages/cnpj-fmt/README.pt.md +++ b/packages/cnpj-fmt/README.pt.md @@ -230,8 +230,8 @@ Todo erro customizado inclui o módulo marcador `CnpjFmt::Error`. Falhas de dom | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CnpjFmt::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada de CNPJ ou opção com tipo de dado incorreto | | `CnpjFmt::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados 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` | | `CnpjFmt::ValidationError` | `CnpjFmt::DomainError` | Erro de domínio | Opção de chave contém caractere proibido | @@ -416,7 +416,7 @@ Após `require 'cnpj-fmt'`: - **`CnpjFmt::CnpjFormatterOptions`**: Classe que armazena opções; suporta mesclagem via construtor, `set` e argumentos nomeados. - **`CnpjFmt::CNPJ_LENGTH`**: `14` (constante). - **`CnpjFmt::VERSION`**: string de versão da gem. -- **Erros**: `CnpjFmt::Error`, `CnpjFmt::DomainError`, `CnpjFmt::TypeMismatchError`, `CnpjFmt::InvalidArgumentCombinationError`, `CnpjFmt::InvalidLengthError`, `CnpjFmt::OutOfRangeError`, `CnpjFmt::ValidationError`. +- **Erros**: `CnpjFmt::Error`, `CnpjFmt::DomainError`, `CnpjFmt::InvalidArgumentCombinationError`, `CnpjFmt::TypeMismatchError`, `CnpjFmt::InvalidLengthError`, `CnpjFmt::OutOfRangeError`, `CnpjFmt::ValidationError`. ### Outros recursos disponíveis From 811178103c6b660b2f728ba56949336ae5d832f0 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 15:56:19 -0300 Subject: [PATCH 09/10] docs(cnpj-gen): sort list of errors alphabetically Adjustment as per @coderabbitai review comment at https://github.com/LacusSolutions/br-utils-ruby/pull/22#discussion_r3624510967. Co-authored-by: CodeRabbit AI <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cnpj-gen/README.md | 2 +- packages/cnpj-gen/README.pt.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cnpj-gen/README.md b/packages/cnpj-gen/README.md index f843368..8a8bf27 100644 --- a/packages/cnpj-gen/README.md +++ b/packages/cnpj-gen/README.md @@ -172,7 +172,7 @@ After `require 'cnpj-gen'`: - **`CnpjGen::CNPJ_PREFIX_MAX_LENGTH`**: `12` (constant). - **`CnpjGen::CNPJ_TYPE_VALUES`**: `%w[alphabetic alphanumeric numeric]` — allowed `type` values. - **`CnpjGen::VERSION`**: gem version string. -- **Errors**: `CnpjGen::Error`, `CnpjGen::DomainError`, `CnpjGen::TypeMismatchError`, `CnpjGen::InvalidArgumentCombinationError`, `CnpjGen::ValidationError`. +- **Errors**: `CnpjGen::Error`, `CnpjGen::DomainError`, `CnpjGen::InvalidArgumentCombinationError`, `CnpjGen::TypeMismatchError`, `CnpjGen::ValidationError`. ### Error handling diff --git a/packages/cnpj-gen/README.pt.md b/packages/cnpj-gen/README.pt.md index fe55e14..53f59a5 100644 --- a/packages/cnpj-gen/README.pt.md +++ b/packages/cnpj-gen/README.pt.md @@ -157,7 +157,7 @@ Após `require 'cnpj-gen'`: - **`CnpjGen::CNPJ_PREFIX_MAX_LENGTH`**: `12` (constante). - **`CnpjGen::CNPJ_TYPE_VALUES`**: `%w[alphabetic alphanumeric numeric]` — valores permitidos para `type`. - **`CnpjGen::VERSION`**: string da versão da gem. -- **Erros**: `CnpjGen::Error`, `CnpjGen::DomainError`, `CnpjGen::TypeMismatchError`, `CnpjGen::InvalidArgumentCombinationError`, `CnpjGen::ValidationError`. +- **Erros**: `CnpjGen::Error`, `CnpjGen::DomainError`, `CnpjGen::InvalidArgumentCombinationError`, `CnpjGen::TypeMismatchError`, `CnpjGen::ValidationError`. ### Tratamento de erros From f02cbda253d9c00e7905b6b50322fdeacebe4614 Mon Sep 17 00:00:00 2001 From: juliolmuller Date: Tue, 21 Jul 2026 15:56:19 -0300 Subject: [PATCH 10/10] docs(cnpj-val): sort list of errors alphabetically Adjustment as per @coderabbitai review comment at https://github.com/LacusSolutions/br-utils-ruby/pull/22#discussion_r3624510967. Co-authored-by: CodeRabbit AI <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Cursor Grok 4.5 Co-authored-by: Cursor Agent --- packages/cnpj-val/README.md | 4 ++-- packages/cnpj-val/README.pt.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cnpj-val/README.md b/packages/cnpj-val/README.md index 8f4026a..8cf17a0 100644 --- a/packages/cnpj-val/README.md +++ b/packages/cnpj-val/README.md @@ -201,8 +201,8 @@ Every custom error includes the `CnpjVal::Error` marker module. Domain failures | Class | Inherits from | Category | Trigger condition | |---|---|---|---| -| `CnpjVal::TypeMismatchError` | `TypeError` (+ `include Error`) | API misuse | CNPJ input or option has the wrong data type | | `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | API misuse | Both an `options` instance/`Hash` and keyword arguments 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 | #### `CnpjVal::Error` (marker module) @@ -337,7 +337,7 @@ After `require 'cnpj-val'`: - **`CnpjVal::CNPJ_LENGTH`**: `14` (constant). - **`CnpjVal::VERSION`**: gem version string. - **Type markers**: `CnpjVal::CnpjInput`, `CnpjVal::CnpjType`, `CnpjVal::CnpjValidatorOptionsInput`. -- **Errors**: `CnpjVal::Error`, `CnpjVal::DomainError`, `CnpjVal::TypeMismatchError`, `CnpjVal::InvalidArgumentCombinationError`, `CnpjVal::ValidationError`. +- **Errors**: `CnpjVal::Error`, `CnpjVal::DomainError`, `CnpjVal::InvalidArgumentCombinationError`, `CnpjVal::TypeMismatchError`, `CnpjVal::ValidationError`. ### Other available resources diff --git a/packages/cnpj-val/README.pt.md b/packages/cnpj-val/README.pt.md index b2847ff..f06b1c9 100644 --- a/packages/cnpj-val/README.pt.md +++ b/packages/cnpj-val/README.pt.md @@ -188,8 +188,8 @@ Todo erro customizado inclui o módulo marcador `CnpjVal::Error`. Falhas de dom | Classe | Herda de | Categoria | Condição de disparo | |---|---|---|---| -| `CnpjVal::TypeMismatchError` | `TypeError` (+ `include Error`) | Uso incorreto da API | Entrada CNPJ ou opção com tipo de dado incorreto | | `CnpjVal::InvalidArgumentCombinationError` | `ArgumentError` (+ `include Error`) | Uso incorreto da API | Instância/`Hash` de `options` e argumentos nomeados 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 | #### `CnpjVal::Error` (módulo marcador) @@ -324,7 +324,7 @@ Após `require 'cnpj-val'`: - **`CnpjVal::CNPJ_LENGTH`**: `14` (constante). - **`CnpjVal::VERSION`**: string de versão da gem. - **Marcadores de tipo**: `CnpjVal::CnpjInput`, `CnpjVal::CnpjType`, `CnpjVal::CnpjValidatorOptionsInput`. -- **Erros**: `CnpjVal::Error`, `CnpjVal::DomainError`, `CnpjVal::TypeMismatchError`, `CnpjVal::InvalidArgumentCombinationError`, `CnpjVal::ValidationError`. +- **Erros**: `CnpjVal::Error`, `CnpjVal::DomainError`, `CnpjVal::InvalidArgumentCombinationError`, `CnpjVal::TypeMismatchError`, `CnpjVal::ValidationError`. ### Outros recursos disponíveis