use proc_macro::TokenStream;
use quote::{format_ident, quote};
use secretspec::{Config, Secret};
use std::collections::{BTreeMap, HashSet};
use syn::{LitStr, parse_macro_input};
#[derive(Clone)]
struct FieldInfo {
name: String,
field_type: proc_macro2::TokenStream,
is_optional: bool,
as_path: bool,
}
impl FieldInfo {
fn new(
name: String,
field_type: proc_macro2::TokenStream,
is_optional: bool,
as_path: bool,
) -> Self {
Self {
name,
field_type,
is_optional,
as_path,
}
}
fn field_name(&self) -> proc_macro2::Ident {
field_name_ident(&self.name)
}
fn generate_struct_field(&self) -> proc_macro2::TokenStream {
let field_name = self.field_name();
let field_type = &self.field_type;
quote! { pub #field_name: #field_type }
}
fn generate_assignment(&self, source: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
generate_secret_assignment(
&self.field_name(),
&self.name,
source,
self.is_optional,
self.as_path,
)
}
fn generate_env_setter(&self) -> proc_macro2::TokenStream {
let field_name = self.field_name();
let env_name = &self.name;
match (self.is_optional, self.as_path) {
(true, true) => {
quote! {
if let Some(ref value) = self.#field_name {
unsafe {
std::env::set_var(#env_name, value.to_string_lossy().as_ref());
}
}
}
}
(true, false) => {
quote! {
if let Some(ref value) = self.#field_name {
unsafe {
std::env::set_var(#env_name, value);
}
}
}
}
(false, true) => {
quote! {
unsafe {
std::env::set_var(#env_name, self.#field_name.to_string_lossy().as_ref());
}
}
}
(false, false) => {
quote! {
unsafe {
std::env::set_var(#env_name, &self.#field_name);
}
}
}
}
}
}
struct ProfileVariant {
name: String,
capitalized: String,
}
impl ProfileVariant {
fn new(name: String) -> Self {
let capitalized = capitalize_first(&name);
Self { name, capitalized }
}
fn as_ident(&self) -> proc_macro2::Ident {
format_ident!("{}", self.capitalized)
}
}
#[proc_macro]
pub fn declare_secrets(input: TokenStream) -> TokenStream {
let path = parse_macro_input!(input as LitStr).value();
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let full_path = std::path::Path::new(&manifest_dir).join(&path);
let config: Config = match Config::try_from(full_path.as_path()) {
Ok(config) => config,
Err(e) => {
let error = format!("Failed to parse TOML: {}", e);
return quote! { compile_error!(#error); }.into();
}
};
if let Err(validation_errors) = validate_config_for_codegen(&config) {
let error_message = format!(
"Invalid secretspec configuration:\n{}",
validation_errors.join("\n")
);
return quote! { compile_error!(#error_message); }.into();
}
let output = generate_secret_spec_code(config);
output.into()
}
fn validate_config_for_codegen(config: &Config) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
validate_rust_identifiers(config, &mut errors);
validate_profile_identifiers(config, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_rust_identifiers(config: &Config, errors: &mut Vec<String>) {
let rust_keywords = [
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
"extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
"mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
"true", "type", "unsafe", "use", "where", "while", "abstract", "become", "box", "do",
"final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
];
for (profile_name, profile_config) in &config.profiles {
let mut profile_field_names = HashSet::new();
for secret_name in profile_config.secrets.keys() {
let field_name = secret_name.to_lowercase();
if !is_valid_rust_identifier(&field_name) {
errors.push(format!(
"Secret '{}' in profile '{}' produces invalid Rust field name '{}'",
secret_name, profile_name, field_name
));
}
if rust_keywords.contains(&field_name.as_str()) {
errors.push(format!(
"Secret '{}' in profile '{}' produces Rust keyword '{}' as field name",
secret_name, profile_name, field_name
));
}
if !profile_field_names.insert(field_name.clone()) {
errors.push(format!(
"Profile '{}' has multiple secrets that produce the same field name '{}' (names are case-insensitive)",
profile_name, field_name
));
}
}
}
}
fn is_valid_rust_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
if let Some(first) = chars.next() {
if !first.is_alphabetic() && first != '_' {
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_')
} else {
false
}
}
fn validate_profile_identifiers(config: &Config, errors: &mut Vec<String>) {
for profile_name in config.profiles.keys() {
let variant_name = capitalize_first(profile_name);
if !is_valid_rust_identifier(&variant_name) {
errors.push(format!(
"Profile '{}' produces invalid Rust enum variant '{}'",
profile_name, variant_name
));
}
}
}
fn field_name_ident(name: &str) -> proc_macro2::Ident {
format_ident!("{}", name.to_lowercase())
}
fn is_secret_optional(secret_config: &Secret) -> bool {
secret_config.required != Some(true)
}
fn is_field_optional_across_profiles(secret_name: &str, config: &Config) -> bool {
for profile_config in config.profiles.values() {
if let Some(secret_config) = profile_config.secrets.get(secret_name) {
if is_secret_optional(secret_config) {
return true;
}
} else {
return true;
}
}
false
}
fn is_field_as_path(secret_name: &str, config: &Config) -> bool {
for profile_config in config.profiles.values() {
if let Some(secret_config) = profile_config.secrets.get(secret_name)
&& secret_config.as_path == Some(true)
{
return true;
}
}
false
}
fn generate_secret_assignment(
field_name: &proc_macro2::Ident,
secret_name: &str,
source: proc_macro2::TokenStream,
is_optional: bool,
as_path: bool,
) -> proc_macro2::TokenStream {
match (is_optional, as_path) {
(true, true) => {
quote! {
#field_name: #source.get(#secret_name).map(|s| std::path::PathBuf::from(s.expose_secret()))
}
}
(true, false) => {
quote! {
#field_name: #source.get(#secret_name).map(|s| s.expose_secret().to_string())
}
}
(false, true) => {
quote! {
#field_name: std::path::PathBuf::from(
#source.get(#secret_name)
.ok_or_else(|| secretspec::SecretSpecError::RequiredSecretMissing(#secret_name.to_string()))?
.expose_secret()
)
}
}
(false, false) => {
quote! {
#field_name: #source.get(#secret_name)
.ok_or_else(|| secretspec::SecretSpecError::RequiredSecretMissing(#secret_name.to_string()))?
.expose_secret()
.to_string()
}
}
}
}
fn analyze_field_types(config: &Config) -> BTreeMap<String, FieldInfo> {
let mut field_info = BTreeMap::new();
for profile_config in config.profiles.values() {
for secret_name in profile_config.secrets.keys() {
field_info.entry(secret_name.clone()).or_insert_with(|| {
let is_optional = is_field_optional_across_profiles(secret_name, config);
let as_path = is_field_as_path(secret_name, config);
let field_type = match (is_optional, as_path) {
(true, true) => quote! { Option<std::path::PathBuf> },
(true, false) => quote! { Option<String> },
(false, true) => quote! { std::path::PathBuf },
(false, false) => quote! { String },
};
FieldInfo::new(secret_name.clone(), field_type, is_optional, as_path)
});
}
}
field_info
}
fn get_profile_variants(profiles: &HashSet<String>) -> Vec<ProfileVariant> {
if profiles.is_empty() {
vec![ProfileVariant::new("default".to_string())]
} else {
let mut variants: Vec<_> = profiles
.iter()
.map(|name| ProfileVariant::new(name.clone()))
.collect();
variants.sort_by(|a, b| a.name.cmp(&b.name));
variants
}
}
mod profile_generation {
use super::*;
pub fn generate_enum(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
let enum_variants = variants.iter().map(|v| {
let ident = v.as_ident();
quote! { #ident }
});
quote! {
#[derive(Debug, Clone, Copy)]
pub enum Profile {
#(#enum_variants,)*
}
}
}
pub fn generate_try_from_impls(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
let from_str_arms = variants.iter().map(|v| {
let ident = v.as_ident();
let str_val = &v.name;
quote! { #str_val => Ok(Profile::#ident) }
});
quote! {
impl std::convert::TryFrom<&str> for Profile {
type Error = secretspec::SecretSpecError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
#(#from_str_arms,)*
_ => Err(secretspec::SecretSpecError::InvalidProfile(value.to_string())),
}
}
}
impl std::convert::TryFrom<String> for Profile {
type Error = secretspec::SecretSpecError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Profile::try_from(value.as_str())
}
}
}
}
pub fn generate_as_str_impl(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
let to_str_arms = variants.iter().map(|v| {
let ident = v.as_ident();
let str_val = &v.name;
quote! { Profile::#ident => #str_val }
});
quote! {
impl Profile {
fn as_str(&self) -> &'static str {
match self {
#(#to_str_arms,)*
}
}
}
}
}
pub fn generate_all(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
let enum_def = generate_enum(variants);
let try_from_impls = generate_try_from_impls(variants);
let as_str_impl = generate_as_str_impl(variants);
quote! {
#enum_def
#try_from_impls
#as_str_impl
}
}
}
mod secret_spec_generation {
use super::*;
pub fn generate_struct(field_info: &BTreeMap<String, FieldInfo>) -> proc_macro2::TokenStream {
let fields = field_info.values().map(|info| info.generate_struct_field());
quote! {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SecretSpec {
#(#fields,)*
}
}
}
pub fn generate_profile_enum(
profile_variants: &[proc_macro2::TokenStream],
) -> proc_macro2::TokenStream {
quote! {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum SecretSpecProfile {
#(#profile_variants,)*
}
}
}
pub fn generate_profile_enum_variants(
config: &Config,
field_info: &BTreeMap<String, FieldInfo>,
variants: &[ProfileVariant],
) -> Vec<proc_macro2::TokenStream> {
if config.profiles.is_empty() {
let fields = field_info.values().map(|info| info.generate_struct_field());
vec![quote! {
Default {
#(#fields,)*
}
}]
} else {
variants
.iter()
.filter_map(|variant| {
config.profiles.get(&variant.name).map(|profile_config| {
let variant_ident = variant.as_ident();
let fields =
profile_config
.secrets
.iter()
.map(|(secret_name, secret_config)| {
let field_name = field_name_ident(secret_name);
let is_optional = is_secret_optional(secret_config);
let as_path = secret_config.as_path.unwrap_or(false);
let field_type = match (is_optional, as_path) {
(true, true) => quote! { Option<std::path::PathBuf> },
(true, false) => quote! { Option<String> },
(false, true) => quote! { std::path::PathBuf },
(false, false) => quote! { String },
};
quote! { #field_name: #field_type }
});
quote! {
#variant_ident {
#(#fields,)*
}
}
})
})
.collect()
}
}
pub fn generate_load_profile_arms(
config: &Config,
field_info: &BTreeMap<String, FieldInfo>,
variants: &[ProfileVariant],
) -> Vec<proc_macro2::TokenStream> {
if config.profiles.is_empty() {
let assignments = field_info
.values()
.map(|info| info.generate_assignment(quote! { secrets }));
vec![quote! {
Profile::Default => Ok(SecretSpecProfile::Default {
#(#assignments,)*
})
}]
} else {
variants
.iter()
.filter_map(|variant| {
config.profiles.get(&variant.name).map(|profile_config| {
let variant_ident = variant.as_ident();
let assignments =
profile_config
.secrets
.iter()
.map(|(secret_name, secret_config)| {
let field_name = field_name_ident(secret_name);
generate_secret_assignment(
&field_name,
secret_name,
quote! { secrets },
is_secret_optional(secret_config),
secret_config.as_path.unwrap_or(false),
)
});
quote! {
Profile::#variant_ident => Ok(SecretSpecProfile::#variant_ident {
#(#assignments,)*
})
}
})
})
.collect()
}
}
pub fn generate_load_internal() -> proc_macro2::TokenStream {
quote! {
fn load_internal(
provider_str: Option<String>,
profile_str: Option<String>,
reason: Option<String>,
) -> Result<secretspec::ValidatedSecrets, secretspec::SecretSpecError> {
let mut spec = secretspec::Secrets::load()?;
if let Some(provider) = provider_str {
spec.set_provider(provider);
}
if let Some(profile) = profile_str {
spec.set_profile(profile);
}
if let Some(reason) = reason {
spec = spec.with_reason(reason);
}
match spec.validate()? {
Ok(valid_secrets) => Ok(valid_secrets),
Err(validation_errors) => Err(secretspec::SecretSpecError::RequiredSecretMissing(
validation_errors.missing_required.join(", ")
))
}
}
}
}
pub fn generate_impl(
load_assignments: &[proc_macro2::TokenStream],
env_setters: Vec<proc_macro2::TokenStream>,
_field_info: &BTreeMap<String, FieldInfo>,
) -> proc_macro2::TokenStream {
quote! {
impl SecretSpec {
pub fn builder() -> SecretSpecBuilder {
SecretSpecBuilder::new()
}
pub fn load<P>(provider: Option<P>, profile: Option<Profile>) -> Result<secretspec::Resolved<Self>, secretspec::SecretSpecError>
where
P: Into<String>,
{
let provider_str = provider.map(Into::into).or_else(|| std::env::var("SECRETSPEC_PROVIDER").ok());
let profile_str = match profile {
Some(p) => Some(p.as_str().to_string()),
None => std::env::var("SECRETSPEC_PROFILE").ok(),
};
let validation_result = load_internal(provider_str, profile_str, None)?;
let provider_name = validation_result.resolved.provider.clone();
let profile = validation_result.resolved.profile.clone();
let secrets = validation_result.resolved.secrets;
let data = Self {
#(#load_assignments,)*
};
Ok(secretspec::Resolved::new(
data,
provider_name,
profile
))
}
pub fn set_as_env_vars(&self) {
#(#env_setters)*
}
}
}
}
}
mod builder_generation {
use super::*;
pub fn generate_struct() -> proc_macro2::TokenStream {
quote! {
pub struct SecretSpecBuilder {
provider: Option<Box<dyn FnOnce() -> Result<Box<dyn secretspec::Provider>, String>>>,
profile: Option<Box<dyn FnOnce() -> Result<Profile, String>>>,
reason: Option<String>,
}
}
}
pub fn generate_basic_methods() -> proc_macro2::TokenStream {
quote! {
impl Default for SecretSpecBuilder {
fn default() -> Self {
Self::new()
}
}
impl SecretSpecBuilder {
pub fn new() -> Self {
Self {
provider: None,
profile: None,
reason: None,
}
}
pub fn with_reason<T>(mut self, reason: T) -> Self
where
T: Into<String>,
{
self.reason = Some(reason.into());
self
}
pub fn with_provider<T>(mut self, provider: T) -> Self
where
T: TryInto<Box<dyn secretspec::Provider>> + 'static,
T::Error: std::fmt::Display + 'static,
{
self.provider = Some(Box::new(move || {
provider.try_into()
.map_err(|e| format!("Invalid provider: {}", e))
}));
self
}
pub fn with_profile<T>(mut self, profile: T) -> Self
where
T: TryInto<Profile>,
T::Error: std::fmt::Display
{
match profile.try_into() {
Ok(p) => {
self.profile = Some(Box::new(move || Ok(p)));
}
Err(e) => {
let error_msg = format!("{}", e);
self.profile = Some(Box::new(move || Err(error_msg)));
}
}
self
}
}
}
}
fn generate_provider_resolution(
provider_expr: proc_macro2::TokenStream,
) -> proc_macro2::TokenStream {
quote! {
let provider_str = if let Some(provider_fn) = #provider_expr {
let provider_box = provider_fn()
.map_err(|e| secretspec::SecretSpecError::ProviderOperationFailed(e))?;
Some(provider_box.uri())
} else {
None
};
}
}
fn generate_profile_resolution(
profile_expr: proc_macro2::TokenStream,
) -> proc_macro2::TokenStream {
quote! {
let profile_str = if let Some(profile_fn) = #profile_expr {
let profile = profile_fn()
.map_err(|e| secretspec::SecretSpecError::InvalidProfile(e))?;
Some(profile.as_str().to_string())
} else {
None
};
}
}
pub fn generate_load_methods(
load_assignments: &[proc_macro2::TokenStream],
load_profile_arms: &[proc_macro2::TokenStream],
first_profile_variant: &proc_macro2::Ident,
) -> proc_macro2::TokenStream {
let resolve_provider_load = generate_provider_resolution(quote! { self.provider.take() });
let resolve_profile_load = generate_profile_resolution(quote! { self.profile.take() });
let resolve_provider_profile =
generate_provider_resolution(quote! { self.provider.take() });
quote! {
impl SecretSpecBuilder {
pub fn load(mut self) -> Result<secretspec::Resolved<SecretSpec>, secretspec::SecretSpecError> {
#resolve_provider_load
#resolve_profile_load
let reason_str = self.reason.take();
let validation_result = load_internal(provider_str, profile_str, reason_str)?;
let provider_name = validation_result.resolved.provider.clone();
let profile = validation_result.resolved.profile.clone();
let secrets = validation_result.resolved.secrets;
let data = SecretSpec {
#(#load_assignments,)*
};
Ok(secretspec::Resolved::new(
data,
provider_name,
profile
))
}
pub fn load_profile(mut self) -> Result<secretspec::Resolved<SecretSpecProfile>, secretspec::SecretSpecError> {
#resolve_provider_profile
let reason_str = self.reason.take();
let (profile_str, selected_profile) = if let Some(profile_fn) = self.profile.take() {
let profile = profile_fn()
.map_err(|e| secretspec::SecretSpecError::InvalidProfile(e))?;
(Some(profile.as_str().to_string()), profile)
} else {
let profile_str = std::env::var("SECRETSPEC_PROFILE").ok();
let selected_profile = if let Some(ref profile_name) = profile_str {
Profile::try_from(profile_name.as_str())?
} else {
Profile::#first_profile_variant
};
(profile_str, selected_profile)
};
let validation_result = load_internal(provider_str, profile_str, reason_str)?;
let provider_name = validation_result.resolved.provider.clone();
let profile = validation_result.resolved.profile.clone();
let secrets = validation_result.resolved.secrets;
let data_result: LoadResult<SecretSpecProfile> = match selected_profile {
#(#load_profile_arms,)*
};
let data = data_result?;
Ok(secretspec::Resolved::new(
data,
provider_name,
profile
))
}
}
}
}
pub fn generate_all(
load_assignments: &[proc_macro2::TokenStream],
load_profile_arms: &[proc_macro2::TokenStream],
first_profile_variant: &proc_macro2::Ident,
) -> proc_macro2::TokenStream {
let struct_def = generate_struct();
let basic_methods = generate_basic_methods();
let load_methods =
generate_load_methods(load_assignments, load_profile_arms, first_profile_variant);
quote! {
#struct_def
#basic_methods
#load_methods
}
}
}
fn generate_secret_spec_code(config: Config) -> proc_macro2::TokenStream {
let all_profiles: HashSet<String> = config.profiles.keys().cloned().collect();
let profile_variants = get_profile_variants(&all_profiles);
let field_info = analyze_field_types(&config);
let load_assignments: Vec<_> = field_info
.values()
.map(|info| info.generate_assignment(quote! { secrets }))
.collect();
let env_setters: Vec<_> = field_info
.values()
.map(|info| info.generate_env_setter())
.collect();
let profile_code = profile_generation::generate_all(&profile_variants);
let secret_spec_struct = secret_spec_generation::generate_struct(&field_info);
let profile_enum_variants = secret_spec_generation::generate_profile_enum_variants(
&config,
&field_info,
&profile_variants,
);
let secret_spec_profile_enum =
secret_spec_generation::generate_profile_enum(&profile_enum_variants);
let load_profile_arms =
secret_spec_generation::generate_load_profile_arms(&config, &field_info, &profile_variants);
let load_internal = secret_spec_generation::generate_load_internal();
let secret_spec_impl =
secret_spec_generation::generate_impl(&load_assignments, env_setters, &field_info);
let first_profile_variant = profile_variants
.first()
.map(|v| v.as_ident())
.unwrap_or_else(|| format_ident!("Default"));
let builder_code = builder_generation::generate_all(
&load_assignments,
&load_profile_arms,
&first_profile_variant,
);
quote! {
use ::secrecy::ExposeSecret;
#secret_spec_struct
#secret_spec_profile_enum
#profile_code
type LoadResult<T> = Result<T, secretspec::SecretSpecError>;
#load_internal
#builder_code
#secret_spec_impl
}
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;