rusty_cdk_macros/
lib.rs

1#![allow(unused_comparisons)]
2//! This crate provides compile-time validation macros for AWS cloud infrastructure configuration.
3//! These macros ensure type safety and enforce AWS service limits at build time, preventing
4//! runtime errors from invalid configurations.
5//!
6//! ## Overview
7//!
8//! All macros perform validation at compile time and generate wrapper types that encapsulate
9//! validated values.
10//!
11//! The macros always return a newtype 'wrapper'.
12//! You should import those from the rusty_cdk::wrappers directory, as seen in the below example.
13//!
14//! ## Usage Example
15//!
16//! ```rust,compile_fail
17//! use rusty_cdk::wrappers::Memory; // import the wrapper
18//! use rusty_cdk::memory;
19//!
20//! // Lambda memory configuration with validated limit
21//! let mem = memory!(512);        // 512 MB (128-10240 range)
22//! ```
23
24mod bucket;
25mod bucket_name;
26mod file_util;
27mod iam_validation;
28mod location_uri;
29mod object_sizes;
30mod strings;
31mod timeouts;
32mod transition_in_days;
33
34use crate::file_util::get_absolute_file_path;
35use crate::iam_validation::{PermissionValidator, ValidationResponse};
36use crate::location_uri::LocationUri;
37use crate::object_sizes::ObjectSizes;
38use crate::strings::{check_string_requirements, StringRequirements};
39use crate::timeouts::Timeouts;
40use crate::transition_in_days::TransitionInfo;
41use proc_macro::TokenStream;
42use quote::__private::Span;
43use quote::quote;
44use std::env;
45use syn::spanned::Spanned;
46use syn::{parse_macro_input, Error, LitInt, LitStr};
47
48/// Creates a validated `StringWithOnlyAlphaNumericsAndUnderscores` wrapper at compile time.
49///
50/// # Validation Rules
51///
52/// - String must not be empty
53/// - Only alphanumeric characters, and underscores are allowed
54#[proc_macro]
55pub fn string_with_only_alphanumerics_and_underscores(input: TokenStream) -> TokenStream {
56    let output: LitStr = syn::parse(input).unwrap();
57    let value = output.value();
58
59    let requirements = StringRequirements::not_empty_allowed_chars(vec!['_']);
60
61    match check_string_requirements(&value, output.span(), requirements) {
62        None => quote!(
63            StringWithOnlyAlphaNumericsAndUnderscores(#value.to_string())
64        )
65        .into(),
66        Some(e) => e.into_compile_error().into(),
67    }
68}
69
70/// Creates a validated `StringWithOnlyAlphaNumericsUnderscoresAndHyphens` wrapper at compile time.
71///
72/// # Validation Rules
73///
74/// - String must not be empty
75/// - Only alphanumeric characters, underscores, and hyphens are allowed
76#[proc_macro]
77pub fn string_with_only_alphanumerics_underscores_and_hyphens(input: TokenStream) -> TokenStream {
78    let output: LitStr = syn::parse(input).unwrap();
79    let value = output.value();
80
81    let requirements = StringRequirements::not_empty_allowed_chars(vec!['_', '-']);
82
83    match check_string_requirements(&value, output.span(), requirements) {
84        None => quote!(
85            StringWithOnlyAlphaNumericsUnderscoresAndHyphens(#value.to_string())
86        )
87        .into(),
88        Some(e) => e.into_compile_error().into(),
89    }
90}
91
92/// Creates a validated `StringWithOnlyAlphaNumericsUnderscoresAndHyphens` wrapper at compile time.
93///
94/// This macro ensures that the input string contains only alphanumeric characters (a-z, A-Z, 0-9),
95/// underscores (_), and hyphens (-). It's designed for creating safe identifiers for AWS resources
96/// that allow hyphens in their naming conventions.
97///
98/// # Validation Rules
99///
100/// - String must not be empty
101/// - Only alphanumeric characters, underscores, and hyphens are allowed
102#[proc_macro]
103pub fn string_with_only_alphanumerics_and_hyphens(input: TokenStream) -> TokenStream {
104    let output: LitStr = syn::parse(input).unwrap();
105    let value = output.value();
106
107    let requirements = StringRequirements::not_empty_allowed_chars(vec!['-']);
108
109    match check_string_requirements(&value, output.span(), requirements) {
110        None => quote!(
111            StringWithOnlyAlphaNumericsAndHyphens(#value.to_string())
112        )
113        .into(),
114        Some(e) => e.into_compile_error().into(),
115    }
116}
117
118/// Creates a validated `AppSyncApiName` wrapper for AppSync Api names at compile time.
119///
120/// This macro ensures that the input string is a valid name for AppSync Apis,
121/// following AWS naming conventions and character restrictions.
122///
123/// # Validation Rules
124///
125/// - String must not be empty
126/// - Only alphanumeric characters, and the following special characters are allowed: _, - and whitespace
127/// - Max length 50 characters
128#[proc_macro]
129pub fn app_sync_api_name(input: TokenStream) -> TokenStream {
130    let output: LitStr = syn::parse(input).unwrap();
131    let value = output.value();
132
133    if value.len() > 50 {
134        return Error::new(output.span(), "name cannot be longer than 50 characters".to_string())
135            .into_compile_error()
136            .into();
137    }
138
139    let requirements = StringRequirements::not_empty_allowed_chars(vec!['-', '_', ' ']);
140
141    match check_string_requirements(&value, output.span(), requirements) {
142        None => quote!(
143            AppSyncApiName(#value.to_string())
144        )
145            .into(),
146        Some(e) => e.into_compile_error().into(),
147    }
148}
149
150/// Creates a validated `ChannelNamespaceName` wrapper for AppSync Api at compile time.
151///
152/// This macro ensures that the input string is a valid name for a Channel Namespace,
153/// following AWS naming conventions and character restrictions.
154///
155/// # Validation Rules
156///
157/// - String must not be empty
158/// - Only alphanumeric characters, and the following special characters are allowed: -
159/// - Max length 50 characters
160#[proc_macro]
161pub fn channel_namespace_name(input: TokenStream) -> TokenStream {
162    let output: LitStr = syn::parse(input).unwrap();
163    let value = output.value();
164
165    if value.len() > 50 {
166        return Error::new(output.span(), "name cannot be longer than 50 characters".to_string())
167            .into_compile_error()
168            .into();
169    }
170
171    let requirements = StringRequirements::not_empty_allowed_chars(vec!['-']);
172
173    match check_string_requirements(&value, output.span(), requirements) {
174        None => quote!(
175            ChannelNamespaceName(#value.to_string())
176        )
177            .into(),
178        Some(e) => e.into_compile_error().into(),
179    }
180}
181
182/// Creates a validated `StringForSecret` wrapper for AWS Secrets Manager secret names at compile time.
183///
184/// This macro ensures that the input string is a valid name for AWS Secrets Manager secrets,
185/// following AWS naming conventions and character restrictions.
186///
187/// # Validation Rules
188///
189/// - String must not be empty
190/// - Only alphanumeric characters, and the following special characters are allowed: /, _, +, =, ., @, -
191#[proc_macro]
192pub fn string_for_secret(input: TokenStream) -> TokenStream {
193    let output: LitStr = syn::parse(input).unwrap();
194    let value = output.value();
195
196    let requirements = StringRequirements::not_empty_allowed_chars(vec!['/', '_', '+', '=', '.', '@', '-']);
197
198    match check_string_requirements(&value, output.span(), requirements) {
199        None => quote!(
200            StringForSecret(#value.to_string())
201        )
202        .into(),
203        Some(e) => e.into_compile_error().into(),
204    }
205}
206
207/// Creates a validated `EnvVarKey` wrapper for AWS Lambda environment variable keys at compile time.
208///
209/// # Validation Rules
210///
211/// - Key must be at least 2 characters long
212/// - Cannot start with an underscore (_)
213/// - Only alphanumeric characters and underscores are allowed
214#[proc_macro]
215pub fn env_var_key(input: TokenStream) -> TokenStream {
216    let output: LitStr = syn::parse(input).unwrap();
217    let value = output.value();
218
219    if value.len() < 2 {
220        return Error::new(output.span(), "env var key should be at least two characters long".to_string())
221            .into_compile_error()
222            .into();
223    }
224
225    if value.get(0..1).expect("just checked that length is at least 2") == "_" {
226        return Error::new(output.span(), "env var key should not start with an underscore".to_string())
227            .into_compile_error()
228            .into();
229    }
230
231    if value.chars().any(|c| !c.is_alphanumeric() && c != '_') {
232        return Error::new(
233            output.span(),
234            "env var key should only contain alphanumeric characters and underscores".to_string(),
235        )
236        .into_compile_error()
237        .into();
238    }
239
240    quote!(
241        EnvVarKey(#value.to_string())
242    )
243    .into()
244}
245
246/// Creates a validated `ZipFile` wrapper for AWS Lambda deployment packages at compile time.
247///
248/// This macro ensures that the input string refers to a valid ZIP file that exists on the filesystem at compile time.
249///
250/// See the `examples` dir of this library for some usage examples
251///
252/// # Validation Rules
253///
254/// - Path must end with `.zip` extension
255/// - File must exist at compile time
256/// - Path must be valid Unicode
257/// - Both relative and absolute paths are allowed
258#[proc_macro]
259pub fn zip_file(input: TokenStream) -> TokenStream {
260    let output: syn::Result<LitStr> = syn::parse(input);
261
262    let output = match output {
263        Ok(output) => output,
264        Err(_) => {
265            return Error::new(Span::call_site(), "zip_file macro should contain value".to_string())
266                .into_compile_error()
267                .into();
268        }
269    };
270
271    let value = output.value();
272
273    if !value.ends_with(".zip") {
274        return Error::new(output.span(), format!("zip should end with `.zip` (found `{value}`)"))
275            .into_compile_error()
276            .into();
277    }
278
279    let value = match get_absolute_file_path(&value) {
280        Ok(v) => v,
281        Err(e) => {
282            return Error::new(output.span(), e).into_compile_error().into();
283        }
284    };
285
286    quote!(
287        ZipFile(#value.to_string())
288    )
289    .into()
290}
291
292/// Creates a validated `TomlFile` wrapper.
293///
294/// See the `examples` dir of this library for some usage examples
295///
296/// # Validation Rules
297///
298/// - Path must end with `.toml` extension
299/// - File must exist at compile time
300/// - Path must be valid Unicode
301/// - Both relative and absolute paths are allowed
302#[proc_macro]
303pub fn toml_file(input: TokenStream) -> TokenStream {
304    let output: syn::Result<LitStr> = syn::parse(input);
305
306    let output = match output {
307        Ok(output) => output,
308        Err(_) => {
309            return Error::new(Span::call_site(), "toml_file macro should contain value".to_string())
310                .into_compile_error()
311                .into();
312        }
313    };
314
315    let value = output.value();
316
317    if !value.ends_with(".toml") {
318        return Error::new(output.span(), format!("toml file should end with `.toml` (found `{value}`)"))
319            .into_compile_error()
320            .into();
321    }
322
323    let value = match get_absolute_file_path(&value) {
324        Ok(v) => v,
325        Err(e) => {
326            return Error::new(output.span(), e).into_compile_error().into();
327        }
328    };
329
330    quote!(
331        TomlFile(#value.to_string())
332    )
333    .into()
334}
335
336/// Creates a validated `NonZeroNumber` wrapper for positive integers at compile time.
337#[proc_macro]
338pub fn non_zero_number(input: TokenStream) -> TokenStream {
339    let output = match syn::parse::<LitInt>(input) {
340        Ok(v) => v,
341        Err(_) => {
342            return Error::new(Span::call_site(), "value is not a valid number".to_string())
343                .into_compile_error()
344                .into();
345        }
346    };
347
348    let as_number: syn::Result<u32> = output.base10_parse();
349
350    let num = if let Ok(num) = as_number {
351        if num == 0 {
352            return Error::new(output.span(), "value should not be null".to_string())
353                .into_compile_error()
354                .into();
355        }
356        num
357    } else {
358        return Error::new(output.span(), "value is not a valid u32 number".to_string())
359            .into_compile_error()
360            .into();
361    };
362
363    quote!(
364        NonZeroNumber(#num)
365    )
366    .into()
367}
368
369macro_rules! number_check {
370    ($name:ident,$min:literal,$max:literal,$output:ident,$type:ty) => {
371        #[doc = "Checks whether the value that will be wrapped in the "]
372		#[doc = stringify!($output)]
373		#[doc = "struct is between "]
374		#[doc = stringify!($min)]
375		#[doc = "and "]
376        #[doc = stringify!($max)]
377        #[proc_macro]
378        pub fn $name(input: TokenStream) -> TokenStream {
379            let output: LitInt = syn::parse(input).unwrap();
380
381            let as_number: syn::Result<$type> = output.base10_parse();
382
383            if let Ok(num) = as_number {
384                if num < $min {
385                    Error::new(output.span(), format!("value should be at least {}", $min)).into_compile_error().into()
386                } else if num > $max {
387                    Error::new(output.span(), format!("value should be at most {}", $max)).into_compile_error().into()
388                } else {
389                    quote!(
390                        $output(#num)
391                    ).into()
392                }
393            } else {
394                Error::new(output.span(), "value is not a valid number".to_string()).into_compile_error().into()
395            }
396        }
397    }
398}
399
400number_check!(memory, 128, 10240, Memory, u16);
401number_check!(timeout, 1, 900, Timeout, u16);
402number_check!(delay_seconds, 0, 900, DelaySeconds, u16);
403number_check!(maximum_message_size, 1024, 1048576, MaximumMessageSize, u32);
404number_check!(message_retention_period, 60, 1209600, MessageRetentionPeriod, u32);
405number_check!(visibility_timeout, 0, 43200, VisibilityTimeout, u32);
406number_check!(receive_message_wait_time, 0, 20, ReceiveMessageWaitTime, u8);
407number_check!(sqs_event_source_max_concurrency, 2, 1000, SqsEventSourceMaxConcurrency, u16);
408number_check!(connection_attempts, 1, 3, ConnectionAttempts, u8);
409number_check!(s3_origin_read_timeout, 1, 120, S3OriginReadTimeout, u8);
410number_check!(deployment_duration_in_minutes, 0, 1440, DeploymentDurationInMinutes, u16);
411number_check!(growth_factor, 0, 100, GrowthFactor, u8);
412
413const NO_REMOTE_OVERRIDE_ENV_VAR_NAME: &str = "RUSTY_CDK_NO_REMOTE";
414const RUSTY_CDK_RECHECK_ENV_VAR_NAME: &str = "RUSTY_CDK_RECHECK";
415
416/// Creates a validated `Bucket` wrapper for existing AWS S3 bucket references at compile time.
417///
418/// This macro ensures that the input string refers to an existing S3 bucket in your AWS account.
419/// It queries S3 to verify the bucket exists.
420///
421/// # Validation Rules
422///
423/// - Value must not be an ARN (cannot start with "arn:")
424/// - Value must not include the "s3:" prefix
425/// - Bucket must exist in your AWS account (verified at compile time)
426///
427/// # Environment Variables
428///
429/// - `rusty_cdk_NO_REMOTE`: Set to `true` to skip remote AWS checks (for offline development)
430/// - `rusty_cdk_RECHECK`: Set to `true` to force revalidation of cached bucket names
431///
432/// # Note
433///
434/// This macro caches validation results to improve compile times. The first compilation will
435/// query AWS to verify the bucket exists. Later compilations will use the cached result unless `rusty_cdk_RECHECK` is set to true.
436///
437/// # Override
438///
439/// You can avoid this verification by using the wrapper directly, but you lose all the above compile time guarantees by doing so.
440#[proc_macro]
441pub fn bucket(input: TokenStream) -> TokenStream {
442    let input: LitStr = syn::parse(input).unwrap();
443    let value = input.value();
444
445    if value.starts_with("arn:") {
446        return Error::new(input.span(), "value is an arn, not a bucket name".to_string())
447            .into_compile_error()
448            .into();
449    }
450
451    if value.starts_with("s3:") {
452        return Error::new(input.span(), "value has s3 prefix, should be plain bucket name".to_string())
453            .into_compile_error()
454            .into();
455    }
456
457    let no_remote_check_wanted = env::var(NO_REMOTE_OVERRIDE_ENV_VAR_NAME)
458        .ok()
459        .and_then(|v| v.parse().ok())
460        .unwrap_or(false);
461
462    if no_remote_check_wanted {
463        return bucket::bucket_output(value);
464    }
465
466    let rechecked_wanted = env::var(RUSTY_CDK_RECHECK_ENV_VAR_NAME)
467        .ok()
468        .and_then(|v| v.parse().ok())
469        .unwrap_or(false);
470
471    if !rechecked_wanted {
472        match bucket::valid_bucket_according_to_file_storage(&value) {
473            bucket::FileStorageOutput::Valid => {
474                return bucket::bucket_output(value)
475            }
476            bucket::FileStorageOutput::Invalid => {
477                return Error::new(input.span(), format!("(cached) did not find bucket with name `{value}` in your account. You can rerun this check by adding setting the `{RUSTY_CDK_RECHECK_ENV_VAR_NAME}` env var to true")).into_compile_error().into()
478            }
479            bucket::FileStorageOutput::Unknown => {}
480        }
481    }
482
483    let rt = tokio::runtime::Runtime::new().unwrap();
484
485    match rt.block_on(bucket::find_bucket(input.clone())) {
486        Ok(_) => {
487            bucket::update_file_storage(bucket::FileStorageInput::Valid(&value));
488            bucket::bucket_output(value)
489        }
490        Err(e) => {
491            bucket::update_file_storage(bucket::FileStorageInput::Invalid(&value));
492            e.into_compile_error().into()
493        }
494    }
495}
496
497const ADDITIONAL_ALLOWED_FOR_BUCKET_NAME: [char; 2] = ['.', '-'];
498
499/// Creates a validated `BucketName` wrapper for new AWS S3 bucket names at compile time.
500///
501/// This macro ensures that the input string is a valid S3 bucket name that follows AWS naming
502/// requirements and verifies the name is available at compile time.
503///
504/// # Validation Rules
505///
506/// - Must contain only lowercase letters, numbers, periods (.), and hyphens (-)
507/// - No uppercase letters are allowed
508/// - Bucket name must be globally unique and available (verified at compile time)
509///
510/// # Environment Variables
511///
512/// - `RUSTY_CDK_NO_REMOTE`: Set to `true` to skip remote AWS checks (for offline development)
513/// - `RUSTY_CDK_RECHECK`: Set to `true` to force revalidation of cached bucket name availability
514///
515/// # Note
516///
517/// This macro caches validation results to improve compile times. The first compilation will
518/// query AWS to verify the bucket name is available. Later compilations will use the cached
519/// result unless `RUSTY_CDK_RECHECK` is set to true.
520///
521/// # Override
522///
523/// You can avoid this verification by using the wrapper directly, but you lose all the above compile time guarantees by doing so.
524#[proc_macro]
525pub fn bucket_name(input: TokenStream) -> TokenStream {
526    let input: LitStr = syn::parse(input).unwrap();
527    let value = input.value();
528
529    if value.chars().any(|c| c.is_uppercase()) {
530        return Error::new(input.span(), "value contains uppercase letters".to_string())
531            .into_compile_error()
532            .into();
533    }
534
535    if value
536        .chars()
537        .any(|c| !c.is_alphanumeric() && !ADDITIONAL_ALLOWED_FOR_BUCKET_NAME.contains(&c))
538    {
539        return Error::new(
540            input.span(),
541            "value should contain only letters, numbers, periods and dashes".to_string(),
542        )
543        .into_compile_error()
544        .into();
545    }
546
547    let no_remote_check_wanted = env::var(NO_REMOTE_OVERRIDE_ENV_VAR_NAME)
548        .ok()
549        .and_then(|v| v.parse().ok())
550        .unwrap_or(false);
551
552    if no_remote_check_wanted {
553        return bucket_name::bucket_name_output(value);
554    }
555
556    let rechecked_wanted = env::var(RUSTY_CDK_RECHECK_ENV_VAR_NAME)
557        .ok()
558        .and_then(|v| v.parse().ok())
559        .unwrap_or(false);
560
561    if !rechecked_wanted {
562        match bucket_name::valid_bucket_name_according_to_file_storage(&value) {
563            bucket_name::FileStorageOutput::Valid => {
564                return bucket_name::bucket_name_output(value)
565            }
566            bucket_name::FileStorageOutput::Invalid => {
567                return Error::new(input.span(), format!("(cached) bucket name is already taken. You can rerun this check by adding setting the `{RUSTY_CDK_RECHECK_ENV_VAR_NAME}` env var to true")).into_compile_error().into()
568            }
569            bucket_name::FileStorageOutput::Unknown => {}
570        }
571    }
572
573    match bucket_name::check_bucket_name(input) {
574        Ok(_) => {
575            bucket_name::update_file_storage(bucket_name::FileStorageInput::Valid(&value));
576            bucket_name::bucket_name_output(value)
577        }
578        Err(e) => {
579            bucket_name::update_file_storage(bucket_name::FileStorageInput::Invalid(&value));
580            e.into_compile_error().into()
581        }
582    }
583}
584
585const POSSIBLE_LOG_RETENTION_VALUES: [u16; 22] = [
586    1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653,
587];
588
589/// Creates a validated `RetentionInDays` wrapper for AWS CloudWatch Logs retention periods at compile time.
590///
591/// # Validation Rules
592///
593/// - Value must be a number, and of the AWS-approved retention periods (in days)
594#[proc_macro]
595pub fn log_retention(input: TokenStream) -> TokenStream {
596    let output = match syn::parse::<LitInt>(input) {
597        Ok(v) => v,
598        Err(_) => {
599            return Error::new(Span::call_site(), "value is not a valid number".to_string())
600                .into_compile_error()
601                .into();
602        }
603    };
604
605    let as_number: syn::Result<u16> = output.base10_parse();
606
607    if let Ok(num) = as_number {
608        if POSSIBLE_LOG_RETENTION_VALUES.contains(&num) {
609            quote! {
610                RetentionInDays(#num)
611            }
612            .into()
613        } else {
614            Error::new(output.span(), format!("value should be one of {:?}", POSSIBLE_LOG_RETENTION_VALUES))
615                .into_compile_error()
616                .into()
617        }
618    } else {
619        Error::new(output.span(), "value is not a valid u16 number".to_string())
620            .into_compile_error()
621            .into()
622    }
623}
624
625const ADDITIONAL_ALLOWED_FOR_LOG_GROUP: [char; 6] = ['.', '-', '_', '#', '/', '\\'];
626
627/// Creates a validated `LogGroupName` wrapper for AWS CloudWatch Logs log group names at compile time.
628///
629/// # Validation Rules
630///
631/// - String must not be empty
632/// - The maximum length is 512 characters
633/// - Only alphanumeric characters, and the following special characters are allowed: . - _ # / \
634#[proc_macro]
635pub fn log_group_name(input: TokenStream) -> TokenStream {
636    let output: LitStr = syn::parse(input).unwrap();
637    let value = output.value();
638
639    if value.is_empty() {
640        return Error::new(output.span(), "value should not be blank".to_string())
641            .into_compile_error()
642            .into();
643    }
644
645    if value.len() > 512 {
646        return Error::new(output.span(), "value should not be longer than 512 chars".to_string())
647            .into_compile_error()
648            .into();
649    }
650
651    if value
652        .chars()
653        .any(|c| !c.is_alphanumeric() && !ADDITIONAL_ALLOWED_FOR_LOG_GROUP.contains(&c))
654    {
655        return Error::new(
656            output.span(),
657            format!(
658                "value should only contain alphanumeric characters and {:?}",
659                ADDITIONAL_ALLOWED_FOR_LOG_GROUP
660            ),
661        )
662        .into_compile_error()
663        .into();
664    }
665
666    quote!(
667        LogGroupName(#value.to_string())
668    )
669    .into()
670}
671
672/// Creates a validated `IamAction` wrapper for AWS IAM permissions at compile time.
673///
674/// This macro ensures that the input string represents a valid AWS IAM action permission.
675/// It validates the action against a comprehensive list of AWS service permissions to catch
676/// typos and invalid permissions at compile time.
677///
678/// # Validation Rules
679///
680/// - String must not be empty
681/// - Action must be a valid AWS IAM action (e.g., "s3:GetObject", "s3:Put*")
682/// - Action is validated against AWS's official permission list
683/// - Wildcards are supported
684///
685#[proc_macro]
686pub fn iam_action(input: TokenStream) -> TokenStream {
687    let output: LitStr = syn::parse(input).unwrap();
688    let value = output.value();
689
690    if value.is_empty() {
691        return Error::new(output.span(), "value should not be blank".to_string())
692            .into_compile_error()
693            .into();
694    }
695
696    let validator = PermissionValidator::new();
697    match validator.is_valid_action(&value) {
698        ValidationResponse::Valid => quote!(
699            IamAction(#value.to_string())
700        )
701        .into(),
702        ValidationResponse::Invalid(message) => Error::new(output.span(), message).into_compile_error().into(),
703    }
704}
705
706/// Creates a validated `S3LifecycleObjectSizes` wrapper for S3 lifecycle rule object size constraints at compile time.
707///
708/// This macro defines minimum and maximum object sizes for S3 lifecycle transitions, allowing
709/// lifecycle rules to apply only to objects within a specific size range.
710///
711/// # Validation Rules
712///
713/// - Both minimum and maximum sizes are optional
714/// - If both are provided, the minimum must be smaller than the maximum
715/// - Values are specified in bytes
716#[proc_macro]
717pub fn lifecycle_object_sizes(input: TokenStream) -> TokenStream {
718    let ObjectSizes { first, second } = parse_macro_input!(input);
719
720    // replace with if let Some
721    if first.is_some() && second.is_some() && first.unwrap() > second.unwrap() {
722        return Error::new(
723            Span::call_site(),
724            format!(
725                "first number ({}) in `lifecycle_object_sizes` should be smaller than second ({})",
726                first.unwrap(),
727                second.unwrap()
728            ),
729        )
730        .into_compile_error()
731        .into();
732    }
733
734    let first_output = if let Some(first) = first {
735        quote! {
736            Some(#first)
737        }
738    } else {
739        quote! { None }
740    };
741
742    let second_output = if let Some(second) = second {
743        quote! {
744            Some(#second)
745        }
746    } else {
747        quote! { None }
748    };
749
750    quote! {
751        S3LifecycleObjectSizes(#first_output, #second_output)
752    }
753    .into()
754}
755
756/// Creates a validated `OriginPath` wrapper for CloudFront origin path prefixes at compile time.
757///
758/// This macro ensures that the path string follows CloudFront's requirements for origin paths, which are appended to requests forwarded to the origin.
759///
760/// # Validation Rules
761///
762/// - Must start with a forward slash (/)
763/// - Must NOT end with a forward slash (/)
764/// - Example: "/production" is valid, but "/production/" and "production" are not
765#[proc_macro]
766pub fn origin_path(input: TokenStream) -> TokenStream {
767    let output: LitStr = syn::parse(input).unwrap();
768    let value = output.value();
769
770    if !value.starts_with("/") || value.ends_with("/") {
771        return Error::new(
772            value.span(),
773            format!("origin path should start with a / and should not end with / (but got {})", value),
774        )
775        .into_compile_error()
776        .into();
777    }
778
779    quote! {
780        OriginPath(#value)
781    }
782    .into()
783}
784
785/// Creates a validated `DefaultRootObject` wrapper for CloudFront default root objects at compile time.
786///
787/// This macro ensures that the object name follows CloudFront's requirements for default root objects, which are returned when viewers request the root URL of a distribution.
788///
789/// # Validation Rules
790///
791/// - Must NOT start with a forward slash (/)
792/// - Must NOT end with a forward slash (/)
793/// - Example: "index.html" is valid, but "/index.html" and "index.html/" are not
794#[proc_macro]
795pub fn default_root_object(input: TokenStream) -> TokenStream {
796    let output: LitStr = syn::parse(input).unwrap();
797    let value = output.value();
798
799    if value.starts_with("/") || value.ends_with("/") {
800        return Error::new(value.span(), "default root object should not start with /".to_string())
801            .into_compile_error()
802            .into();
803    }
804
805    quote! {
806        DefaultRootObject(#value)
807    }
808    .into()
809}
810
811/// Creates a validated `CfConnectionTimeout` wrapper for CloudFront origin connection timeouts at compile time.
812///
813/// # Validation Rules
814///
815/// - Connection timeout (first value) must be between 1 and 10 seconds (if provided)
816/// - Response completion timeout (second value) must be greater than or equal to connection timeout (if both provided)
817/// - Both values are optional
818#[proc_macro]
819pub fn cf_connection_timeout(input: TokenStream) -> TokenStream {
820    let Timeouts { first, second } = parse_macro_input!(input);
821
822    if let Some(first) = first {
823        if first > 10 {
824            return Error::new(
825                Span::call_site(),
826                format!("connection timeout was {} but should be between 1 and 10", first),
827            )
828            .into_compile_error()
829            .into();
830        } else if let Some(second) = second && second < first {
831            return Error::new(
832                Span::call_site(),
833                format!(
834                    "response completion timeout was {} but should be larger than connection timeout ({})",
835                    second, first
836                ),
837            )
838            .into_compile_error()
839            .into();
840        }
841    }
842
843    let first_output = if let Some(first) = first {
844        quote! {
845            Some(#first)
846        }
847    } else {
848        quote! { None }
849    };
850
851    let second_output = if let Some(second) = second {
852        quote! {
853            Some(#second)
854        }
855    } else {
856        quote! { None }
857    };
858
859    quote! {
860        CfConnectionTimeout(#first_output, #second_output)
861    }
862    .into()
863}
864
865/// Creates a validated `LambdaPermissionAction` wrapper for Lambda resource-based policy actions at compile time.
866///
867/// This macro ensures that the action string is properly formatted for Lambda resource-based
868/// policies, which control what AWS services and accounts can invoke Lambda functions.
869///
870/// # Validation Rules
871///
872/// - String must not be empty
873/// - Must start with "lambda:" prefix
874/// - Common values include "lambda:InvokeFunction" and "lambda:GetFunction"
875#[proc_macro]
876pub fn lambda_permission_action(input: TokenStream) -> TokenStream {
877    let output: LitStr = syn::parse(input).unwrap();
878    let value = output.value();
879
880    let requirements = StringRequirements::not_empty_prefix("lambda");
881
882    match check_string_requirements(&value, output.span(), requirements) {
883        None => quote!(
884            LambdaPermissionAction(#value.to_string())
885        )
886        .into(),
887        Some(e) => e.into_compile_error().into(),
888    }
889}
890
891/// Creates a validated `AppConfigName` wrapper for AWS AppConfig resource names at compile time.
892///
893/// This macro ensures that the name string follows AWS AppConfig naming conventions and
894/// length restrictions for applications, environments, and configuration profiles.
895///
896/// # Validation Rules
897///
898/// - String must not be empty
899/// - Maximum length of 64 characters
900/// - Used for AppConfig application names, environment names, and configuration profile names
901#[proc_macro]
902pub fn app_config_name(input: TokenStream) -> TokenStream {
903    let output: LitStr = syn::parse(input).unwrap();
904    let value = output.value();
905
906    if value.is_empty() || value.len() > 64 {
907        return Error::new(
908            Span::call_site(),
909            "app config name should be between 1 and 64 chars in length".to_string(),
910        )
911        .into_compile_error()
912        .into();
913    }
914
915    quote! {
916        AppConfigName(#value.to_string())
917    }
918    .into()
919}
920
921const LIFECYCLE_STORAGE_TYPES: [&str; 6] = [
922    "IntelligentTiering",
923    "OneZoneIA",
924    "StandardIA",
925    "GlacierDeepArchive",
926    "Glacier",
927    "GlacierInstantRetrieval",
928];
929const LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS: [&str; 2] = ["OneZoneIA", "StandardIA"];
930
931/// Creates a validated `LifecycleTransitionInDays` wrapper for S3 lifecycle transition rules at compile time.
932///
933/// # Validation Rules
934///
935/// - Days must be a positive number
936/// - Storage class must be one of: IntelligentTiering, OneZoneIA, StandardIA, GlacierDeepArchive, Glacier, GlacierInstantRetrieval
937/// - OneZoneIA and StandardIA storage classes require at least 30 days (not allowed to transition sooner)
938#[proc_macro]
939pub fn lifecycle_transition_in_days(input: TokenStream) -> TokenStream {
940    let TransitionInfo { days, service } = parse_macro_input!(input);
941    let service = service.trim();
942
943    if !LIFECYCLE_STORAGE_TYPES.contains(&service) {
944        return Error::new(
945            Span::call_site(),
946            format!("service should be one of {} (was {})", LIFECYCLE_STORAGE_TYPES.join(","), service),
947        )
948        .into_compile_error()
949        .into();
950    } else if LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS.contains(&service) && days <= 30 {
951        return Error::new(
952            Span::call_site(),
953            format!(
954                "service of type {} cannot have transition under 30 days",
955                LIFECYCLE_STORAGE_TYPES_MORE_THAN_THIRTY_DAYS.join(" or ")
956            ),
957        )
958        .into_compile_error()
959        .into();
960    }
961
962    quote! {
963        LifecycleTransitionInDays(#days)
964    }
965    .into()
966}
967
968const LOCATION_URI_TYPES: [&str; 4] = ["hosted", "codepipeline", "secretsmanager", "s3"];
969const LOCATION_URI_CODEPIPELINE_START: &str = "codepipeline://";
970const LOCATION_URI_SECRETS_MANAGER_START: &str = "secretsmanager://";
971const LOCATION_URI_S3_START: &str = "s3://";
972
973
974/// Creates a validated `LocationUri` wrapper for AppConfig
975///
976/// # Validation Rules
977///
978/// - Must be one of "hosted", "codepipeline", "secretsmanager", "s3"
979/// - Hosted does not need an additional argument
980/// - The other values require a second value, separated from the first by a comma
981#[proc_macro]
982pub fn location_uri(input: TokenStream) -> TokenStream {
983    let LocationUri {
984        location_uri_type,
985        content,
986    } = parse_macro_input!(input);
987    let location_uri_type = location_uri_type.trim();
988
989    #[allow(unused)] // bug? is used at the end for the error?
990    let mut error = None;
991
992    if !LOCATION_URI_TYPES.contains(&location_uri_type) {
993        error = Some(format!(
994            "unrecognized location uri {}, should be one of {}",
995            location_uri_type,
996            LOCATION_URI_TYPES.join(",")
997        ));
998    } else {
999        if location_uri_type == "hosted" {
1000            return quote! {
1001                LocationUri(#location_uri_type.to_string())
1002            }
1003            .into();
1004        } else if content.is_none() {
1005            error = Some(format!("location uri of type {}, should have content", location_uri_type));
1006        } else {
1007            let content = content.expect("just checked that this is present");
1008
1009            if location_uri_type == "codepipeline" && !content.starts_with(LOCATION_URI_CODEPIPELINE_START) {
1010                error = Some(format!(
1011                    "content of type codepipeline should start with {}",
1012                    LOCATION_URI_CODEPIPELINE_START
1013                ));
1014            } else if location_uri_type == "secretsmanager" && !content.starts_with(LOCATION_URI_SECRETS_MANAGER_START) {
1015                error = Some(format!(
1016                    "content of type secretsmanager should start with {}",
1017                    LOCATION_URI_SECRETS_MANAGER_START
1018                ));
1019            } else if location_uri_type == "s3" && !content.starts_with(LOCATION_URI_S3_START) {
1020                error = Some(format!("content of type s3 should start with {}", LOCATION_URI_S3_START));
1021            } else {
1022                return quote! {
1023                    LocationUri(#content.to_string())
1024                }
1025                .into();
1026            }
1027        }
1028    }
1029
1030    Error::new(
1031        Span::call_site(),
1032        error.unwrap_or_else(|| "unknown error".to_string()),
1033    )
1034    .into_compile_error()
1035    .into()
1036}