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 #[arg(short, long, value_hint = ValueHint::DirPath)]
43 #[serde(default)]
44 pub lambda_dir: Option<PathBuf>,
45
46 #[arg(long, value_name = "PATH", default_value = DEFAULT_MANIFEST_PATH)]
48 #[serde(default)]
49 pub manifest_path: Option<PathBuf>,
50
51 #[arg(long, conflicts_with = "binary_path")]
53 #[serde(default)]
54 pub binary_name: Option<String>,
55
56 #[arg(long, conflicts_with = "binary_name")]
58 #[serde(default)]
59 pub binary_path: Option<PathBuf>,
60
61 #[arg(long)]
63 #[serde(default)]
64 pub s3_bucket: Option<String>,
65
66 #[arg(long)]
68 #[serde(default)]
69 pub s3_key: Option<String>,
70
71 #[arg(long)]
73 #[serde(default)]
74 pub extension: bool,
75
76 #[arg(long, requires = "extension")]
78 #[serde(default)]
79 pub internal: bool,
80
81 #[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 #[arg(short, long)]
94 #[serde(default)]
95 output_format: Option<OutputFormat>,
96
97 #[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 #[arg(short, long)]
105 #[serde(default)]
106 pub include: Option<Vec<String>>,
107
108 #[arg(long, alias = "dry-run")]
110 #[serde(default)]
111 pub dry: bool,
112
113 #[arg(long)]
117 #[serde(default)]
118 pub merge_env: bool,
119
120 #[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 self.remote_env.clone()
197 } else {
198 HashMap::new()
199 };
200
201 env.extend(self.base_env.clone());
203
204 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 #[arg(long)]
334 #[serde(default)]
335 pub enable_function_url: bool,
336
337 #[arg(long)]
339 #[serde(default)]
340 pub disable_function_url: bool,
341
342 #[arg(long, alias = "memory-size", value_parser = MemoryValueParser)]
344 #[serde(default)]
345 pub memory: Option<Memory>,
346
347 #[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 #[arg(long)]
358 #[serde(default)]
359 pub tracing: Option<Tracing>,
360
361 #[arg(long, visible_alias = "iam-role")]
363 #[serde(default, alias = "iam_role")]
364 pub role: Option<String>,
365
366 #[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 #[arg(long, default_value = DEFAULT_RUNTIME)]
382 #[serde(default)]
383 pub runtime: Option<String>,
384
385 #[arg(long)]
387 #[serde(default)]
388 pub description: Option<String>,
389
390 #[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 #[arg(long, value_delimiter = ',')]
515 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub subnet_ids: Option<Vec<String>>,
517
518 #[arg(long, value_delimiter = ',')]
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub security_group_ids: Option<Vec<String>>,
522
523 #[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 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 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 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 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 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 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 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 let serialized = serde_json::to_value(&deploy).unwrap();
815
816 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 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}