Core concepts and motivation

Version 0.7.0 Updated Jan 02, 2026

Rust projects often wire together clap for CLI parsing, serde for de/serialization, and ad‑hoc code for loading *.toml files or reading environment variables. Mapping between different naming conventions (kebab‑case flags, UPPER_SNAKE_CASE environment variables, and snake_case struct fields) can be tedious. OrthoConfig addresses these problems by letting developers describe their configuration once and then automatically loading values from multiple sources. The core features are:

  • Layered configuration – Configuration values can come from application defaults, configuration files, environment variables and command‑line arguments. Later sources override earlier ones. Command‑line arguments have the highest precedence and defaults the lowest.

  • Orthographic naming – A single field in a Rust struct is automatically mapped to a CLI flag (kebab‑case), an environment variable (upper snake case with a prefix), and a file key (snake case). This removes the need for manual aliasing.

  • Type‑safe deserialization – Values are deserialized into strongly typed Rust structs using serde.

  • Easy adoption – A procedural macro #[derive(OrthoConfig)] adds the necessary code. Developers only need to derive serde traits on their configuration struct and call a generated method to load the configuration.

  • Customizable behaviour – Attributes such as default, cli_long, cli_short, and merge_strategy provide fine‑grained control over naming and merging behaviour.

  • Declarative merge tooling – Every configuration struct exposes a merge_from_layers helper along with MergeComposer, making it simple to compose defaults, files, environment captures, and CLI values in unit tests or bespoke loaders without instantiating the CLI parser. Vector fields honour the append strategy by default, so defaults flow through alongside environment and CLI additions.

The workspace bundles an executable Hello World example under examples/hello_world. It layers defaults, environment variables, and CLI flags via the derive macro; see its README for a step-by-step walkthrough and the rstest-bdd (Behaviour-Driven Development) scenarios that validate behaviour end-to-end.

Run make test to execute the example’s coverage. The unit suite uses rstest fixtures to exercise parsing, validation, and command planning across parameterized edge-cases (conflicting delivery modes, blank salutations, and custom punctuation). Behavioural coverage comes from the rstest-bdd integration test under tests/rstest_bdd, which spawns the compiled binary inside a temporary working directory, layers .hello_world.toml defaults via cap-std, and sets HELLO_WORLD_* environment variables per scenario to demonstrate precedence: configuration files < environment variables < CLI arguments. Scenarios tagged @requires.yaml are gated by compile-time tag filters, so non-yaml builds skip them automatically.

ConfigDiscovery exposes the same search order used by the example so applications can replace bespoke path juggling with a single call. By default the helper honours HELLO_WORLD_CONFIG_PATH, then searches $XDG_CONFIG_HOME/hello_world, each entry in $XDG_CONFIG_DIRS (falling back to /etc/xdg on Unix-like targets), Windows application data directories, $HOME/.config/hello_world, $HOME/.hello_world.toml, and finally the project root. Candidates are deduplicated in precedence order (case-insensitively on Windows). Call utf8_candidates() to receive a Vec<camino::Utf8PathBuf> without manual conversions:

use ortho_config::ConfigDiscovery;

# fn load() -> ortho_config::OrthoResult<()> {
let discovery = ConfigDiscovery::builder("hello_world")
    .env_var("HELLO_WORLD_CONFIG_PATH")
    .build();

if let Some(figment) = discovery.load_first()? {
    // Extract your configuration struct from the figment here.
    println!(
        "Loaded configuration from {:?}",
        discovery.candidates().first()
    );
} else {
    // Fall back to defaults when no configuration files exist.
}
# Ok(())
# }

The repository ships config/overrides.toml, which extends config/baseline.toml to set is_excited = true, provide a Layered hello preamble, and swap the greet punctuation for !!!. Behavioural tests and demo scripts assert the uppercase output to guard this layering.

Declarative merging

The derive macro now emits helpers for composing configuration layers without going through Figment directly. MergeComposer collects MergeLayer instances for defaults, files, environment, and CLI input; once constructed, pass the layers to YourConfig::merge_from_layers to build the final struct:

use ortho_config::{MergeComposer, OrthoConfig};
use serde::Deserialize;
use serde_json::json;

#[derive(Debug, Deserialize, OrthoConfig)]
struct AppConfig {
    recipient: String,
    salutations: Vec<String>,
}

let mut composer = MergeComposer::new();
composer.push_defaults(json!({"recipient": "Defaults", "salutations": ["Hi"] }));
composer.push_environment(json!({"salutations": ["Env"] }));
composer.push_cli(json!({"recipient": "Cli" }));

let merged = AppConfig::merge_from_layers(composer.layers())?;
assert_eq!(merged.recipient, "Cli");
assert_eq!(
    merged.salutations,
    vec![String::from("Hi"), String::from("Env")]
);

This API surfaces the same precedence as the generated load() method while making it trivial to drive unit and behavioural tests with hand-crafted layers. Vec<_> fields accumulate values from each layer in order, so defaults can coexist with environment or CLI extensions. The Hello World example’s behavioural suite includes a dedicated scenario that parses JSON descriptors into MergeLayer values and asserts the merged configuration via these helpers. Unit tests can mirror this approach with rstest fixtures: define fixtures for default payloads, then enumerate cases for file, environment, and CLI layers. This validates every precedence permutation without copy-pasting setup.

Every derived configuration also exposes compose_layers() and compose_layers_from_iter(..). These helpers discover configuration files, serialize environment variables, and capture CLI input as a LayerComposition, keeping discovery separate from merging. The returned composition includes both the ordered layers and any collected errors, letting callers push additional layers or aggregate errors before invoking merge_from_layers.

Post-merge hooks

Some configuration structs require custom adjustments after the standard merge pipeline completes. The PostMergeHook trait provides an opt-in hook that the library invokes automatically when the #[ortho_config(post_merge_hook)] attribute is present.

use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook};
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Deserialize, Serialize, OrthoConfig)]
#[ortho_config(prefix = "APP_", post_merge_hook)]
struct GreetArgs {
    #[ortho_config(default = String::from("!"))]
    punctuation: String,
    preamble: Option<String>,
}

impl PostMergeHook for GreetArgs {
    fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> {
        // Normalize whitespace-only preambles to None
        if self.preamble.as_ref().is_some_and(|p| p.trim().is_empty()) {
            self.preamble = None;
        }
        Ok(())
    }
}

The PostMergeContext provides metadata about the merge process:

  • prefix() – the environment variable prefix used during loading
  • loaded_files() – paths of configuration files that contributed to the merge
  • has_cli_input() – whether CLI arguments were present in the merge

Use post-merge hooks sparingly. Most configuration needs are satisfied by the standard merge pipeline combined with field-level attributes like cli_default_as_absent and merge_strategy. Hooks are best suited for:

  • Normalizing values after all layers have been applied
  • Performing validation that depends on multiple fields being merged
  • Conditional transformations based on which sources contributed

The Hello World example demonstrates this pattern with GreetCommand, which uses a post-merge hook to clean up whitespace-only preambles.

Localizing CLI copy

ortho_config exposes a Localizer trait, so applications can swap the text clap displays without abandoning sensible defaults. Each implementation is Send + Sync and returns owned String instances, making it cheap to cache resolved messages or fall back to the stock help text. The helper type LocalizationArgs<'a> = HashMap<&'a str, FluentValue<'a>> mirrors Fluent’s placeholder model, keeping argument-aware lookups ergonomic.

The crate now ships a Fluent-backed implementation. FluentLocalizer embeds an English catalogue at locales/en-US/messages.ftl, layers any consumer bundles over those defaults, logs formatting errors with tracing, and falls back to the next bundle when a lookup fails:

use ortho_config::{langid, FluentLocalizer, LocalizationArgs, Localizer};

static APP_EN: &str = include_str!("../locales/en-US/app.ftl");

let localizer = FluentLocalizer::builder(langid!("en-US"))
    .with_consumer_resources([APP_EN])
    .try_build()
    .expect("embedded locales load successfully");

let mut args: LocalizationArgs<'_> = LocalizationArgs::default();
args.insert("binary", "demo".into());
assert_eq!(
localizer
    .lookup("cli.usage", Some(&args))
    .expect("usage copy exists"),
    "Usage: demo [OPTIONS] <COMMAND>"
);

Applications can inject a custom logger with with_error_reporter when they need to capture Fluent formatting errors alongside command parsing failures.

The Hello World example ships hello_world::localizer::DemoLocalizer, which builds a FluentLocalizer from examples/hello_world/locales/en-US and drives CommandLine::command().localize(&localizer) and CommandLine::try_parse_localized_env. If the localization setup ever fails, the example falls back to NoOpLocalizer, preserving the stock clap strings until translations are fixed.

Errors surfaced by clap can be localized as well. Use localize_clap_error_with_command to map each ErrorKind to a Fluent identifier of the form clap-error-<kebab-case>, forwarding argument context such as the missing flag or the offending value. Supplying the command enables the helper to populate missing context (for example, the available subcommands when clap emits DisplayHelpOnMissingArgumentOrSubcommand). When no translation exists, the helper returns the original clap error unchanged:

use clap::CommandFactory;
use ortho_config::{localize_clap_error_with_command, Localizer};

# #[derive(clap::Parser)]
# struct Cli {}
fn parse(localizer: &dyn Localizer) -> Result<Cli, clap::Error> {
    let mut command = Cli::command().localize(localizer);
    let mut matches = command
        .try_get_matches()
        .map_err(|err| {
            localize_clap_error_with_command(err, localizer, Some(&command))
        })?;

    Cli::from_arg_matches_mut(&mut matches).map_err(|err| {
        let err = err.with_cmd(&command);
        localize_clap_error_with_command(err, localizer, Some(&command))
    })
}