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 Cucumber 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
parameterised edge-cases (conflicting delivery modes, blank salutations, and
custom punctuation). Behavioural coverage comes from the cucumber-rs runner
in tests/cucumber.rs, 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.
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.