Skip to main content

secretspec_derive/
lib.rs

1//! # SecretSpec Derive Macros
2//!
3//! This crate provides procedural macros for the SecretSpec library, enabling compile-time
4//! generation of strongly-typed secret structs from `secretspec.toml` configuration files.
5//!
6//! ## Overview
7//!
8//! The macro system reads your `secretspec.toml` at compile time and generates:
9//! - A `SecretSpec` struct with all secrets as fields (union of all profiles)
10//! - A `SecretSpecProfile` enum with profile-specific structs
11//! - A `Profile` enum representing available profiles
12//! - Type-safe loading methods with automatic validation
13//!
14//! ## Key Features
15//!
16//! - **Compile-time validation**: Invalid configurations are caught during compilation
17//! - **Type safety**: Secrets are accessed as struct fields, not strings
18//! - **Profile awareness**: Different types for different profiles (e.g., production vs development)
19//! - **Builder pattern**: Flexible configuration with method chaining
20//! - **Environment integration**: Automatic environment variable handling
21
22use proc_macro::TokenStream;
23use quote::{format_ident, quote};
24use secretspec::Config;
25use secretspec::codegen::{CodegenIr, IrField, build_ir, capitalize};
26use std::collections::{BTreeMap, HashSet};
27use syn::{LitStr, parse_macro_input};
28
29/// Holds metadata about a field in the generated struct.
30///
31/// This struct contains all the information needed to generate:
32/// - Struct field declarations
33/// - Field assignments from secret maps
34/// - Environment variable setters
35///
36/// # Fields
37///
38/// * `name` - The original secret name (e.g., "DATABASE_URL")
39/// * `field_type` - The Rust type for this field (String, PathBuf, or Option variants)
40/// * `is_optional` - Whether this field is optional across all profiles
41/// * `as_path` - Whether this field represents a path to a temporary file
42#[derive(Clone)]
43struct FieldInfo {
44    name: String,
45    field_type: proc_macro2::TokenStream,
46    is_optional: bool,
47    as_path: bool,
48}
49
50impl FieldInfo {
51    /// Creates a new FieldInfo instance.
52    ///
53    /// # Arguments
54    ///
55    /// * `name` - The secret name as defined in the config
56    /// * `field_type` - The generated Rust type (String, PathBuf, or Option variants)
57    /// * `is_optional` - Whether the field should be optional
58    /// * `as_path` - Whether this field represents a path to a temporary file
59    fn new(
60        name: String,
61        field_type: proc_macro2::TokenStream,
62        is_optional: bool,
63        as_path: bool,
64    ) -> Self {
65        Self {
66            name,
67            field_type,
68            is_optional,
69            as_path,
70        }
71    }
72
73    /// Build a `FieldInfo` from a shared-IR field. The IR is the single source
74    /// of the optionality/as_path decisions; this only maps them to a Rust type.
75    fn from_ir(field: &IrField) -> Self {
76        Self::new(
77            field.name.clone(),
78            ir_field_type(field),
79            field.optional,
80            field.as_path,
81        )
82    }
83
84    /// Get the field name as a Rust identifier.
85    ///
86    /// Converts the secret name to a valid Rust field name by:
87    /// - Converting to lowercase
88    /// - Preserving underscores
89    ///
90    /// # Example
91    ///
92    /// - "DATABASE_URL" becomes `database_url`
93    /// - "API_KEY" becomes `api_key`
94    fn field_name(&self) -> proc_macro2::Ident {
95        field_name_ident(&self.name)
96    }
97
98    /// Generate the struct field declaration.
99    ///
100    /// Creates a public field declaration for use in the generated struct.
101    ///
102    /// # Returns
103    ///
104    /// A token stream representing `pub field_name: FieldType`
105    ///
106    /// # Example Output
107    ///
108    /// ```ignore
109    /// pub database_url: String
110    /// pub api_key: Option<String>
111    /// ```
112    fn generate_struct_field(&self) -> proc_macro2::TokenStream {
113        let field_name = self.field_name();
114        let field_type = &self.field_type;
115        quote! { pub #field_name: #field_type }
116    }
117
118    /// Generate a field assignment from a secrets map.
119    ///
120    /// Creates code to assign a value from a HashMap<String, String> to this field.
121    /// Handles both required and optional fields appropriately.
122    ///
123    /// # Arguments
124    ///
125    /// * `source` - The token stream representing the source map (e.g., `secrets`)
126    ///
127    /// # Returns
128    ///
129    /// Token stream for the field assignment, with proper error handling for required fields
130    fn generate_assignment(&self, source: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
131        generate_secret_assignment(
132            &self.field_name(),
133            &self.name,
134            source,
135            self.is_optional,
136            self.as_path,
137        )
138    }
139
140    /// Generate environment variable setter.
141    ///
142    /// Creates code to set an environment variable from this field's value.
143    /// For optional fields, only sets the variable if a value is present.
144    /// For PathBuf fields, converts to string using to_string_lossy().
145    ///
146    /// # Safety
147    ///
148    /// The generated code uses `unsafe` because `std::env::set_var` is unsafe
149    /// in multi-threaded contexts. Users should ensure thread safety when calling
150    /// the generated `set_as_env_vars` method.
151    ///
152    /// # Returns
153    ///
154    /// Token stream that sets the environment variable when executed
155    fn generate_env_setter(&self) -> proc_macro2::TokenStream {
156        let field_name = self.field_name();
157        let env_name = &self.name;
158
159        match (self.is_optional, self.as_path) {
160            (true, true) => {
161                // Optional PathBuf
162                quote! {
163                    if let Some(ref value) = self.#field_name {
164                        unsafe {
165                            std::env::set_var(#env_name, value.to_string_lossy().as_ref());
166                        }
167                    }
168                }
169            }
170            (true, false) => {
171                // Optional String
172                quote! {
173                    if let Some(ref value) = self.#field_name {
174                        unsafe {
175                            std::env::set_var(#env_name, value);
176                        }
177                    }
178                }
179            }
180            (false, true) => {
181                // Required PathBuf
182                quote! {
183                    unsafe {
184                        std::env::set_var(#env_name, self.#field_name.to_string_lossy().as_ref());
185                    }
186                }
187            }
188            (false, false) => {
189                // Required String
190                quote! {
191                    unsafe {
192                        std::env::set_var(#env_name, &self.#field_name);
193                    }
194                }
195            }
196        }
197    }
198}
199
200/// Profile variant information for enum generation.
201///
202/// Represents a profile that will become an enum variant in the generated code.
203/// Handles the conversion from profile names to valid Rust enum variants.
204///
205/// # Fields
206///
207/// * `name` - The original profile name (e.g., "production", "development")
208/// * `capitalized` - The capitalized variant name (e.g., "Production", "Development")
209struct ProfileVariant {
210    name: String,
211    capitalized: String,
212}
213
214impl ProfileVariant {
215    /// Creates a new ProfileVariant with automatic capitalization.
216    ///
217    /// # Arguments
218    ///
219    /// * `name` - The profile name from the configuration
220    ///
221    /// # Example
222    ///
223    /// ```ignore
224    /// let variant = ProfileVariant::new("production".to_string());
225    /// // variant.name == "production"
226    /// // variant.capitalized == "Production"
227    /// ```
228    fn new(name: String) -> Self {
229        let capitalized = capitalize(&name);
230        Self { name, capitalized }
231    }
232
233    /// Convert the variant to a Rust identifier.
234    ///
235    /// # Returns
236    ///
237    /// A proc_macro2::Ident suitable for use as an enum variant
238    fn as_ident(&self) -> proc_macro2::Ident {
239        format_ident!("{}", self.capitalized)
240    }
241}
242
243/// Generates typed SecretSpec structs from your secretspec.toml file.
244///
245/// # Example
246/// ```ignore
247/// // In your main.rs or lib.rs:
248/// secretspec_derive::declare_secrets!("secretspec.toml");
249///
250/// use secretspec::Provider;
251///
252/// fn main() -> Result<(), Box<dyn std::error::Error>> {
253///     // Load with union types (safe for any profile) using the builder pattern
254///     let secrets = SecretSpec::builder()
255///         .with_provider(Provider::Keyring)
256///         .load()?;
257///     println!("Database URL: {}", secrets.secrets.database_url);
258///
259///     // Load with profile-specific types
260///     let profile_secrets = SecretSpec::builder()
261///         .with_provider(Provider::Keyring)
262///         .with_profile(Profile::Production)
263///         .load_profile()?;
264///     
265///     match profile_secrets.secrets {
266///         SecretSpecProfile::Production { api_key, database_url, .. } => {
267///             println!("Production API key: {}", api_key);
268///         }
269///         _ => unreachable!(),
270///     }
271///
272///     Ok(())
273/// }
274/// ```
275#[proc_macro]
276pub fn declare_secrets(input: TokenStream) -> TokenStream {
277    let path = parse_macro_input!(input as LitStr).value();
278
279    // Get the manifest directory of the crate using the macro
280    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
281    let full_path = std::path::Path::new(&manifest_dir).join(&path);
282
283    let config: Config = match Config::try_from(full_path.as_path()) {
284        Ok(config) => config,
285        Err(e) => {
286            let error = format!("Failed to parse TOML: {}", e);
287            return quote! { compile_error!(#error); }.into();
288        }
289    };
290
291    // Validate the configuration at compile time
292    if let Err(validation_errors) = validate_config_for_codegen(&config) {
293        let error_message = format!(
294            "Invalid secretspec configuration:\n{}",
295            validation_errors.join("\n")
296        );
297        return quote! { compile_error!(#error_message); }.into();
298    }
299
300    // Generate all the code
301    let output = generate_secret_spec_code(config);
302    output.into()
303}
304
305// ===== Core Helper Functions =====
306
307/// Validate configuration for code generation concerns only.
308///
309/// This performs compile-time validation to ensure the configuration can be
310/// converted into valid Rust code. This is different from runtime validation -
311/// we only check things that would prevent generating valid Rust code.
312///
313/// # Validation Checks
314///
315/// - Secret names must produce valid Rust identifiers
316/// - Secret names must not be Rust keywords
317/// - Profile names must produce valid enum variants
318/// - No duplicate field names within a profile (case-insensitive)
319///
320/// # Arguments
321///
322/// * `config` - The parsed project configuration
323///
324/// # Returns
325///
326/// - `Ok(())` if validation passes
327/// - `Err(Vec<String>)` containing all validation errors if any are found
328fn validate_config_for_codegen(config: &Config) -> Result<(), Vec<String>> {
329    let mut errors = Vec::new();
330
331    // Validate secret names produce valid Rust identifiers
332    validate_rust_identifiers(config, &mut errors);
333
334    // Validate profile names produce valid Rust enum variants
335    validate_profile_identifiers(config, &mut errors);
336
337    if errors.is_empty() {
338        Ok(())
339    } else {
340        Err(errors)
341    }
342}
343
344/// Validate all secret names produce valid Rust identifiers.
345///
346/// Checks that each secret name, when converted to a field name:
347/// - Forms a valid Rust identifier (alphanumeric + underscores)
348/// - Doesn't conflict with Rust keywords
349/// - Doesn't create duplicate field names within a profile
350///
351/// # Arguments
352///
353/// * `config` - The project configuration to validate
354/// * `errors` - Mutable vector to collect error messages
355///
356/// # Error Cases
357///
358/// - Secret names with invalid characters (e.g., "my-secret" with hyphen)
359/// - Secret names that are Rust keywords (e.g., "TYPE", "IMPL")
360/// - Multiple secrets producing the same field name (e.g., "API_KEY" and "api_key")
361fn validate_rust_identifiers(config: &Config, errors: &mut Vec<String>) {
362    let rust_keywords = [
363        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
364        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
365        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
366        "true", "type", "unsafe", "use", "where", "while", "abstract", "become", "box", "do",
367        "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try",
368    ];
369
370    for (profile_name, profile_config) in &config.profiles {
371        let mut profile_field_names = HashSet::new();
372
373        for secret_name in profile_config.secrets.keys() {
374            let field_name = secret_name.to_lowercase();
375
376            // Check if it produces a valid Rust identifier
377            if !is_valid_rust_identifier(&field_name) {
378                errors.push(format!(
379                    "Secret '{}' in profile '{}' produces invalid Rust field name '{}'",
380                    secret_name, profile_name, field_name
381                ));
382            }
383
384            // Check for Rust keywords
385            if rust_keywords.contains(&field_name.as_str()) {
386                errors.push(format!(
387                    "Secret '{}' in profile '{}' produces Rust keyword '{}' as field name",
388                    secret_name, profile_name, field_name
389                ));
390            }
391
392            // Check for duplicate field names within the same profile
393            if !profile_field_names.insert(field_name.clone()) {
394                errors.push(format!(
395                    "Profile '{}' has multiple secrets that produce the same field name '{}' (names are case-insensitive)",
396                    profile_name, field_name
397                ));
398            }
399        }
400    }
401}
402
403/// Check if a string is a valid Rust identifier.
404///
405/// A valid Rust identifier must:
406/// - Start with a letter or underscore
407/// - Contain only letters, numbers, and underscores
408/// - Not be empty
409///
410/// # Arguments
411///
412/// * `s` - The string to validate
413///
414/// # Returns
415///
416/// `true` if the string is a valid Rust identifier, `false` otherwise
417///
418/// # Examples
419///
420/// ```ignore
421/// assert!(is_valid_rust_identifier("my_var"));
422/// assert!(is_valid_rust_identifier("_private"));
423/// assert!(!is_valid_rust_identifier("123start"));
424/// assert!(!is_valid_rust_identifier("my-var"));
425/// ```
426fn is_valid_rust_identifier(s: &str) -> bool {
427    if s.is_empty() {
428        return false;
429    }
430
431    let mut chars = s.chars();
432    if let Some(first) = chars.next() {
433        // First character must be alphabetic or underscore
434        if !first.is_alphabetic() && first != '_' {
435            return false;
436        }
437        // Remaining characters must be alphanumeric or underscore
438        chars.all(|c| c.is_alphanumeric() || c == '_')
439    } else {
440        false
441    }
442}
443
444/// Validate profile names produce valid Rust enum variants.
445///
446/// Ensures that each profile name, when capitalized, forms a valid Rust enum variant.
447///
448/// # Arguments
449///
450/// * `config` - The project configuration to validate
451/// * `errors` - Mutable vector to collect error messages
452///
453/// # Error Cases
454///
455/// - Profile names that start with numbers (e.g., "1production")
456/// - Profile names with invalid characters (e.g., "prod-env")
457fn validate_profile_identifiers(config: &Config, errors: &mut Vec<String>) {
458    for profile_name in config.profiles.keys() {
459        let variant_name = capitalize(profile_name);
460        if !is_valid_rust_identifier(&variant_name) {
461            errors.push(format!(
462                "Profile '{}' produces invalid Rust enum variant '{}'",
463                profile_name, variant_name
464            ));
465        }
466    }
467}
468
469/// Convert a secret name to a field identifier.
470///
471/// Converts environment variable style names to Rust field names by:
472/// - Converting to lowercase
473/// - Preserving underscores
474///
475/// # Arguments
476///
477/// * `name` - The secret name (typically uppercase with underscores)
478///
479/// # Returns
480///
481/// A proc_macro2::Ident suitable for use as a struct field
482///
483/// # Example
484///
485/// ```ignore
486/// let ident = field_name_ident("DATABASE_URL");
487/// // Generates: database_url
488/// ```
489fn field_name_ident(name: &str) -> proc_macro2::Ident {
490    format_ident!("{}", name.to_lowercase())
491}
492
493/// Map a shared-IR field's optionality and path-ness to its Rust type.
494///
495/// This is the only typing decision the derive macro still makes locally; the
496/// underlying optional/as_path facts come from [`secretspec::codegen`].
497fn ir_field_type(field: &IrField) -> proc_macro2::TokenStream {
498    match (field.optional, field.as_path) {
499        (true, true) => quote! { Option<std::path::PathBuf> },
500        (true, false) => quote! { Option<String> },
501        (false, true) => quote! { std::path::PathBuf },
502        (false, false) => quote! { String },
503    }
504}
505
506/// Generate a unified secret assignment from a HashMap.
507///
508/// Creates the code to assign a value from a secrets map to a struct field,
509/// with appropriate error handling based on whether the field is optional.
510///
511/// # Arguments
512///
513/// * `field_name` - The struct field identifier
514/// * `secret_name` - The key to look up in the map
515/// * `source` - Token stream representing the source map
516/// * `is_optional` - Whether to generate Option<T> or T assignment
517/// * `as_path` - Whether to generate PathBuf or String
518///
519/// # Generated Code
520///
521/// For required String fields:
522/// ```ignore
523/// field_name: source.get("SECRET_NAME")
524///     .ok_or_else(|| SecretSpecError::RequiredSecretMissing("SECRET_NAME".to_string()))?
525///     .expose_secret().to_string()
526/// ```
527///
528/// For required PathBuf fields:
529/// ```ignore
530/// field_name: std::path::PathBuf::from(source.get("SECRET_NAME")
531///     .ok_or_else(|| SecretSpecError::RequiredSecretMissing("SECRET_NAME".to_string()))?
532///     .expose_secret())
533/// ```
534///
535/// For optional fields:
536/// ```ignore
537/// field_name: source.get("SECRET_NAME").map(|s| s.expose_secret().to_string())
538/// field_name: source.get("SECRET_NAME").map(|s| std::path::PathBuf::from(s.expose_secret()))
539/// ```
540fn generate_secret_assignment(
541    field_name: &proc_macro2::Ident,
542    secret_name: &str,
543    source: proc_macro2::TokenStream,
544    is_optional: bool,
545    as_path: bool,
546) -> proc_macro2::TokenStream {
547    match (is_optional, as_path) {
548        (true, true) => {
549            // Optional PathBuf
550            quote! {
551                #field_name: #source.get(#secret_name).map(|s| std::path::PathBuf::from(s.expose_secret()))
552            }
553        }
554        (true, false) => {
555            // Optional String
556            quote! {
557                #field_name: #source.get(#secret_name).map(|s| s.expose_secret().to_string())
558            }
559        }
560        (false, true) => {
561            // Required PathBuf
562            quote! {
563                #field_name: std::path::PathBuf::from(
564                    #source.get(#secret_name)
565                        .ok_or_else(|| secretspec::SecretSpecError::RequiredSecretMissing(#secret_name.to_string()))?
566                        .expose_secret()
567                )
568            }
569        }
570        (false, false) => {
571            // Required String
572            quote! {
573                #field_name: #source.get(#secret_name)
574                    .ok_or_else(|| secretspec::SecretSpecError::RequiredSecretMissing(#secret_name.to_string()))?
575                    .expose_secret()
576                    .to_string()
577            }
578        }
579    }
580}
581
582/// Build the union struct's fields from the shared IR.
583///
584/// The IR already determined the union field set and each field's
585/// optionality/as_path; this just maps them to `FieldInfo`, keyed and ordered
586/// by name (the IR union is pre-sorted).
587fn union_field_info(ir: &CodegenIr) -> BTreeMap<String, FieldInfo> {
588    ir.union
589        .iter()
590        .map(|field| (field.name.clone(), FieldInfo::from_ir(field)))
591        .collect()
592}
593
594/// Profile variants for enum generation, taken from the shared IR.
595///
596/// The IR's profile list is already sorted and already substitutes a single
597/// `default` profile when the manifest declares none, so this is a direct map.
598fn profile_variants_from_ir(ir: &CodegenIr) -> Vec<ProfileVariant> {
599    ir.profiles
600        .iter()
601        .map(|name| ProfileVariant::new(name.clone()))
602        .collect()
603}
604
605// ===== Profile Generation Module =====
606
607/// Module for generating Profile enum and related implementations.
608///
609/// This module handles:
610/// - Profile enum definition
611/// - TryFrom implementations for string conversion
612/// - as_str() method for profile serialization
613mod profile_generation {
614    use super::*;
615
616    /// Generate just the Profile enum.
617    ///
618    /// Creates an enum with variants for each profile in the configuration.
619    ///
620    /// # Arguments
621    ///
622    /// * `variants` - List of profile variants to generate
623    ///
624    /// # Generated Code Example
625    ///
626    /// ```ignore
627    /// #[derive(Debug, Clone, Copy)]
628    /// pub enum Profile {
629    ///     Development,
630    ///     Production,
631    ///     Staging,
632    /// }
633    /// ```
634    pub fn generate_enum(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
635        let enum_variants = variants.iter().map(|v| {
636            let ident = v.as_ident();
637            quote! { #ident }
638        });
639
640        quote! {
641            #[derive(Debug, Clone, Copy)]
642            pub enum Profile {
643                #(#enum_variants,)*
644            }
645        }
646    }
647
648    /// Generate TryFrom implementations for Profile.
649    ///
650    /// Creates implementations to convert strings to Profile enum variants,
651    /// supporting both &str and String inputs.
652    ///
653    /// # Arguments
654    ///
655    /// * `variants` - List of profile variants
656    ///
657    /// # Generated Code
658    ///
659    /// - `TryFrom<&str>` implementation with match arms for each profile
660    /// - `TryFrom<String>` implementation that delegates to &str
661    /// - Returns `SecretSpecError::InvalidProfile` for unknown profiles
662    pub fn generate_try_from_impls(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
663        let from_str_arms = variants.iter().map(|v| {
664            let ident = v.as_ident();
665            let str_val = &v.name;
666            quote! { #str_val => Ok(Profile::#ident) }
667        });
668
669        quote! {
670            impl std::convert::TryFrom<&str> for Profile {
671                type Error = secretspec::SecretSpecError;
672
673                fn try_from(value: &str) -> Result<Self, Self::Error> {
674                    match value {
675                        #(#from_str_arms,)*
676                        _ => Err(secretspec::SecretSpecError::InvalidProfile(value.to_string())),
677                    }
678                }
679            }
680
681            impl std::convert::TryFrom<String> for Profile {
682                type Error = secretspec::SecretSpecError;
683
684                fn try_from(value: String) -> Result<Self, Self::Error> {
685                    Profile::try_from(value.as_str())
686                }
687            }
688        }
689    }
690
691    /// Generate as_str implementation for Profile.
692    ///
693    /// Creates a method to convert Profile enum variants back to their string representation.
694    ///
695    /// # Arguments
696    ///
697    /// * `variants` - List of profile variants
698    ///
699    /// # Generated Code Example
700    ///
701    /// ```ignore
702    /// impl Profile {
703    ///     fn as_str(&self) -> &'static str {
704    ///         match self {
705    ///             Profile::Development => "development",
706    ///             Profile::Production => "production",
707    ///         }
708    ///     }
709    /// }
710    /// ```
711    pub fn generate_as_str_impl(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
712        let to_str_arms = variants.iter().map(|v| {
713            let ident = v.as_ident();
714            let str_val = &v.name;
715            quote! { Profile::#ident => #str_val }
716        });
717
718        quote! {
719            impl Profile {
720                fn as_str(&self) -> &'static str {
721                    match self {
722                        #(#to_str_arms,)*
723                    }
724                }
725            }
726        }
727    }
728
729    /// Generate all profile-related code.
730    ///
731    /// Combines all profile generation functions into a single token stream.
732    ///
733    /// # Arguments
734    ///
735    /// * `variants` - List of profile variants
736    ///
737    /// # Returns
738    ///
739    /// Complete token stream containing:
740    /// - Profile enum definition
741    /// - TryFrom implementations
742    /// - as_str() method
743    pub fn generate_all(variants: &[ProfileVariant]) -> proc_macro2::TokenStream {
744        let enum_def = generate_enum(variants);
745        let try_from_impls = generate_try_from_impls(variants);
746        let as_str_impl = generate_as_str_impl(variants);
747
748        quote! {
749            #enum_def
750            #try_from_impls
751            #as_str_impl
752        }
753    }
754}
755
756// ===== SecretSpec Generation Module =====
757
758/// Module for generating SecretSpec struct and related implementations.
759///
760/// This module handles:
761/// - SecretSpec struct (union of all secrets)
762/// - SecretSpecProfile enum (profile-specific types)
763/// - Loading implementations
764/// - Environment variable integration
765mod secret_spec_generation {
766    use super::*;
767
768    /// Generate the SecretSpec struct.
769    ///
770    /// Creates a struct containing all secrets from all profiles as fields.
771    /// This is the "union" type that can safely hold secrets from any profile.
772    ///
773    /// # Arguments
774    ///
775    /// * `field_info` - Map of all fields with their type information
776    ///
777    /// # Generated Code Example
778    ///
779    /// ```ignore
780    /// #[derive(Debug, serde::Serialize, serde::Deserialize)]
781    /// pub struct SecretSpec {
782    ///     pub database_url: String,
783    ///     pub api_key: Option<String>,
784    ///     pub redis_url: Option<String>,
785    /// }
786    /// ```
787    pub fn generate_struct(field_info: &BTreeMap<String, FieldInfo>) -> proc_macro2::TokenStream {
788        let fields = field_info.values().map(|info| info.generate_struct_field());
789
790        quote! {
791            #[derive(Debug, serde::Serialize, serde::Deserialize)]
792            pub struct SecretSpec {
793                #(#fields,)*
794            }
795        }
796    }
797
798    /// Generate the SecretSpecProfile enum.
799    ///
800    /// Creates an enum where each variant contains only the secrets defined
801    /// for that specific profile. This provides stronger type safety when
802    /// working with profile-specific secrets.
803    ///
804    /// # Arguments
805    ///
806    /// * `profile_variants` - Generated enum variant definitions
807    ///
808    /// # Generated Code Example
809    ///
810    /// ```ignore
811    /// #[derive(Debug, serde::Serialize, serde::Deserialize)]
812    /// pub enum SecretSpecProfile {
813    ///     Development {
814    ///         database_url: String,
815    ///         redis_url: Option<String>,
816    ///     },
817    ///     Production {
818    ///         database_url: String,
819    ///         api_key: String,
820    ///         redis_url: String,
821    ///     },
822    /// }
823    /// ```
824    pub fn generate_profile_enum(
825        profile_variants: &[proc_macro2::TokenStream],
826    ) -> proc_macro2::TokenStream {
827        quote! {
828            #[derive(Debug, serde::Serialize, serde::Deserialize)]
829            pub enum SecretSpecProfile {
830                #(#profile_variants,)*
831            }
832        }
833    }
834
835    /// Generate SecretSpecProfile enum variants.
836    ///
837    /// Creates the individual variants for the SecretSpecProfile enum,
838    /// each containing only the fields defined for that profile.
839    ///
840    /// # Arguments
841    ///
842    /// * `config` - The project configuration
843    /// * `field_info` - Field information (used for empty profile case)
844    /// * `variants` - Profile variants to generate
845    ///
846    /// # Returns
847    ///
848    /// Vector of token streams, each representing one enum variant
849    ///
850    /// # Special Cases
851    ///
852    /// - Empty profiles → generates a Default variant with all fields
853    /// - Each profile → generates variant with profile-specific fields
854    pub fn generate_profile_enum_variants(ir: &CodegenIr) -> Vec<proc_macro2::TokenStream> {
855        // The IR's per-profile field sets already handle the empty-profiles case
856        // (a single `default` profile carrying the union), so there is no special
857        // branch here: one variant per IR profile, with that profile's exact
858        // (raw, non-merged) fields.
859        ir.profile_fields
860            .iter()
861            .map(|profile| {
862                let variant_ident = ProfileVariant::new(profile.name.clone()).as_ident();
863                let fields = profile.fields.iter().map(|field| {
864                    let field_name = field_name_ident(&field.name);
865                    let field_type = ir_field_type(field);
866                    quote! { #field_name: #field_type }
867                });
868                quote! {
869                    #variant_ident {
870                        #(#fields,)*
871                    }
872                }
873            })
874            .collect()
875    }
876
877    /// Generate load_profile match arms.
878    ///
879    /// Creates the match arms for loading profile-specific secrets into
880    /// the appropriate SecretSpecProfile variant.
881    ///
882    /// # Arguments
883    ///
884    /// * `config` - The project configuration
885    /// * `field_info` - Field information (for empty profile case)
886    /// * `variants` - Profile variants to generate arms for
887    ///
888    /// # Returns
889    ///
890    /// Vector of match arms for the profile loading logic
891    ///
892    /// # Generated Code Example
893    ///
894    /// ```ignore
895    /// Profile::Production => Ok(SecretSpecProfile::Production {
896    ///     database_url: secrets.get("DATABASE_URL")
897    ///         .ok_or_else(|| SecretSpecError::RequiredSecretMissing("DATABASE_URL".to_string()))?
898    ///         .clone(),
899    ///     api_key: secrets.get("API_KEY").cloned(),
900    /// })
901    /// ```
902    pub fn generate_load_profile_arms(ir: &CodegenIr) -> Vec<proc_macro2::TokenStream> {
903        // One arm per IR profile, assigning that profile's exact fields. The
904        // empty-profiles case is already a single `default` profile in the IR.
905        ir.profile_fields
906            .iter()
907            .map(|profile| {
908                let variant_ident = ProfileVariant::new(profile.name.clone()).as_ident();
909                let assignments = profile.fields.iter().map(|field| {
910                    generate_secret_assignment(
911                        &field_name_ident(&field.name),
912                        &field.name,
913                        quote! { secrets },
914                        field.optional,
915                        field.as_path,
916                    )
917                });
918                quote! {
919                    Profile::#variant_ident => Ok(SecretSpecProfile::#variant_ident {
920                        #(#assignments,)*
921                    })
922                }
923            })
924            .collect()
925    }
926
927    /// Generate the shared load_internal implementation.
928    ///
929    /// Creates a helper function that handles the common loading logic
930    /// for both SecretSpec and SecretSpecProfile loading methods.
931    ///
932    /// # Generated Function
933    ///
934    /// The function:
935    /// 1. Loads the SecretSpec configuration
936    /// 2. Validates it with the given provider and profile
937    /// 3. Returns the validation result containing loaded secrets
938    pub fn generate_load_internal() -> proc_macro2::TokenStream {
939        quote! {
940            fn load_internal(
941                provider_str: Option<String>,
942                profile_str: Option<String>,
943                reason: Option<String>,
944            ) -> Result<secretspec::ValidatedSecrets, secretspec::SecretSpecError> {
945                let mut spec = secretspec::Secrets::load()?;
946                // A typed loader expects the full generated struct shape, so an
947                // ambient `SECRETSPEC_SCOPE` must not silently narrow it below that
948                // shape (which would surface as a spurious `RequiredSecretMissing`).
949                // The untyped CLI/SDK paths keep honoring the env scope.
950                spec.set_ignore_ambient_scope(true);
951                if let Some(provider) = provider_str {
952                    spec.set_provider(provider);
953                }
954                if let Some(profile) = profile_str {
955                    spec.set_profile(profile);
956                }
957                // Apply an explicit builder reason on top of any SECRETSPEC_REASON
958                // already resolved by `Secrets::load`. Required to satisfy the
959                // `require_reason` policy (default "agents") from typed SDK code,
960                // which otherwise has no way to supply a reason. A blank reason is
961                // ignored by `with_reason`, leaving the env-resolved value intact.
962                if let Some(reason) = reason {
963                    spec = spec.with_reason(reason);
964                }
965                match spec.validate()? {
966                    Ok(valid_secrets) => Ok(valid_secrets),
967                    Err(validation_errors) if validation_errors.constraint_violations.is_empty() => {
968                        Err(secretspec::SecretSpecError::RequiredSecretMissing(
969                            validation_errors.missing_required.join(", ")
970                        ))
971                    }
972                    Err(validation_errors) => Err(secretspec::SecretSpecError::ValidationFailed(
973                        Box::new(validation_errors)
974                    ))
975                }
976            }
977        }
978    }
979
980    /// Generate SecretSpec implementation.
981    ///
982    /// Creates the impl block for SecretSpec with:
983    /// - builder() method for creating a builder
984    /// - load() method for loading with union types
985    /// - set_as_env_vars() method for environment variable integration
986    ///
987    /// # Arguments
988    ///
989    /// * `load_assignments` - Field assignments for the load method
990    /// * `env_setters` - Environment variable setter statements
991    /// * `_field_info` - Field information (currently unused)
992    ///
993    /// # Generated Methods
994    ///
995    /// - `builder()` - Creates a new SecretSpecBuilder
996    /// - `load()` - Loads secrets with optional provider/profile
997    /// - `set_as_env_vars()` - Sets all secrets as environment variables
998    pub fn generate_impl(
999        load_assignments: &[proc_macro2::TokenStream],
1000        env_setters: Vec<proc_macro2::TokenStream>,
1001        _field_info: &BTreeMap<String, FieldInfo>,
1002    ) -> proc_macro2::TokenStream {
1003        quote! {
1004            impl SecretSpec {
1005                /// Create a new builder for loading secrets
1006                pub fn builder() -> SecretSpecBuilder {
1007                    SecretSpecBuilder::new()
1008                }
1009
1010                /// Load secrets with optional provider and/or profile
1011                /// Provider can be any type that implements Into<String> (e.g., &str, String, etc.)
1012                /// If provider is None, uses SECRETSPEC_PROVIDER env var or global config
1013                /// If profile is None, uses SECRETSPEC_PROFILE env var if set
1014                pub fn load<P>(provider: Option<P>, profile: Option<Profile>) -> Result<secretspec::Resolved<Self>, secretspec::SecretSpecError>
1015                where
1016                    P: Into<String>,
1017                {
1018                    // Convert options to strings
1019                    let provider_str = provider.map(Into::into).or_else(|| std::env::var("SECRETSPEC_PROVIDER").ok());
1020
1021                    let profile_str = match profile {
1022                        Some(p) => Some(p.as_str().to_string()),
1023                        None => std::env::var("SECRETSPEC_PROFILE").ok(),
1024                    };
1025
1026                    // The static `load` has no reason parameter; a reason is supplied
1027                    // via the SECRETSPEC_REASON env var (honored by `Secrets::load`)
1028                    // or through `SecretSpec::builder().with_reason(...)`.
1029                    let validation_result = load_internal(provider_str, profile_str, None)?;
1030                    let provider_name = validation_result.resolved.provider.clone();
1031                    let profile = validation_result.resolved.profile.clone();
1032                    let secrets = validation_result.resolved.secrets;
1033
1034                    let data = Self {
1035                        #(#load_assignments,)*
1036                    };
1037
1038                    Ok(secretspec::Resolved::new(
1039                        data,
1040                        provider_name,
1041                        profile
1042                    ))
1043                }
1044
1045                pub fn set_as_env_vars(&self) {
1046                    #(#env_setters)*
1047                }
1048            }
1049        }
1050    }
1051}
1052
1053// ===== Builder Generation Module =====
1054
1055/// Module for generating the builder pattern implementation.
1056///
1057/// The builder provides a fluent API for configuring how secrets are loaded,
1058/// with support for:
1059/// - Custom providers (via URIs)
1060/// - Profile selection
1061/// - Type-safe loading (union or profile-specific)
1062mod builder_generation {
1063    use super::*;
1064
1065    /// Generate the builder struct definition.
1066    ///
1067    /// The builder uses boxed closures to defer provider/profile resolution
1068    /// until load time, allowing for flexible configuration.
1069    ///
1070    /// # Generated Struct
1071    ///
1072    /// ```ignore
1073    /// pub struct SecretSpecBuilder {
1074    ///     provider: Option<Box<dyn FnOnce() -> Result<Box<dyn secretspec::Provider>, String>>>,
1075    ///     profile: Option<Box<dyn FnOnce() -> Result<Profile, String>>>,
1076    ///     reason: Option<String>,
1077    /// }
1078    /// ```
1079    pub fn generate_struct() -> proc_macro2::TokenStream {
1080        quote! {
1081            pub struct SecretSpecBuilder {
1082                provider: Option<Box<dyn FnOnce() -> Result<Box<dyn secretspec::Provider>, String>>>,
1083                profile: Option<Box<dyn FnOnce() -> Result<Profile, String>>>,
1084                reason: Option<String>,
1085            }
1086        }
1087    }
1088
1089    /// Generate builder basic methods.
1090    ///
1091    /// Creates the foundational builder methods:
1092    /// - Default implementation
1093    /// - new() constructor
1094    /// - with_provider() for setting provider
1095    /// - with_profile() for setting profile
1096    ///
1097    /// # Type Flexibility
1098    ///
1099    /// Both with_provider and with_profile accept anything that can be
1100    /// converted to the target type (Uri or Profile), providing flexibility:
1101    ///
1102    /// ```ignore
1103    /// builder.with_provider("keyring://")           // &str
1104    ///        .with_provider(Provider::Keyring)      // Provider enum
1105    ///        .with_profile("production")            // &str
1106    ///        .with_profile(Profile::Production)      // Profile enum
1107    /// ```
1108    pub fn generate_basic_methods() -> proc_macro2::TokenStream {
1109        quote! {
1110            impl Default for SecretSpecBuilder {
1111                fn default() -> Self {
1112                    Self::new()
1113                }
1114            }
1115
1116            impl SecretSpecBuilder {
1117                pub fn new() -> Self {
1118                    Self {
1119                        provider: None,
1120                        profile: None,
1121                        reason: None,
1122                    }
1123                }
1124
1125                /// Set a human-readable reason for this session's secret access.
1126                ///
1127                /// Required to satisfy the project's `require_reason` policy
1128                /// (`[project].require_reason` in secretspec.toml, default `"agents"`)
1129                /// when loading from agent contexts, and recorded in the audit log.
1130                /// Mirrors the CLI `--reason` flag and `Secrets::with_reason`. A blank
1131                /// reason is ignored, falling back to the `SECRETSPEC_REASON` env var.
1132                pub fn with_reason<T>(mut self, reason: T) -> Self
1133                where
1134                    T: Into<String>,
1135                {
1136                    self.reason = Some(reason.into());
1137                    self
1138                }
1139
1140                pub fn with_provider<T>(mut self, provider: T) -> Self
1141                where
1142                    T: TryInto<Box<dyn secretspec::Provider>> + 'static,
1143                    T::Error: std::fmt::Display + 'static,
1144                {
1145                    self.provider = Some(Box::new(move || {
1146                        provider.try_into()
1147                            .map_err(|e| format!("Invalid provider: {}", e))
1148                    }));
1149                    self
1150                }
1151
1152                pub fn with_profile<T>(mut self, profile: T) -> Self
1153                where
1154                    T: TryInto<Profile>,
1155                    T::Error: std::fmt::Display
1156                {
1157                    match profile.try_into() {
1158                        Ok(p) => {
1159                            self.profile = Some(Box::new(move || Ok(p)));
1160                        }
1161                        Err(e) => {
1162                            let error_msg = format!("{}", e);
1163                            self.profile = Some(Box::new(move || Err(error_msg)));
1164                        }
1165                    }
1166                    self
1167                }
1168            }
1169        }
1170    }
1171
1172    /// Generate provider resolution logic.
1173    ///
1174    /// Creates code to resolve a provider from the builder's boxed closure.
1175    ///
1176    /// # Arguments
1177    ///
1178    /// * `provider_expr` - Expression to access the provider option
1179    ///
1180    /// # Generated Logic
1181    ///
1182    /// 1. If provider is set, call the closure to get the Provider instance
1183    /// 2. Convert any errors to SecretSpecError
1184    /// 3. Extract the provider name to pass to the loading system
1185    fn generate_provider_resolution(
1186        provider_expr: proc_macro2::TokenStream,
1187    ) -> proc_macro2::TokenStream {
1188        quote! {
1189            let provider_str = if let Some(provider_fn) = #provider_expr {
1190                let provider_box = provider_fn()
1191                    .map_err(|e| secretspec::SecretSpecError::ProviderOperationFailed(e))?;
1192                // Get the full URI to pass as a string to set_provider (preserves vault info)
1193                Some(provider_box.uri())
1194            } else {
1195                None
1196            };
1197        }
1198    }
1199
1200    /// Generate profile resolution logic.
1201    ///
1202    /// Creates code to resolve a profile from the builder's boxed closure.
1203    ///
1204    /// # Arguments
1205    ///
1206    /// * `profile_expr` - Expression to access the profile option
1207    ///
1208    /// # Generated Logic
1209    ///
1210    /// 1. If profile is set, call the closure to get the Profile
1211    /// 2. Convert any errors to SecretSpecError
1212    /// 3. Convert Profile to string for the loading system
1213    fn generate_profile_resolution(
1214        profile_expr: proc_macro2::TokenStream,
1215    ) -> proc_macro2::TokenStream {
1216        quote! {
1217            let profile_str = if let Some(profile_fn) = #profile_expr {
1218                let profile = profile_fn()
1219                    .map_err(|e| secretspec::SecretSpecError::InvalidProfile(e))?;
1220                Some(profile.as_str().to_string())
1221            } else {
1222                None
1223            };
1224        }
1225    }
1226
1227    /// Generate load methods for the builder.
1228    ///
1229    /// Creates two loading methods:
1230    /// - `load()` - Returns SecretSpec (union type)
1231    /// - `load_profile()` - Returns SecretSpecProfile (profile-specific type)
1232    ///
1233    /// # Arguments
1234    ///
1235    /// * `load_assignments` - Field assignments for union type
1236    /// * `load_profile_arms` - Match arms for profile-specific loading
1237    /// * `first_profile_variant` - Default profile if none specified
1238    ///
1239    /// # Key Differences
1240    ///
1241    /// - `load()` returns all secrets with optional fields for safety
1242    /// - `load_profile()` returns only profile-specific secrets with exact types
1243    pub fn generate_load_methods(
1244        load_assignments: &[proc_macro2::TokenStream],
1245        load_profile_arms: &[proc_macro2::TokenStream],
1246        first_profile_variant: &proc_macro2::Ident,
1247    ) -> proc_macro2::TokenStream {
1248        let resolve_provider_load = generate_provider_resolution(quote! { self.provider.take() });
1249        let resolve_profile_load = generate_profile_resolution(quote! { self.profile.take() });
1250        let resolve_provider_profile =
1251            generate_provider_resolution(quote! { self.provider.take() });
1252
1253        quote! {
1254            impl SecretSpecBuilder {
1255                pub fn load(mut self) -> Result<secretspec::Resolved<SecretSpec>, secretspec::SecretSpecError> {
1256                    #resolve_provider_load
1257                    #resolve_profile_load
1258                    let reason_str = self.reason.take();
1259
1260                    let validation_result = load_internal(provider_str, profile_str, reason_str)?;
1261                    let provider_name = validation_result.resolved.provider.clone();
1262                    let profile = validation_result.resolved.profile.clone();
1263                    let secrets = validation_result.resolved.secrets;
1264
1265                    let data = SecretSpec {
1266                        #(#load_assignments,)*
1267                    };
1268
1269                    Ok(secretspec::Resolved::new(
1270                        data,
1271                        provider_name,
1272                        profile
1273                    ))
1274                }
1275
1276                pub fn load_profile(mut self) -> Result<secretspec::Resolved<SecretSpecProfile>, secretspec::SecretSpecError> {
1277                    #resolve_provider_profile
1278                    let reason_str = self.reason.take();
1279
1280                    let (profile_str, selected_profile) = if let Some(profile_fn) = self.profile.take() {
1281                        let profile = profile_fn()
1282                            .map_err(|e| secretspec::SecretSpecError::InvalidProfile(e))?;
1283                        (Some(profile.as_str().to_string()), profile)
1284                    } else {
1285                        // Check env var for profile. A blank value is treated as
1286                        // unset (matching `secretspec::Secrets`) and a padded
1287                        // value is trimmed, so a stray empty var or a `$(cat
1288                        // file)` trailing newline neither errors here nor selects
1289                        // a nonexistent profile.
1290                        let profile_str = std::env::var("SECRETSPEC_PROFILE")
1291                            .ok()
1292                            .map(|s| s.trim().to_string())
1293                            .filter(|s| !s.is_empty());
1294                        let selected_profile = if let Some(ref profile_name) = profile_str {
1295                            Profile::try_from(profile_name.as_str())?
1296                        } else {
1297                            Profile::#first_profile_variant
1298                        };
1299                        (profile_str, selected_profile)
1300                    };
1301
1302                    let validation_result = load_internal(provider_str, profile_str, reason_str)?;
1303                    let provider_name = validation_result.resolved.provider.clone();
1304                    let profile = validation_result.resolved.profile.clone();
1305                    let secrets = validation_result.resolved.secrets;
1306
1307                    let data_result: LoadResult<SecretSpecProfile> = match selected_profile {
1308                        #(#load_profile_arms,)*
1309                    };
1310                    let data = data_result?;
1311
1312                    Ok(secretspec::Resolved::new(
1313                        data,
1314                        provider_name,
1315                        profile
1316                    ))
1317                }
1318            }
1319        }
1320    }
1321
1322    /// Generate all builder-related code.
1323    ///
1324    /// Combines all builder components into a complete implementation.
1325    ///
1326    /// # Arguments
1327    ///
1328    /// * `load_assignments` - Field assignments for union loading
1329    /// * `load_profile_arms` - Match arms for profile loading
1330    /// * `first_profile_variant` - Default profile variant
1331    ///
1332    /// # Returns
1333    ///
1334    /// Complete token stream containing:
1335    /// - Builder struct definition
1336    /// - Basic builder methods
1337    /// - Loading methods (load and load_profile)
1338    pub fn generate_all(
1339        load_assignments: &[proc_macro2::TokenStream],
1340        load_profile_arms: &[proc_macro2::TokenStream],
1341        first_profile_variant: &proc_macro2::Ident,
1342    ) -> proc_macro2::TokenStream {
1343        let struct_def = generate_struct();
1344        let basic_methods = generate_basic_methods();
1345        let load_methods =
1346            generate_load_methods(load_assignments, load_profile_arms, first_profile_variant);
1347
1348        quote! {
1349            #struct_def
1350            #basic_methods
1351            #load_methods
1352        }
1353    }
1354}
1355
1356/// Main code generation function.
1357///
1358/// Orchestrates the entire code generation process, coordinating all modules
1359/// to produce the complete macro output.
1360///
1361/// # Arguments
1362///
1363/// * `config` - The validated project configuration
1364///
1365/// # Returns
1366///
1367/// Complete token stream containing all generated code
1368///
1369/// # Generation Process
1370///
1371/// 1. Analyze profiles and field types
1372/// 2. Generate Profile enum and implementations
1373/// 3. Generate SecretSpec struct (union type)
1374/// 4. Generate SecretSpecProfile enum (profile-specific types)
1375/// 5. Generate builder pattern implementation
1376/// 6. Combine all components with necessary imports
1377fn generate_secret_spec_code(config: Config) -> proc_macro2::TokenStream {
1378    // Reduce the manifest to the shared codegen IR once. Every typing decision
1379    // (union vs per-profile fields, optionality, as_path, profile list) comes
1380    // from here, so this macro and the other-language emitters cannot drift.
1381    let ir = build_ir(&config);
1382
1383    let profile_variants = profile_variants_from_ir(&ir);
1384
1385    // Union struct fields.
1386    let field_info = union_field_info(&ir);
1387
1388    // Generate field assignments for load()
1389    let load_assignments: Vec<_> = field_info
1390        .values()
1391        .map(|info| info.generate_assignment(quote! { secrets }))
1392        .collect();
1393
1394    // Generate env var setters
1395    let env_setters: Vec<_> = field_info
1396        .values()
1397        .map(|info| info.generate_env_setter())
1398        .collect();
1399
1400    // Generate profile components
1401    let profile_code = profile_generation::generate_all(&profile_variants);
1402
1403    // Generate SecretSpec components
1404    let secret_spec_struct = secret_spec_generation::generate_struct(&field_info);
1405    let profile_enum_variants = secret_spec_generation::generate_profile_enum_variants(&ir);
1406    let secret_spec_profile_enum =
1407        secret_spec_generation::generate_profile_enum(&profile_enum_variants);
1408    let load_profile_arms = secret_spec_generation::generate_load_profile_arms(&ir);
1409    let load_internal = secret_spec_generation::generate_load_internal();
1410    let secret_spec_impl =
1411        secret_spec_generation::generate_impl(&load_assignments, env_setters, &field_info);
1412
1413    // Get first profile variant for defaults
1414    // Get first profile variant for defaults
1415    let first_profile_variant = profile_variants
1416        .first()
1417        .map(|v| v.as_ident())
1418        .unwrap_or_else(|| format_ident!("Default"));
1419
1420    // Generate builder
1421    let builder_code = builder_generation::generate_all(
1422        &load_assignments,
1423        &load_profile_arms,
1424        &first_profile_variant,
1425    );
1426
1427    // Combine all components
1428    quote! {
1429        use ::secrecy::ExposeSecret;
1430
1431        #secret_spec_struct
1432        #secret_spec_profile_enum
1433        #profile_code
1434
1435
1436        // Type alias to help with type inference
1437        type LoadResult<T> = Result<T, secretspec::SecretSpecError>;
1438
1439        #load_internal
1440        #builder_code
1441        #secret_spec_impl
1442    }
1443}
1444
1445#[cfg(test)]
1446#[path = "tests.rs"]
1447mod derive_tests;