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 deriveserdetraits on their configuration struct and call a generated method to load the configuration. -
Customizable behaviour – Attributes such as
default,cli_long,cli_short, andmerge_strategyprovide fine‑grained control over naming and merging behaviour. - Declarative merge tooling – Every configuration struct exposes a
merge_from_layershelper along withMergeComposer, 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 loadingloaded_files()– paths of configuration files that contributed to the mergehas_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))
})
}