ddd-toolkit-macro 0.3.0

Derive macros for ddd-toolkit-core's ValueObject, EntityId, SecretVo, and EnumVo traits.
Documentation
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Data, DeriveInput, Fields};

use crate::{
    attributes::parse_vo_attrs,
    krate::core_crate_path,
    vo::{reject_validate, reject_zeroize},
};

fn rename(pascal: &str, rule: Option<&str>) -> Result<String, String> {
    let snake = || {
        let mut out = String::new();
        for (i, c) in pascal.chars().enumerate() {
            if c.is_uppercase() {
                if i != 0 {
                    out.push('_');
                }
                out.extend(c.to_lowercase());
            } else {
                out.push(c);
            }
        }
        out
    };
    match rule {
        None | Some("PascalCase") => Ok(pascal.to_string()),
        Some("snake_case") => Ok(snake()),
        Some("SCREAMING_SNAKE_CASE") => Ok(snake().to_uppercase()),
        Some("kebab-case") => Ok(snake().replace('_', "-")),
        Some("lowercase") => Ok(pascal.to_lowercase()),
        Some("UPPERCASE") => Ok(pascal.to_uppercase()),
        Some("camelCase") => {
            let mut chars = pascal.chars();
            Ok(match chars.next() {
                Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            })
        }
        Some(other) => Err(format!(
            "unknown rename_all rule `{other}` (expected one of: PascalCase, snake_case, \
             SCREAMING_SNAKE_CASE, kebab-case, lowercase, UPPERCASE, camelCase)"
        )),
    }
}

// Same rationale as `vo::serde_bundle`: gated on this crate's own `serde`
// Cargo feature, generates a transparent `Serialize`/`Deserialize` pair
// using the same variant/string table `Display`/`FromStr` are built from,
// so it can't drift out of sync with either.
#[cfg(feature = "serde")]
fn serde_bundle(name: &syn::Ident, idents: &[&syn::Ident], strings: &[String]) -> TokenStream2 {
    quote! {
        impl ::serde::Serialize for #name {
            fn serialize<S>(&self, serializer: S) -> ::core::result::Result<S::Ok, S::Error>
            where
                S: ::serde::Serializer,
            {
                serializer.serialize_str(match self {
                    #(Self::#idents => #strings,)*
                })
            }
        }

        impl<'de> ::serde::Deserialize<'de> for #name {
            fn deserialize<D>(deserializer: D) -> ::core::result::Result<Self, D::Error>
            where
                D: ::serde::Deserializer<'de>,
            {
                let s = <::std::string::String as ::serde::Deserialize>::deserialize(deserializer)?;
                <Self as ::core::str::FromStr>::from_str(&s).map_err(::serde::de::Error::custom)
            }
        }
    }
}

#[cfg(not(feature = "serde"))]
fn serde_bundle(_name: &syn::Ident, _idents: &[&syn::Ident], _strings: &[String]) -> TokenStream2 {
    quote!()
}

pub fn expand_enum_vo(input: &DeriveInput) -> syn::Result<TokenStream2> {
    let attrs = parse_vo_attrs(input)?;
    reject_zeroize(input, &attrs)?;
    reject_validate(input, &attrs)?;
    let name = &input.ident;
    let Data::Enum(data) = &input.data else {
        return Err(syn::Error::new_spanned(
            name,
            "EnumVo can only be derived for enums",
        ));
    };
    let mut idents = Vec::new();
    let mut strings = Vec::new();
    for variant in &data.variants {
        if !matches!(variant.fields, Fields::Unit) {
            return Err(syn::Error::new_spanned(
                variant,
                "EnumVo requires fieldless variants (a variant with data is no longer a simple enumeration)",
            ));
        }
        strings.push(
            rename(&variant.ident.to_string(), attrs.rename_all.as_deref())
                .map_err(|msg| syn::Error::new_spanned(&input.ident, msg))?,
        );
        idents.push(&variant.ident);
    }
    let krate = core_crate_path();
    let type_name = name.to_string();
    let expected = format!("expected one of: {}", strings.join(", "));
    let serde_impl = serde_bundle(name, &idents, &strings);
    let display = if attrs.no_display {
        quote!()
    } else {
        quote! {
            impl ::core::fmt::Display for #name {
                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
                    f.write_str(match self {
                        #(Self::#idents => #strings,)*
                    })
                }
            }
        }
    };
    Ok(quote! {
        impl #krate::domain::ValueObject for #name {}

        impl #krate::domain::EnumVo for #name {
            fn variants() -> &'static [Self] {
                &[#(Self::#idents),*]
            }
        }

        #display

        impl ::core::str::FromStr for #name {
            type Err = #krate::domain::ValidationError;

            fn from_str(s: &str) -> ::core::result::Result<Self, Self::Err> {
                match s {
                    #(#strings => ::core::result::Result::Ok(Self::#idents),)*
                    _ => ::core::result::Result::Err(
                        #krate::domain::ValidationError::new(#type_name, #expected),
                    ),
                }
            }
        }

        #serde_impl
    })
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn rename_none_rule_returns_pascal_case_unchanged() {
        assert_eq!(rename("FooBar", None), Ok("FooBar".to_string()));
    }

    #[test]
    fn rename_pascal_case_rule_returns_unchanged() {
        assert_eq!(
            rename("FooBar", Some("PascalCase")),
            Ok("FooBar".to_string())
        );
    }

    #[test]
    fn rename_snake_case_rule_converts_pascal_to_snake() {
        assert_eq!(
            rename("FooBar", Some("snake_case")),
            Ok("foo_bar".to_string())
        );
    }

    #[test]
    fn rename_screaming_snake_case_rule_converts_to_upper_snake() {
        assert_eq!(
            rename("FooBar", Some("SCREAMING_SNAKE_CASE")),
            Ok("FOO_BAR".to_string())
        );
    }

    #[test]
    fn rename_kebab_case_rule_converts_to_kebab() {
        assert_eq!(
            rename("FooBar", Some("kebab-case")),
            Ok("foo-bar".to_string())
        );
    }

    #[test]
    fn rename_lowercase_rule_converts_without_separators() {
        assert_eq!(
            rename("FooBar", Some("lowercase")),
            Ok("foobar".to_string())
        );
    }

    #[test]
    fn rename_uppercase_rule_converts_without_separators() {
        assert_eq!(
            rename("FooBar", Some("UPPERCASE")),
            Ok("FOOBAR".to_string())
        );
    }

    #[test]
    fn rename_camel_case_rule_lowercases_first_letter_only() {
        assert_eq!(
            rename("FooBar", Some("camelCase")),
            Ok("fooBar".to_string())
        );
    }

    #[test]
    fn rename_unknown_rule_returns_err_listing_valid_options() {
        let err = rename("FooBar", Some("Bogus")).unwrap_err();
        for expected in [
            "PascalCase",
            "snake_case",
            "SCREAMING_SNAKE_CASE",
            "kebab-case",
            "lowercase",
            "UPPERCASE",
            "camelCase",
        ] {
            assert!(err.contains(expected), "missing `{expected}` in: {err}");
        }
    }

    #[test]
    fn rename_single_char_variant_snake_case_has_no_leading_underscore() {
        assert_eq!(rename("A", Some("snake_case")), Ok("a".to_string()));
    }

    #[test]
    fn rename_consecutive_uppercase_snake_case_inserts_underscore_between_each() {
        assert_eq!(
            rename("HTTPServer", Some("snake_case")),
            Ok("h_t_t_p_server".to_string())
        );
    }
}