How do I define helper methods to be used across rules?

I have rules with repeated logic in them that I would like to extract to prevent duplication. Here, I’m using values[:age] < 21 as an example of the repeated logic:

# frozen_string_literal: true

require 'dry-validation'

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:rent_car, :age) do
    key.failure("Not old enough to rent a car") if values[:age] < 21
  end

  rule(:rent_boat, :age) do
    key.failure("Not old enough to rent a boat") if values[:age] < 21
  end
end

validation = MyContract.new.call(age: 10, rent_car: true, rent_boat: true)
p validation.errors[:rent_car]
p validation.errors[:rent_boat]

My naive attempt to define a method fails:

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:rent_car, :age) do
    key.failure("Not old enough to rent a car") unless old_enough?
  end

  rule(:rent_boat, :age) do
    key.failure("Not old enough to rent a boat") unless old_enough?
  end

  def old_enough?
    values[:age] >= 21
  end
end
Traceback (most recent call last):
	12: from repro.rb:25:in `<main>'
	11: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:94:in `call'
	10: from /.../gems/dry-validation-1.2.1/lib/dry/validation/result.rb:25:in `new'
	 9: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:95:in `block in call'
	 8: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:95:in `each'
	 7: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:98:in `block (2 levels) in call'
	 6: from /.../gems/dry-validation-1.2.1/lib/dry/validation/rule.rb:38:in `call'
	 5: from /.../gems/dry-validation-1.2.1/lib/dry/validation/rule.rb:38:in `new'
	 4: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:73:in `initialize'
	 3: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:73:in `instance_exec'
	 2: from repro.rb:13:in `block in <class:MyContract>'
	 1: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:190:in `method_missing'
repro.rb:21:in `old_enough?': undefined local variable or method `values' for #<MyContract:0x00007fa3799928d8> (NameError)

What is the appropriate pattern to avoid repeating logic like this?

What do you think about:

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:vehicle).value(:bool)
  end

  rule(:vehicle, :age) do
    key.failure("Not old enough to rent a vehicle") if values[:age] < 21
  end
end

You could do this:

require 'dry-validation'

class MyContract < Dry::Validation::Contract
  register_macro(:old_enough?) do
    type = keys.last

    if values[type] && value < 21
      name = { rent_car: "a car", rent_boat: "a boat" }[type]
      key.failure("Not old enough to rent #{name}")
    end
  end
  
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:age, :rent_car).validate(:old_enough?)
  rule(:age, :rent_boat).validate(:old_enough?)
end

my_contract = MyContract.new

puts my_contract.(age: 12, rent_car: true, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a car" path=[:age] meta={}>, #<Dry::Validation::Message text="Not old enough to rent a boat" path=[:age] meta={}>] options={}>

puts my_contract.(age: 12, rent_car: true, rent_boat: false).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a car" path=[:age] meta={}>] options={}>

puts my_contract.(age: 12, rent_car: false, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a boat" path=[:age] meta={}>] options={}>

puts my_contract.(age: 21, rent_car: true, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[] options={}>

puts my_contract.(age: 12, rent_car: false, rent_boat: false).errors.inspect
#<Dry::Validation::MessageSet messages=[] options={}>

To make it nicer, I would use localized messages and use failure identifier rather than building up that string message.