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)