1use std::fs;
4use std::path::Path;
5
6use serde::Deserialize;
7use vs_plugin_api::{
8 AvailableVersion, EnvKey, InstallArtifact, InstallPlan, InstallSource, InstalledRuntime,
9 Plugin, PluginBackendKind, PluginError, PluginManifest,
10};
11
12#[derive(Debug, Deserialize)]
13struct DescriptorFile {
14 plugin: PluginSection,
15 #[serde(default)]
16 versions: Vec<VersionSection>,
17 #[serde(default)]
18 env: Vec<EnvSection>,
19}
20
21#[derive(Debug, Deserialize)]
22struct PluginSection {
23 name: String,
24 #[serde(default)]
25 description: Option<String>,
26 #[serde(default)]
27 aliases: Vec<String>,
28 #[serde(default)]
29 legacy_filenames: Vec<String>,
30}
31
32#[derive(Debug, Deserialize)]
33struct VersionSection {
34 version: String,
35 source: String,
36 #[serde(default)]
37 note: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41struct EnvSection {
42 key: String,
43 value: String,
44}
45
46#[derive(Debug)]
48pub struct WasiPlugin {
49 manifest: PluginManifest,
50 versions: Vec<VersionSection>,
51 env: Vec<EnvSection>,
52 legacy_filenames: Vec<String>,
53}
54
55impl WasiPlugin {
56 pub fn load(source: &Path) -> Result<Self, PluginError> {
58 let path = source.join("component.toml");
59 let content = fs::read_to_string(&path).map_err(|error| PluginError::InvalidSource {
60 path: path.clone(),
61 message: error.to_string(),
62 })?;
63 let descriptor = toml::from_str::<DescriptorFile>(&content).map_err(|error| {
64 PluginError::InvalidSource {
65 path,
66 message: error.to_string(),
67 }
68 })?;
69
70 Ok(Self {
71 manifest: PluginManifest {
72 name: descriptor.plugin.name,
73 backend: PluginBackendKind::Wasi,
74 source: source.to_path_buf(),
75 description: descriptor.plugin.description,
76 aliases: descriptor.plugin.aliases,
77 version: None,
78 homepage: None,
79 license: None,
80 update_url: None,
81 manifest_url: None,
82 min_runtime_version: None,
83 notes: Vec::new(),
84 legacy_filenames: descriptor.plugin.legacy_filenames.clone(),
85 },
86 versions: descriptor.versions,
87 env: descriptor.env,
88 legacy_filenames: descriptor.plugin.legacy_filenames,
89 })
90 }
91}
92
93impl Plugin for WasiPlugin {
94 fn manifest(&self) -> &PluginManifest {
95 &self.manifest
96 }
97
98 fn available_versions(&self, _args: &[String]) -> Result<Vec<AvailableVersion>, PluginError> {
99 Ok(self
100 .versions
101 .iter()
102 .map(|version| AvailableVersion {
103 version: version.version.clone(),
104 note: version.note.clone(),
105 additions: Vec::new(),
106 })
107 .collect())
108 }
109
110 fn install_plan(&self, version: &str) -> Result<InstallPlan, PluginError> {
111 let version = self
112 .versions
113 .iter()
114 .find(|candidate| candidate.version == version)
115 .ok_or_else(|| PluginError::VersionNotFound {
116 plugin: self.manifest.name.clone(),
117 version: version.to_string(),
118 })?;
119 Ok(InstallPlan {
120 plugin: self.manifest.name.clone(),
121 version: version.version.clone(),
122 main: InstallArtifact {
123 name: self.manifest.name.clone(),
124 version: version.version.clone(),
125 source: InstallSource::Directory {
126 path: self.manifest.source.join(&version.source),
127 },
128 note: version.note.clone(),
129 checksum: None,
130 },
131 additions: Vec::new(),
132 legacy_filenames: self.legacy_filenames.clone(),
133 })
134 }
135
136 fn env_keys(&self, runtime: &InstalledRuntime) -> Result<Vec<EnvKey>, PluginError> {
137 Ok(self
138 .env
139 .iter()
140 .map(|entry| EnvKey {
141 key: entry.key.clone(),
142 value: entry
143 .value
144 .replace("{install_dir}", &runtime.main.path.display().to_string()),
145 })
146 .collect())
147 }
148
149 fn parse_legacy_file(
150 &self,
151 file_name: &str,
152 _file_path: &Path,
153 content: &str,
154 installed_versions: &[String],
155 strategy: &str,
156 ) -> Result<Option<String>, PluginError> {
157 if self.legacy_filenames.iter().any(|name| name == file_name) {
158 let trimmed = content.trim();
159 match strategy {
160 "latest_installed" => Ok(select_matching_version(trimmed, installed_versions)
161 .or_else(|| (!trimmed.is_empty()).then(|| trimmed.to_string()))),
162 "latest_available" => {
163 let available = self
164 .available_versions(&[])?
165 .into_iter()
166 .map(|version| version.version)
167 .collect::<Vec<_>>();
168 Ok(select_matching_version(trimmed, &available)
169 .or_else(|| (!trimmed.is_empty()).then(|| trimmed.to_string())))
170 }
171 _ => Ok((!trimmed.is_empty()).then(|| trimmed.to_string())),
172 }
173 } else {
174 Ok(None)
175 }
176 }
177}
178
179#[derive(Debug, Default, Clone, Copy)]
181pub struct WasiBackend;
182
183impl WasiBackend {
184 pub fn load(&self, source: &Path) -> Result<Box<dyn Plugin>, PluginError> {
186 Ok(Box::new(WasiPlugin::load(source)?))
187 }
188}
189
190fn select_matching_version(selector: &str, candidates: &[String]) -> Option<String> {
191 if candidates.is_empty() {
192 return None;
193 }
194 let selector = selector.trim();
195 if selector.is_empty() {
196 return candidates.first().cloned();
197 }
198 if let Some(exact) = candidates.iter().find(|candidate| candidate == &selector) {
199 return Some(exact.clone());
200 }
201 let prefix = format!("{selector}.");
202 candidates
203 .iter()
204 .find(|candidate| candidate.starts_with(&prefix))
205 .cloned()
206}