lumen_plugin_abi/
config.rs1use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum PluginSource {
31 Path(String),
35 Version(String),
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct PluginCfg {
44 pub name: String,
47 pub source: PluginSource,
49 pub config: toml::Table,
51}
52
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct RawPluginCfg {
56 name: String,
57 path: Option<String>,
58 version: Option<String>,
59 git: Option<String>,
60 rev: Option<String>,
61 registry: Option<String>,
62 permissions: Option<toml::Value>,
63 #[serde(default)]
64 config: toml::Table,
65}
66
67impl<'de> Deserialize<'de> for PluginCfg {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: serde::Deserializer<'de>,
71 {
72 let raw = RawPluginCfg::deserialize(deserializer)?;
73 if raw.name.trim().is_empty() {
74 return Err(serde::de::Error::custom(
75 "plugins: `name` must not be empty",
76 ));
77 }
78 let reserved = [
79 ("git", &raw.git),
80 ("rev", &raw.rev),
81 ("registry", &raw.registry),
82 ];
83 if let Some((key, _)) = reserved.iter().find(|(_, v)| v.is_some()) {
84 return Err(serde::de::Error::custom(format!(
85 "plugin '{}': `{key}` sources are not supported yet; use `version` (a registry package) or `path` (a built cdylib)",
86 raw.name
87 )));
88 }
89 if raw.permissions.is_some() {
90 return Err(serde::de::Error::custom(format!(
91 "plugin '{}': capability declarations are not supported yet; a plugin is native \
92 code running in the compiler's process, the same trust model as a build script",
93 raw.name
94 )));
95 }
96 let source = match (raw.path, raw.version) {
97 (Some(path), None) => PluginSource::Path(path),
98 (None, Some(version)) => PluginSource::Version(version),
99 (Some(_), Some(_)) => {
100 return Err(serde::de::Error::custom(format!(
101 "plugin '{}': `path` and `version` are two sources; declare exactly one",
102 raw.name
103 )));
104 }
105 (None, None) => {
106 return Err(serde::de::Error::custom(format!(
107 "plugin '{}': a source is required - `version` (a registry package) or `path` (a built cdylib)",
108 raw.name
109 )));
110 }
111 };
112 Ok(PluginCfg {
113 name: raw.name,
114 source,
115 config: raw.config,
116 })
117 }
118}
119
120impl PluginCfg {
121 pub fn from_document(doc: &toml::Table) -> Result<Vec<PluginCfg>, String> {
125 let Some(value) = doc.get("plugins") else {
126 return Ok(Vec::new());
127 };
128 value
129 .clone()
130 .try_into::<Vec<PluginCfg>>()
131 .map_err(|e| format!("lumen.toml [[plugins]]: {e}"))
132 }
133}
134
135pub fn library_spellings(name: &str) -> Vec<String> {
144 let mut spellings = Vec::new();
145 let mut push = |name: &str| {
146 let host = format!(
147 "{}{name}{}",
148 std::env::consts::DLL_PREFIX,
149 std::env::consts::DLL_SUFFIX
150 );
151 for cand in [
152 host,
153 format!("lib{name}.so"),
154 format!("lib{name}.dylib"),
155 format!("{name}.dll"),
156 ] {
157 if !spellings.contains(&cand) {
158 spellings.push(cand);
159 }
160 }
161 };
162 push(name);
163 if name.contains('-') {
164 push(&name.replace('-', "_"));
165 }
166 spellings
167}
168
169pub fn resolve_plugin_path(app_dir: &Path, path: &str) -> Result<PathBuf, Vec<PathBuf>> {
174 let base = {
175 let p = Path::new(path);
176 if p.is_absolute() {
177 p.to_path_buf()
178 } else {
179 app_dir.join(p)
180 }
181 };
182 if base.extension().is_some() {
183 return if base.is_file() {
184 Ok(base)
185 } else {
186 Err(vec![base])
187 };
188 }
189 let stem = base
190 .file_name()
191 .map(|n| n.to_string_lossy().into_owned())
192 .unwrap_or_default();
193 let dir = base.parent().unwrap_or(Path::new("."));
194 let candidates: Vec<PathBuf> = library_spellings(&stem)
195 .iter()
196 .map(|f| dir.join(f))
197 .collect();
198 for c in &candidates {
199 if c.is_file() {
200 return Ok(c.clone());
201 }
202 }
203 Err(candidates)
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn parse(doc: &str) -> Result<Vec<PluginCfg>, String> {
211 let table: toml::Table = toml::from_str(doc).unwrap();
212 PluginCfg::from_document(&table)
213 }
214
215 #[test]
216 fn declaration_order_is_preserved() {
217 let cfgs = parse(
218 "[[plugins]]\nname = \"a\"\npath = \"a\"\n\
219 [[plugins]]\nname = \"b\"\npath = \"b\"\n\
220 [[plugins]]\nname = \"c\"\npath = \"c\"\n",
221 )
222 .unwrap();
223 let names: Vec<&str> = cfgs.iter().map(|c| c.name.as_str()).collect();
224 assert_eq!(names, ["a", "b", "c"]);
225 }
226
227 #[test]
228 fn missing_source_is_an_error() {
229 let err = parse("[[plugins]]\nname = \"x\"\n").unwrap_err();
230 assert!(err.contains("plugin 'x'"), "{err}");
231 assert!(err.contains("a source is required"), "{err}");
232 }
233
234 #[test]
235 fn two_sources_are_an_error() {
236 let err = parse("[[plugins]]\nname = \"x\"\npath = \"x\"\nversion = \"1\"\n").unwrap_err();
237 assert!(err.contains("exactly one"), "{err}");
238 }
239
240 #[test]
241 fn version_source_parses() {
242 let cfgs = parse("[[plugins]]\nname = \"x\"\nversion = \"1.2\"\n").unwrap();
243 assert_eq!(cfgs[0].source, PluginSource::Version("1.2".to_string()));
244 }
245
246 #[test]
247 fn reserved_sources_are_rejected_with_the_reason() {
248 for key in ["git", "rev", "registry"] {
249 let err = parse(&format!("[[plugins]]\nname = \"x\"\n{key} = \"1\"\n")).unwrap_err();
250 assert!(err.contains("not supported yet"), "{key}: {err}");
251 }
252 }
253
254 #[test]
255 fn permissions_are_rejected_with_the_trust_model() {
256 let err =
257 parse("[[plugins]]\nname = \"x\"\npath = \"x\"\npermissions = [\"fs\"]\n").unwrap_err();
258 assert!(err.contains("trust model"), "{err}");
259 }
260
261 #[test]
262 fn config_table_passes_through() {
263 let cfgs =
264 parse("[[plugins]]\nname = \"x\"\npath = \"x\"\nconfig = { flavor = \"gfm\" }\n")
265 .unwrap();
266 assert_eq!(
267 cfgs[0].config.get("flavor").and_then(|v| v.as_str()),
268 Some("gfm")
269 );
270 }
271
272 #[test]
273 fn an_empty_name_is_refused() {
274 let err = parse("[[plugins]]\nname = \" \"\npath = \"x\"\n").unwrap_err();
275 assert!(err.contains("must not be empty"), "{err}");
276 }
277
278 #[test]
279 fn an_extensioned_path_that_does_not_exist_reports_itself() {
280 let dir = std::env::temp_dir();
281 let err = resolve_plugin_path(&dir, "nope/libmissing.so").unwrap_err();
282 assert_eq!(err.len(), 1);
283 assert!(err[0].ends_with("nope/libmissing.so"), "{err:?}");
284 }
285
286 #[test]
287 fn no_plugins_array_is_empty() {
288 assert!(parse("[app]\nname = \"x\"\n").unwrap().is_empty());
289 }
290
291 #[test]
292 fn extensionless_path_probes_platform_spellings() {
293 let dir = std::env::temp_dir().join("lumenc-plugin-probe-test");
294 let _ = std::fs::remove_dir_all(&dir);
295 std::fs::create_dir_all(&dir).unwrap();
296 let err = resolve_plugin_path(&dir, "plugins/demo").unwrap_err();
297 let names: Vec<String> = err
298 .iter()
299 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
300 .collect();
301 assert!(
303 names[0].starts_with(std::env::consts::DLL_PREFIX),
304 "{names:?}"
305 );
306 for want in ["libdemo.so", "libdemo.dylib", "demo.dll"] {
307 assert!(names.iter().any(|n| n == want), "{names:?}");
308 }
309
310 std::fs::create_dir_all(dir.join("plugins")).unwrap();
311 let hit = dir.join("plugins").join("libdemo.so");
312 std::fs::write(&hit, b"").unwrap();
313 assert_eq!(resolve_plugin_path(&dir, "plugins/demo").unwrap(), hit);
314 let _ = std::fs::remove_dir_all(&dir);
315 }
316}
317
318#[cfg(test)]
319mod spelling_tests {
320 use super::*;
321
322 #[test]
323 fn hyphenated_names_probe_the_underscore_variant() {
324 let names = library_spellings("foo-bar");
325 assert!(names.iter().any(|n| n == "libfoo-bar.so"), "{names:?}");
326 assert!(names.iter().any(|n| n == "libfoo_bar.so"), "{names:?}");
327 assert!(names.iter().any(|n| n == "foo_bar.dll"), "{names:?}");
328 }
329}