cargo_lambda_metadata/cargo/
mod.rs

1pub use cargo_metadata::{
2    Metadata as CargoMetadata, Package as CargoPackage, Target as CargoTarget,
3};
4use cargo_options::CommonOptions;
5use miette::Result;
6use serde::{Deserialize, Serialize, ser::SerializeStruct};
7use serde_json::Value;
8use std::{
9    collections::{HashMap, HashSet},
10    fmt::Debug,
11    fs::{metadata, read_to_string},
12    path::{Path, PathBuf},
13};
14use tracing::{Level, enabled, trace};
15
16use crate::error::MetadataError;
17
18pub mod build;
19use build::Build;
20
21pub mod deploy;
22use deploy::Deploy;
23
24pub mod profile;
25use profile::CargoProfile;
26
27pub mod watch;
28use watch::Watch;
29const STRIP_CONFIG: &str = "profile.release.strip=\"symbols\"";
30const LTO_CONFIG: &str = "profile.release.lto=\"thin\"";
31const CODEGEN_CONFIG: &str = "profile.release.codegen-units=1";
32const PANIC_CONFIG: &str = "profile.release.panic=\"abort\"";
33
34#[derive(Debug, Default, Deserialize, Serialize)]
35#[non_exhaustive]
36pub struct Metadata {
37    #[serde(default)]
38    pub lambda: LambdaMetadata,
39    #[serde(default)]
40    profile: Option<CargoProfile>,
41}
42
43#[derive(Clone, Debug, Default, Deserialize, Serialize)]
44#[non_exhaustive]
45pub struct LambdaMetadata {
46    #[serde(flatten)]
47    pub package: PackageMetadata,
48    #[serde(default)]
49    pub bin: HashMap<String, PackageMetadata>,
50}
51
52#[derive(Clone, Debug, Default, Deserialize, Serialize)]
53#[non_exhaustive]
54pub struct PackageMetadata {
55    #[serde(default)]
56    pub env: HashMap<String, String>,
57    #[serde(default)]
58    pub deploy: Option<Deploy>,
59    #[serde(default)]
60    pub build: Option<Build>,
61    #[serde(default)]
62    pub watch: Option<Watch>,
63}
64
65/// Extract all the binary target names from a Cargo.toml file
66pub fn binary_targets<P: AsRef<Path> + Debug>(
67    manifest_path: P,
68    build_examples: bool,
69) -> Result<HashSet<String>, MetadataError> {
70    let metadata = load_metadata(manifest_path)?;
71    Ok(binary_targets_from_metadata(&metadata, build_examples))
72}
73
74pub fn binary_targets_from_metadata(
75    metadata: &CargoMetadata,
76    build_examples: bool,
77) -> HashSet<String> {
78    let condition = if build_examples {
79        kind_example_filter
80    } else {
81        kind_bin_filter
82    };
83
84    let package_filter: Option<fn(&&CargoPackage) -> bool> = None;
85    filter_binary_targets_from_metadata(metadata, condition, package_filter)
86}
87
88pub fn kind_bin_filter(target: &CargoTarget) -> bool {
89    target.kind.iter().any(|k| k == "bin")
90}
91
92pub fn selected_bin_filter(selected_bins: Vec<String>) -> Box<dyn Fn(&CargoTarget) -> bool> {
93    let bins: HashSet<String> = selected_bins.into_iter().collect();
94    Box::new(move |t: &CargoTarget| kind_bin_filter(t) && bins.contains(&t.name))
95}
96
97// Several targets can have `crate_type` be `bin`, we're only
98// interested in the ones which `kind` is `bin` or `example`.
99// See https://doc.rust-lang.org/cargo/commands/cargo-metadata.html?highlight=targets%20metadata#json-format
100pub fn kind_example_filter(target: &CargoTarget) -> bool {
101    target.kind.iter().any(|k| k == "example") && target.crate_types.iter().any(|t| t == "bin")
102}
103
104/// Extract all the binary target names from a Cargo.toml file
105pub fn filter_binary_targets<P, F, K>(
106    manifest_path: P,
107    target_filter: F,
108    package_filter: Option<K>,
109) -> Result<HashSet<String>, MetadataError>
110where
111    P: AsRef<Path> + Debug,
112    F: FnMut(&CargoTarget) -> bool,
113    K: FnMut(&&CargoPackage) -> bool,
114{
115    let metadata = load_metadata(manifest_path)?;
116    Ok(filter_binary_targets_from_metadata(
117        &metadata,
118        target_filter,
119        package_filter,
120    ))
121}
122
123pub fn filter_binary_targets_from_metadata<F, P>(
124    metadata: &CargoMetadata,
125    target_filter: F,
126    package_filter: Option<P>,
127) -> HashSet<String>
128where
129    F: FnMut(&CargoTarget) -> bool,
130    P: FnMut(&&CargoPackage) -> bool,
131{
132    let packages = metadata.packages.iter();
133    let targets = if let Some(filter) = package_filter {
134        packages
135            .filter(filter)
136            .flat_map(|p| p.targets.clone())
137            .collect::<Vec<_>>()
138    } else {
139        packages.flat_map(|p| p.targets.clone()).collect::<Vec<_>>()
140    };
141
142    targets
143        .into_iter()
144        .filter(target_filter)
145        .map(|target| target.name.clone())
146        .collect::<_>()
147}
148
149/// Extract target directory information
150///
151/// This fetches the target directory from `cargo metadata`, resolving the
152/// user and project configuration and the environment variables in the right
153/// way.
154pub fn target_dir_from_metadata(metadata: &CargoMetadata) -> Result<PathBuf> {
155    Ok(metadata.target_directory.clone().into_std_path_buf())
156}
157
158/// Attempt to read the release profile section in the Cargo manifest.
159/// Cargo metadata doesn't expose profile information, so we try
160/// to read it from the Cargo.toml file directly.
161pub fn cargo_release_profile_config<'a>(
162    metadata: &CargoMetadata,
163) -> Result<HashSet<&'a str>, MetadataError> {
164    let path = metadata.workspace_root.join("Cargo.toml");
165    let file =
166        read_to_string(&path).map_err(|e| MetadataError::InvalidManifestFile(path.into(), e))?;
167
168    let metadata: Metadata = toml::from_str(&file).map_err(MetadataError::InvalidTomlManifest)?;
169
170    Ok(cargo_release_profile_config_from_metadata(metadata))
171}
172
173fn cargo_release_profile_config_from_metadata(metadata: Metadata) -> HashSet<&'static str> {
174    let mut config = HashSet::from([STRIP_CONFIG, LTO_CONFIG, CODEGEN_CONFIG, PANIC_CONFIG]);
175
176    let Some(profile) = &metadata.profile else {
177        return config;
178    };
179    let Some(release) = &profile.release else {
180        return config;
181    };
182
183    if release.strip.is_some() || release.debug_enabled() {
184        config.remove(STRIP_CONFIG);
185    }
186    if release.lto.is_some() {
187        config.remove(LTO_CONFIG);
188    }
189    if release.codegen_units.is_some() {
190        config.remove(CODEGEN_CONFIG);
191    }
192    if release.panic.is_some() {
193        config.remove(PANIC_CONFIG);
194    }
195
196    config
197}
198
199/// Create metadata about the root package in the Cargo manifest, without any dependencies.
200#[tracing::instrument(target = "cargo_lambda")]
201pub fn load_metadata<P: AsRef<Path> + Debug>(
202    manifest_path: P,
203) -> Result<CargoMetadata, MetadataError> {
204    trace!("loading Cargo metadata");
205    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
206    metadata_cmd
207        .no_deps()
208        .verbose(enabled!(target: "cargo_lambda", Level::TRACE));
209
210    // try to split manifest path and assign current_dir to enable parsing a project-specific
211    // cargo config
212    let manifest_ref = manifest_path.as_ref();
213
214    match (manifest_ref.parent(), manifest_ref.file_name()) {
215        (Some(project), Some(manifest)) if is_project_metadata_ok(project) => {
216            metadata_cmd.current_dir(project);
217            metadata_cmd.manifest_path(manifest);
218        }
219        _ => {
220            // fall back to using the manifest_path without changing the dir
221            // this means there will not be any project-specific config parsing
222            metadata_cmd.manifest_path(manifest_ref);
223        }
224    }
225
226    trace!(metadata = ?metadata_cmd, "loading cargo metadata");
227    let meta = metadata_cmd
228        .exec()
229        .map_err(MetadataError::FailedCmdExecution)?;
230    trace!(metadata = ?meta, "loaded cargo metadata");
231    Ok(meta)
232}
233
234/// Load the main binary in the project.
235/// It returns an error if the project includes from than one binary.
236/// Use this function when the user didn't provide any funcion name
237/// assuming that there is only one binary in the project
238pub fn main_binary_from_metadata(metadata: &CargoMetadata) -> Result<String, MetadataError> {
239    let targets = binary_targets_from_metadata(metadata, false);
240    if targets.len() > 1 {
241        let mut vec = targets.into_iter().collect::<Vec<_>>();
242        vec.sort();
243        Err(MetadataError::MultipleBinariesInProject(vec.join(", ")))
244    } else if targets.is_empty() {
245        Err(MetadataError::MissingBinaryInProject)
246    } else {
247        targets
248            .into_iter()
249            .next()
250            .ok_or(MetadataError::MissingBinaryInProject)
251    }
252}
253
254fn is_project_metadata_ok(path: &Path) -> bool {
255    path.is_dir() && metadata(path).is_ok()
256}
257
258pub(crate) fn serialize_common_options<S>(
259    state: &mut <S as serde::Serializer>::SerializeStruct,
260    opts: &CommonOptions,
261) -> Result<(), S::Error>
262where
263    S: serde::Serializer,
264{
265    if opts.quiet {
266        state.serialize_field("quiet", &true)?;
267    }
268    if let Some(jobs) = opts.jobs {
269        state.serialize_field("jobs", &jobs)?;
270    }
271    if opts.keep_going {
272        state.serialize_field("keep_going", &true)?;
273    }
274    if let Some(profile) = &opts.profile {
275        state.serialize_field("profile", profile)?;
276    }
277    if !opts.features.is_empty() {
278        state.serialize_field("features", &opts.features)?;
279    }
280    if opts.all_features {
281        state.serialize_field("all_features", &true)?;
282    }
283    if opts.no_default_features {
284        state.serialize_field("no_default_features", &true)?;
285    }
286    if !opts.target.is_empty() {
287        state.serialize_field("target", &opts.target)?;
288    }
289    if let Some(target_dir) = &opts.target_dir {
290        state.serialize_field("target_dir", target_dir)?;
291    }
292    if !opts.message_format.is_empty() {
293        state.serialize_field("message_format", &opts.message_format)?;
294    }
295    if opts.verbose > 0 {
296        state.serialize_field("verbose", &opts.verbose)?;
297    }
298    if let Some(color) = &opts.color {
299        state.serialize_field("color", color)?;
300    }
301    if opts.frozen {
302        state.serialize_field("frozen", &true)?;
303    }
304    if opts.locked {
305        state.serialize_field("locked", &true)?;
306    }
307    if opts.offline {
308        state.serialize_field("offline", &true)?;
309    }
310    if !opts.config.is_empty() {
311        state.serialize_field("config", &opts.config)?;
312    }
313    if !opts.unstable_flags.is_empty() {
314        state.serialize_field("unstable_flags", &opts.unstable_flags)?;
315    }
316    if let Some(timings) = &opts.timings {
317        state.serialize_field("timings", timings)?;
318    }
319
320    Ok(())
321}
322
323pub(crate) fn count_common_options(opts: &CommonOptions) -> usize {
324    opts.quiet as usize
325        + opts.jobs.is_some() as usize
326        + opts.keep_going as usize
327        + opts.profile.is_some() as usize
328        + !opts.features.is_empty() as usize
329        + opts.all_features as usize
330        + opts.no_default_features as usize
331        + !opts.target.is_empty() as usize
332        + opts.target_dir.is_some() as usize
333        + !opts.message_format.is_empty() as usize
334        + (opts.verbose > 0) as usize
335        + opts.color.is_some() as usize
336        + opts.frozen as usize
337        + opts.locked as usize
338        + opts.offline as usize
339        + !opts.config.is_empty() as usize
340        + !opts.unstable_flags.is_empty() as usize
341        + opts.timings.is_some() as usize
342}
343
344pub(crate) fn deserialize_vec_or_map<'de, D>(
345    deserializer: D,
346) -> Result<Option<Vec<String>>, D::Error>
347where
348    D: serde::Deserializer<'de>,
349{
350    let value = Value::deserialize(deserializer)?;
351
352    match value {
353        Value::Array(arr) => {
354            let el = arr
355                .into_iter()
356                .map(|v| v.as_str().map(String::from))
357                .collect::<Option<Vec<_>>>();
358            Ok(el)
359        }
360        Value::Object(map) => {
361            let el = map
362                .into_iter()
363                .map(|(k, v)| format!("{}={}", k, v.as_str().unwrap_or("")))
364                .collect();
365            Ok(Some(el))
366        }
367        _ => Ok(None),
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use crate::tests::fixture_metadata;
374
375    use super::*;
376
377    #[test]
378    fn test_binary_packages() {
379        let bins = binary_targets(fixture_metadata("single-binary-package"), false).unwrap();
380        assert_eq!(1, bins.len());
381        assert!(bins.contains("basic-lambda"));
382    }
383
384    #[test]
385    fn test_binary_packages_with_mutiple_bin_entries() {
386        let bins = binary_targets(fixture_metadata("multi-binary-package"), false).unwrap();
387        assert_eq!(5, bins.len());
388        assert!(bins.contains("delete-product"));
389        assert!(bins.contains("get-product"));
390        assert!(bins.contains("get-products"));
391        assert!(bins.contains("put-product"));
392        assert!(bins.contains("dynamodb-streams"));
393    }
394
395    #[test]
396    fn test_binary_packages_with_workspace() {
397        let bins = binary_targets(fixture_metadata("workspace-package"), false).unwrap();
398        assert_eq!(3, bins.len());
399        assert!(bins.contains("basic-lambda-1"));
400        assert!(bins.contains("basic-lambda-2"));
401        assert!(bins.contains("crate-3"));
402    }
403
404    #[test]
405    fn test_binary_packages_with_mixed_workspace() {
406        let bins = binary_targets(fixture_metadata("mixed-workspace-package"), false).unwrap();
407        assert_eq!(1, bins.len());
408        assert!(bins.contains("function-crate"), "{bins:?}");
409    }
410
411    #[test]
412    fn test_binary_packages_with_missing_binary_info() {
413        let err = binary_targets(fixture_metadata("missing-binary-package"), false).unwrap_err();
414        assert!(
415            err.to_string()
416                .contains("a [lib] section, or [[bin]] section must be present")
417        );
418    }
419
420    #[test]
421    fn test_main_binary_with_package_name() {
422        let manifest_path = fixture_metadata("single-binary-package");
423        let metadata = load_metadata(manifest_path).unwrap();
424        let name = main_binary_from_metadata(&metadata).unwrap();
425        assert_eq!("basic-lambda", name);
426    }
427
428    #[test]
429    fn test_main_binary_with_binary_name() {
430        let manifest_path = fixture_metadata("single-binary-different-name");
431        let metadata = load_metadata(manifest_path).unwrap();
432        let name = main_binary_from_metadata(&metadata).unwrap();
433        assert_eq!("basic-lambda-binary", name);
434    }
435
436    #[test]
437    fn test_main_binary_multi_binaries() {
438        let manifest_path = fixture_metadata("multi-binary-package");
439        let metadata = load_metadata(manifest_path).unwrap();
440        let err = main_binary_from_metadata(&metadata).unwrap_err();
441        assert_eq!(
442            "there are more than one binary in the project, please specify a binary name with --binary-name or --binary-path. This is the list of binaries I found: delete-product, dynamodb-streams, get-product, get-products, put-product",
443            err.to_string()
444        );
445    }
446
447    #[test]
448    fn test_select_binary() {
449        let manifest_path = fixture_metadata("multi-binary-package");
450        let metadata = load_metadata(manifest_path).unwrap();
451
452        let package_filter: Option<fn(&&CargoPackage) -> bool> = None;
453
454        let bin = "delete-product".to_string();
455        let binary_filter = selected_bin_filter(vec![bin.clone()]);
456
457        let binaries =
458            filter_binary_targets_from_metadata(&metadata, binary_filter, package_filter);
459
460        assert_eq!(1, binaries.len());
461        assert!(binaries.contains(&bin));
462    }
463
464    #[test]
465    fn test_example_packages() {
466        let bins = binary_targets(fixture_metadata("examples-package"), true).unwrap();
467        assert_eq!(1, bins.len());
468        assert!(bins.contains("example-lambda"));
469    }
470
471    #[test]
472    fn test_release_config() {
473        let config = cargo_release_profile_config_from_metadata(Metadata::default());
474        assert!(config.contains(STRIP_CONFIG));
475        assert!(config.contains(LTO_CONFIG));
476        assert!(config.contains(CODEGEN_CONFIG));
477        assert!(config.contains(PANIC_CONFIG));
478    }
479
480    #[test]
481    fn test_release_config_with_workspace() {
482        let metadata = load_metadata(fixture_metadata("workspace-package")).unwrap();
483        let config = cargo_release_profile_config(&metadata).unwrap();
484        assert!(config.contains(STRIP_CONFIG));
485        assert!(!config.contains(LTO_CONFIG));
486    }
487}