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)]
20pub struct ConfigOptions {
21 pub name: Option<String>,
22 pub context: Option<String>,
23 pub global: Option<PathBuf>,
24 pub admerge: bool,
25}
26
27#[derive(Debug, Default, Deserialize, Serialize)]
28pub struct Config {
29 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
30 pub env: HashMap<String, String>,
31 pub build: Build,
32 pub deploy: Deploy,
33 pub watch: Watch,
34}
35
36impl From<PackageMetadata> for Config {
37 fn from(meta: PackageMetadata) -> Self {
38 Config {
39 env: meta.env,
40 build: meta.build.unwrap_or_default(),
41 watch: meta.watch.unwrap_or_default(),
42 deploy: meta.deploy.unwrap_or_default(),
43 }
44 }
45}
46
47pub fn load_config(
48 args_config: &Config,
49 metadata: &CargoMetadata,
50 options: &ConfigOptions,
51) -> Result<Config> {
52 let mut figment = figment_from_metadata(metadata, options)?;
53
54 let mut args_serialized = Serialized::defaults(args_config);
55 if let Some(context) = &options.context {
56 args_serialized = args_serialized.profile(context);
57 }
58
59 figment = if options.admerge {
60 figment.admerge(args_serialized)
61 } else {
62 figment.merge(args_serialized)
63 };
64
65 figment.extract().into_diagnostic()
66}
67
68pub fn load_config_without_cli_flags(
69 metadata: &CargoMetadata,
70 options: &ConfigOptions,
71) -> Result<Config> {
72 let figment = figment_from_metadata(metadata, options)?;
73 figment.extract().into_diagnostic()
74}
75
76fn figment_from_metadata(metadata: &CargoMetadata, options: &ConfigOptions) -> Result<Figment> {
77 let (bin_metadata, package_metadata, mut figment) = general_config_figment(metadata, options)?;
78
79 if let Some(bin_metadata) = bin_metadata {
80 let mut bin_serialized = Serialized::defaults(bin_metadata);
81 if let Some(context) = &options.context {
82 bin_serialized = bin_serialized.profile(context);
83 }
84
85 if options.admerge {
86 figment = figment.admerge(bin_serialized);
87 } else {
88 figment = figment.merge(bin_serialized);
89 }
90 }
91
92 if let Some(package_metadata) = package_metadata {
93 let mut package_serialized = Serialized::defaults(package_metadata);
94 if let Some(context) = &options.context {
95 package_serialized = package_serialized.profile(context);
96 }
97
98 if options.admerge {
99 figment = figment.admerge(package_serialized);
100 } else {
101 figment = figment.merge(package_serialized);
102 }
103 }
104
105 Ok(figment)
106}
107
108pub fn general_config_figment(
109 metadata: &CargoMetadata,
110 options: &ConfigOptions,
111) -> Result<(Option<Config>, Option<Config>, Figment)> {
112 let (ws_metadata, bin_metadata) = workspace_metadata(metadata, options.name.as_deref())?;
113 let package_metadata = package_metadata(metadata, options.name.as_deref())?;
114
115 let mut config_file = options
116 .global
117 .as_ref()
118 .map(Toml::file)
119 .unwrap_or_else(|| Toml::file("CargoLambda.toml"));
120
121 if options.context.is_some() {
122 config_file = config_file.nested()
123 }
124
125 let mut figment = Figment::new();
126 if let Some(context) = &options.context {
127 figment = figment.select(context)
128 }
129
130 let mut env_serialized = Env::prefixed("CARGO_LAMBDA_");
131 if let Some(context) = &options.context {
132 env_serialized = env_serialized.profile(context);
133 }
134
135 figment = figment.merge(env_serialized);
136 figment = if options.admerge {
137 figment.admerge(config_file)
138 } else {
139 figment.merge(config_file)
140 };
141
142 let mut ws_serialized = Serialized::defaults(ws_metadata);
143 if let Some(context) = &options.context {
144 ws_serialized = ws_serialized.profile(context);
145 }
146
147 if options.admerge {
148 figment = figment.admerge(ws_serialized);
149 } else {
150 figment = figment.merge(ws_serialized);
151 }
152
153 Ok((bin_metadata, package_metadata, figment))
154}
155
156fn workspace_metadata(
157 metadata: &CargoMetadata,
158 name: Option<&str>,
159) -> Result<(Config, Option<Config>)> {
160 if metadata.workspace_metadata.is_null() || !metadata.workspace_metadata.is_object() {
161 return Ok((Config::default(), None));
162 }
163
164 let meta: Metadata =
165 serde_json::from_value(metadata.workspace_metadata.clone()).into_diagnostic()?;
166
167 let ws_config = meta.lambda.package.into();
168 if let Some(name) = name {
169 if let Some(bin_config) = meta.lambda.bin.get(name) {
170 return Ok((ws_config, Some(bin_config.clone().into())));
171 }
172 }
173
174 Ok((ws_config, None))
175}
176
177fn package_metadata(metadata: &CargoMetadata, name: Option<&str>) -> Result<Option<Config>> {
178 let kind_condition = |pkg: &Package, target: &Target| {
179 target.kind.iter().any(|kind| kind == "bin") && pkg.metadata.is_object()
180 };
181
182 let Some(name) = name else {
183 if metadata.packages.len() == 1 {
184 return get_config_from_root(metadata);
185 }
186
187 let targets = binary_targets_from_metadata(metadata, false);
188 trace!(
189 ?targets,
190 "inspecting targets for a command without package name"
191 );
192 if targets.len() == 1 {
193 let name = targets
194 .into_iter()
195 .next()
196 .ok_or(MetadataError::MissingBinaryInProject)?;
197 return get_config_from_packages(metadata, kind_condition, &name);
198 }
199
200 return Ok(None);
201 };
202
203 get_config_from_packages(metadata, kind_condition, name)
204}
205
206fn get_config_from_packages(
207 metadata: &CargoMetadata,
208 kind_condition: impl Fn(&Package, &Target) -> bool,
209 name: &str,
210) -> Result<Option<Config>> {
211 for pkg in &metadata.packages {
212 for target in &pkg.targets {
213 if kind_condition(pkg, target) && target.name == name {
214 let meta: Metadata =
215 serde_json::from_value(pkg.metadata.clone()).into_diagnostic()?;
216
217 if let Some(bin_config) = meta.lambda.bin.get(name) {
218 return Ok(Some(bin_config.clone().into()));
219 }
220
221 return Ok(Some(meta.lambda.package.into()));
222 }
223 }
224 }
225
226 Ok(None)
227}
228
229pub fn get_config_from_all_packages(metadata: &CargoMetadata) -> Result<HashMap<String, Config>> {
230 let kind_condition = |pkg: &Package, target: &Target| {
231 target.kind.iter().any(|kind| kind == "bin") && pkg.metadata.is_object()
232 };
233
234 let mut configs = HashMap::new();
235 for pkg in &metadata.packages {
236 for target in &pkg.targets {
237 if kind_condition(pkg, target) {
238 let meta: Metadata =
239 serde_json::from_value(pkg.metadata.clone()).into_diagnostic()?;
240
241 configs.insert(pkg.name.clone(), meta.lambda.package.into());
242 }
243 }
244 }
245
246 Ok(configs)
247}
248
249fn get_config_from_root(metadata: &CargoMetadata) -> Result<Option<Config>> {
250 let Some(root) = metadata.root_package() else {
251 return Ok(None);
252 };
253
254 if root.metadata.is_null() || !root.metadata.is_object() {
255 return Ok(None);
256 }
257
258 let meta: Metadata = serde_json::from_value(root.metadata.clone()).into_diagnostic()?;
259 Ok(Some(meta.lambda.package.into()))
260}
261
262#[cfg(test)]
263mod tests {
264
265 use matchit::MatchError;
266
267 use super::*;
268 use crate::{
269 cargo::{build::CompilerOptions, load_metadata},
270 lambda::Tracing,
271 tests::fixture_metadata,
272 };
273
274 #[test]
275 fn test_load_env_from_metadata() {
276 let metadata = load_metadata(fixture_metadata("single-binary-package")).unwrap();
277 let config = load_config_without_cli_flags(&metadata, &ConfigOptions::default()).unwrap();
278
279 assert_eq!(
280 config.deploy.lambda_tags(),
281 Some(HashMap::from([
282 ("organization".to_string(), "aws".to_string()),
283 ("team".to_string(), "lambda".to_string())
284 ]))
285 );
286
287 assert_eq!(config.env.get("FOO"), Some(&"BAR".to_string()));
288 assert_eq!(config.deploy.function_config.memory, Some(512.into()));
289 assert_eq!(config.deploy.function_config.timeout, Some(60.into()));
290
291 assert_eq!(
292 config.deploy.function_config.layer,
293 Some(vec![
294 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer1".to_string(),
295 "arn:aws:lambda:us-east-1:xxxxxxxx:layers:layer2".to_string()
296 ])
297 );
298
299 let tracing = config.deploy.function_config.tracing.unwrap();
300 assert_eq!(tracing, Tracing::Active);
301 assert_eq!(
302 config.deploy.function_config.role,
303 Some("arn:aws:lambda:us-east-1:xxxxxxxx:iam:role1".to_string())
304 );
305
306 let env_options = config.deploy.function_config.env_options.unwrap();
307 assert_eq!(env_options.env_var, Some(vec!["VAR1=VAL1".to_string()]));
308 assert_eq!(env_options.env_file, Some(".env.production".into()));
309
310 let compiler = config.build.compiler.unwrap();
311
312 let cargo_compiler = match compiler {
313 CompilerOptions::Cargo(opts) => opts,
314 other => panic!("unexpected compiler: {:?}", other),
315 };
316 assert_eq!(
317 cargo_compiler.subcommand,
318 Some(vec!["brazil".to_string(), "build".to_string()])
319 );
320 assert_eq!(
321 cargo_compiler.extra_args,
322 Some(vec!["--release".to_string()])
323 );
324 }
325
326 #[test]
327 fn test_load_router_from_metadata_admerge() {
328 let options = ConfigOptions {
329 name: Some("crate-3".to_string()),
330 admerge: true,
331 ..Default::default()
332 };
333
334 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
335 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
336
337 let router = config.watch.router.unwrap();
338 assert_eq!(
339 router.at("/foo", "GET"),
340 Ok(("crate-1".to_string(), HashMap::new()))
341 );
342 assert_eq!(
343 router.at("/bar", "GET"),
344 Ok(("crate-1".to_string(), HashMap::new()))
345 );
346 assert_eq!(
347 router.at("/bar", "POST"),
348 Ok(("crate-2".to_string(), HashMap::new()))
349 );
350 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
351 assert_eq!(
352 router.at("/qux", "GET"),
353 Ok(("crate-3".to_string(), HashMap::new()))
354 );
355 }
356
357 #[test]
358 fn test_load_router_from_metadata_strict() {
359 let options = ConfigOptions {
360 name: Some("crate-3".to_string()),
361 ..Default::default()
362 };
363
364 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
365 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
366
367 let router = config.watch.router.unwrap();
368 assert_eq!(router.raw.len(), 1);
369 assert_eq!(router.at("/foo", "GET"), Err(MatchError::NotFound));
370 assert_eq!(router.at("/bar", "GET"), Err(MatchError::NotFound));
371 assert_eq!(router.at("/bar", "POST"), Err(MatchError::NotFound));
372 assert_eq!(router.at("/baz", "GET"), Err(MatchError::NotFound));
373 assert_eq!(
374 router.at("/qux", "GET"),
375 Ok(("crate-3".to_string(), HashMap::new()))
376 );
377 }
378
379 #[test]
380 fn test_extend_env_from_workspace() {
381 let options = ConfigOptions {
382 name: Some("basic-lambda-1".to_string()),
383 admerge: true,
384 ..Default::default()
385 };
386
387 let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
388 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
389
390 assert_eq!(config.env.get("FOO"), Some(&"BAR".to_string()));
391 assert_eq!(config.env.get("EXTRA"), Some(&"TRUE".to_string()));
392 assert_eq!(config.env.get("AWS_REGION"), Some(&"us-west-2".to_string()));
393 }
394
395 #[test]
396 fn test_config_with_context() {
397 let manifest = fixture_metadata("config-with-context");
398 let global = manifest.parent().unwrap().join("CargoLambda.toml");
399
400 let options = ConfigOptions {
401 context: Some("production".to_string()),
402 global: Some(global.clone()),
403 ..Default::default()
404 };
405
406 let metadata = load_metadata(manifest).unwrap();
407 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
408 assert_eq!(config.deploy.function_config.memory, Some(1024.into()));
409
410 let options = ConfigOptions {
411 context: Some("development".to_string()),
412 global: Some(global.clone()),
413 ..Default::default()
414 };
415
416 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
417 assert_eq!(config.deploy.function_config.memory, Some(512.into()));
418
419 let options = ConfigOptions {
420 global: Some(global),
421 ..Default::default()
422 };
423
424 let config = load_config_without_cli_flags(&metadata, &options).unwrap();
425 assert_eq!(config.deploy.function_config.memory, Some(256.into()));
426 }
427
428 #[test]
429 fn test_config_with_context_and_cli_flags() {
430 let manifest = fixture_metadata("config-with-context");
431 let global = manifest.parent().unwrap().join("CargoLambda.toml");
432
433 let options = ConfigOptions {
434 context: Some("production".to_string()),
435 global: Some(global.clone()),
436 ..Default::default()
437 };
438
439 let mut deploy = Deploy::default();
440 deploy.function_config.memory = Some(2048.into());
441
442 let args_config = Config {
443 deploy,
444 ..Default::default()
445 };
446
447 let metadata = load_metadata(manifest).unwrap();
448 let config = load_config(&args_config, &metadata, &options).unwrap();
449 assert_eq!(config.deploy.function_config.memory, Some(2048.into()));
450 }
451}