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#[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::{Memory, 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}