Skip to main content

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}