Config Pipeline

Load application config from multiple sources using Alt, Traversable, Foldable, and Monoid.

Overview

Real applications rarely load configuration from a single source. Environment variables, config files, and hardcoded defaults each provide a partial picture. This example builds a configuration pipeline that:

The full source is at karpal-std/examples/config_pipeline.rs.

1. Domain Types

The example defines a simple AppConfig struct representing a database connection configuration:

#[derive(Debug, Clone, PartialEq)]
struct AppConfig {
    db_host: String,
    db_port: u16,
    db_name: String,
    max_connections: u16,
    timeout_ms: u64,
}

2. Simulated Config Sources

Three functions simulate different configuration sources. Each takes a key and returns Option<String>Some if the source knows about that key, None otherwise.

fn from_env(key: &str) -> Option<String> {
    // Simulate environment variables (only DB_HOST and DB_PORT are set)
    match key {
        "DB_HOST" => Some("prod-db.example.com".into()),
        "DB_PORT" => Some("5432".into()),
        _ => None,
    }
}

fn from_file(key: &str) -> Option<String> {
    // Simulate a config file (has DB_NAME and MAX_CONNECTIONS)
    match key {
        "DB_NAME" => Some("myapp".into()),
        "MAX_CONNECTIONS" => Some("20".into()),
        _ => None,
    }
}

fn from_default(key: &str) -> Option<String> {
    // Hardcoded defaults for everything
    match key {
        "DB_HOST" => Some("localhost".into()),
        "DB_PORT" => Some("5432".into()),
        "DB_NAME" => Some("app".into()),
        "MAX_CONNECTIONS" => Some("10".into()),
        "TIMEOUT_MS" => Some("5000".into()),
        _ => None,
    }
}

No single source has every key. Environment variables provide the host and port; the config file provides the database name and connection pool size; defaults fill in anything still missing, including the timeout.

3. Alt Fallback Chains

The Alt trait provides an associative "or" operation on type constructors. For Option, Alt::alt returns the first Some value, falling through to the next source if the current one returns None.

/// Try env first, then file, then defaults.
fn resolve(key: &str) -> Option<String> {
    OptionF::alt(OptionF::alt(from_env(key), from_file(key)), from_default(key))
}

This reads inside-out: try from_env, fall back to from_file, then fall back to from_default. Because Alt is associative, the grouping does not matter — only the left-to-right priority order.

For example, resolve("DB_HOST") returns Some("prod-db.example.com") from the environment, while resolve("TIMEOUT_MS") skips both env and file (neither has it) and returns Some("5000") from defaults.

4. Loading the Full Config

With resolve in hand, loading the full AppConfig is straightforward. String fields are resolved directly; numeric fields are resolved and then parsed:

fn load_config() -> Option<AppConfig> {
    // Resolve each key independently via Alt fallback chains
    let db_host = resolve("DB_HOST")?;
    let db_name = resolve("DB_NAME")?;

    // For numeric fields, resolve then parse
    let db_port = resolve("DB_PORT").and_then(parse_u16)?;
    let max_connections = resolve("MAX_CONNECTIONS").and_then(parse_u16)?;
    let timeout_ms = resolve("TIMEOUT_MS").and_then(parse_u64)?;

    Some(AppConfig {
        db_host,
        db_port,
        db_name,
        max_connections,
        timeout_ms,
    })
}

The ? operator short-circuits the entire function if any key cannot be resolved or any parse fails, returning None.

5. Connection String with do_!

The do_! macro provides monadic sequencing. Here it combines three resolved values into a formatted connection string:

fn load_connection_string() -> Option<String> {
    do_! { OptionF;
        host = resolve("DB_HOST");
        port = resolve("DB_PORT");
        name = resolve("DB_NAME");
        Some(format!("postgres://{}:{}/{}", host, port, name))
    }
}

Each name = expr line unwraps the Option. If any call to resolve returns None, the entire block short-circuits. The final expression produces the connection string wrapped in Some.

6. Batch Validation with Traversable

Traversable provides all-or-nothing semantics: apply a fallible function to every element in a collection, and if any element fails, the entire result is None.

fn parse_u16(s: String) -> Option<u16> {
    s.parse().ok()
}

fn validate_ports(ports: Vec<&str>) -> Option<Vec<u16>> {
    VecF::traverse::<OptionF, _, _, _>(
        ports.into_iter().map(String::from).collect(),
        parse_u16,
    )
}

VecF::traverse maps parse_u16 over each element and collects the results. If every element parses successfully, the result is Some(vec![...]). If any element fails, the result is None:

let good = validate_ports(vec!["80", "443", "8080"]);
// => Some([80, 443, 8080])

let bad = validate_ports(vec!["80", "not_a_port", "8080"]);
// => None

This is strictly stronger than filtering out failures — it guarantees that either all values are valid or the caller knows something went wrong.

7. Config Summary with Foldable and Monoid

Foldable provides structural traversal, and Monoid provides an identity element and associative combination. Together, fold_map transforms each element and concatenates the results:

fn summarize_keys(keys: Vec<&str>) -> String {
    VecF::fold_map(
        keys.into_iter().map(String::from).collect::<Vec<_>>(),
        |key| {
            match resolve(&key) {
                Some(val) => format!("  {} = {}\n", key, val),
                None => format!("  {} = <missing>\n", key),
            }
        },
    )
}

For String, the Monoid instance uses the empty string as the identity and string concatenation as the combining operation. The result is a single string summarizing all resolved (or missing) configuration keys.

8. The main Function

The main function exercises each section and prints the results:

fn main() {
    println!("=== Config Pipeline Example ===\n");

    // 1. Alt fallback chains
    println!("--- Resolving individual keys (Alt fallback) ---");
    println!("DB_HOST:         {:?}", resolve("DB_HOST"));
    println!("DB_PORT:         {:?}", resolve("DB_PORT"));
    println!("DB_NAME:         {:?}", resolve("DB_NAME"));
    println!("MAX_CONNECTIONS: {:?}", resolve("MAX_CONNECTIONS"));
    println!("TIMEOUT_MS:      {:?}", resolve("TIMEOUT_MS"));
    println!("UNKNOWN_KEY:     {:?}", resolve("UNKNOWN_KEY"));

    // 2. Full config loading
    println!("\n--- Loading full config ---");
    match load_config() {
        Some(config) => println!("{:#?}", config),
        None => println!("Failed to load config!"),
    }

    // 3. do_! for independent lookups
    println!("\n--- Connection string (do_!) ---");
    println!("{:?}", load_connection_string());

    // 4. Traversable: all-or-nothing validation
    println!("\n--- Batch port validation (Traversable) ---");
    let good_ports = vec!["80", "443", "8080"];
    let good_result = validate_ports(good_ports.clone());
    println!("Valid ports {:?}: {:?}", good_ports, good_result);

    let bad_ports = vec!["80", "not_a_port", "8080"];
    let bad_result = validate_ports(bad_ports.clone());
    println!("Mixed ports {:?}: {:?}", bad_ports, bad_result);

    // 5. Foldable + Monoid: summarize
    println!("\n--- Config summary (Foldable + Monoid) ---");
    let summary = summarize_keys(vec![
        "DB_HOST", "DB_PORT", "DB_NAME", "MAX_CONNECTIONS", "TIMEOUT_MS", "MISSING",
    ]);
    print!("{}", summary);
}

Run It

From the workspace root:

cargo run -p karpal-std --example config_pipeline

Traits Used

TraitPurpose in this exampleReference
Alt Fallback chains across config sources Alt Family
Monad (via do_!) Sequential composition of dependent lookups Functor Family
Traversable All-or-nothing batch validation Foldable & Traversable
Foldable Structural traversal with fold_map Foldable & Traversable
Monoid String concatenation as the combining operation for fold_map Semigroup & Monoid