lib/seahorse/client/configuration.rb



# frozen_string_literal: true

require 'set'

module Seahorse
  module Client

    # Configuration is used to define possible configuration options and
    # then build read-only structures with user-supplied data.
    #
    # ## Adding Configuration Options
    #
    # Add configuration options with optional default values.  These are used
    # when building configuration objects.
    #
    #     configuration = Configuration.new
    #
    #     configuration.add_option(:max_retries, 3)
    #     configuration.add_option(:use_ssl, true)
    #
    #     cfg = configuration.build!
    #     #=> #<struct max_retires=3 use_ssl=true>
    #
    # ## Building Configuration Objects
    #
    # Calling {#build!} on a {Configuration} object causes it to return
    # a read-only (frozen) struct.  Options passed to {#build!} are merged
    # on top of any default options.
    #
    #     configuration = Configuration.new
    #     configuration.add_option(:color, 'red')
    #
    #     # default
    #     cfg1 = configuration.build!
    #     cfg1.color #=> 'red'
    #
    #     # supplied color
    #     cfg2 = configuration.build!(color: 'blue')
    #     cfg2.color #=> 'blue'
    #
    # ## Accepted Options
    #
    # If you try to {#build!} a {Configuration} object with an unknown
    # option, an `ArgumentError` is raised.
    #
    #     configuration = Configuration.new
    #     configuration.add_option(:color)
    #     configuration.add_option(:size)
    #     configuration.add_option(:category)
    #
    #     configuration.build!(price: 100)
    #     #=> raises an ArgumentError, :price was not added as an option
    #
    class Configuration

      # @api private
      Defaults = Class.new(Array) do
        def each(&block)
          reverse.to_a.each(&block)
        end
      end

      # @api private
      class DynamicDefault
        attr_accessor :block

        def initialize(block = nil)
          @block = block
        end

        def call(*args)
          @block.call(*args)
        end
      end

      # @api private
      def initialize
        @defaults = Hash.new { |h,k| h[k] = Defaults.new }
      end

      # Adds a getter method that returns the named option or a default
      # value.  Default values can be passed as a static positional argument
      # or via a block.
      #
      #    # defaults to nil
      #    configuration.add_option(:name)
      #
      #    # with a string default
      #    configuration.add_option(:name, 'John Doe')
      #
      #    # with a dynamic default value, evaluated once when calling #build!
      #    configuration.add_option(:name, 'John Doe')
      #    configuration.add_option(:username) do |config|
      #       config.name.gsub(/\W+/, '').downcase
      #    end
      #    cfg = configuration.build!
      #    cfg.name #=> 'John Doe'
      #    cfg.username #=> 'johndoe'
      #
      # @param [Symbol] name The name of the configuration option.  This will
      #   be used to define a getter by the same name.
      #
      # @param default The default value for this option.  You can specify
      #   a default by passing a value, a `Proc` object or a block argument.
      #   Procs and blocks are evaluated when {#build!} is called.
      #
      # @return [self]
      def add_option(name, default = nil, &block)
        default = DynamicDefault.new(block) if block_given?
        @defaults[name.to_sym] << default
        self
      end

      # Constructs and returns a configuration structure.
      # Values not present in `options` will default to those supplied via
      # add option.
      #
      #     configuration = Configuration.new
      #     configuration.add_option(:enabled, true)
      #
      #     cfg1 = configuration.build!
      #     cfg1.enabled #=> true
      #
      #     cfg2 = configuration.build!(enabled: false)
      #     cfg2.enabled #=> false
      #
      # If you pass in options to `#build!` that have not been defined,
      # then an `ArgumentError` will be raised.
      #
      #     configuration = Configuration.new
      #     configuration.add_option(:enabled, true)
      #
      #     # oops, spelling error for :enabled
      #     cfg = configuration.build!(enabld: true)
      #     #=> raises ArgumentError
      #
      # The object returned is a frozen `Struct`.
      #
      #     configuration = Configuration.new
      #     configuration.add_option(:enabled, true)
      #
      #     cfg = configuration.build!
      #     cfg.enabled #=> true
      #     cfg[:enabled] #=> true
      #     cfg['enabled'] #=> true
      #
      # @param [Hash] options ({}) A hash of configuration options.
      # @return [Struct] Returns a frozen configuration `Struct`.
      def build!(options = {})
        struct = empty_struct
        apply_options(struct, options)
        apply_defaults(struct, options)
        struct
      end

      private

      def empty_struct
        Struct.new(*@defaults.keys.sort).new
      end

      def apply_options(struct, options)
        options.each do |opt, value|
          begin
            struct[opt] = value
          rescue NameError
            msg = "invalid configuration option `#{opt.inspect}'"
            raise ArgumentError, msg
          end
        end
      end

      def apply_defaults(struct, options)
        @defaults.each do |opt_name, defaults|
          unless options.key?(opt_name)
            struct[opt_name] = defaults
          end
        end
        DefaultResolver.new(struct).resolve
      end

      # @api private
      class DefaultResolver

        def initialize(struct)
          @struct = struct
          @members = Set.new(@struct.members)
        end

        def resolve
          @members.each { |opt_name| value_at(opt_name) }
        end

        def respond_to?(method_name, *args)
          @members.include?(method_name) or super
        end

        def override_config(k, v)
          @struct[k] = v
        end

        private

        def value_at(opt_name)
          value = @struct[opt_name]
          if value.is_a?(Defaults)
            resolve_defaults(opt_name, value)
          else
            value
          end
        end

        def resolve_defaults(opt_name, defaults)
          defaults.each do |default|
            default = default.call(self) if default.is_a?(DynamicDefault)
            @struct[opt_name] = default
            break if !default.nil?
          end
          @struct[opt_name]
        end

        def method_missing(method_name, *args)
          if @members.include?(method_name)
            value_at(method_name)
          else
            super
          end
        end

      end
    end
  end
end