Skip to main content

vs_plugin_wasi/
backend.rs

1//! Backend adapter for loading native WASI plugins.
2
3use 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/// Manifest-backed runtime that models a native WASI plugin contract.
47#[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    /// Loads a native plugin descriptor from `component.toml`.
57    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/// Loads native plugins backed by a typed descriptor.
180#[derive(Debug, Default, Clone, Copy)]
181pub struct WasiBackend;
182
183impl WasiBackend {
184    /// Loads a native plugin from disk.
185    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}