Skip to main content

wasm_pkg_core/
manifest.rs

1//! Type definitions and functions for working with `wkg.toml` files.
2
3use std::{
4    collections::HashMap,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use semver::VersionReq;
10use serde::{Deserialize, Serialize};
11mod paths;
12pub mod workspace;
13
14use workspace::*;
15
16use crate::manifest::paths::{find_root_iter, find_root_manifest_for_wd};
17
18/// The default name of the manifest file.
19pub const MANIFEST_FILE_NAME: &str = "wkg.toml";
20/// Directory next to the root [`MANIFEST_FILE_NAME`] that holds multi-package `deps` and `config.toml`.
21pub const WORKSPACE_OUT_DIR: &str = "wkg";
22
23/// The structure for a wkg.toml manifest file. This file is entirely optional and is used for
24/// overriding and annotating wasm packages.
25/// `workspace` is mutually exclusive with `overrides` and top-level `metadata`
26#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
27#[serde(deny_unknown_fields)]
28pub struct Manifest {
29    /// Workspace declaration.
30    // TODO: this should be a `TomlWorkspace` so that serialization is not coupled to the config structure
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub workspace: Option<WorkspaceConfig>,
33    /// Overrides for various packages
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub overrides: Option<HashMap<String, Override>>,
36    /// Additional metadata about the package. This will override any metadata already set by other
37    /// tools.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub metadata: Option<Metadata>,
40}
41
42impl Manifest {
43    fn from_toml(contents: &str) -> Result<Manifest> {
44        let manifest: Manifest = toml::from_str(contents)?;
45        manifest.validate()?;
46        Ok(manifest)
47    }
48
49    /// Loads a manifest file from the given path.
50    pub async fn load_from_path(path: impl AsRef<Path>) -> Result<Manifest> {
51        let path = path.as_ref();
52        tracing::info!(path = %path.display(), "loading wkg manifest file");
53        let contents = std::fs::read_to_string(path)
54            .with_context(|| format!("unable to load manifest from {}", path.display()))?;
55        let mut manifest = Self::from_toml(&contents)
56            .with_context(|| format!("invalid manifest at {}", path.display()))?;
57        if let Some(WorkspaceConfig::Root(root)) = &mut manifest.workspace {
58            root.root_dir = path
59                .parent()
60                .with_context(|| {
61                    format!("manifest path has no parent directory: {}", path.display())
62                })?
63                .to_path_buf();
64            // Resolve globs and relative paths eagerly
65            root.members = WorkspaceRootConfig::resolve_members(&root.members, &root.root_dir);
66        }
67        Ok(manifest)
68    }
69
70    fn root(&self) -> Option<&WorkspaceRootConfig> {
71        if let Some(WorkspaceConfig::Root(root)) = &self.workspace {
72            return Some(root);
73        }
74        None
75    }
76
77    // `Manifest` validations, mirrors cargo's `Workspace::validate`
78    fn validate(&self) -> Result<()> {
79        self.validate_workspace_exclusivity()?;
80        // Add new validation rules with `self.validate_*()?;`
81        Ok(())
82    }
83
84    // no overrides or top-level metadata when workspace is present
85    fn validate_workspace_exclusivity(&self) -> Result<()> {
86        if self.workspace.is_none() {
87            return Ok(());
88        }
89        let mut conflicts = Vec::new();
90        if self.overrides.is_some() {
91            conflicts.push("overrides");
92        }
93        if self.metadata.is_some() {
94            conflicts.push("metadata");
95        }
96        if conflicts.is_empty() {
97            return Ok(());
98        }
99        anyhow::bail!(
100            "`[workspace]` cannot coexist with: `[{}]` - \
101             use `[workspace.metadata]` for workspace level values",
102            conflicts.join("]`, `[")
103        );
104    }
105
106    /// Attempts to load the manifest from the current directory. Most of the time, users of this
107    /// crate should use this function. Right now it just checks for a `wkg.toml` file in the current
108    /// directory, but we could add more resolution logic in the future. If the file is not found, a
109    /// default empty manifest is returned.
110    pub async fn load() -> Result<Manifest> {
111        let manifest_path = PathBuf::from(MANIFEST_FILE_NAME);
112        if !tokio::fs::try_exists(&manifest_path).await? {
113            return Ok(Manifest::default());
114        }
115        Self::load_from_path(manifest_path).await
116    }
117
118    /// Tries to find the root workspace config
119    /// Returns `Ok(None)` when there is no `wkg.toml` ancestor that can be [`WorkspaceRootConfig`]
120    // TODO(maktychev): reconcile load_from_path and load_root_workspace
121    pub async fn load_root_workspace(cwd: &Path) -> Result<Option<WorkspaceRootConfig>> {
122        let Some(manifest_file) = find_root_manifest_for_wd(cwd) else {
123            return Ok(None);
124        };
125        let manifest_dir = manifest_file
126            .parent()
127            .context("unexpectedly missing directory containing manifest")?;
128        let manifest = Self::load_from_path(&manifest_file).await?;
129
130        if let Some(root) = manifest.root() {
131            return Ok(Some(root.clone()));
132        }
133
134        // keep walking up if we have not found root
135        for file in find_root_iter(&manifest_file) {
136            let manifest = Self::load_from_path(&file).await?;
137            if let Some(WorkspaceConfig::Root(root)) = manifest.workspace
138                && root.is_explicitly_listed_member(manifest_dir)
139            {
140                return Ok(Some(root));
141            }
142        }
143
144        Ok(None)
145    }
146
147    /// Serializes and writes the manifest to the given path.
148    pub async fn write(&self, path: impl AsRef<Path>) -> Result<()> {
149        let contents = toml::to_string_pretty(self)?;
150        tokio::fs::write(path, contents)
151            .await
152            .context("unable to write manifest to path")
153    }
154
155    /// Returns a matching override name and value for the input path
156    pub(crate) fn has_override(&self, path: impl AsRef<Path>) -> bool {
157        let path = path.as_ref().canonicalize().ok();
158        self.overrides
159            .iter()
160            .flat_map(|map| map.iter())
161            .find(|(_, o)| o.path.as_ref().and_then(|p| p.canonicalize().ok()) == path)
162            .is_some()
163    }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
167#[serde(deny_unknown_fields)]
168pub struct Override {
169    /// A path to the package on disk. If this is set, the package will be loaded from the given
170    /// path. If this is not set, the package will be loaded from the registry.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub path: Option<PathBuf>,
173    /// Overrides the version of a package specified in a world file. This is for advanced use only
174    /// and may break things.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub version: Option<VersionReq>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
180#[serde(deny_unknown_fields)]
181pub struct Metadata {
182    /// The author(s) of the package. Alias supports prior definition as `author`.
183    /// Note that unlike in a Cargo.toml, this authors is a string, not a list of string.
184    #[serde(default, skip_serializing_if = "Option::is_none", alias = "author")]
185    pub authors: Option<String>,
186    /// The package description.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub description: Option<String>,
189    /// The package license.
190    #[serde(default, skip_serializing_if = "Option::is_none", alias = "license")]
191    pub licenses: Option<String>,
192    /// The package source code URL.
193    #[serde(default, skip_serializing_if = "Option::is_none", alias = "repository")]
194    pub source: Option<String>,
195    /// The package homepage URL.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub homepage: Option<String>,
198    /// The package source control revision.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub revision: Option<String>,
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[tokio::test]
208    async fn test_roundtrip() {
209        let tempdir = tempfile::tempdir().unwrap();
210        let manifest_path = tempdir.path().join(MANIFEST_FILE_NAME);
211        let manifest = Manifest {
212            workspace: None,
213            overrides: Some(HashMap::from([(
214                "foo:bar".to_string(),
215                Override {
216                    path: Some(PathBuf::from("bar")),
217                    version: Some(VersionReq::parse("1.0.0").unwrap()),
218                },
219            )])),
220            metadata: Some(Metadata {
221                authors: Some("Foo Bar".to_string()),
222                description: Some("Foobar baz".to_string()),
223                licenses: Some("FBB".to_string()),
224                source: Some("https://gitfoo/bar".to_string()),
225                homepage: Some("https://foo.bar".to_string()),
226                revision: Some("f00ba4".to_string()),
227            }),
228        };
229
230        manifest
231            .write(&manifest_path)
232            .await
233            .expect("unable to write manifest");
234        let loaded_manifest = Manifest::load_from_path(manifest_path)
235            .await
236            .expect("unable to load manifest");
237        assert_eq!(
238            manifest, loaded_manifest,
239            "manifest loaded from file does not match original manifest"
240        );
241    }
242}