1use std::{collections::HashMap, path::PathBuf};
2
3use crate::{
4 cargo::{
5 CargoMetadata, Metadata, PackageMetadata, binary_targets_from_metadata, build::Build,
6 deploy::Deploy, watch::Watch,
7 },
8 error::MetadataError,
9};
10use cargo_metadata::{Package, Target};
11use figment::{
12 Figment,
13 providers::{Env, Format, Serialized, Toml},
14};
15use miette::{IntoDiagnostic, Result};
16use serde::{Deserialize, Serialize};
17use tracing::trace;
18
19#[derive(Debug, Default)]
23pub struct FunctionNames {
24 package: Option<String>,
25 binary: Option<String>,
26}
27
28impl FunctionNames {
29 pub fn from_package(package: &str) -> Self {
30 FunctionNames::new(Some(package.to_string()), None)
31 }
32
33 pub fn from_binary(binary: &str) -> Self {
34 FunctionNames::new(None, Some(binary.to_string()))
35 }
36
37 pub fn new(package: Option<String>, binary: Option<String>) -> Self {
38 FunctionNames { package, binary }
39 }
40
41 pub fn is_empty(&self) -> bool {
42 self.package.is_none() && self.binary.is_none()
43 }
44
45 pub fn include(&self, name: &str) -> bool {
46 self.package.as_ref().is_some_and(|p| p == name)
47 || self.binary.as_ref().is_some_and(|b| b == name)
48 }
49
50 pub fn find_binary_metadata<'a>(
51 &'a self,
52 metadata: &'a HashMap<String, PackageMetadata>,
53 ) -> Option<&'a PackageMetadata> {
54 let bin_meta = self.binary.as_ref().and_then(|binary| metadata.get(binary));
55 if bin_meta.is_some() {
56 return bin_meta;
57 }
58
59 self.package
60 .as_ref()
61 .and_then(|package| metadata.get(package))
62 }
63}
64
65impl From<(&str, &str)> for FunctionNames {
66 fn from((package, binary): (&str, &str)) -> Self {
67 FunctionNames::new(Some(package.to_string()), Some(binary.to_string()))
68 }
69}
70
71#[derive(Debug, Default)]
72pub struct ConfigOptions {
73 pub names: FunctionNames,
74 pub context: Option<String>,
75 pub global: Option<PathBuf>,
76 pub admerge: bool,
77}
78
79#[derive(Debug, Default, Deserialize, Serialize)]
80pub struct Config {
81 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
82 pub env: HashMap<String, String>,
83 pub build: Build,
84 pub deploy: Deploy,
85 pub watch: Watch,
86}
87
88impl From<PackageMetadata> for Config {
89 fn from(meta: PackageMetadata) -> Self {
90 Config {
91 env: meta.env,
92 build: meta.build.unwrap_or_default(),
93 watch: meta.watch.unwrap_or_default(),
94 deploy: meta.deploy.unwrap_or_default(),
95 }
96 }
97}
98
99pub fn load_config(
100 args_config: &Config,
101 metadata: &CargoMetadata,
102 options: &ConfigOptions,
103) -> Result<Config> {
104 let mut figment = figment_from_metadata(metadata, options)?;
105
106 let mut args_serialized = Serialized::defaults(args_config);
107 if let Some(context) = &options.context {
108 args_serialized = args_serialized.profile(context);
109 }
110
111 figment = if options.admerge {
112 figment.admerge(args_serialized)
113 } else {
114 figment.merge(args_serialized)
115 };
116
117 figment.extract().into_diagnostic()
118}
119
120pub fn load_config_without_cli_flags(
121 metadata: &CargoMetadata,
122 options: &ConfigOptions,
123) -> Result<Config> {
124 let figment = figment_from_metadata(metadata, options)?;
125 figment.extract().into_diagnostic()
126}
127
128fn figment_from_metadata(metadata: &CargoMetadata, options: &ConfigOptions) -> Result<Figment> {
129 let (bin_metadata, package_metadata, mut figment) = general_config_figment(metadata, options)?;
130
131 if let Some(bin_metadata) = bin_metadata {
132 let mut bin_serialized = Serialized::defaults(bin_metadata);
133 if let Some(context) = &options.context {
134 bin_serialized = bin_serialized.profile(context);
135 }
136
137 if options.admerge {
138 figment = figment.admerge(bin_serialized);
139 } else {
140 figment = figment.merge(bin_serialized);
141 }
142 }
143
144 if let Some(package_metadata) = package_metadata {
145 let mut package_serialized = Serialized::defaults(package_metadata);
146 if let Some(context) = &options.context {
147 package_serialized = package_serialized.profile(context);
148 }
149
150 if options.admerge {
151 figment = figment.admerge(package_serialized);
152 } else {
153 figment = figment.merge(package_serialized);
154 }
155 }
156
157 Ok(figment)
158}
159
160pub fn general_config_figment(
161 metadata: &CargoMetadata,
162 options: &ConfigOptions,
163) -> Result<(Option<Config>, Option<Config>, Figment)> {
164 let (ws_metadata, bin_metadata) = workspace_metadata(metadata, &options.names)?;
165 let package_metadata = package_metadata(metadata, &options.names)?;
166
167 let mut config_file = options
168 .global
169 .as_ref()
170 .map(Toml::file)
171 .unwrap_or_else(|| Toml::file("CargoLambda.toml"));
172
173 if options.context.is_some() {
174 config_file = config_file.nested()
175 }
176
177 let mut figment = Figment::new();
178 if let Some(context) = &options.context {
179 figment = figment.select(context)
180 }
181
182 let mut env_serialized = Env::prefixed("CARGO_LAMBDA_");
183 if let Some(context) = &options.context {
184 env_serialized = env_serialized.profile(context);
185 }
186
187 figment = figment.merge(env_serialized);
188 figment = if options.admerge {
189 figment.admerge(config_file)
190 } else {
191 figment.merge(config_file)
192 };
193
194 let mut ws_serialized = Serialized::defaults(ws_metadata);
195 if let Some(context) = &options.context {
196 ws_serialized = ws_serialized.profile(context);
197 }
198
199 if options.admerge {
200 figment = figment.admerge(ws_serialized);
201 } else {
202 figment = figment.merge(ws_serialized);
203 }
204
205 Ok((bin_metadata, package_metadata, figment))
206}
207
208fn workspace_metadata(
209 metadata: &CargoMetadata,
210 name: &FunctionNames,
211) -> Result<(Config, Option<Config>)> {
212 if metadata.workspace_metadata.is_null() || !metadata.workspace_metadata.is_object() {
213 return Ok((Config::default(), None));
214 }
215
216 let meta: Metadata =
217 serde_json::from_value(metadata.workspace_metadata.clone()).into_diagnostic()?;
218
219 let ws_config = meta.lambda.package.into();
220 if !name.is_empty() {
221 if let Some(bin_config) = name.find_binary_metadata(&meta.lambda.bin) {
222 return Ok((ws_config, Some(bin_config.clone().into())));
223 }
224 }
225
226 Ok((ws_config, None))
227}
228
229fn package_metadata(metadata: &CargoMetadata, name: &FunctionNames) -> Result<Option<Config>> {
230 let kind_condition = |pkg: &Package, target: &Target| {
231 target.kind.iter().any(|kind| kind == "bin") && pkg.metadata.is_object()
232 };
233
234 if name.is_empty() {
235 if metadata.packages.len() == 1 {
236 return get_config_from_root(metadata);
237 }
238
239 let targets = binary_targets_from_metadata(metadata, false);
240 trace!(
241 ?targets,
242 "inspecting targets for a command without package name"
243 );
244 if targets.len() == 1 {
245 let name = targets
246 .into_iter()
247 .next()
248 .ok_or(MetadataError::MissingBinaryInProject)?;
249 return get_config_from_packages(
250 metadata,
251 kind_condition,
252 &FunctionNames::from_package(&name),
253 );
254 }
255
256 return Ok(None);
257 };
258
259 get_config_from_packages(metadata, kind_condition, name)
260}
261
262fn get_config_from_packages(
263 metadata: &CargoMetadata,
264 kind_condition: impl Fn(&Package, &Target) -> bool,
265 name: &FunctionNames,
266) -> Result<Option<Config>> {
267 for pkg in &metadata.packages {
268 for target in &pkg.targets {
269 if kind_condition(pkg, target)
270 && (name.include(&target.name) || name.include(&pkg.name))
271 {
272 let meta: Metadata =
273 serde_json::from_value(pkg.metadata.clone()).into_diagnostic()?;
274
275 if let Some(bin_config) = name.find_binary_metadata(&meta.lambda.bin) {
276 return Ok(Some(bin_config.clone().into()));
277 }
278
279 return Ok(Some(meta.lambda.package.into()));
280 }
281 }
282 }
283
284 Ok(None)
285}
286
287pub fn get_config_from_all_packages(metadata: &CargoMetadata) -> Result<HashMap<String, Config>> {
288 let kind_condition = |pkg: &Package, target: &Target| {
289 target.kind.iter().any(|kind| kind == "bin") && pkg.metadata.is_object()
290 };
291
292 let mut configs = HashMap::new();
293 for pkg in &metadata.packages {
294 for target in &pkg.targets {
295 if kind_condition(pkg, target) {
296 let meta: Metadata =
297 serde_json::from_value(pkg.metadata.clone()).into_diagnostic()?;
298
299 configs.insert(pkg.name.clone(), meta.lambda.package.into());
300 }
301 }
302 }
303
304 Ok(configs)
305}
306
307fn get_config_from_root(metadata: &CargoMetadata) -> Result<Option<Config>> {
308 let Some(root) = metadata.root_package() else {
309 return Ok(None);
310 };
311
312 get_config_from_package(root)
313}
314
315fn get_config_from_package(package: &Package) -> Result<Option<Config>> {
316 if package.metadata.is_null() || !package.metadata.is_object() {
317 return Ok(None);
318 }
319
320 let meta: Metadata = serde_json::from_value(package.metadata.clone()).into_diagnostic()?;
321 Ok(Some(meta.lambda.package.into()))
322}
323
324#[cfg(test)]
325mod tests {
326
327 use matchit::MatchError;
328
329 use super::*;
330 use crate::{
331 cargo::{
332 build::{CompilerOptions, OutputFormat},
333 load_metadata,
334 },
335 lambda::Tracing,
336 tests::fixture_metadata,
337 };
338
339 #[test]
340 fn test_load_env_from_metadata() {
341 let metadata = load_metadata(fixture_metadata("single-binary-package"), None).unwrap();
342 let config = load_config_without_cli_flags(&metadata, &ConfigOptions::default()).unwrap();
343
344 assert_eq!(
345 config.deploy.lambda_tags(),
346 Some(HashMap::from([
347 ("organization".to_string(), "aws".to_string()),
348 ("team".to_string(), "lambda".to_string())
349 ]))
350 );
351
352 assert_eq!(config.env.get("FOO"), Some(&"BAR".to_string()));
353 assert_eq!(config.deploy.function_config.memory, Some(512.into()));
354 assert_eq!(config.deploy.function_config.timeout, Some(60.into()));
355 assert_eq!(config.deploy.merge_env, true);
356
357 assert_eq!(
358 config.deploy.function_config.layer,
359 Some(vec![
360 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1".to_string(),
361 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2".to_string()
362 ])
363 );
364
365 let tracing = config.deploy.function_config.tracing.unwrap();
366 assert_eq!(tracing, Tracing::Active);
367 assert_eq!(
368 config.deploy.function_config.role,
369 Some("arn:aws:lambda:us-east-1:xxxxxxxx:iam:role1".to_string())
370 );
371
372 let env_options = config.deploy.function_config.env_options.unwrap();
373 assert_eq!(env_options.env_var, Some(vec!["VAR1=VAL1".to_string()]));
374 assert_eq!(env_options.env_file, Some(".env.production".into()));
375
376 let compiler = config.build.compiler.unwrap();
377
378 let cargo_compiler = match compiler {
379 CompilerOptions::Cargo(opts) => opts,
380 other => panic!("unexpected compiler: {other:?}"),
381 };
382 assert_eq!(
383 cargo_compiler.subcommand,
384 Some(vec!["brazil".to_string(), "build".to_string()])
385 );
386 assert_eq!(
387 cargo_compiler.extra_args,
388 Some(vec!["--release".to_string()])
389 );
390 }
391
392 #[test]
393 fn test_load_router_from_metadata_admerge() {
394 let options = ConfigOptions {
395 names: FunctionNames::from_package("crate-3"),
396 admerge: true,
397 ..Default::default()
398 };
399
400 let metadata = load_metadata(fixture_metadata("workspace-package"), None).unwrap();
401 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
402
403 let router = config.watch.router.unwrap();
404 assert_eq!(
405 router.at("/foo", "GET"),
406 Ok(("crate-1".to_string(), HashMap::new()))
407 );
408 assert_eq!(
409 router.at("/bar", "GET"),
410 Ok(("crate-1".to_string(), HashMap::new()))
411 );
412 assert_eq!(
413 router.at("/bar", "POST"),
414 Ok(("crate-2".to_string(), HashMap::new()))
415 );
416 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
417 assert_eq!(
418 router.at("/qux", "GET"),
419 Ok(("crate-3".to_string(), HashMap::new()))
420 );
421 }
422
423 #[test]
424 fn test_load_router_from_metadata_strict() {
425 let options = ConfigOptions {
426 names: FunctionNames::from_package("crate-3"),
427 ..Default::default()
428 };
429
430 let metadata = load_metadata(fixture_metadata("workspace-package"), None).unwrap();
431 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
432
433 let router = config.watch.router.unwrap();
434 assert_eq!(router.raw.len(), 1);
435 assert_eq!(router.at("/foo", "GET"), Err(MatchError::NotFound));
436 assert_eq!(router.at("/bar", "GET"), Err(MatchError::NotFound));
437 assert_eq!(router.at("/bar", "POST"), Err(MatchError::NotFound));
438 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
439 assert_eq!(
440 router.at("/qux", "GET"),
441 Ok(("crate-3".to_string(), HashMap::new()))
442 );
443 }
444
445 #[test]
446 fn test_extend_env_from_workspace() {
447 let options = ConfigOptions {
448 names: FunctionNames::from_binary("basic-lambda-1"),
449 admerge: true,
450 ..Default::default()
451 };
452
453 let metadata = load_metadata(fixture_metadata("workspace-package"), None).unwrap();
454 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
455
456 assert_eq!(config.env.get("FOO"), Some(&"BAR".to_string()));
457 assert_eq!(config.env.get("EXTRA"), Some(&"TRUE".to_string()));
458 assert_eq!(config.env.get("AWS_REGION"), Some(&"us-west-2".to_string()));
459 }
460
461 #[test]
462 fn test_config_with_context() {
463 let manifest = fixture_metadata("config-with-context");
464 let global = manifest.parent().unwrap().join("CargoLambda.toml");
465
466 let options = ConfigOptions {
467 context: Some("production".to_string()),
468 global: Some(global.clone()),
469 ..Default::default()
470 };
471
472 let metadata = load_metadata(manifest, None).unwrap();
473 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
474 assert_eq!(config.deploy.function_config.memory, Some(1024.into()));
475
476 let options = ConfigOptions {
477 context: Some("development".to_string()),
478 global: Some(global.clone()),
479 ..Default::default()
480 };
481
482 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
483 assert_eq!(config.deploy.function_config.memory, Some(512.into()));
484
485 let options = ConfigOptions {
486 global: Some(global),
487 ..Default::default()
488 };
489
490 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
491 assert_eq!(config.deploy.function_config.memory, Some(256.into()));
492 }
493
494 #[test]
495 fn test_config_with_context_and_cli_flags() {
496 let manifest = fixture_metadata("config-with-context");
497 let global = manifest.parent().unwrap().join("CargoLambda.toml");
498
499 let options = ConfigOptions {
500 context: Some("production".to_string()),
501 global: Some(global.clone()),
502 ..Default::default()
503 };
504
505 let mut deploy = Deploy::default();
506 deploy.function_config.memory = Some(2048.into());
507
508 let args_config = Config {
509 deploy,
510 ..Default::default()
511 };
512
513 let metadata = load_metadata(manifest, None).unwrap();
514 let config = load_config(&args_config, &metadata, &options).unwrap();
515 assert_eq!(config.deploy.function_config.memory, Some(2048.into()));
516 }
517
518 #[test]
519 fn test_cargo_toml_merge_env_not_overridden_by_cli() {
520 let metadata = load_metadata(fixture_metadata("single-binary-package"), None).unwrap();
522
523 let args_config = Config {
525 deploy: Deploy::default(),
526 ..Default::default()
527 };
528
529 let config = load_config(&args_config, &metadata, &ConfigOptions::default()).unwrap();
530
531 assert_eq!(
533 config.deploy.merge_env, true,
534 "merge_env from Cargo.toml should be preserved when CLI doesn't set it"
535 );
536 }
537
538 #[test]
539 fn test_load_metadata_from_package_workspace() {
540 let options = ConfigOptions {
541 names: FunctionNames::from_package("package-1"),
542 ..Default::default()
543 };
544
545 let metadata =
546 load_metadata(fixture_metadata("workspace-with-package-config"), None).unwrap();
547 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
548
549 assert_eq!(
550 config.build.cargo_opts.common.features,
551 vec!["lol".to_string()]
552 );
553 assert_eq!(config.build.output_format, Some(OutputFormat::Zip));
554 }
555
556 #[test]
557 fn test_load_concurrency_from_metadata() {
558 let metadata = load_metadata(fixture_metadata("single-binary-package")).unwrap();
559 let config = load_config_without_cli_flags(&metadata, &ConfigOptions::default()).unwrap();
560
561 assert_eq!(
562 config.watch.concurrency, 5,
563 "concurrency should be loaded from package metadata"
564 );
565 }
566
567 #[test]
568 fn test_concurrency_cli_override() {
569 let metadata = load_metadata(fixture_metadata("single-binary-package")).unwrap();
570
571 let mut watch = Watch::default();
572 watch.concurrency = 10;
573
574 let args_config = Config {
575 watch,
576 ..Default::default()
577 };
578
579 let config = load_config(&args_config, &metadata, &ConfigOptions::default()).unwrap();
580
581 assert_eq!(
582 config.watch.concurrency, 10,
583 "CLI concurrency should override Cargo.toml metadata"
584 );
585 }
586
587 #[test]
588 fn test_load_concurrency_from_workspace_metadata() {
589 let options = ConfigOptions {
590 names: FunctionNames::from_package("crate-1"),
591 ..Default::default()
592 };
593
594 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
595 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
596
597 assert_eq!(
598 config.watch.concurrency, 3,
599 "concurrency should be loaded from workspace metadata"
600 );
601 }
602}