Core concepts and motivation

Version 0.6.0 Updated Nov 12, 2025

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 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.