confroid 0.0.1

The n+1-st config reader for your environment-based configs.
Documentation
//! Edge cases and the semantics pinned down in the spec discussion:
//! defaults, optional structs, vector contiguity, naming overrides, and the
//! breadth of supported scalar types.

use confroid::{ConfroidError, from_pairs};
use std::collections::HashMap;

// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------

#[derive(confroid::Config, Debug)]
struct Defaults {
    #[confroid(default = 8080)]
    port: u16,
    #[confroid(default)] // Default::default() == 0
    retries: u16,
    #[confroid(default = "Hello")]
    greeting: String,
    #[confroid(default)] // Default::default() == ""
    note: String,
}

#[test]
fn explicit_and_bare_defaults_apply_when_unset() {
    let config: Defaults = from_pairs(Vec::<(String, String)>::new()).unwrap();
    assert_eq!(config.port, 8080);
    assert_eq!(config.retries, 0);
    assert_eq!(config.greeting, "Hello");
    assert_eq!(config.note, "");
}

#[test]
fn defaults_are_overridden_when_set() {
    let config: Defaults = from_pairs([
        ("PORT", "1"),
        ("RETRIES", "5"),
        ("GREETING", "Hi"),
        ("NOTE", "n"),
    ])
    .unwrap();
    assert_eq!(config.port, 1);
    assert_eq!(config.retries, 5);
    assert_eq!(config.greeting, "Hi");
    assert_eq!(config.note, "n");
}

#[test]
fn default_does_not_mask_a_parse_error() {
    // A value that is set but invalid must error, not fall back to the default.
    let err = from_pairs::<Defaults, _, _, _>([("PORT", "not-a-number")]).unwrap_err();
    assert!(matches!(err, ConfroidError::EnvVarInvalid { .. }));
}

// ---------------------------------------------------------------------------
// Optional structs
// ---------------------------------------------------------------------------

#[derive(confroid::Config, Debug)]
struct WithTls {
    tls: Option<TlsConfig>,
}

#[derive(confroid::Config, Debug)]
struct TlsConfig {
    cert: String,
    key: String,
}

#[test]
fn optional_struct_absent_is_none() {
    let config: WithTls = from_pairs(Vec::<(String, String)>::new()).unwrap();
    assert!(config.tls.is_none());
}

#[test]
fn optional_struct_fully_set_is_some() {
    let config: WithTls = from_pairs([("TLS__CERT", "c.pem"), ("TLS__KEY", "k.pem")]).unwrap();
    let tls = config.tls.unwrap();
    assert_eq!(tls.cert, "c.pem");
    assert_eq!(tls.key, "k.pem");
}

#[test]
fn optional_struct_partially_set_errors() {
    // CERT present but KEY missing -> error, not silently None.
    let err = from_pairs::<WithTls, _, _, _>([("TLS__CERT", "c.pem")]).unwrap_err();
    assert_eq!(
        err,
        ConfroidError::EnvVarNotFound {
            var_name: "TLS__KEY".to_string(),
            field: "tls.key".to_string(),
        }
    );
}

// ---------------------------------------------------------------------------
// Vector edge cases
// ---------------------------------------------------------------------------

#[derive(confroid::Config, Debug)]
#[allow(dead_code)] // only exercised via error paths
struct ReqVec {
    names: Vec<String>,
}

#[derive(confroid::Config, Debug)]
struct OptVec {
    #[confroid(default)]
    names: Vec<String>,
}

#[derive(confroid::Config, Debug)]
struct AutoVec {
    #[confroid(auto_vec)]
    names: Vec<String>,
}

#[test]
fn missing_vector_without_default_is_missing_error() {
    let err = from_pairs::<ReqVec, _, _, _>(Vec::<(String, String)>::new()).unwrap_err();
    assert_eq!(
        err,
        ConfroidError::EnvVarNotFound {
            var_name: "NAMES".to_string(),
            field: "names".to_string(),
        }
    );
}

#[test]
fn missing_vector_with_bare_default_is_empty() {
    let config: OptVec = from_pairs(Vec::<(String, String)>::new()).unwrap();
    assert!(config.names.is_empty());
}

#[test]
fn vector_index_gap_is_an_error() {
    let err = from_pairs::<ReqVec, _, _, _>([("NAMES__0", "a"), ("NAMES__2", "c")]).unwrap_err();
    assert_eq!(
        err,
        ConfroidError::VecIndexGap {
            var_name: "NAMES".to_string(),
            field: "names".to_string(),
            missing: 1,
        }
    );
}

#[test]
fn empty_delimited_vector_is_empty() {
    let config: AutoVec = from_pairs([("NAMES", "")]).unwrap();
    assert!(config.names.is_empty());
}

#[test]
fn delimited_vector_of_numbers() {
    #[derive(confroid::Config, Debug)]
    struct Ports {
        #[confroid(auto_vec)]
        ports: Vec<u16>,
    }
    let config: Ports = from_pairs([("PORTS", "80,443,8080")]).unwrap();
    assert_eq!(config.ports, vec![80, 443, 8080]);
}

#[test]
fn delimited_vector_element_parse_error() {
    #[derive(confroid::Config, Debug)]
    #[allow(dead_code)] // only exercised via error paths
    struct Ports {
        #[confroid(auto_vec)]
        ports: Vec<u16>,
    }
    let err = from_pairs::<Ports, _, _, _>([("PORTS", "80,nope")]).unwrap_err();
    match err {
        ConfroidError::EnvVarInvalid { value, field, .. } => {
            assert_eq!(value, "nope");
            assert_eq!(field, "ports");
        }
        other => panic!("expected EnvVarInvalid, got {other:?}"),
    }
}

// ---------------------------------------------------------------------------
// Naming overrides
// ---------------------------------------------------------------------------

#[derive(confroid::Config, Debug)]
struct Renamed {
    #[confroid(name = "LISTEN_PORT")]
    port: u16,
    #[confroid(name = "SERVER")]
    http: Inner,
}

#[derive(confroid::Config, Debug)]
struct Inner {
    port: u16,
}

#[test]
fn leaf_rename() {
    let config: Renamed = from_pairs([("LISTEN_PORT", "1234"), ("SERVER__PORT", "80")]).unwrap();
    assert_eq!(config.port, 1234);
}

#[test]
fn nested_rename_propagates_to_children() {
    // Renaming the parent renames the whole prefix: http.port -> SERVER__PORT.
    let config: Renamed = from_pairs([("LISTEN_PORT", "1234"), ("SERVER__PORT", "8443")]).unwrap();
    assert_eq!(config.http.port, 8443);
}

// ---------------------------------------------------------------------------
// Scalar type breadth
// ---------------------------------------------------------------------------

#[derive(confroid::Config, Debug)]
struct Scalars {
    a: i64,
    b: f64,
    c: bool,
    d: char,
    e: std::net::IpAddr,
    f: std::path::PathBuf,
}

#[test]
fn many_scalar_types() {
    let config: Scalars = from_pairs([
        ("A", "-42"),
        ("B", "3.5"),
        ("C", "true"),
        ("D", "x"),
        ("E", "127.0.0.1"),
        ("F", "/etc/config"),
    ])
    .unwrap();
    assert_eq!(config.a, -42);
    assert_eq!(config.b, 3.5);
    assert!(config.c);
    assert_eq!(config.d, 'x');
    assert_eq!(config.e, "127.0.0.1".parse::<std::net::IpAddr>().unwrap());
    assert_eq!(config.f, std::path::PathBuf::from("/etc/config"));
}

// ---------------------------------------------------------------------------
// HashMap of scalars (not just structs)
// ---------------------------------------------------------------------------

#[test]
fn hashmap_of_scalars_preserves_key_case() {
    #[derive(confroid::Config, Debug)]
    struct Weights {
        weights: HashMap<String, u32>,
    }
    let config: Weights = from_pairs([("WEIGHTS__Alice", "3"), ("WEIGHTS__bob", "7")]).unwrap();
    assert_eq!(config.weights.len(), 2);
    assert_eq!(config.weights.get("Alice"), Some(&3));
    assert_eq!(config.weights.get("bob"), Some(&7));
}