Resolve discovery and merging without process state

Version 0.9.1 Updated Sep 04, 2026

Tests and embedded callers can resolve the complete configuration without mutating the process environment. Use ScanEnvSource for the explicit ability to enumerate variables for the merge layer, and pass it to CsvEnv::with_source. The derive-generated OrthoConfig::load_from_iter_with_sources entry point accepts both the discovery SharedEnvSource and merge SharedScanEnvSource, so configuration-path selection, files, and the environment layer use the same supplied values.

MapEnv implements both source traits. Create one Arc<MapEnv>, then coerce cloned Arc values to SharedEnvSource and SharedScanEnvSource; the same map can therefore drive discovery lookups and merge enumeration. The ordinary load() and load_from_iter() methods continue to use ProcessEnv by default.

For an upgrade checklist covering this opt-in API, see the v0.10.0 migration guide.

For a directly composed environment layer, attach the scan source to CsvEnv with with_source:

use ortho_config::{CsvEnv, MapEnv};
use std::sync::Arc;

let source = Arc::new(
    MapEnv::new()
        .with_var("APP_DATABASE__HOST", "localhost")
        .with_var("APP_TAGS", "one,two"),
);
let provider = CsvEnv::prefixed("APP_")
    .uppercase(true)
    .split("__")
    .with_source(source);
let _figment = ortho_config::figment::Figment::new().merge(provider);

The injected provider follows the same declarative rules as the process-backed provider: prefix matching is case-insensitive and strips the matching prefix, uppercase and lowercase transform names at their place in the builder chain, and each split replaces its separator with a dotted key component. CSV parsing is enabled by default; comma-containing scalar values become lists, while values beginning with [, {, or a quote remain structured or quoted values. Use csv(false) when a comma must remain part of a scalar value. The source-aware path rejects map() and filter_map() providers because arbitrary closures cannot be replayed against a ScanEnvSource.

For example, this complete load uses one map for the selector and merge layers:

use ortho_config::{
    MapEnv, OrthoConfig, OrthoResult, SharedEnvSource, SharedScanEnvSource,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Deserialize, Serialize, OrthoConfig)]
#[ortho_config(prefix = "ACME_")]
struct Config {
    #[ortho_config(default = 8080)]
    port: u16,
}

fn main() -> OrthoResult<()> {
    let environment = Arc::new(MapEnv::new().with_var("ACME_PORT", "9000"));
    let discovery: SharedEnvSource = environment.clone();
    let merge: SharedScanEnvSource = environment;

    let config = Config::load_from_iter_with_sources(["acme"], discovery, merge)?;
    println!("port={}", config.port);
    Ok(())
}

The example prints port=9000. No process variable is read: the discovery source handles named lookups, while the scan source supplies the merge layer.