Skip to main content

cargo_lambda_metadata/cargo/
deploy.rs

1use cargo_lambda_remote::{
2    RemoteConfig,
3    aws_sdk_iam::types::Tag,
4    aws_sdk_lambda::types::{Environment, TracingConfig},
5};
6use clap::{ArgAction, Args, ValueHint};
7use serde::{Deserialize, Serialize, ser::SerializeStruct};
8use std::{collections::HashMap, fmt::Debug, path::PathBuf};
9use strum_macros::{Display, EnumString};
10
11use crate::{
12    env::EnvOptions,
13    error::MetadataError,
14    lambda::{Memory, MemoryValueParser, Timeout, Tracing},
15};
16
17use crate::cargo::deserialize_vec_or_map;
18
19const DEFAULT_MANIFEST_PATH: &str = "Cargo.toml";
20const DEFAULT_COMPATIBLE_RUNTIMES: &str = "provided.al2,provided.al2023";
21const DEFAULT_RUNTIME: &str = "provided.al2023";
22
23#[derive(Args, Clone, Debug, Default, Deserialize)]
24#[command(
25    name = "deploy",
26    after_help = "Full command documentation: https://www.cargo-lambda.info/commands/deploy.html"
27)]
28pub struct Deploy {
29    #[command(flatten)]
30    #[serde(default, flatten, skip_serializing_if = "Option::is_none")]
31    pub remote_config: Option<RemoteConfig>,
32
33    #[command(flatten)]
34    #[serde(
35        default,
36        flatten,
37        skip_serializing_if = "FunctionDeployConfig::is_empty"
38    )]
39    pub function_config: FunctionDeployConfig,
40
41    /// Directory where the lambda binaries are located
42    #[arg(short, long, value_hint = ValueHint::DirPath)]
43    #[serde(default)]
44    pub lambda_dir: Option<PathBuf>,
45
46    /// Path to Cargo.toml
47    #[arg(long, value_name = "PATH", default_value = DEFAULT_MANIFEST_PATH)]
48    #[serde(default)]
49    pub manifest_path: Option<PathBuf>,
50
51    /// Name of the binary to deploy if it doesn't match the name that you want to deploy it with
52    #[arg(long, conflicts_with = "binary_path")]
53    #[serde(default)]
54    pub binary_name: Option<String>,
55
56    /// Local path of the binary to deploy if it doesn't match the target path generated by cargo-lambda-build
57    #[arg(long, conflicts_with = "binary_name")]
58    #[serde(default)]
59    pub binary_path: Option<PathBuf>,
60
61    /// S3 bucket to upload the code to
62    #[arg(long)]
63    #[serde(default)]
64    pub s3_bucket: Option<String>,
65
66    /// Name with prefix where the code will be uploaded to in S3
67    #[arg(long)]
68    #[serde(default)]
69    pub s3_key: Option<String>,
70
71    /// Whether the code that you're deploying is a Lambda Extension
72    #[arg(long)]
73    #[serde(default)]
74    pub extension: bool,
75
76    /// Whether an extension is internal or external
77    #[arg(long, requires = "extension")]
78    #[serde(default)]
79    pub internal: bool,
80
81    /// Comma separated list with compatible runtimes for the Lambda Extension (--compatible_runtimes=provided.al2,nodejs16.x)
82    /// List of allowed runtimes can be found in the AWS documentation: https://docs.aws.amazon.com/lambda/latest/dg/API_CreateFunction.html#SSS-CreateFunction-request-Runtime
83    #[arg(
84        long,
85        value_delimiter = ',',
86        default_value = DEFAULT_COMPATIBLE_RUNTIMES,
87        requires = "extension"
88    )]
89    #[serde(default)]
90    compatible_runtimes: Option<Vec<String>>,
91
92    /// Format to render the output (text, or json)
93    #[arg(short, long)]
94    #[serde(default)]
95    output_format: Option<OutputFormat>,
96
97    /// Comma separated list of tags to apply to the function or extension (--tag organization=aws,team=lambda).
98    /// It can be used multiple times to add more tags. (--tag organization=aws --tag team=lambda)
99    #[arg(long, value_delimiter = ',', action = ArgAction::Append, visible_alias = "tags")]
100    #[serde(default, alias = "tags", deserialize_with = "deserialize_vec_or_map")]
101    pub tag: Option<Vec<String>>,
102
103    /// Option to add one or more files and directories to include in the zip file to upload.
104    #[arg(short, long)]
105    #[serde(default)]
106    pub include: Option<Vec<String>>,
107
108    /// Perform all the operations to locate and package the binary to deploy, but don't do the final deploy.
109    #[arg(long, alias = "dry-run")]
110    #[serde(default)]
111    pub dry: bool,
112
113    /// Merge environment variables with existing ones instead of overwriting them.
114    /// When enabled, existing environment variables on AWS Lambda are preserved,
115    /// and only new variables are added or updated from the configuration.
116    #[arg(long)]
117    #[serde(default)]
118    pub merge_env: bool,
119
120    /// Name of the function or extension to deploy
121    #[arg(value_name = "NAME")]
122    #[serde(default)]
123    pub name: Option<String>,
124
125    #[arg(skip)]
126    #[serde(skip)]
127    pub base_env: HashMap<String, String>,
128
129    #[arg(skip)]
130    #[serde(skip)]
131    pub remote_env: HashMap<String, String>,
132}
133
134impl Deploy {
135    pub fn manifest_path(&self) -> PathBuf {
136        self.manifest_path
137            .clone()
138            .unwrap_or_else(default_manifest_path)
139    }
140
141    pub fn output_format(&self) -> OutputFormat {
142        self.output_format.clone().unwrap_or_default()
143    }
144
145    pub fn compatible_runtimes(&self) -> Vec<String> {
146        self.compatible_runtimes
147            .clone()
148            .unwrap_or_else(default_compatible_runtimes)
149    }
150
151    pub fn tracing_config(&self) -> Option<TracingConfig> {
152        let tracing = self.function_config.tracing.clone()?;
153
154        Some(
155            TracingConfig::builder()
156                .mode(tracing.as_str().into())
157                .build(),
158        )
159    }
160
161    pub fn lambda_tags(&self) -> Option<HashMap<String, String>> {
162        match &self.tag {
163            None => None,
164            Some(tags) if tags.is_empty() => None,
165            Some(tags) => Some(extract_tags(tags)),
166        }
167    }
168
169    pub fn s3_tags(&self) -> Option<String> {
170        match &self.tag {
171            None => None,
172            Some(tags) if tags.is_empty() => None,
173            Some(tags) => Some(tags.join("&")),
174        }
175    }
176
177    pub fn iam_tags(&self) -> Option<Vec<Tag>> {
178        match &self.tag {
179            None => None,
180            Some(tags) if tags.is_empty() => None,
181            Some(tags) => Some(
182                extract_tags(tags)
183                    .into_iter()
184                    .map(|(k, v)| Tag::builder().key(k).value(v).build())
185                    .collect::<Result<Vec<_>, _>>()
186                    .expect("failed to build IAM tags"),
187            ),
188        }
189    }
190
191    pub fn lambda_environment(&self) -> Result<Option<Environment>, MetadataError> {
192        let builder = Environment::builder();
193
194        let mut env = if self.merge_env {
195            // Start with remote environment variables when merging
196            self.remote_env.clone()
197        } else {
198            HashMap::new()
199        };
200
201        // Add base environment variables
202        env.extend(self.base_env.clone());
203
204        // Add or override with environment variables from configuration
205        match &self.function_config.env_options {
206            None => {}
207            Some(env_options) => {
208                let local_env = env_options.lambda_environment(&HashMap::new())?;
209                env.extend(local_env);
210            }
211        };
212
213        if env.is_empty() {
214            return Ok(None);
215        }
216
217        Ok(Some(builder.set_variables(Some(env)).build()))
218    }
219
220    pub fn publish_code_without_description(&self) -> bool {
221        self.function_config.description.is_none()
222    }
223
224    pub fn deploy_alias(&self) -> Option<String> {
225        self.remote_config.as_ref().and_then(|r| r.alias.clone())
226    }
227}
228
229impl Serialize for Deploy {
230    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
231    where
232        S: serde::Serializer,
233    {
234        use serde::ser::SerializeStruct;
235
236        let len = self.manifest_path.is_some() as usize
237            + self.lambda_dir.is_some() as usize
238            + self.binary_path.is_some() as usize
239            + self.binary_name.is_some() as usize
240            + self.s3_bucket.is_some() as usize
241            + self.s3_key.is_some() as usize
242            + self.extension as usize
243            + self.internal as usize
244            + self.compatible_runtimes.is_some() as usize
245            + self.output_format.is_some() as usize
246            + self.tag.is_some() as usize
247            + self.include.is_some() as usize
248            + self.dry as usize
249            + self.merge_env as usize
250            + self.name.is_some() as usize
251            + self.remote_config.as_ref().map_or(0, |r| r.count_fields())
252            + self.function_config.count_fields();
253
254        let mut state = serializer.serialize_struct("Deploy", len)?;
255
256        if let Some(ref path) = self.manifest_path {
257            state.serialize_field("manifest_path", path)?;
258        }
259        if let Some(ref dir) = self.lambda_dir {
260            state.serialize_field("lambda_dir", dir)?;
261        }
262        if let Some(ref path) = self.binary_path {
263            state.serialize_field("binary_path", path)?;
264        }
265        if let Some(ref name) = self.binary_name {
266            state.serialize_field("binary_name", name)?;
267        }
268        if let Some(ref bucket) = self.s3_bucket {
269            state.serialize_field("s3_bucket", bucket)?;
270        }
271        if let Some(ref key) = self.s3_key {
272            state.serialize_field("s3_key", key)?;
273        }
274        if self.extension {
275            state.serialize_field("extension", &self.extension)?;
276        }
277        if self.internal {
278            state.serialize_field("internal", &self.internal)?;
279        }
280        if let Some(ref runtimes) = self.compatible_runtimes {
281            state.serialize_field("compatible_runtimes", runtimes)?;
282        }
283        if let Some(ref format) = self.output_format {
284            state.serialize_field("output_format", format)?;
285        }
286        if let Some(ref tag) = self.tag {
287            state.serialize_field("tag", tag)?;
288        }
289        if let Some(ref include) = self.include {
290            state.serialize_field("include", include)?;
291        }
292        if self.dry {
293            state.serialize_field("dry", &self.dry)?;
294        }
295        if self.merge_env {
296            state.serialize_field("merge_env", &self.merge_env)?;
297        }
298        if let Some(ref name) = self.name {
299            state.serialize_field("name", name)?;
300        }
301        if let Some(ref remote_config) = self.remote_config {
302            remote_config.serialize_fields::<S>(&mut state)?;
303        }
304        self.function_config.serialize_fields::<S>(&mut state)?;
305
306        state.end()
307    }
308}
309
310fn default_manifest_path() -> PathBuf {
311    PathBuf::from(DEFAULT_MANIFEST_PATH)
312}
313
314fn default_compatible_runtimes() -> Vec<String> {
315    DEFAULT_COMPATIBLE_RUNTIMES
316        .split(',')
317        .map(String::from)
318        .collect()
319}
320
321#[derive(Clone, Debug, Default, Deserialize, Display, EnumString, Serialize)]
322#[strum(ascii_case_insensitive)]
323#[serde(rename_all = "lowercase")]
324pub enum OutputFormat {
325    #[default]
326    Text,
327    Json,
328}
329
330#[derive(Args, Clone, Debug, Default, Deserialize, Serialize)]
331pub struct FunctionDeployConfig {
332    /// Enable function URL for this function
333    #[arg(long)]
334    #[serde(default)]
335    pub enable_function_url: bool,
336
337    /// Disable function URL for this function
338    #[arg(long)]
339    #[serde(default)]
340    pub disable_function_url: bool,
341
342    /// Memory allocated for the function. Value must be between 128 and 10240.
343    #[arg(long, alias = "memory-size", value_parser = MemoryValueParser)]
344    #[serde(default)]
345    pub memory: Option<Memory>,
346
347    /// How long the function can be running for, in seconds
348    #[arg(long)]
349    #[serde(default)]
350    pub timeout: Option<Timeout>,
351
352    #[command(flatten)]
353    #[serde(default, flatten, skip_serializing_if = "Option::is_none")]
354    pub env_options: Option<EnvOptions>,
355
356    /// Tracing mode with X-Ray
357    #[arg(long)]
358    #[serde(default)]
359    pub tracing: Option<Tracing>,
360
361    /// IAM Role associated with the function
362    #[arg(long, visible_alias = "iam-role")]
363    #[serde(default, alias = "iam_role")]
364    pub role: Option<String>,
365
366    /// Lambda Layer ARN to associate the deployed function with.
367    /// Can be used multiple times to add more layers.
368    /// `--layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1 --layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2`.
369    /// It can also be used with comma separated list of layer ARNs.
370    /// `--layer arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1,arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2`.
371    #[arg(long, value_delimiter = ',', action = ArgAction::Append, visible_alias = "layer-arn")]
372    #[serde(default, alias = "layers")]
373    pub layer: Option<Vec<String>>,
374
375    #[command(flatten)]
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub vpc: Option<VpcConfig>,
378
379    /// Choose a different Lambda runtime to deploy with.
380    /// The only other option that might work is `provided.al2`.
381    #[arg(long, default_value = DEFAULT_RUNTIME)]
382    #[serde(default)]
383    pub runtime: Option<String>,
384
385    /// A description for the new function version.
386    #[arg(long)]
387    #[serde(default)]
388    pub description: Option<String>,
389
390    /// Retention policy for the function's log group.
391    /// The value is the number of days to keep the logs.
392    #[arg(long)]
393    #[serde(default)]
394    pub log_retention: Option<i32>,
395}
396
397fn default_runtime() -> String {
398    DEFAULT_RUNTIME.to_string()
399}
400
401impl FunctionDeployConfig {
402    #[allow(dead_code)]
403    fn is_empty(&self) -> bool {
404        self.runtime.is_none()
405            && self.memory.is_none()
406            && self.timeout.is_none()
407            && self.env_options.is_none()
408            && self.tracing.is_none()
409            && self.role.is_none()
410            && self.vpc.is_none()
411            && self.description.is_none()
412            && self.log_retention.is_none()
413            && self.layer.is_none()
414            && !self.disable_function_url
415            && !self.enable_function_url
416    }
417
418    pub fn runtime(&self) -> String {
419        self.runtime.clone().unwrap_or_else(default_runtime)
420    }
421
422    pub fn should_update(&self) -> bool {
423        let Ok(val) = serde_json::to_value(self) else {
424            return false;
425        };
426        let Ok(default) = serde_json::to_value(FunctionDeployConfig::default()) else {
427            return false;
428        };
429        val != default
430    }
431
432    fn count_fields(&self) -> usize {
433        self.disable_function_url as usize
434            + self.enable_function_url as usize
435            + self.layer.as_ref().is_some_and(|l| !l.is_empty()) as usize
436            + self.tracing.is_some() as usize
437            + self.role.is_some() as usize
438            + self.memory.is_some() as usize
439            + self.timeout.is_some() as usize
440            + self.runtime.is_some() as usize
441            + self.description.is_some() as usize
442            + self.log_retention.is_some() as usize
443            + self.vpc.is_some() as usize
444            + self
445                .env_options
446                .as_ref()
447                .map_or(0, |env| env.count_fields())
448    }
449
450    fn serialize_fields<S>(
451        &self,
452        state: &mut <S as serde::Serializer>::SerializeStruct,
453    ) -> Result<(), S::Error>
454    where
455        S: serde::Serializer,
456    {
457        if self.disable_function_url {
458            state.serialize_field("disable_function_url", &true)?;
459        }
460
461        if self.enable_function_url {
462            state.serialize_field("enable_function_url", &true)?;
463        }
464
465        if let Some(memory) = &self.memory {
466            state.serialize_field("memory", &memory)?;
467        }
468
469        if let Some(timeout) = &self.timeout {
470            state.serialize_field("timeout", &timeout)?;
471        }
472
473        if let Some(runtime) = &self.runtime {
474            state.serialize_field("runtime", &runtime)?;
475        }
476
477        if let Some(tracing) = &self.tracing {
478            state.serialize_field("tracing", &tracing)?;
479        }
480
481        if let Some(role) = &self.role {
482            state.serialize_field("role", &role)?;
483        }
484
485        if let Some(layer) = &self.layer {
486            if !layer.is_empty() {
487                state.serialize_field("layer", &layer)?;
488            }
489        }
490
491        if let Some(description) = &self.description {
492            state.serialize_field("description", &description)?;
493        }
494
495        if let Some(log_retention) = &self.log_retention {
496            state.serialize_field("log_retention", &log_retention)?;
497        }
498
499        if let Some(vpc) = &self.vpc {
500            state.serialize_field("vpc", vpc)?;
501        }
502
503        if let Some(env_options) = &self.env_options {
504            env_options.serialize_fields::<S>(state)?;
505        }
506
507        Ok(())
508    }
509}
510
511#[derive(Args, Clone, Debug, Default, Deserialize, Serialize)]
512pub struct VpcConfig {
513    /// Subnet IDs to associate the deployed function with a VPC
514    #[arg(long, value_delimiter = ',')]
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub subnet_ids: Option<Vec<String>>,
517
518    /// Security Group IDs to associate the deployed function
519    #[arg(long, value_delimiter = ',')]
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub security_group_ids: Option<Vec<String>>,
522
523    /// Allow outbound IPv6 traffic on VPC functions that are connected to dual-stack subnets
524    #[arg(long)]
525    #[serde(default, skip_serializing_if = "is_false")]
526    pub ipv6_allowed_for_dual_stack: bool,
527}
528
529fn is_false(b: &bool) -> bool {
530    !b
531}
532
533impl VpcConfig {
534    pub fn should_update(&self) -> bool {
535        let Ok(val) = serde_json::to_value(self) else {
536            return false;
537        };
538        let Ok(default) = serde_json::to_value(VpcConfig::default()) else {
539            return false;
540        };
541        val != default
542    }
543}
544
545fn extract_tags(tags: &Vec<String>) -> HashMap<String, String> {
546    let mut map = HashMap::new();
547
548    for var in tags {
549        let mut split = var.splitn(2, '=');
550        if let (Some(k), Some(v)) = (split.next(), split.next()) {
551            map.insert(k.to_string(), v.to_string());
552        }
553    }
554
555    map
556}
557
558#[cfg(test)]
559mod tests {
560    use crate::{
561        cargo::load_metadata,
562        config::{ConfigOptions, FunctionNames, load_config_without_cli_flags},
563        lambda::Timeout,
564        tests::fixture_metadata,
565    };
566
567    use super::*;
568
569    #[test]
570    fn test_extract_tags() {
571        let tags = vec!["organization=aws".to_string(), "team=lambda".to_string()];
572        let map = extract_tags(&tags);
573        assert_eq!(map.get("organization"), Some(&"aws".to_string()));
574        assert_eq!(map.get("team"), Some(&"lambda".to_string()));
575    }
576
577    #[test]
578    fn test_lambda_environment() {
579        let deploy = Deploy::default();
580        let env = deploy.lambda_environment().unwrap();
581        assert_eq!(env, None);
582
583        let deploy = Deploy {
584            base_env: HashMap::from([("FOO".to_string(), "BAR".to_string())]),
585            ..Default::default()
586        };
587        let env = deploy.lambda_environment().unwrap().unwrap();
588        assert_eq!(env.variables().unwrap().len(), 1);
589        assert_eq!(
590            env.variables().unwrap().get("FOO"),
591            Some(&"BAR".to_string())
592        );
593
594        let deploy = Deploy {
595            function_config: FunctionDeployConfig {
596                env_options: Some(EnvOptions {
597                    env_var: Some(vec!["FOO=BAR".to_string()]),
598                    ..Default::default()
599                }),
600                ..Default::default()
601            },
602            ..Default::default()
603        };
604        let env = deploy.lambda_environment().unwrap().unwrap();
605        assert_eq!(env.variables().unwrap().len(), 1);
606        assert_eq!(
607            env.variables().unwrap().get("FOO"),
608            Some(&"BAR".to_string())
609        );
610
611        let deploy = Deploy {
612            function_config: FunctionDeployConfig {
613                env_options: Some(EnvOptions {
614                    env_var: Some(vec!["FOO=BAR".to_string()]),
615                    ..Default::default()
616                }),
617                ..Default::default()
618            },
619            base_env: HashMap::from([("BAZ".to_string(), "QUX".to_string())]),
620            ..Default::default()
621        };
622        let env = deploy.lambda_environment().unwrap().unwrap();
623        assert_eq!(env.variables().unwrap().len(), 2);
624        assert_eq!(
625            env.variables().unwrap().get("BAZ"),
626            Some(&"QUX".to_string())
627        );
628        assert_eq!(
629            env.variables().unwrap().get("FOO"),
630            Some(&"BAR".to_string())
631        );
632
633        let temp_file = tempfile::NamedTempFile::new().unwrap();
634        let path = temp_file.path();
635        std::fs::write(path, "FOO=BAR\nBAZ=QUX").unwrap();
636
637        let deploy = Deploy {
638            function_config: FunctionDeployConfig {
639                env_options: Some(EnvOptions {
640                    env_file: Some(path.to_path_buf()),
641                    ..Default::default()
642                }),
643                ..Default::default()
644            },
645            base_env: HashMap::from([("QUUX".to_string(), "QUUX".to_string())]),
646            ..Default::default()
647        };
648        let env = deploy.lambda_environment().unwrap().unwrap();
649        assert_eq!(env.variables().unwrap().len(), 3);
650        assert_eq!(
651            env.variables().unwrap().get("BAZ"),
652            Some(&"QUX".to_string())
653        );
654        assert_eq!(
655            env.variables().unwrap().get("FOO"),
656            Some(&"BAR".to_string())
657        );
658        assert_eq!(
659            env.variables().unwrap().get("QUUX"),
660            Some(&"QUUX".to_string())
661        );
662    }
663
664    #[test]
665    fn test_lambda_environment_merge_mode() {
666        // Test merge_env=false (default behavior - overwrites)
667        let deploy = Deploy {
668            function_config: FunctionDeployConfig {
669                env_options: Some(EnvOptions {
670                    env_var: Some(vec!["LOCAL=VALUE".to_string()]),
671                    ..Default::default()
672                }),
673                ..Default::default()
674            },
675            remote_env: HashMap::from([
676                ("REMOTE1".to_string(), "REMOTE_VALUE1".to_string()),
677                ("REMOTE2".to_string(), "REMOTE_VALUE2".to_string()),
678            ]),
679            merge_env: false,
680            ..Default::default()
681        };
682        let env = deploy.lambda_environment().unwrap().unwrap();
683        let vars = env.variables().unwrap();
684        // When merge_env is false, only local env vars should be present
685        assert_eq!(vars.len(), 1);
686        assert_eq!(vars.get("LOCAL"), Some(&"VALUE".to_string()));
687        assert_eq!(vars.get("REMOTE1"), None);
688        assert_eq!(vars.get("REMOTE2"), None);
689
690        // Test merge_env=true (merge behavior - preserves remote vars)
691        let deploy = Deploy {
692            function_config: FunctionDeployConfig {
693                env_options: Some(EnvOptions {
694                    env_var: Some(vec!["LOCAL=VALUE".to_string()]),
695                    ..Default::default()
696                }),
697                ..Default::default()
698            },
699            remote_env: HashMap::from([
700                ("REMOTE1".to_string(), "REMOTE_VALUE1".to_string()),
701                ("REMOTE2".to_string(), "REMOTE_VALUE2".to_string()),
702            ]),
703            merge_env: true,
704            ..Default::default()
705        };
706        let env = deploy.lambda_environment().unwrap().unwrap();
707        let vars = env.variables().unwrap();
708        // When merge_env is true, both remote and local vars should be present
709        assert_eq!(vars.len(), 3);
710        assert_eq!(vars.get("LOCAL"), Some(&"VALUE".to_string()));
711        assert_eq!(vars.get("REMOTE1"), Some(&"REMOTE_VALUE1".to_string()));
712        assert_eq!(vars.get("REMOTE2"), Some(&"REMOTE_VALUE2".to_string()));
713
714        // Test merge_env=true with overlapping keys (local should win)
715        let deploy = Deploy {
716            function_config: FunctionDeployConfig {
717                env_options: Some(EnvOptions {
718                    env_var: Some(vec!["REMOTE1=LOCAL_OVERRIDE".to_string()]),
719                    ..Default::default()
720                }),
721                ..Default::default()
722            },
723            remote_env: HashMap::from([
724                ("REMOTE1".to_string(), "REMOTE_VALUE1".to_string()),
725                ("REMOTE2".to_string(), "REMOTE_VALUE2".to_string()),
726            ]),
727            merge_env: true,
728            ..Default::default()
729        };
730        let env = deploy.lambda_environment().unwrap().unwrap();
731        let vars = env.variables().unwrap();
732        // Local value should override remote value
733        assert_eq!(vars.len(), 2);
734        assert_eq!(vars.get("REMOTE1"), Some(&"LOCAL_OVERRIDE".to_string()));
735        assert_eq!(vars.get("REMOTE2"), Some(&"REMOTE_VALUE2".to_string()));
736    }
737
738    #[test]
739    fn test_load_config_from_workspace() {
740        let options = ConfigOptions {
741            names: FunctionNames::from_package("crate-3"),
742            admerge: true,
743            ..Default::default()
744        };
745
746        let metadata = load_metadata(fixture_metadata("workspace-package"), None).unwrap();
747        let config = load_config_without_cli_flags(&metadata, &options).unwrap();
748        assert_eq!(
749            config.deploy.function_config.timeout,
750            Some(Timeout::new(120))
751        );
752        assert_eq!(config.deploy.function_config.memory, Some(10240.into()));
753
754        let tags = config.deploy.lambda_tags().unwrap();
755        assert_eq!(tags.len(), 2);
756        assert_eq!(tags.get("organization"), Some(&"aws".to_string()));
757        assert_eq!(tags.get("team"), Some(&"lambda".to_string()));
758
759        assert_eq!(
760            config.deploy.include,
761            Some(vec!["src/bin/main.rs".to_string()])
762        );
763
764        assert_eq!(
765            config.deploy.function_config.env_options.unwrap().env_var,
766            Some(vec!["APP_ENV=production".to_string()])
767        );
768
769        assert_eq!(config.deploy.function_config.log_retention, Some(14));
770    }
771
772    #[test]
773    fn test_load_region_from_package_metadata() {
774        let metadata = load_metadata(fixture_metadata("single-binary-package"), None).unwrap();
775
776        let options = ConfigOptions {
777            names: FunctionNames::from_package("basic-lambda"),
778            ..Default::default()
779        };
780
781        let config = load_config_without_cli_flags(&metadata, &options).unwrap();
782
783        // Region and profile should be loaded from package.metadata.lambda.deploy
784        let remote_config = config
785            .deploy
786            .remote_config
787            .expect("remote_config should be Some");
788        assert_eq!(
789            remote_config.region,
790            Some("eu-central-1".to_string()),
791            "region should be loaded from Cargo.toml"
792        );
793        assert_eq!(
794            remote_config.profile,
795            Some("test-profile".to_string()),
796            "profile should be loaded from Cargo.toml"
797        );
798    }
799
800    #[test]
801    fn test_deploy_serialization_with_remote_config() {
802        use cargo_lambda_remote::RemoteConfig;
803
804        let deploy = Deploy {
805            remote_config: Some(RemoteConfig {
806                region: Some("us-west-2".to_string()),
807                profile: Some("production".to_string()),
808                ..Default::default()
809            }),
810            ..Default::default()
811        };
812
813        // Serialize to JSON
814        let serialized = serde_json::to_value(&deploy).unwrap();
815
816        // Should serialize as flat fields at top level
817        assert_eq!(serialized["region"], "us-west-2");
818        assert_eq!(serialized["profile"], "production");
819        assert!(
820            !serialized
821                .as_object()
822                .unwrap()
823                .contains_key("remote_config"),
824            "remote_config should be flattened, not nested"
825        );
826
827        // Deserialize back
828        let deserialized: Deploy = serde_json::from_value(serialized).unwrap();
829        let rc = deserialized.remote_config.unwrap();
830        assert_eq!(rc.region, Some("us-west-2".to_string()));
831        assert_eq!(rc.profile, Some("production".to_string()));
832    }
833}