use std::ffi::{CStr, OsStr};
use std::fs;
use std::path::{Path, PathBuf};
use libloading::{Library, Symbol};
use thiserror::Error;
pub const ABI_VERSION: u32 = 1;
pub type AbiVersionFn = unsafe extern "C" fn() -> u32;
pub type NameFn = unsafe extern "C" fn() -> *const std::os::raw::c_char;
pub type OnEntriesJsonFn = unsafe extern "C" fn(*const u8, usize, *mut u8, *mut usize) -> i32;
#[derive(Debug, Error)]
pub enum PluginError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("load {path}: {source}")]
Load {
path: PathBuf,
#[source]
source: libloading::Error,
},
#[error("plugin {path}: missing symbol {symbol}")]
MissingSymbol { path: PathBuf, symbol: &'static str },
#[error("plugin {path}: ABI {got} != host {ABI_VERSION}")]
AbiMismatch { path: PathBuf, got: u32 },
#[error("plugin {path}: invalid name pointer")]
BadName { path: PathBuf },
#[error("plugin {name}: decorate failed (code {code})")]
DecorateFailed { name: String, code: i32 },
#[error("plugin {name}: output not valid UTF-8 JSON")]
BadOutput { name: String },
}
pub struct Plugin {
name: String,
path: PathBuf,
_lib: Library,
on_entries: Option<OnEntriesJsonFn>,
}
impl Plugin {
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn transform_entries_json(&self, input: &str) -> Result<String, PluginError> {
let Some(func) = self.on_entries else {
return Ok(input.to_string());
};
let mut out = vec![0u8; (input.len() * 4).max(4096)];
let mut out_len = out.len();
let code = unsafe { func(input.as_ptr(), input.len(), out.as_mut_ptr(), &mut out_len) };
if code != 0 {
return Err(PluginError::DecorateFailed {
name: self.name.clone(),
code,
});
}
if out_len > out.len() {
return Err(PluginError::DecorateFailed {
name: self.name.clone(),
code: -2,
});
}
out.truncate(out_len);
String::from_utf8(out).map_err(|_| PluginError::BadOutput {
name: self.name.clone(),
})
}
}
pub fn load_plugin(path: &Path) -> Result<Plugin, PluginError> {
let lib = unsafe { Library::new(path) }.map_err(|source| PluginError::Load {
path: path.to_path_buf(),
source,
})?;
let abi: Symbol<AbiVersionFn> =
unsafe { lib.get(b"f00_plugin_abi_version\0") }.map_err(|_| {
PluginError::MissingSymbol {
path: path.to_path_buf(),
symbol: "f00_plugin_abi_version",
}
})?;
let got = unsafe { abi() };
if got != ABI_VERSION {
return Err(PluginError::AbiMismatch {
path: path.to_path_buf(),
got,
});
}
let name_fn: Symbol<NameFn> =
unsafe { lib.get(b"f00_plugin_name\0") }.map_err(|_| PluginError::MissingSymbol {
path: path.to_path_buf(),
symbol: "f00_plugin_name",
})?;
let name_ptr = unsafe { name_fn() };
if name_ptr.is_null() {
return Err(PluginError::BadName {
path: path.to_path_buf(),
});
}
let name = unsafe { CStr::from_ptr(name_ptr) }
.to_string_lossy()
.into_owned();
let on_entries = unsafe { lib.get::<OnEntriesJsonFn>(b"f00_plugin_on_entries_json\0") }
.ok()
.map(|s| *s);
Ok(Plugin {
name,
path: path.to_path_buf(),
_lib: lib,
on_entries,
})
}
fn plugin_extension() -> &'static OsStr {
#[cfg(target_os = "windows")]
{
OsStr::new("dll")
}
#[cfg(target_os = "macos")]
{
OsStr::new("dylib")
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
OsStr::new("so")
}
}
pub fn plugin_search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(raw) = std::env::var("F00_PLUGIN_DIR") {
let sep = if cfg!(windows) { ';' } else { ':' };
for p in raw.split(sep).filter(|s| !s.is_empty()) {
dirs.push(PathBuf::from(p));
}
}
if let Some(home) = directories::UserDirs::new().map(|u| u.home_dir().to_path_buf()) {
dirs.push(home.join(".f00").join("plugins"));
}
if let Some(proj) = directories::ProjectDirs::from("", "", "f00") {
dirs.push(proj.config_dir().join("plugins"));
}
dirs
}
pub fn load_all_plugins(strict: bool) -> Result<Vec<Plugin>, PluginError> {
let mut out = Vec::new();
let ext = plugin_extension();
for dir in plugin_search_dirs() {
let rd = match fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for ent in rd.flatten() {
let path = ent.path();
if path.extension() != Some(ext) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let looks_like = name.contains("f00")
&& (name.ends_with(".so")
|| name.ends_with(".dylib")
|| name.ends_with(".dll"));
if !looks_like {
continue;
}
}
match load_plugin(&path) {
Ok(p) => out.push(p),
Err(e) if strict => return Err(e),
Err(_) => continue,
}
}
}
Ok(out)
}
pub fn discover_plugin_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let ext = plugin_extension();
for dir in plugin_search_dirs() {
let Ok(rd) = fs::read_dir(&dir) else {
continue;
};
for ent in rd.flatten() {
let path = ent.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_lib = path.extension() == Some(ext)
|| name.ends_with(".so")
|| name.ends_with(".dylib")
|| name.ends_with(".dll");
if is_lib && (name.contains("f00") || path.extension() == Some(ext)) {
paths.push(path);
}
}
}
paths.sort();
paths.dedup();
paths
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abi_version_is_one() {
assert_eq!(ABI_VERSION, 1);
}
#[test]
fn search_dirs_non_empty_when_home_exists() {
let _ = plugin_search_dirs();
}
}