1use crate::{error::*, git::GitSource, manifest::PluginManifest, store::manifest_file_name};
2use semver::Version;
3use std::{
4 fs::File,
5 path::{Path, PathBuf},
6};
7use url::Url;
8
9const PLUGINS_REPO_LOCAL_DIRECTORY: &str = ".spin-plugins";
12
13pub(crate) const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests";
15
16pub(crate) const SPIN_PLUGINS_REPO: &str = "https://github.com/spinframework/spin-plugins/";
17
18pub struct PluginLookup {
20 pub name: String,
21 pub version: Option<Version>,
22}
23
24impl PluginLookup {
25 pub fn new(name: &str, version: Option<Version>) -> Self {
26 Self {
27 name: name.to_lowercase(),
28 version,
29 }
30 }
31
32 pub async fn resolve_manifest(
33 &self,
34 plugins_dir: &Path,
35 skip_compatibility_check: bool,
36 spin_version: &str,
37 ) -> PluginLookupResult<PluginManifest> {
38 let exact = self.resolve_manifest_exact(plugins_dir).await?;
39 if skip_compatibility_check
40 || self.version.is_some()
41 || exact.is_compatible_spin_version(spin_version)
42 {
43 return Ok(exact);
44 }
45
46 let store = crate::store::PluginStore::new(plugins_dir.to_owned());
47
48 let manifests = store.catalogue_manifests()?;
50 let relevant_manifests = manifests.into_iter().filter(|m| m.name() == self.name);
51 let compatible_manifests = relevant_manifests
52 .filter(|m| m.has_compatible_package() && m.is_compatible_spin_version(spin_version));
53 let highest_compatible_manifest =
54 compatible_manifests.max_by_key(|m| m.try_version().unwrap_or_else(|_| null_version()));
55
56 Ok(highest_compatible_manifest.unwrap_or(exact))
57 }
58
59 pub async fn resolve_manifest_exact(
60 &self,
61 plugins_dir: &Path,
62 ) -> PluginLookupResult<PluginManifest> {
63 let url = plugins_repo_url()?;
64 tracing::info!("Pulling manifest for plugin {} from {url}", self.name);
65 fetch_plugins_repo(&url, plugins_dir, false)
66 .await
67 .map_err(|e| {
68 Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string()))
69 })?;
70
71 self.resolve_manifest_exact_from_good_repo(plugins_dir)
72 }
73
74 #[allow(clippy::let_and_return)]
77 pub fn resolve_manifest_exact_from_good_repo(
78 &self,
79 plugins_dir: &Path,
80 ) -> PluginLookupResult<PluginManifest> {
81 let expected_path = spin_plugins_repo_manifest_path(&self.name, &self.version, plugins_dir);
82
83 let not_found = |e: std::io::Error| {
84 Err(Error::NotFound(NotFoundError::new(
85 Some(self.name.clone()),
86 expected_path.display().to_string(),
87 e.to_string(),
88 )))
89 };
90
91 let manifest = match File::open(&expected_path) {
92 Ok(file) => serde_json::from_reader(file).map_err(|e| {
93 Error::InvalidManifest(InvalidManifestError::new(
94 Some(self.name.clone()),
95 expected_path.display().to_string(),
96 e.to_string(),
97 ))
98 }),
99 Err(e) if e.kind() == std::io::ErrorKind::NotFound && self.version.is_some() => {
100 let latest = Self::new(&self.name, None);
103 match latest.resolve_manifest_exact_from_good_repo(plugins_dir) {
104 Ok(manifest) if manifest.try_version().ok() == self.version => Ok(manifest),
105 _ => not_found(e),
106 }
107 }
108 Err(e) => not_found(e),
109 };
110
111 manifest
112 }
113}
114
115pub fn plugins_repo_url() -> Result<Url, url::ParseError> {
116 Url::parse(SPIN_PLUGINS_REPO)
117}
118
119#[cfg(not(test))]
120fn accept_as_repo(git_root: &Path) -> bool {
121 git_root.join(".git").exists()
122}
123
124#[cfg(test)]
125fn accept_as_repo(git_root: &Path) -> bool {
126 git_root.join(".git").exists() || git_root.join("_spin_test_dot_git").exists()
127}
128
129pub async fn fetch_plugins_repo(
130 repo_url: &Url,
131 plugins_dir: &Path,
132 update: bool,
133) -> anyhow::Result<()> {
134 let git_root = plugin_manifests_repo_path(plugins_dir);
135 let git_source = GitSource::new(repo_url, None, &git_root);
136 if accept_as_repo(&git_root) {
137 if update {
138 git_source.pull().await?;
139 }
140 } else {
141 git_source.clone_repo().await?;
142 }
143 Ok(())
144}
145
146fn plugin_manifests_repo_path(plugins_dir: &Path) -> PathBuf {
147 plugins_dir.join(PLUGINS_REPO_LOCAL_DIRECTORY)
148}
149
150fn manifest_file_name_version(plugin_name: &str, version: &Option<semver::Version>) -> String {
152 match version {
153 Some(v) => format!("{}@{}.json", plugin_name, v),
154 None => manifest_file_name(plugin_name),
155 }
156}
157
158fn spin_plugins_repo_manifest_path(
161 plugin_name: &str,
162 plugin_version: &Option<Version>,
163 plugins_dir: &Path,
164) -> PathBuf {
165 spin_plugins_repo_manifest_dir(plugins_dir)
166 .join(plugin_name)
167 .join(manifest_file_name_version(plugin_name, plugin_version))
168}
169
170pub fn spin_plugins_repo_manifest_dir(plugins_dir: &Path) -> PathBuf {
171 plugins_dir
172 .join(PLUGINS_REPO_LOCAL_DIRECTORY)
173 .join(PLUGINS_REPO_MANIFESTS_DIRECTORY)
174}
175
176fn null_version() -> semver::Version {
177 semver::Version::new(0, 0, 0)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 const TEST_NAME: &str = "some-spin-ver-some-not";
185 const TESTS_STORE_DIR: &str = "tests";
186
187 fn tests_store_dir() -> PathBuf {
188 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(TESTS_STORE_DIR)
189 }
190
191 #[tokio::test]
192 async fn if_no_version_given_and_latest_is_compatible_then_latest() -> PluginLookupResult<()> {
193 let lookup = PluginLookup::new(TEST_NAME, None);
194 let resolved = lookup
195 .resolve_manifest(&tests_store_dir(), false, "99.0.0")
196 .await?;
197 assert_eq!("99.0.1", resolved.version);
198 Ok(())
199 }
200
201 #[tokio::test]
202 async fn if_no_version_given_and_latest_is_not_compatible_then_highest_compatible(
203 ) -> PluginLookupResult<()> {
204 let lookup = PluginLookup::new(TEST_NAME, None);
208 let resolved = lookup
209 .resolve_manifest(&tests_store_dir(), false, "98.0.0")
210 .await?;
211 assert_eq!("98.0.0", resolved.version);
212 Ok(())
213 }
214
215 #[tokio::test]
216 async fn if_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
217 let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.0").unwrap()));
218 let resolved = lookup
219 .resolve_manifest(&tests_store_dir(), false, "98.0.0")
220 .await?;
221 assert_eq!("99.0.0", resolved.version);
222 Ok(())
223 }
224
225 #[tokio::test]
226 async fn if_latest_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
227 let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.1").unwrap()));
228 let resolved = lookup
229 .resolve_manifest(&tests_store_dir(), false, "98.0.0")
230 .await?;
231 assert_eq!("99.0.1", resolved.version);
232 Ok(())
233 }
234
235 #[tokio::test]
236 async fn if_no_version_given_but_skip_compat_then_highest() -> PluginLookupResult<()> {
237 let lookup = PluginLookup::new(TEST_NAME, None);
238 let resolved = lookup
239 .resolve_manifest(&tests_store_dir(), true, "98.0.0")
240 .await?;
241 assert_eq!("99.0.1", resolved.version);
242 Ok(())
243 }
244
245 #[tokio::test]
246 async fn if_non_existent_version_given_then_error() -> PluginLookupResult<()> {
247 let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("177.7.7").unwrap()));
248 lookup
249 .resolve_manifest(&tests_store_dir(), true, "99.0.0")
250 .await
251 .expect_err("Should have errored because plugin v177.7.7 does not exist");
252 Ok(())
253 }
254}