ortho_config_macros 0.9.0

Procedural macros for ortho_config.
Documentation
//! Tests for `#[ortho_config(...)]` parsing behaviour.

use super::super::*;
use anyhow::{Result, anyhow, ensure};
use quote::quote;
use rstest::rstest;
use syn::{DeriveInput, parse_quote};

/// Helper to assert that a `merge_strategy` attribute is correctly parsed.
struct MergeStrategyCase<'a> {
    strategy_name: &'a str,
    expected: MergeStrategy,
    struct_name: &'a str,
    field_name: &'a str,
    field_type: proc_macro2::TokenStream,
}

fn assert_merge_strategy(case: &MergeStrategyCase<'_>) -> Result<()> {
    let input: DeriveInput = syn::parse_str(&format!(
        r#"
        struct {struct_name} {{
            #[ortho_config(merge_strategy = "{strategy_name}")]
            {field_name}: {field_type},
        }}
        "#,
        struct_name = case.struct_name,
        strategy_name = case.strategy_name,
        field_name = case.field_name,
        field_type = &case.field_type,
    ))
    .map_err(|err| anyhow!("failed to parse input: {err}"))?;

    let (_, _, _, attrs_vec) = parse_input(&input).map_err(|err| anyhow!(err))?;
    let attrs = attrs_vec
        .first()
        .ok_or_else(|| anyhow!("missing field attributes"))?;
    ensure!(
        attrs.merge_strategy == Some(case.expected),
        "{strategy} strategy not parsed",
        strategy = case.strategy_name,
    );
    Ok(())
}

#[test]
fn parses_struct_and_field_attributes() -> Result<()> {
    let input: DeriveInput = parse_quote! {
        #[ortho_config(prefix = "CFG_")]
        struct Demo {
            #[ortho_config(cli_long = "opt", cli_short = 'o', default = 5)]
            field1: Option<u32>,
            #[ortho_config(merge_strategy = "append")]
            field2: Vec<String>,
        }
    };

    let (ident, fields, struct_attrs, field_attrs) =
        parse_input(&input).map_err(|err| anyhow!(err))?;

    ensure!(ident == "Demo", "expected Demo ident, got {ident}");
    ensure!(fields.len() == 2, "expected 2 fields, got {}", fields.len());
    ensure!(
        struct_attrs.prefix.as_deref() == Some("CFG_"),
        "expected CFG_ prefix"
    );
    ensure!(field_attrs.len() == 2, "expected 2 field attrs");
    ensure!(
        field_attrs
            .first()
            .and_then(|attrs| attrs.cli_long.as_deref())
            == Some("opt"),
        "expected first cli_long opt"
    );
    ensure!(
        field_attrs.first().and_then(|attrs| attrs.cli_short) == Some('o'),
        "expected first cli_short o"
    );
    ensure!(
        matches!(
            field_attrs.get(1).and_then(|attrs| attrs.merge_strategy),
            Some(MergeStrategy::Append)
        ),
        "expected second field append strategy"
    );
    Ok(())
}

/// Verify that a single-field `#[ortho_config(...)]` flag is correctly parsed.
fn assert_field_flag<F>(attribute: &str, check: F, error_msg: &str) -> Result<()>
where
    F: FnOnce(&FieldAttrs) -> bool,
{
    let input: DeriveInput = syn::parse_str(&format!(
        r"
        struct Demo {{
            #[ortho_config({attribute})]
            field: String,
        }}
        ",
    ))
    .map_err(|err| anyhow!("failed to parse input: {err}"))?;

    let (_, fields, _, field_attrs) = parse_input(&input).map_err(|err| anyhow!(err))?;
    ensure!(fields.len() == 1, "expected single field");
    let attrs = field_attrs
        .first()
        .ok_or_else(|| anyhow!("missing field attributes"))?;
    ensure!(check(attrs), "{error_msg}");
    Ok(())
}

/// Verify that the `post_merge_hook` struct attribute is correctly parsed.
pub(super) fn assert_post_merge_hook(
    input: &DeriveInput,
    expected: bool,
    error_msg: &str,
) -> Result<()> {
    let (_, _, struct_attrs, _) = parse_input(input).map_err(|err| anyhow!(err))?;
    ensure!(struct_attrs.post_merge_hook == expected, "{error_msg}");
    Ok(())
}

/// Verify that `#[ortho_config(crate = "...")]` is correctly parsed.
pub(super) fn assert_crate_path(
    input: &DeriveInput,
    expected_crate_path: &str,
    expected_prefix: Option<&str>,
    error_msg: &str,
) -> Result<()> {
    let (_, _, struct_attrs, _) = parse_input(input).map_err(|err| anyhow!(err))?;
    if let Some(prefix) = expected_prefix {
        ensure!(
            struct_attrs.prefix.as_deref() == Some(prefix),
            "expected prefix {prefix}"
        );
    }
    let crate_path = struct_attrs
        .crate_path
        .ok_or_else(|| anyhow!("expected crate_path to be Some"))?;
    ensure!(
        quote!(#crate_path).to_string() == expected_crate_path,
        "{error_msg}: got {}",
        quote!(#crate_path)
    );
    Ok(())
}

#[test]
fn parses_skip_cli_flag() -> Result<()> {
    assert_field_flag(
        "skip_cli",
        |attrs| attrs.skip_cli,
        "skip_cli flag was not set",
    )
}

#[test]
fn parses_cli_default_as_absent_flag() -> Result<()> {
    assert_field_flag(
        "cli_default_as_absent",
        |attrs| attrs.cli_default_as_absent,
        "cli_default_as_absent flag was not set",
    )
}

#[test]
fn parses_cli_default_as_absent_false_disables_flag() -> Result<()> {
    assert_field_flag(
        "cli_default_as_absent = false",
        |attrs| !attrs.cli_default_as_absent,
        "cli_default_as_absent flag remained enabled",
    )
}

#[test]
fn parses_discovery_attributes() -> Result<()> {
    let input: DeriveInput = parse_quote! {
        #[ortho_config(prefix = "CFG_", discovery(
            app_name = "demo",
            env_var = "DEMO_CONFIG",
            config_file_name = "demo.toml",
            dotfile_name = ".demo.toml",
            project_file_name = "demo-config.toml",
            config_cli_long = "config",
            config_cli_short = 'c',
            config_cli_visible = true,
        ))]
        struct Demo {
            value: u32,
        }
    };

    let (_, _, struct_attrs, _) = parse_input(&input).map_err(|err| anyhow!(err))?;
    let discovery = struct_attrs
        .discovery
        .ok_or_else(|| anyhow!("missing discovery attrs"))?;
    ensure!(
        discovery.app_name.as_deref() == Some("demo"),
        "app_name mismatch"
    );
    ensure!(
        discovery.env_var.as_deref() == Some("DEMO_CONFIG"),
        "env_var mismatch"
    );
    ensure!(
        discovery.config_file_name.as_deref() == Some("demo.toml"),
        "config_file_name mismatch"
    );
    ensure!(
        discovery.dotfile_name.as_deref() == Some(".demo.toml"),
        "dotfile mismatch"
    );
    ensure!(
        discovery.project_file_name.as_deref() == Some("demo-config.toml"),
        "project file mismatch"
    );
    ensure!(
        discovery.config_cli_long.as_deref() == Some("config"),
        "cli long mismatch"
    );
    ensure!(
        discovery.config_cli_short == Some('c'),
        "cli short mismatch"
    );
    ensure!(
        discovery.config_cli_visible == Some(true),
        "visibility mismatch"
    );
    Ok(())
}

#[rstest]
#[case::append(MergeStrategyCase {
    strategy_name: "append",
    expected: MergeStrategy::Append,
    struct_name: "AppendDemo",
    field_name: "values",
    field_type: quote!(Vec<String>),
})]
#[case::replace(MergeStrategyCase {
    strategy_name: "replace",
    expected: MergeStrategy::Replace,
    struct_name: "ReplaceDemo",
    field_name: "items",
    field_type: quote!(Vec<u32>),
})]
#[case::keyed(MergeStrategyCase {
    strategy_name: "keyed",
    expected: MergeStrategy::Keyed,
    struct_name: "KeyedDemo",
    field_name: "mapping",
    field_type: quote!(BTreeMap<String, String>),
})]
fn parses_merge_strategy(#[case] case: MergeStrategyCase<'static>) -> Result<()> {
    assert_merge_strategy(&case)
}

#[test]
fn parses_merge_strategy_invalid() -> Result<()> {
    let invalid: DeriveInput = parse_quote! {
        struct InvalidDemo {
            #[ortho_config(merge_strategy = "unknown")]
            values: Vec<String>,
        }
    };
    let err = parse_input(&invalid)
        .err()
        .ok_or_else(|| anyhow!("expected merge strategy error"))?;
    ensure!(
        err.to_string().contains("unknown merge_strategy"),
        "unexpected error message: {err}",
    );
    Ok(())
}