1use std::ffi::{CStr, CString, c_char};
11use std::path::Path;
12
13use libloading::{Library, Symbol};
14use serde::{Deserialize, Serialize};
15
16pub const COTIS_PLUGIN_API_VERSION: u32 = 1;
18
19type ApiVersionFn = unsafe extern "C" fn() -> u32;
20type RunFn = unsafe extern "C" fn(argc: i32, argv: *const *const c_char) -> i32;
21type HelpFn = unsafe extern "C" fn() -> *mut c_char;
22type DescriptorFn = unsafe extern "C" fn() -> *mut c_char;
23type FreeStringFn = unsafe extern "C" fn(ptr: *mut c_char);
24type FinishInstallationFn = unsafe extern "C" fn() -> i32;
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct PluginDescriptor {
31 pub name: String,
33 pub version: String,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37 pub finish_installation: bool,
38}
39
40#[derive(Debug)]
44pub struct LoadedPlugin {
45 _lib: Library,
46 api_version: Symbol<'static, ApiVersionFn>,
47 run: Symbol<'static, RunFn>,
48 help: Symbol<'static, HelpFn>,
49 descriptor_json: Symbol<'static, DescriptorFn>,
50 free_string: Symbol<'static, FreeStringFn>,
51 finish_installation: Option<Symbol<'static, FinishInstallationFn>>,
52}
53
54impl LoadedPlugin {
55 pub unsafe fn load(path: &Path) -> Result<Self, String> {
79 let lib = unsafe {
80 Library::new(path)
81 .map_err(|e| format!("Failed to load plugin {}: {e}", path.display()))?
82 };
83
84 unsafe fn sym<T>(lib: &Library, name: &[u8]) -> Result<Symbol<'static, T>, String> {
85 let s: Symbol<T> = unsafe {
86 lib.get(name).map_err(|e| {
87 format!("Missing symbol {:?}: {e}", String::from_utf8_lossy(name))
88 })?
89 };
90 Ok(unsafe { std::mem::transmute::<Symbol<T>, Symbol<'static, T>>(s) })
91 }
92
93 let api_version = unsafe { sym::<ApiVersionFn>(&lib, b"cotis_plugin_api_version\0")? };
94 let run = unsafe { sym::<RunFn>(&lib, b"cotis_plugin_run\0")? };
95 let help = unsafe { sym::<HelpFn>(&lib, b"cotis_plugin_help\0")? };
96 let descriptor_json =
97 unsafe { sym::<DescriptorFn>(&lib, b"cotis_plugin_descriptor_json\0")? };
98 let free_string = unsafe { sym::<FreeStringFn>(&lib, b"cotis_plugin_free_string\0")? };
99 let finish_installation = unsafe {
100 lib.get::<FinishInstallationFn>(b"cotis_plugin_finish_installation\0")
101 .ok()
102 .map(|s| {
103 std::mem::transmute::<
104 Symbol<FinishInstallationFn>,
105 Symbol<'static, FinishInstallationFn>,
106 >(s)
107 })
108 };
109
110 Ok(Self {
111 _lib: lib,
112 api_version,
113 run,
114 help,
115 descriptor_json,
116 free_string,
117 finish_installation,
118 })
119 }
120
121 pub fn ensure_compatible(&self) -> Result<(), String> {
127 let v = unsafe { (self.api_version)() };
128 if v != COTIS_PLUGIN_API_VERSION {
129 return Err(format!(
130 "Incompatible plugin API version: plugin={v}, host={}",
131 COTIS_PLUGIN_API_VERSION
132 ));
133 }
134 Ok(())
135 }
136
137 pub fn help(&self) -> Result<String, String> {
143 unsafe { self.get_owned_string(self.help.clone()) }
144 }
145
146 pub fn descriptor_json(&self) -> Result<String, String> {
152 unsafe { self.get_owned_string(self.descriptor_json.clone()) }
153 }
154
155 pub fn descriptor(&self) -> Result<PluginDescriptor, String> {
161 let json = self.descriptor_json()?;
162 serde_json::from_str(&json).map_err(|e| format!("Failed to parse descriptor JSON: {e}"))
163 }
164
165 pub fn run_with_args(&self, args: &[String]) -> Result<i32, String> {
175 let cstrs: Vec<CString> = args
176 .iter()
177 .map(|s| {
178 CString::new(s.as_str()).map_err(|_| "Argument contained NUL byte".to_string())
179 })
180 .collect::<Result<_, _>>()?;
181 let ptrs: Vec<*const c_char> = cstrs.iter().map(|s| s.as_ptr()).collect();
182 let rc = unsafe { (self.run)(ptrs.len() as i32, ptrs.as_ptr()) };
183 Ok(rc)
184 }
185
186 pub fn finish_installation_if_requested(&self, desc: &PluginDescriptor) -> Result<(), String> {
196 if !desc.finish_installation {
197 return Ok(());
198 }
199 let Some(fin) = &self.finish_installation else {
200 return Err(
201 "Plugin descriptor sets \"finish_installation\": true but the library does not export cotis_plugin_finish_installation"
202 .to_string(),
203 );
204 };
205 let rc = unsafe { (fin)() };
206 if rc != 0 {
207 return Err(format!(
208 "cotis_plugin_finish_installation returned non-zero exit code: {rc}"
209 ));
210 }
211 Ok(())
212 }
213
214 unsafe fn get_owned_string(
215 &self,
216 f: Symbol<'static, unsafe extern "C" fn() -> *mut c_char>,
217 ) -> Result<String, String> {
218 let ptr = unsafe { (f)() };
219 if ptr.is_null() {
220 return Err("Plugin returned null string".to_string());
221 }
222 let s = unsafe {
223 CStr::from_ptr(ptr)
224 .to_str()
225 .map_err(|_| "Plugin returned non-utf8 string".to_string())?
226 .to_string()
227 };
228 unsafe {
229 (self.free_string)(ptr);
230 }
231 Ok(s)
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::PluginDescriptor;
238
239 #[test]
240 fn plugin_descriptor_round_trip_without_finish_installation() {
241 let json = r#"{"name":"my_routine","version":"0.1.0"}"#;
242 let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
243 assert_eq!(desc.name, "my_routine");
244 assert_eq!(desc.version, "0.1.0");
245 assert!(!desc.finish_installation);
246 assert_eq!(serde_json::to_string(&desc).unwrap(), json);
247 }
248
249 #[test]
250 fn plugin_descriptor_round_trip_with_finish_installation() {
251 let json = r#"{"name":"my_routine","version":"0.1.0","finish_installation":true}"#;
252 let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
253 assert!(desc.finish_installation);
254 assert_eq!(serde_json::to_string(&desc).unwrap(), json);
255 }
256}