use proc_macro::TokenStream;
use quote::{format_ident, quote};
use secretspec::Config;
use secretspec::codegen::{CodegenIr, IrField, build_ir, capitalize};
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 from_ir(field: &IrField) -> Self {
Self::new(
field.name.clone(),
ir_field_type(field),
field.optional,
field.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(&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(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 ir_field_type(field: &IrField) -> proc_macro2::TokenStream {
match (field.optional, field.as_path) {
(true, true) => quote! { Option<std::path::PathBuf> },
(true, false) => quote! { Option<String> },
(false, true) => quote! { std::path::PathBuf },
(false, false) => quote! { String },
}
}
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 union_field_info(ir: &CodegenIr) -> BTreeMap<String, FieldInfo> {
ir.union
.iter()
.map(|field| (field.name.clone(), FieldInfo::from_ir(field)))
.collect()
}
fn profile_variants_from_ir(ir: &CodegenIr) -> Vec<ProfileVariant> {
ir.profiles
.iter()
.map(|name| ProfileVariant::new(name.clone()))
.collect()
}
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(ir: &CodegenIr) -> Vec<proc_macro2::TokenStream> {
ir.profile_fields
.iter()
.map(|profile| {
let variant_ident = ProfileVariant::new(profile.name.clone()).as_ident();
let fields = profile.fields.iter().map(|field| {
let field_name = field_name_ident(&field.name);
let field_type = ir_field_type(field);
quote! { #field_name: #field_type }
});
quote! {
#variant_ident {
#(#fields,)*
}
}
})
.collect()
}
pub fn generate_load_profile_arms(ir: &CodegenIr) -> Vec<proc_macro2::TokenStream> {
ir.profile_fields
.iter()
.map(|profile| {
let variant_ident = ProfileVariant::new(profile.name.clone()).as_ident();
let assignments = profile.fields.iter().map(|field| {
generate_secret_assignment(
&field_name_ident(&field.name),
&field.name,
quote! { secrets },
field.optional,
field.as_path,
)
});
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()?;
spec.set_ignore_ambient_scope(true);
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) if validation_errors.constraint_violations.is_empty() => {
Err(secretspec::SecretSpecError::RequiredSecretMissing(
validation_errors.missing_required.join(", ")
))
}
Err(validation_errors) => Err(secretspec::SecretSpecError::ValidationFailed(
Box::new(validation_errors)
))
}
}
}
}
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 data = {
let secrets = &validation_result.resolved.secrets;
Self {
#(#load_assignments,)*
}
};
Ok(validation_result.into_resolved(data))
}
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 data = {
let secrets = &validation_result.resolved.secrets;
SecretSpec {
#(#load_assignments,)*
}
};
Ok(validation_result.into_resolved(data))
}
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()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
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 data_result: LoadResult<SecretSpecProfile> = {
let secrets = &validation_result.resolved.secrets;
match selected_profile {
#(#load_profile_arms,)*
}
};
let data = data_result?;
Ok(validation_result.into_resolved(data))
}
}
}
}
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 ir = build_ir(&config);
let profile_variants = profile_variants_from_ir(&ir);
let field_info = union_field_info(&ir);
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(&ir);
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(&ir);
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
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod derive_tests;