spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Plugin system for spool.
//!
//! This module provides a stable extension point for the (future) Pro version.
//! The open-source build always returns an empty `PluginRegistry` — Pro builds
//! will dynamically load `.dylib`/`.so`/`.dll` files from `~/.spool/plugins/`.
//!
//! ## Design goals
//!
//! 1. **Zero overhead in OSS build** — no plugin loading code path is taken
//!    when no plugins are installed.
//! 2. **License boundary** — plugins are independent dynamic libraries with
//!    their own license, never compiled into the OSS binary.
//! 3. **Stable interface** — the `MemoryPlugin` trait is the public API
//!    contract; Pro plugins are written against this.
//!
//! ## Future Pro version flow
//!
//! ```text
//! User installs Spool-Pro.dmg
//!   → Drops `team-sync.dylib` into ~/.spool/plugins/
//!   → Writes ~/.spool/license.json
//!   → Spool detects plugins on next start, loads them
//!   → Team features become available
//! ```
//!
//! The OSS code is untouched. The `load_from_dir` function below currently
//! returns an empty registry; the Pro distribution will replace this binary
//! with one that includes a real loader (or use libloading).

use std::path::Path;

/// Stable plugin interface. Pro plugins implement this trait and export a
/// registration function via C ABI.
///
/// All methods have empty default implementations so plugins can pick which
/// hooks they care about.
pub trait MemoryPlugin: Send + Sync {
    /// Plugin identifier, e.g. `"spool-team-sync"`. Must be unique.
    fn name(&self) -> &str;

    /// Plugin version string, e.g. `"1.0.0"`.
    fn version(&self) -> &str {
        "unknown"
    }

    /// Called once at registry load time. Plugins can initialize resources
    /// here. Returns an error to abort plugin loading.
    fn on_init(&self) -> Result<(), String> {
        Ok(())
    }

    /// Called when the registry is being torn down (e.g. app shutdown).
    fn on_shutdown(&self) {}
}

/// Plugin registry. The OSS build always constructs an empty registry; the
/// Pro build will populate it from `~/.spool/plugins/`.
#[derive(Default)]
pub struct PluginRegistry {
    plugins: Vec<Box<dyn MemoryPlugin>>,
}

impl PluginRegistry {
    /// Construct an empty registry. Used by the OSS build.
    pub fn empty() -> Self {
        Self::default()
    }

    /// Load plugins from a directory. The OSS build is a no-op stub —
    /// plugins are never loaded in the open-source distribution.
    ///
    /// The Pro build will replace this function (via cargo feature or
    /// separate compilation unit) with a real `libloading`-based loader.
    pub fn load_from_dir(_dir: &Path) -> Self {
        Self::empty()
    }

    /// Number of loaded plugins. Always 0 in OSS builds.
    pub fn len(&self) -> usize {
        self.plugins.len()
    }

    /// Whether the registry has any plugins. Always true (empty) in OSS.
    pub fn is_empty(&self) -> bool {
        self.plugins.is_empty()
    }

    /// Iterate plugin names. Empty iterator in OSS builds.
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.plugins.iter().map(|p| p.name())
    }
}

impl Drop for PluginRegistry {
    fn drop(&mut self) {
        for plugin in &self.plugins {
            plugin.on_shutdown();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_registry_should_have_zero_plugins() {
        let registry = PluginRegistry::empty();
        assert_eq!(registry.len(), 0);
        assert!(registry.is_empty());
        assert_eq!(registry.names().count(), 0);
    }

    #[test]
    fn load_from_dir_should_return_empty_in_oss_build() {
        let registry = PluginRegistry::load_from_dir(Path::new("/nonexistent"));
        assert!(registry.is_empty());
    }
}