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