DEV Community

Cover image for A configuration system for ruby CLIs
Oinak
Oinak

Posted on

A configuration system for ruby CLIs

If you have developed Rails applications you might be familiar with its configuration system but if your are working on a CLI utility you might miss part of that convenience.

Let me share with you a simple system that gets you set with just a couple of files:

The hook

somewhere close to the first lines of code you run you have something like this:

    options = Parser.parse_options
    Config.setup(**options)
Enter fullscreen mode Exit fullscreen mode

I do this just after setting zeitwerk up.
the you need 2 files:

The Parser

This is based on rubys stdlib's OptionParser class.

require "optparse"

module Parser
  extend self

  def parse_options
    options = {}
    OptionParser.new do |parser|
      load(parser, options)
      progress(parser, options)
      memory(parser, options)
      #...
    end.parse!
    options
  end

  def load(parser, options)
    parser.on("-l", "--load", "Load data files") do |l|
      options[:load_files] = l
    end
  end

  def progress(parser, options)
    parser.on("-p", "--progress", "Show progress bar for long processes") do |p|
      options[:progress] = p
    end
  end

  def memory(parser, options)
    parser.on("-M", "--memory", "Use in-memory db") do |m|
      options[:memory] = m
    end
  end
  #...
end
Enter fullscreen mode Exit fullscreen mode

This is very reusable because it is initializing options as an empty hash, a returning a hash with only the options that have been set up.

If you want to show the default value on some option you can use #{Config::DEFAULTS[:option_name]}.

But if you are not using the next part, you can just use this and have a hash of options to store where you like.

The Config Singleton

require "singleton"

# Global configuration object with automatic defaults
#
# Usage:
#   Configure with: Config.setup(load_files: true, ...)
#   Access with:    Config.value(:load_files)
#
class Config
  include Singleton

  DEFAULTS = {
    load_files: false,
    progress: false,
    memory: false,
    option_name: "default_value",
  }.freeze

  Options = Data.define(*DEFAULTS.keys)

  def self.setup(**) = instance.setup(**)

  def self.value(key)
    raise ArgumentError, "Unknown configuration key: #{key}" unless DEFAULTS.key?(key)

    instance.config.public_send(key)
  end

  def config = @config ||= Options.new(*DEFAULTS.values)

  def setup(**kwargs)
    updated_values = DEFAULTS.merge(kwargs)
    @config = Options.new(*updated_values.values_at(*DEFAULTS.keys))
  end
end
Enter fullscreen mode Exit fullscreen mode

First we declare DEFAULTS as a hash with the keys being our option names and the values being the default values if the option is not explicitly configures by the user.

Then we define the Value Object with

    Options = Data.define(*DEFAULTS.keys)
Enter fullscreen mode Exit fullscreen mode

This takes advantage of Ruby's Data.define functionality. You can see the documentation here, but the TL;DR it is a Struct like immutable object.

The we have this slightly cryptic:

   def self.setup(**) = instance.setup(**)
Enter fullscreen mode Exit fullscreen mode

This means, if you call Config.setup on the class, we are going to call it on the (singular) instance that is keeping the state.

Then you have a class method for reading a config value:

    def self.value(key)
      raise ArgumentError, "Unknown configuration key: #{key}" unless DEFAULTS.key?(key)

      instance.config.send(key)
    end
Enter fullscreen mode Exit fullscreen mode

It uses DEFAULTS as an allow list to prevent the dynamic send we do on the Data instance from posing any security risk on malicious config keys.

We would use instance.config.to_h[key] instead.

The we have

  def config = @config ||= Options.new(*DEFAULTS.values)
Enter fullscreen mode Exit fullscreen mode

which is memoizing the value object on the Singleton instance.

And then the trickiest part:

    def setup(**kwargs)
      updated_values = DEFAULTS.merge(kwargs)
      @config = Options.new(*updated_values.values_at(*DEFAULTS.keys))
    end
Enter fullscreen mode Exit fullscreen mode

This is the method that allows you to update a config value (or several). Because Data.define object are immutable, when you have a new config, you create a new one based on the existing one plus your changes.

If you choose to use a hash instead of a Data.define, you could use merge! here.

This is the method that we were calling from "the hook" with the options hash returned from the Parser class.

With just this now you have a global Config object you can access anywhere in your app.

If you want extra ergonomics, you can have a concern like this:

module Configurable
  def self.included(base)
    base.extend Methods # for class methods
    base.include Methods # for instance methods
  end

  # Methods module to allow including and extending
  module Methods
    # Convenience method for configuration values
    def config(key, setup_config: ::Setup::Config)
      setup_config.value(key)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And then in any of you classes do something like:

class MyClass
  include Configurable

  def my_method
    if config :option_name
      puts "optional behavior"
    end
  end
Enter fullscreen mode Exit fullscreen mode

I hope you find it useful.

Let me know if you use it on any projects.

--
cover image: yinka adeoti

Top comments (0)