gifnoc 0.1.5

Type-safe configuration with layered overrides via a proc-macro DSL
Documentation

Build Publish

gifnoc

Type-safe and layered project configuration from multiple sources

Motivation

Using various third-party tools, I often dispair when trying to configure them. Some options can only be set via a command-line flag, some are read from a config file, while still others can only be set via environment variables. These ambiguities are exacerbated when using third-party tools in docker or docker compose.

At the same time, I know from personal experience how tedious it can be to maintain a well-documented set of configuration options, especially in a fast moving world, where the code base changes on a daily basis and quick experiments need to be run on short notice.

I have implemented a solution in python that I use in all my projects. It can be found in the cli and jsonobject subpackages of swak, available from the python package index PyPI. Working mostly in data-science and machine learning and, therefore, using pola.rs for data pipelines, the gifnoc crate (config in reverse), is my attempt to replicate a pragmatic project configuration solution in rust.

Design

  • There is one and only one global project configuration.
  • Every configuration option has a default.
  • The project configuration has the form of a (nested) struct.
  • All configuration options can be set by all mechanisms:
    1. command-line arguments
    2. environment variables
    3. configuration file (TOML or YAML)
  • The precedence of these update is a matter of choice and taste.
  • Adding fields to the (nested) config struct(s) is the only code change required to make a new option available to all mechanisms.

Usage Example

Consider the following (simplified) main.rs of your rust application yourapp.

use gifnoc::{Configurable, config};

// Define defaults
config! {
    RouteConfig {
        api_prefix: String = "/api",
    }
}

config! {
    ServerConfig {
        host: String = "0.0.0.0",
        port: u32 = 8080u32,
        routes: RouteConfig = RouteConfig::default(),
    }
}

config! {
    WorkerConfig {
        interval_seconds: u32 = 60u32,
        queue_name: String = "default",
    }
}

config! {
    AppConfig {
        server: ServerConfig = ServerConfig::default(),
        worker: WorkerConfig = WorkerConfig::default(),
    }
}

fn main() {
    let cfg_file = gifnoc::toml::from_file("config.toml");  // Read config file
    let env_vars = gifnoc::env::with_prefix("APP");         // Parse environment
    let (positional, flags) = gifnoc::args::parse();        // Read command line

    // Chose order of precedence
    let config = AppConfig::default()
        .update(cfg_file)
        .update(env_vars)
        .update(args);

    // positional arguments can be flexibly used to steer app behaviour
    println!("positional arguments: {:?}", positional);

}

Part of your settings could be in a config.toml.

[server]
port = 8888

[server.routes]
api_prefix = "/api/v1"

The other parts could be in the form of environment variables or command-line flags (in long form only).

APP_SERVER__PORT=9000 yourapp step1 step2 --server.routes.api_prefix "/api/v2"

Note the use of double underscore in the environment variable name to indicate nesting versus the dot.separation to indicate nesting for the command-line flag. With the chosen order of precedence, the server's port would now be 9000 and the route's API prefix would be "/api/v2". The positional arguments step1 and step2 are free for use as you see fit.