spool/plugins/mod.rs
1//! Plugin system for spool.
2//!
3//! This module provides a stable extension point for the (future) Pro version.
4//! The open-source build always returns an empty `PluginRegistry` — Pro builds
5//! will dynamically load `.dylib`/`.so`/`.dll` files from `~/.spool/plugins/`.
6//!
7//! ## Design goals
8//!
9//! 1. **Zero overhead in OSS build** — no plugin loading code path is taken
10//! when no plugins are installed.
11//! 2. **License boundary** — plugins are independent dynamic libraries with
12//! their own license, never compiled into the OSS binary.
13//! 3. **Stable interface** — the `MemoryPlugin` trait is the public API
14//! contract; Pro plugins are written against this.
15//!
16//! ## Future Pro version flow
17//!
18//! ```text
19//! User installs Spool-Pro.dmg
20//! → Drops `team-sync.dylib` into ~/.spool/plugins/
21//! → Writes ~/.spool/license.json
22//! → Spool detects plugins on next start, loads them
23//! → Team features become available
24//! ```
25//!
26//! The OSS code is untouched. The `load_from_dir` function below currently
27//! returns an empty registry; the Pro distribution will replace this binary
28//! with one that includes a real loader (or use libloading).
29
30use std::path::Path;
31
32/// Stable plugin interface. Pro plugins implement this trait and export a
33/// registration function via C ABI.
34///
35/// All methods have empty default implementations so plugins can pick which
36/// hooks they care about.
37pub trait MemoryPlugin: Send + Sync {
38 /// Plugin identifier, e.g. `"spool-team-sync"`. Must be unique.
39 fn name(&self) -> &str;
40
41 /// Plugin version string, e.g. `"1.0.0"`.
42 fn version(&self) -> &str {
43 "unknown"
44 }
45
46 /// Called once at registry load time. Plugins can initialize resources
47 /// here. Returns an error to abort plugin loading.
48 fn on_init(&self) -> Result<(), String> {
49 Ok(())
50 }
51
52 /// Called when the registry is being torn down (e.g. app shutdown).
53 fn on_shutdown(&self) {}
54}
55
56/// Plugin registry. The OSS build always constructs an empty registry; the
57/// Pro build will populate it from `~/.spool/plugins/`.
58#[derive(Default)]
59pub struct PluginRegistry {
60 plugins: Vec<Box<dyn MemoryPlugin>>,
61}
62
63impl PluginRegistry {
64 /// Construct an empty registry. Used by the OSS build.
65 pub fn empty() -> Self {
66 Self::default()
67 }
68
69 /// Load plugins from a directory. The OSS build is a no-op stub —
70 /// plugins are never loaded in the open-source distribution.
71 ///
72 /// The Pro build will replace this function (via cargo feature or
73 /// separate compilation unit) with a real `libloading`-based loader.
74 pub fn load_from_dir(_dir: &Path) -> Self {
75 Self::empty()
76 }
77
78 /// Number of loaded plugins. Always 0 in OSS builds.
79 pub fn len(&self) -> usize {
80 self.plugins.len()
81 }
82
83 /// Whether the registry has any plugins. Always true (empty) in OSS.
84 pub fn is_empty(&self) -> bool {
85 self.plugins.is_empty()
86 }
87
88 /// Iterate plugin names. Empty iterator in OSS builds.
89 pub fn names(&self) -> impl Iterator<Item = &str> {
90 self.plugins.iter().map(|p| p.name())
91 }
92}
93
94impl Drop for PluginRegistry {
95 fn drop(&mut self) {
96 for plugin in &self.plugins {
97 plugin.on_shutdown();
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn empty_registry_should_have_zero_plugins() {
108 let registry = PluginRegistry::empty();
109 assert_eq!(registry.len(), 0);
110 assert!(registry.is_empty());
111 assert_eq!(registry.names().count(), 0);
112 }
113
114 #[test]
115 fn load_from_dir_should_return_empty_in_oss_build() {
116 let registry = PluginRegistry::load_from_dir(Path::new("/nonexistent"));
117 assert!(registry.is_empty());
118 }
119}