use darling::{FromDeriveInput, FromField};
use syn::{GenericArgument, Ident, PathArguments, Type};
const MAX_PREFIX_LENGTH: usize = 64;
const MAX_NAME_LENGTH: usize = 256;
#[derive(Debug, FromDeriveInput)]
#[darling(attributes(config), supports(struct_named))]
#[allow(dead_code)]
pub struct StructAttrs {
pub ident: Ident,
#[darling(default)]
pub validate: bool,
pub env_prefix: Option<String>,
pub app_name: Option<String>,
#[darling(default)]
pub strict: bool,
#[darling(default)]
pub watch: bool,
pub version: Option<u32>,
#[darling(default)]
pub profile: bool,
pub profile_env: Option<String>,
}
impl StructAttrs {
pub fn effective_env_prefix(&self) -> &str {
self.env_prefix.as_deref().unwrap_or("")
}
#[allow(dead_code)]
pub fn effective_profile_env(&self) -> &str {
self.profile_env.as_deref().unwrap_or("APP_ENV")
}
pub fn validate(&self, input: &syn::DeriveInput) -> darling::Result<()> {
let mut errors = darling::Error::accumulator();
if let Some(version) = self.version {
if version == 0 {
errors.push(
darling::Error::custom("version must be a positive integer (1 or greater)")
.with_span(&input.ident),
);
}
}
if let Some(ref prefix) = self.env_prefix {
if prefix.len() > MAX_PREFIX_LENGTH {
errors.push(
darling::Error::custom(format!(
"env_prefix exceeds maximum length of {} characters (current: {})",
MAX_PREFIX_LENGTH,
prefix.len()
))
.with_span(&input.ident),
);
}
if prefix.is_empty() {
errors.push(
darling::Error::custom(
"env_prefix cannot be empty. Remove the attribute to use no prefix",
)
.with_span(&input.ident),
);
}
if !prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
errors.push(
darling::Error::custom(
"env_prefix must only contain alphanumeric characters and underscores",
)
.with_span(&input.ident),
);
}
if prefix.chars().any(|c| c.is_control()) {
errors.push(
darling::Error::custom("env_prefix cannot contain control characters")
.with_span(&input.ident),
);
}
}
if let Some(ref app_name) = self.app_name {
if app_name.len() > MAX_NAME_LENGTH {
errors.push(
darling::Error::custom(format!(
"app_name exceeds maximum length of {} characters",
MAX_NAME_LENGTH
))
.with_span(&input.ident),
);
}
if app_name.is_empty() {
errors.push(
darling::Error::custom("app_name cannot be empty").with_span(&input.ident),
);
}
}
errors.finish()
}
}
#[derive(Debug, FromField)]
#[darling(attributes(config))]
#[allow(dead_code)]
pub struct FieldAttrs {
pub ident: Option<Ident>,
pub ty: Type,
pub default: Option<syn::Expr>,
pub description: Option<String>,
pub name: Option<String>,
pub name_env: Option<String>,
pub name_clap_long: Option<String>,
pub name_clap_short: Option<char>,
#[darling(default)]
pub sensitive: bool,
pub encrypt: Option<String>,
#[darling(default)]
pub flatten: bool,
#[darling(default)]
pub skip: bool,
#[darling(default)]
pub interpolate: bool,
pub merge_strategy: Option<String>,
#[darling(default)]
pub dynamic: bool,
pub module_group: Option<String>,
}
impl FieldAttrs {
pub fn effective_name(&self) -> String {
self.name.clone().unwrap_or_else(|| {
self.ident
.as_ref()
.map(|i| i.to_string())
.unwrap_or_default()
})
}
pub fn effective_env_name(&self, prefix: &str) -> String {
if let Some(ref name_env) = self.name_env {
name_env.clone()
} else {
let key = self.effective_name();
format!("{}{}", prefix, key.to_uppercase().replace('.', "_"))
}
}
pub fn is_secret_string(&self) -> bool {
is_secret_type(&self.ty)
}
pub fn is_sensitive_effective(&self) -> bool {
self.sensitive || self.encrypt.is_some() || self.is_secret_string()
}
pub fn validate(&self, _field: &syn::Field) -> darling::Result<()> {
let mut errors = darling::Error::accumulator();
if let Some(ref algo) = self.encrypt {
match algo.as_str() {
"xchacha20" | "aes256-gcm" => {}
_ => {
if let Some(ident) = self.ident.as_ref() {
errors.push(
darling::Error::custom(format!(
"unsupported encryption algorithm '{}'\n\
supported algorithms: \"xchacha20\", \"aes256-gcm\"",
algo
))
.with_span(ident),
);
}
}
}
}
if let Some(ref strategy) = self.merge_strategy {
let valid_strategies = [
"replace",
"join",
"append",
"prepend",
"join_append",
"deep_merge",
];
if !valid_strategies.contains(&strategy.as_str()) {
if let Some(ident) = self.ident.as_ref() {
errors.push(
darling::Error::custom(format!(
"invalid merge strategy '{}'\n\
valid strategies: {}",
strategy,
valid_strategies.join(", ")
))
.with_span(ident),
);
}
}
}
if self.sensitive && !self.is_secret_string() {
if let Some(ident) = self.ident.as_ref() {
errors.push(
darling::Error::custom(format!(
"sensitive field '{}' should use SecretString or SecretBytes type for security",
ident
))
.with_span(ident),
);
}
}
errors.finish()
}
}
pub fn is_secret_type(ty: &Type) -> bool {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
return segment.ident == "SecretString" || segment.ident == "SecretBytes";
}
}
false
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypeCategory {
String,
Integer,
Float,
Boolean,
Option,
Vec,
Map,
Secret,
Custom,
}
impl TypeCategory {
#[allow(dead_code)]
pub fn from_type(ty: &Type) -> Self {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
match segment.ident.to_string().as_str() {
"String" | "str" => return Self::String,
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" => return Self::Integer,
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" => return Self::Integer,
"f32" | "f64" => return Self::Float,
"bool" => return Self::Boolean,
"Option" => return Self::Option,
"Vec" => return Self::Vec,
"HashMap" | "BTreeMap" | "Map" => return Self::Map,
"SecretString" | "SecretBytes" => return Self::Secret,
_ => {}
}
}
}
Self::Custom
}
}
pub fn is_option_type(ty: &Type) -> bool {
if let syn::Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
return segment.ident == "Option";
}
}
false
}
pub fn is_vec_type(ty: &Type) -> bool {
if let syn::Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
return segment.ident == "Vec";
}
}
false
}
#[allow(dead_code)]
pub fn extract_inner_type(ty: &Type) -> Option<&Type> {
if let syn::Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
if let PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(GenericArgument::Type(inner)) = args.args.first() {
return Some(inner);
}
}
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum MergeStrategyKind {
#[default]
Replace,
Join,
Append,
Prepend,
JoinAppend,
DeepMerge,
}
impl MergeStrategyKind {
#[allow(dead_code)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"join" => Self::Join,
"append" => Self::Append,
"prepend" => Self::Prepend,
"join_append" | "joinappend" => Self::JoinAppend,
"deep_merge" | "deepmerge" => Self::DeepMerge,
_ => Self::Replace,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn test_is_option_type() {
let ty: Type = parse_quote!(Option<String>);
assert!(is_option_type(&ty));
let ty: Type = parse_quote!(String);
assert!(!is_option_type(&ty));
}
#[test]
fn test_is_vec_type() {
let ty: Type = parse_quote!(Vec<String>);
assert!(is_vec_type(&ty));
let ty: Type = parse_quote!(String);
assert!(!is_vec_type(&ty));
}
#[test]
fn test_extract_inner_type() {
let ty: Type = parse_quote!(Option<String>);
let inner = extract_inner_type(&ty);
assert!(inner.is_some());
let ty: Type = parse_quote!(Vec<i32>);
let inner = extract_inner_type(&ty);
assert!(inner.is_some());
}
#[test]
fn test_merge_strategy_from_str() {
assert_eq!(
MergeStrategyKind::from_str("replace"),
MergeStrategyKind::Replace
);
assert_eq!(MergeStrategyKind::from_str("join"), MergeStrategyKind::Join);
assert_eq!(
MergeStrategyKind::from_str("append"),
MergeStrategyKind::Append
);
assert_eq!(
MergeStrategyKind::from_str("deep_merge"),
MergeStrategyKind::DeepMerge
);
}
}