Skip to main content

progit_plugin_sdk/
storage.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2025 Markus Maiwald
3
4//! Plugin storage API
5//!
6//! Provides persistent key-value storage for plugins.
7//! Each plugin gets an isolated storage directory.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::traits::PluginResult;
15
16/// Storage API for plugins (sandboxed to plugin directory)
17///
18/// Storage location: `.progit/plugins/<plugin-name>/state.json`
19pub trait PluginStorage {
20    /// Get plugin's private storage directory
21    fn storage_path(&self) -> &Path;
22
23    /// Read a value from plugin's key-value store
24    fn get(&self, key: &str) -> PluginResult<Option<serde_json::Value>>;
25
26    /// Write a value to plugin's key-value store
27    fn set(&mut self, key: &str, value: &serde_json::Value) -> PluginResult<()>;
28
29    /// Delete a key
30    fn delete(&mut self, key: &str) -> PluginResult<bool>;
31
32    /// List all keys
33    fn keys(&self) -> PluginResult<Vec<String>>;
34
35    /// Clear all stored data
36    fn clear(&mut self) -> PluginResult<()>;
37}
38
39/// JSON file-based storage implementation
40#[derive(Debug, Clone)]
41pub struct JsonFileStorage {
42    /// Path to the storage directory
43    storage_dir: PathBuf,
44    /// Cached state (loaded from file)
45    state: HashMap<String, serde_json::Value>,
46    /// Whether state has been modified
47    dirty: bool,
48}
49
50impl JsonFileStorage {
51    /// Create a new storage instance for a plugin
52    ///
53    /// # Arguments
54    /// * `repo_root` - Path to the repository root
55    /// * `plugin_name` - Name of the plugin (used for directory)
56    pub fn new(repo_root: &Path, plugin_name: &str) -> Self {
57        let storage_dir = repo_root
58            .join(".progit")
59            .join("plugins")
60            .join(plugin_name);
61
62        let mut storage = Self {
63            storage_dir,
64            state: HashMap::new(),
65            dirty: false,
66        };
67
68        // Load existing state if available
69        let _ = storage.load();
70
71        storage
72    }
73
74    /// Load state from disk
75    fn load(&mut self) -> PluginResult<()> {
76        let state_file = self.storage_dir.join("state.json");
77
78        if state_file.exists() {
79            let content = fs::read_to_string(&state_file)?;
80            self.state = serde_json::from_str(&content)?;
81        }
82
83        self.dirty = false;
84        Ok(())
85    }
86
87    /// Persist state to disk
88    pub fn save(&mut self) -> PluginResult<()> {
89        if !self.dirty {
90            return Ok(());
91        }
92
93        // Ensure directory exists
94        fs::create_dir_all(&self.storage_dir)?;
95
96        let state_file = self.storage_dir.join("state.json");
97        let content = serde_json::to_string_pretty(&self.state)?;
98        fs::write(&state_file, content)?;
99
100        self.dirty = false;
101        Ok(())
102    }
103}
104
105impl PluginStorage for JsonFileStorage {
106    fn storage_path(&self) -> &Path {
107        &self.storage_dir
108    }
109
110    fn get(&self, key: &str) -> PluginResult<Option<serde_json::Value>> {
111        Ok(self.state.get(key).cloned())
112    }
113
114    fn set(&mut self, key: &str, value: &serde_json::Value) -> PluginResult<()> {
115        self.state.insert(key.to_string(), value.clone());
116        self.dirty = true;
117        // Auto-save on every write for durability
118        self.save()
119    }
120
121    fn delete(&mut self, key: &str) -> PluginResult<bool> {
122        let existed = self.state.remove(key).is_some();
123        if existed {
124            self.dirty = true;
125            self.save()?;
126        }
127        Ok(existed)
128    }
129
130    fn keys(&self) -> PluginResult<Vec<String>> {
131        Ok(self.state.keys().cloned().collect())
132    }
133
134    fn clear(&mut self) -> PluginResult<()> {
135        self.state.clear();
136        self.dirty = true;
137        self.save()
138    }
139}
140
141/// Typed wrapper for common storage patterns
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct SyncState {
144    /// Last successful sync timestamp (ISO 8601)
145    pub last_sync: Option<String>,
146    /// Mapping of local IDs to external IDs
147    pub id_mappings: HashMap<String, String>,
148    /// Sync cursor/token for pagination
149    pub cursor: Option<String>,
150    /// Error count for backoff
151    pub error_count: u32,
152    /// Custom metadata
153    pub metadata: HashMap<String, serde_json::Value>,
154}
155
156impl Default for SyncState {
157    fn default() -> Self {
158        Self {
159            last_sync: None,
160            id_mappings: HashMap::new(),
161            cursor: None,
162            error_count: 0,
163            metadata: HashMap::new(),
164        }
165    }
166}
167
168impl SyncState {
169    /// Load sync state from storage
170    pub fn load(storage: &dyn PluginStorage) -> PluginResult<Self> {
171        match storage.get("sync_state")? {
172            Some(value) => Ok(serde_json::from_value(value)?),
173            None => Ok(Self::default()),
174        }
175    }
176
177    /// Save sync state to storage
178    pub fn save(&self, storage: &mut dyn PluginStorage) -> PluginResult<()> {
179        let value = serde_json::to_value(self)?;
180        storage.set("sync_state", &value)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::env;
188
189    #[test]
190    fn test_json_storage() {
191        let temp_dir = env::temp_dir().join("progit-test-storage");
192        let _ = fs::remove_dir_all(&temp_dir);
193
194        let mut storage = JsonFileStorage::new(&temp_dir, "test-plugin");
195
196        // Test set/get
197        let value = serde_json::json!({"foo": "bar"});
198        storage.set("key1", &value).unwrap();
199
200        let retrieved = storage.get("key1").unwrap();
201        assert_eq!(retrieved, Some(value));
202
203        // Test keys
204        let keys = storage.keys().unwrap();
205        assert_eq!(keys, vec!["key1".to_string()]);
206
207        // Test delete
208        let deleted = storage.delete("key1").unwrap();
209        assert!(deleted);
210        assert!(storage.get("key1").unwrap().is_none());
211
212        // Cleanup
213        let _ = fs::remove_dir_all(&temp_dir);
214    }
215
216    #[test]
217    fn test_sync_state() {
218        let temp_dir = env::temp_dir().join("progit-test-sync-state");
219        let _ = fs::remove_dir_all(&temp_dir);
220
221        let mut storage = JsonFileStorage::new(&temp_dir, "test-plugin");
222
223        // Create and save state
224        let mut state = SyncState::default();
225        state.last_sync = Some("2025-01-14T10:00:00Z".to_string());
226        state.id_mappings.insert("local-1".to_string(), "external-1".to_string());
227        state.save(&mut storage).unwrap();
228
229        // Load and verify
230        let loaded = SyncState::load(&storage).unwrap();
231        assert_eq!(loaded.last_sync, Some("2025-01-14T10:00:00Z".to_string()));
232        assert_eq!(loaded.id_mappings.get("local-1"), Some(&"external-1".to_string()));
233
234        // Cleanup
235        let _ = fs::remove_dir_all(&temp_dir);
236    }
237}