cargo_lambda_metadata/
config.rs

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/// A function can be identified by its package name or by its binary name.
20/// We need both to be able to load the config from the package metadata,
21/// regardless of which one is provided by the user.
22#[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}