1use std::ffi::{CStr, OsStr};
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use libloading::{Library, Symbol};
25use thiserror::Error;
26
27pub const ABI_VERSION: u32 = 1;
29
30pub type AbiVersionFn = unsafe extern "C" fn() -> u32;
32pub type NameFn = unsafe extern "C" fn() -> *const std::os::raw::c_char;
34pub type OnEntriesJsonFn = unsafe extern "C" fn(*const u8, usize, *mut u8, *mut usize) -> i32;
39
40#[derive(Debug, Error)]
41pub enum PluginError {
42 #[error("io: {0}")]
43 Io(#[from] std::io::Error),
44 #[error("load {path}: {source}")]
45 Load {
46 path: PathBuf,
47 #[source]
48 source: libloading::Error,
49 },
50 #[error("plugin {path}: missing symbol {symbol}")]
51 MissingSymbol { path: PathBuf, symbol: &'static str },
52 #[error("plugin {path}: ABI {got} != host {ABI_VERSION}")]
53 AbiMismatch { path: PathBuf, got: u32 },
54 #[error("plugin {path}: invalid name pointer")]
55 BadName { path: PathBuf },
56 #[error("plugin {name}: decorate failed (code {code})")]
57 DecorateFailed { name: String, code: i32 },
58 #[error("plugin {name}: output not valid UTF-8 JSON")]
59 BadOutput { name: String },
60}
61
62pub struct Plugin {
64 name: String,
65 path: PathBuf,
66 _lib: Library,
68 on_entries: Option<OnEntriesJsonFn>,
69}
70
71impl Plugin {
72 pub fn name(&self) -> &str {
73 &self.name
74 }
75
76 pub fn path(&self) -> &Path {
77 &self.path
78 }
79
80 pub fn transform_entries_json(&self, input: &str) -> Result<String, PluginError> {
82 let Some(func) = self.on_entries else {
83 return Ok(input.to_string());
84 };
85 let mut out = vec![0u8; (input.len() * 4).max(4096)];
87 let mut out_len = out.len();
88 let code = unsafe { func(input.as_ptr(), input.len(), out.as_mut_ptr(), &mut out_len) };
89 if code != 0 {
90 return Err(PluginError::DecorateFailed {
91 name: self.name.clone(),
92 code,
93 });
94 }
95 if out_len > out.len() {
96 return Err(PluginError::DecorateFailed {
97 name: self.name.clone(),
98 code: -2,
99 });
100 }
101 out.truncate(out_len);
102 String::from_utf8(out).map_err(|_| PluginError::BadOutput {
103 name: self.name.clone(),
104 })
105 }
106}
107
108pub fn load_plugin(path: &Path) -> Result<Plugin, PluginError> {
110 let lib = unsafe { Library::new(path) }.map_err(|source| PluginError::Load {
111 path: path.to_path_buf(),
112 source,
113 })?;
114
115 let abi: Symbol<AbiVersionFn> =
116 unsafe { lib.get(b"f00_plugin_abi_version\0") }.map_err(|_| {
117 PluginError::MissingSymbol {
118 path: path.to_path_buf(),
119 symbol: "f00_plugin_abi_version",
120 }
121 })?;
122 let got = unsafe { abi() };
123 if got != ABI_VERSION {
124 return Err(PluginError::AbiMismatch {
125 path: path.to_path_buf(),
126 got,
127 });
128 }
129
130 let name_fn: Symbol<NameFn> =
131 unsafe { lib.get(b"f00_plugin_name\0") }.map_err(|_| PluginError::MissingSymbol {
132 path: path.to_path_buf(),
133 symbol: "f00_plugin_name",
134 })?;
135 let name_ptr = unsafe { name_fn() };
136 if name_ptr.is_null() {
137 return Err(PluginError::BadName {
138 path: path.to_path_buf(),
139 });
140 }
141 let name = unsafe { CStr::from_ptr(name_ptr) }
142 .to_string_lossy()
143 .into_owned();
144
145 let on_entries = unsafe { lib.get::<OnEntriesJsonFn>(b"f00_plugin_on_entries_json\0") }
146 .ok()
147 .map(|s| *s);
148
149 Ok(Plugin {
151 name,
152 path: path.to_path_buf(),
153 _lib: lib,
154 on_entries,
155 })
156}
157
158fn plugin_extension() -> &'static OsStr {
159 #[cfg(target_os = "windows")]
160 {
161 OsStr::new("dll")
162 }
163 #[cfg(target_os = "macos")]
164 {
165 OsStr::new("dylib")
166 }
167 #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
168 {
169 OsStr::new("so")
170 }
171}
172
173pub fn plugin_search_dirs() -> Vec<PathBuf> {
175 let mut dirs = Vec::new();
176 if let Ok(raw) = std::env::var("F00_PLUGIN_DIR") {
177 let sep = if cfg!(windows) { ';' } else { ':' };
178 for p in raw.split(sep).filter(|s| !s.is_empty()) {
179 dirs.push(PathBuf::from(p));
180 }
181 }
182 if let Some(home) = directories::UserDirs::new().map(|u| u.home_dir().to_path_buf()) {
183 dirs.push(home.join(".f00").join("plugins"));
184 }
185 if let Some(proj) = directories::ProjectDirs::from("", "", "f00") {
186 dirs.push(proj.config_dir().join("plugins"));
187 }
188 dirs
189}
190
191pub fn load_all_plugins(strict: bool) -> Result<Vec<Plugin>, PluginError> {
194 let mut out = Vec::new();
195 let ext = plugin_extension();
196 for dir in plugin_search_dirs() {
197 let rd = match fs::read_dir(&dir) {
198 Ok(r) => r,
199 Err(_) => continue,
200 };
201 for ent in rd.flatten() {
202 let path = ent.path();
203 if path.extension() != Some(ext) {
204 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
206 let looks_like = name.contains("f00")
207 && (name.ends_with(".so")
208 || name.ends_with(".dylib")
209 || name.ends_with(".dll"));
210 if !looks_like {
211 continue;
212 }
213 }
214 match load_plugin(&path) {
215 Ok(p) => out.push(p),
216 Err(e) if strict => return Err(e),
217 Err(_) => continue,
218 }
219 }
220 }
221 Ok(out)
222}
223
224pub fn discover_plugin_paths() -> Vec<PathBuf> {
226 let mut paths = Vec::new();
227 let ext = plugin_extension();
228 for dir in plugin_search_dirs() {
229 let Ok(rd) = fs::read_dir(&dir) else {
230 continue;
231 };
232 for ent in rd.flatten() {
233 let path = ent.path();
234 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
235 let is_lib = path.extension() == Some(ext)
236 || name.ends_with(".so")
237 || name.ends_with(".dylib")
238 || name.ends_with(".dll");
239 if is_lib && (name.contains("f00") || path.extension() == Some(ext)) {
240 paths.push(path);
241 }
242 }
243 }
244 paths.sort();
245 paths.dedup();
246 paths
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn abi_version_is_one() {
255 assert_eq!(ABI_VERSION, 1);
256 }
257
258 #[test]
259 fn search_dirs_non_empty_when_home_exists() {
260 let _ = plugin_search_dirs();
262 }
263}