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