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")).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
356 assert_eq!(
357 config.deploy.function_config.layer,
358 Some(vec![
359 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1".to_string(),
360 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2".to_string()
361 ])
362 );
363
364 let tracing = config.deploy.function_config.tracing.unwrap();
365 assert_eq!(tracing, Tracing::Active);
366 assert_eq!(
367 config.deploy.function_config.role,
368 Some("arn:aws:lambda:us-east-1:xxxxxxxx:iam:role1".to_string())
369 );
370
371 let env_options = config.deploy.function_config.env_options.unwrap();
372 assert_eq!(env_options.env_var, Some(vec!["VAR1=VAL1".to_string()]));
373 assert_eq!(env_options.env_file, Some(".env.production".into()));
374
375 let compiler = config.build.compiler.unwrap();
376
377 let cargo_compiler = match compiler {
378 CompilerOptions::Cargo(opts) => opts,
379 other => panic!("unexpected compiler: {other:?}"),
380 };
381 assert_eq!(
382 cargo_compiler.subcommand,
383 Some(vec!["brazil".to_string(), "build".to_string()])
384 );
385 assert_eq!(
386 cargo_compiler.extra_args,
387 Some(vec!["--release".to_string()])
388 );
389 }
390
391 #[test]
392 fn test_load_router_from_metadata_admerge() {
393 let options = ConfigOptions {
394 names: FunctionNames::from_package("crate-3"),
395 admerge: true,
396 ..Default::default()
397 };
398
399 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
400 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
401
402 let router = config.watch.router.unwrap();
403 assert_eq!(
404 router.at("/foo", "GET"),
405 Ok(("crate-1".to_string(), HashMap::new()))
406 );
407 assert_eq!(
408 router.at("/bar", "GET"),
409 Ok(("crate-1".to_string(), HashMap::new()))
410 );
411 assert_eq!(
412 router.at("/bar", "POST"),
413 Ok(("crate-2".to_string(), HashMap::new()))
414 );
415 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
416 assert_eq!(
417 router.at("/qux", "GET"),
418 Ok(("crate-3".to_string(), HashMap::new()))
419 );
420 }
421
422 #[test]
423 fn test_load_router_from_metadata_strict() {
424 let options = ConfigOptions {
425 names: FunctionNames::from_package("crate-3"),
426 ..Default::default()
427 };
428
429 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
430 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
431
432 let router = config.watch.router.unwrap();
433 assert_eq!(router.raw.len(), 1);
434 assert_eq!(router.at("/foo", "GET"), Err(MatchError::NotFound));
435 assert_eq!(router.at("/bar", "GET"), Err(MatchError::NotFound));
436 assert_eq!(router.at("/bar", "POST"), Err(MatchError::NotFound));
437 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
438 assert_eq!(
439 router.at("/qux", "GET"),
440 Ok(("crate-3".to_string(), HashMap::new()))
441 );
442 }
443
444 #[test]
445 fn test_extend_env_from_workspace() {
446 let options = ConfigOptions {
447 names: FunctionNames::from_binary("basic-lambda-1"),
448 admerge: true,
449 ..Default::default()
450 };
451
452 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
453 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
454
455 assert_eq!(config.env.get("FOO"), Some(&"BAR".to_string()));
456 assert_eq!(config.env.get("EXTRA"), Some(&"TRUE".to_string()));
457 assert_eq!(config.env.get("AWS_REGION"), Some(&"us-west-2".to_string()));
458 }
459
460 #[test]
461 fn test_config_with_context() {
462 let manifest = fixture_metadata("config-with-context");
463 let global = manifest.parent().unwrap().join("CargoLambda.toml");
464
465 let options = ConfigOptions {
466 context: Some("production".to_string()),
467 global: Some(global.clone()),
468 ..Default::default()
469 };
470
471 let metadata = load_metadata(manifest).unwrap();
472 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
473 assert_eq!(config.deploy.function_config.memory, Some(1024.into()));
474
475 let options = ConfigOptions {
476 context: Some("development".to_string()),
477 global: Some(global.clone()),
478 ..Default::default()
479 };
480
481 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
482 assert_eq!(config.deploy.function_config.memory, Some(512.into()));
483
484 let options = ConfigOptions {
485 global: Some(global),
486 ..Default::default()
487 };
488
489 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
490 assert_eq!(config.deploy.function_config.memory, Some(256.into()));
491 }
492
493 #[test]
494 fn test_config_with_context_and_cli_flags() {
495 let manifest = fixture_metadata("config-with-context");
496 let global = manifest.parent().unwrap().join("CargoLambda.toml");
497
498 let options = ConfigOptions {
499 context: Some("production".to_string()),
500 global: Some(global.clone()),
501 ..Default::default()
502 };
503
504 let mut deploy = Deploy::default();
505 deploy.function_config.memory = Some(2048.into());
506
507 let args_config = Config {
508 deploy,
509 ..Default::default()
510 };
511
512 let metadata = load_metadata(manifest).unwrap();
513 let config = load_config(&args_config, &metadata, &options).unwrap();
514 assert_eq!(config.deploy.function_config.memory, Some(2048.into()));
515 }
516
517 #[test]
518 fn test_load_metadata_from_package_workspace() {
519 let options = ConfigOptions {
520 names: FunctionNames::from_package("package-1"),
521 ..Default::default()
522 };
523
524 let metadata = load_metadata(fixture_metadata("workspace-with-package-config")).unwrap();
525 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
526
527 assert_eq!(
528 config.build.cargo_opts.common.features,
529 vec!["lol".to_string()]
530 );
531 assert_eq!(config.build.output_format, Some(OutputFormat::Zip));
532 }
533}