use std::ffi::{CStr, CString, c_char};
use std::path::Path;
use libloading::{Library, Symbol};
use serde::{Deserialize, Serialize};
pub const COTIS_PLUGIN_API_VERSION: u32 = 1;
type ApiVersionFn = unsafe extern "C" fn() -> u32;
type RunFn = unsafe extern "C" fn(argc: i32, argv: *const *const c_char) -> i32;
type HelpFn = unsafe extern "C" fn() -> *mut c_char;
type DescriptorFn = unsafe extern "C" fn() -> *mut c_char;
type FreeStringFn = unsafe extern "C" fn(ptr: *mut c_char);
type FinishInstallationFn = unsafe extern "C" fn() -> i32;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PluginDescriptor {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finish_installation: bool,
}
#[derive(Debug)]
pub struct LoadedPlugin {
_lib: Library,
api_version: Symbol<'static, ApiVersionFn>,
run: Symbol<'static, RunFn>,
help: Symbol<'static, HelpFn>,
descriptor_json: Symbol<'static, DescriptorFn>,
free_string: Symbol<'static, FreeStringFn>,
finish_installation: Option<Symbol<'static, FinishInstallationFn>>,
}
impl LoadedPlugin {
pub unsafe fn load(path: &Path) -> Result<Self, String> {
let lib = unsafe {
Library::new(path)
.map_err(|e| format!("Failed to load plugin {}: {e}", path.display()))?
};
unsafe fn sym<T>(lib: &Library, name: &[u8]) -> Result<Symbol<'static, T>, String> {
let s: Symbol<T> = unsafe {
lib.get(name).map_err(|e| {
format!("Missing symbol {:?}: {e}", String::from_utf8_lossy(name))
})?
};
Ok(unsafe { std::mem::transmute::<Symbol<T>, Symbol<'static, T>>(s) })
}
let api_version = unsafe { sym::<ApiVersionFn>(&lib, b"cotis_plugin_api_version\0")? };
let run = unsafe { sym::<RunFn>(&lib, b"cotis_plugin_run\0")? };
let help = unsafe { sym::<HelpFn>(&lib, b"cotis_plugin_help\0")? };
let descriptor_json =
unsafe { sym::<DescriptorFn>(&lib, b"cotis_plugin_descriptor_json\0")? };
let free_string = unsafe { sym::<FreeStringFn>(&lib, b"cotis_plugin_free_string\0")? };
let finish_installation = unsafe {
lib.get::<FinishInstallationFn>(b"cotis_plugin_finish_installation\0")
.ok()
.map(|s| {
std::mem::transmute::<
Symbol<FinishInstallationFn>,
Symbol<'static, FinishInstallationFn>,
>(s)
})
};
Ok(Self {
_lib: lib,
api_version,
run,
help,
descriptor_json,
free_string,
finish_installation,
})
}
pub fn ensure_compatible(&self) -> Result<(), String> {
let v = unsafe { (self.api_version)() };
if v != COTIS_PLUGIN_API_VERSION {
return Err(format!(
"Incompatible plugin API version: plugin={v}, host={}",
COTIS_PLUGIN_API_VERSION
));
}
Ok(())
}
pub fn help(&self) -> Result<String, String> {
unsafe { self.get_owned_string(self.help.clone()) }
}
pub fn descriptor_json(&self) -> Result<String, String> {
unsafe { self.get_owned_string(self.descriptor_json.clone()) }
}
pub fn descriptor(&self) -> Result<PluginDescriptor, String> {
let json = self.descriptor_json()?;
serde_json::from_str(&json).map_err(|e| format!("Failed to parse descriptor JSON: {e}"))
}
pub fn run_with_args(&self, args: &[String]) -> Result<i32, String> {
let cstrs: Vec<CString> = args
.iter()
.map(|s| {
CString::new(s.as_str()).map_err(|_| "Argument contained NUL byte".to_string())
})
.collect::<Result<_, _>>()?;
let ptrs: Vec<*const c_char> = cstrs.iter().map(|s| s.as_ptr()).collect();
let rc = unsafe { (self.run)(ptrs.len() as i32, ptrs.as_ptr()) };
Ok(rc)
}
pub fn finish_installation_if_requested(&self, desc: &PluginDescriptor) -> Result<(), String> {
if !desc.finish_installation {
return Ok(());
}
let Some(fin) = &self.finish_installation else {
return Err(
"Plugin descriptor sets \"finish_installation\": true but the library does not export cotis_plugin_finish_installation"
.to_string(),
);
};
let rc = unsafe { (fin)() };
if rc != 0 {
return Err(format!(
"cotis_plugin_finish_installation returned non-zero exit code: {rc}"
));
}
Ok(())
}
unsafe fn get_owned_string(
&self,
f: Symbol<'static, unsafe extern "C" fn() -> *mut c_char>,
) -> Result<String, String> {
let ptr = unsafe { (f)() };
if ptr.is_null() {
return Err("Plugin returned null string".to_string());
}
let s = unsafe {
CStr::from_ptr(ptr)
.to_str()
.map_err(|_| "Plugin returned non-utf8 string".to_string())?
.to_string()
};
unsafe {
(self.free_string)(ptr);
}
Ok(s)
}
}
#[cfg(test)]
mod tests {
use super::PluginDescriptor;
#[test]
fn plugin_descriptor_round_trip_without_finish_installation() {
let json = r#"{"name":"my_routine","version":"0.1.0"}"#;
let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
assert_eq!(desc.name, "my_routine");
assert_eq!(desc.version, "0.1.0");
assert!(!desc.finish_installation);
assert_eq!(serde_json::to_string(&desc).unwrap(), json);
}
#[test]
fn plugin_descriptor_round_trip_with_finish_installation() {
let json = r#"{"name":"my_routine","version":"0.1.0","finish_installation":true}"#;
let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
assert!(desc.finish_installation);
assert_eq!(serde_json::to_string(&desc).unwrap(), json);
}
}