Skip to main content

f00_plugin/
lib.rs

1//! f00 plugin host ABI.
2//!
3//! ## ABI contract (v1)
4//!
5//! Dynamic plugins are shared libraries (`.so` / `.dylib` / `.dll`) that export:
6//!
7//! | Symbol | Signature | Purpose |
8//! |--------|-----------|---------|
9//! | `f00_plugin_abi_version` | `extern "C" fn() -> u32` | Must return [`ABI_VERSION`] |
10//! | `f00_plugin_name` | `extern "C" fn() -> *const c_char` | NUL-terminated UTF-8 name |
11//! | `f00_plugin_on_entries_json` | optional `extern "C" fn(*const u8, usize, *mut u8, *mut usize) -> i32` | Transform JSON entry array |
12//!
13//! Host discovery paths (first match wins per file):
14//! - `$F00_PLUGIN_DIR` (colon/semicolon separated)
15//! - `~/.f00/plugins`
16//! - `~/.config/f00/plugins` (Unix) / `%APPDATA%\f00\plugins` (Windows)
17//!
18//! Enable with Cargo feature `plugins` on `f00-cli`.
19
20use std::ffi::{CStr, OsStr};
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use libloading::{Library, Symbol};
25use thiserror::Error;
26
27/// Current host/plugin ABI version. Bump only on incompatible C ABI changes.
28pub const ABI_VERSION: u32 = 1;
29
30/// Required export: returns [`ABI_VERSION`].
31pub type AbiVersionFn = unsafe extern "C" fn() -> u32;
32/// Required export: static C string name.
33pub type NameFn = unsafe extern "C" fn() -> *const std::os::raw::c_char;
34/// Optional export: rewrite a JSON array of entries.
35///
36/// Input: UTF-8 JSON bytes. Output buffer is provided by the host; plugin writes
37/// JSON and sets `*out_len`. Return `0` on success, non-zero on error.
38pub 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
62/// A loaded plugin library.
63pub struct Plugin {
64    name: String,
65    path: PathBuf,
66    /// Kept alive so symbols remain valid.
67    _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    /// Apply optional JSON transform. Returns original input if no decorator.
81    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        // Generous buffer; plugins that need more should return error.
86        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
108/// Load a single plugin library from `path`.
109pub 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    // Leak the Symbol lifetime into owned fn pointers by keeping Library.
150    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
173/// Directories searched for plugins (may not exist).
174pub 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
191/// Load all plugins found under search dirs. Failures for individual files are skipped
192/// when `strict` is false; returned as errors when `strict` is true.
193pub 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                // Also accept lib*.so style without requiring exact ext alone
205                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
224/// List plugin library basenames (for `f00 --list-plugins`).
225pub 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        // Always at least env-driven list (may be empty) — function should not panic.
261        let _ = plugin_search_dirs();
262    }
263}