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