progit-plugin-sdk 0.2.1

Plugin SDK for ProGit — sandboxed LuaJIT runtime with capability-based security. LSL-1.0 (file-level copyleft, proprietary plugins allowed via the commercial bridge).
Documentation
// SPDX-License-Identifier: LSL-1.0
// Copyright (c) 2025 Markus Maiwald

//! Plugin storage API
//!
//! Provides persistent key-value storage for plugins.
//! Each plugin gets an isolated storage directory.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use crate::traits::PluginResult;

/// Storage API for plugins (sandboxed to plugin directory)
///
/// Storage location: `.progit/plugins/<plugin-name>/state.json`
pub trait PluginStorage {
    /// Get plugin's private storage directory
    fn storage_path(&self) -> &Path;

    /// Read a value from plugin's key-value store
    fn get(&self, key: &str) -> PluginResult<Option<serde_json::Value>>;

    /// Write a value to plugin's key-value store
    fn set(&mut self, key: &str, value: &serde_json::Value) -> PluginResult<()>;

    /// Delete a key
    fn delete(&mut self, key: &str) -> PluginResult<bool>;

    /// List all keys
    fn keys(&self) -> PluginResult<Vec<String>>;

    /// Clear all stored data
    fn clear(&mut self) -> PluginResult<()>;
}

/// JSON file-based storage implementation
#[derive(Debug, Clone)]
pub struct JsonFileStorage {
    /// Path to the storage directory
    storage_dir: PathBuf,
    /// Cached state (loaded from file)
    state: HashMap<String, serde_json::Value>,
    /// Whether state has been modified
    dirty: bool,
}

impl JsonFileStorage {
    /// Create a new storage instance for a plugin
    ///
    /// # Arguments
    /// * `repo_root` - Path to the repository root
    /// * `plugin_name` - Name of the plugin (used for directory)
    pub fn new(repo_root: &Path, plugin_name: &str) -> Self {
        let storage_dir = repo_root
            .join(".progit")
            .join("plugins")
            .join(plugin_name);

        let mut storage = Self {
            storage_dir,
            state: HashMap::new(),
            dirty: false,
        };

        // Load existing state if available
        let _ = storage.load();

        storage
    }

    /// Load state from disk
    fn load(&mut self) -> PluginResult<()> {
        let state_file = self.storage_dir.join("state.json");

        if state_file.exists() {
            let content = fs::read_to_string(&state_file)?;
            self.state = serde_json::from_str(&content)?;
        }

        self.dirty = false;
        Ok(())
    }

    /// Persist state to disk
    pub fn save(&mut self) -> PluginResult<()> {
        if !self.dirty {
            return Ok(());
        }

        // Ensure directory exists
        fs::create_dir_all(&self.storage_dir)?;

        let state_file = self.storage_dir.join("state.json");
        let content = serde_json::to_string_pretty(&self.state)?;
        fs::write(&state_file, content)?;

        self.dirty = false;
        Ok(())
    }
}

impl PluginStorage for JsonFileStorage {
    fn storage_path(&self) -> &Path {
        &self.storage_dir
    }

    fn get(&self, key: &str) -> PluginResult<Option<serde_json::Value>> {
        Ok(self.state.get(key).cloned())
    }

    fn set(&mut self, key: &str, value: &serde_json::Value) -> PluginResult<()> {
        self.state.insert(key.to_string(), value.clone());
        self.dirty = true;
        // Auto-save on every write for durability
        self.save()
    }

    fn delete(&mut self, key: &str) -> PluginResult<bool> {
        let existed = self.state.remove(key).is_some();
        if existed {
            self.dirty = true;
            self.save()?;
        }
        Ok(existed)
    }

    fn keys(&self) -> PluginResult<Vec<String>> {
        Ok(self.state.keys().cloned().collect())
    }

    fn clear(&mut self) -> PluginResult<()> {
        self.state.clear();
        self.dirty = true;
        self.save()
    }
}

/// Typed wrapper for common storage patterns
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncState {
    /// Last successful sync timestamp (ISO 8601)
    pub last_sync: Option<String>,
    /// Mapping of local IDs to external IDs
    pub id_mappings: HashMap<String, String>,
    /// Sync cursor/token for pagination
    pub cursor: Option<String>,
    /// Error count for backoff
    pub error_count: u32,
    /// Custom metadata
    pub metadata: HashMap<String, serde_json::Value>,
}

impl Default for SyncState {
    fn default() -> Self {
        Self {
            last_sync: None,
            id_mappings: HashMap::new(),
            cursor: None,
            error_count: 0,
            metadata: HashMap::new(),
        }
    }
}

impl SyncState {
    /// Load sync state from storage
    pub fn load(storage: &dyn PluginStorage) -> PluginResult<Self> {
        match storage.get("sync_state")? {
            Some(value) => Ok(serde_json::from_value(value)?),
            None => Ok(Self::default()),
        }
    }

    /// Save sync state to storage
    pub fn save(&self, storage: &mut dyn PluginStorage) -> PluginResult<()> {
        let value = serde_json::to_value(self)?;
        storage.set("sync_state", &value)
    }
}

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

    #[test]
    fn test_json_storage() {
        let temp_dir = env::temp_dir().join("progit-test-storage");
        let _ = fs::remove_dir_all(&temp_dir);

        let mut storage = JsonFileStorage::new(&temp_dir, "test-plugin");

        // Test set/get
        let value = serde_json::json!({"foo": "bar"});
        storage.set("key1", &value).unwrap();

        let retrieved = storage.get("key1").unwrap();
        assert_eq!(retrieved, Some(value));

        // Test keys
        let keys = storage.keys().unwrap();
        assert_eq!(keys, vec!["key1".to_string()]);

        // Test delete
        let deleted = storage.delete("key1").unwrap();
        assert!(deleted);
        assert!(storage.get("key1").unwrap().is_none());

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }

    #[test]
    fn test_sync_state() {
        let temp_dir = env::temp_dir().join("progit-test-sync-state");
        let _ = fs::remove_dir_all(&temp_dir);

        let mut storage = JsonFileStorage::new(&temp_dir, "test-plugin");

        // Create and save state
        let mut state = SyncState::default();
        state.last_sync = Some("2025-01-14T10:00:00Z".to_string());
        state.id_mappings.insert("local-1".to_string(), "external-1".to_string());
        state.save(&mut storage).unwrap();

        // Load and verify
        let loaded = SyncState::load(&storage).unwrap();
        assert_eq!(loaded.last_sync, Some("2025-01-14T10:00:00Z".to_string()));
        assert_eq!(loaded.id_mappings.get("local-1"), Some(&"external-1".to_string()));

        // Cleanup
        let _ = fs::remove_dir_all(&temp_dir);
    }
}