mockforge_plugin_loader/
metadata.rs

1//! Plugin installation metadata
2//!
3//! This module tracks the installation source of each plugin, enabling updates
4//! by refetching from the original source.
5
6use crate::{LoaderResult, PluginLoaderError, PluginSource};
7use mockforge_plugin_core::PluginId;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tokio::fs;
12
13/// Plugin installation metadata
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PluginMetadata {
16    /// Plugin ID
17    pub plugin_id: PluginId,
18    /// Original installation source
19    pub source: PluginSource,
20    /// Installation timestamp (Unix epoch seconds)
21    pub installed_at: u64,
22    /// Last update timestamp (Unix epoch seconds, None if never updated)
23    pub updated_at: Option<u64>,
24    /// Current installed version
25    pub version: String,
26}
27
28impl PluginMetadata {
29    /// Create new plugin metadata
30    pub fn new(plugin_id: PluginId, source: PluginSource, version: String) -> Self {
31        let now = std::time::SystemTime::now()
32            .duration_since(std::time::UNIX_EPOCH)
33            .unwrap()
34            .as_secs();
35
36        Self {
37            plugin_id,
38            source,
39            installed_at: now,
40            updated_at: None,
41            version,
42        }
43    }
44
45    /// Mark as updated
46    pub fn mark_updated(&mut self, new_version: String) {
47        let now = std::time::SystemTime::now()
48            .duration_since(std::time::UNIX_EPOCH)
49            .unwrap()
50            .as_secs();
51
52        self.updated_at = Some(now);
53        self.version = new_version;
54    }
55}
56
57/// Plugin metadata store
58pub struct MetadataStore {
59    /// Path to metadata directory
60    metadata_dir: PathBuf,
61    /// In-memory cache
62    cache: HashMap<PluginId, PluginMetadata>,
63}
64
65impl MetadataStore {
66    /// Create a new metadata store
67    pub fn new(metadata_dir: PathBuf) -> Self {
68        Self {
69            metadata_dir,
70            cache: HashMap::new(),
71        }
72    }
73
74    /// Initialize the metadata store (create directory if needed)
75    pub async fn init(&self) -> LoaderResult<()> {
76        if !self.metadata_dir.exists() {
77            fs::create_dir_all(&self.metadata_dir).await.map_err(|e| {
78                PluginLoaderError::fs(format!("Failed to create metadata directory: {}", e))
79            })?;
80        }
81        Ok(())
82    }
83
84    /// Load all metadata from disk
85    pub async fn load(&mut self) -> LoaderResult<()> {
86        self.init().await?;
87
88        let mut entries = fs::read_dir(&self.metadata_dir).await.map_err(|e| {
89            PluginLoaderError::fs(format!("Failed to read metadata directory: {}", e))
90        })?;
91
92        while let Ok(Some(entry)) = entries.next_entry().await {
93            let path = entry.path();
94
95            // Only process .json files
96            if path.extension().and_then(|s| s.to_str()) != Some("json") {
97                continue;
98            }
99
100            match self.load_metadata_file(&path).await {
101                Ok(metadata) => {
102                    self.cache.insert(metadata.plugin_id.clone(), metadata);
103                }
104                Err(e) => {
105                    tracing::warn!("Failed to load metadata file {}: {}", path.display(), e);
106                }
107            }
108        }
109
110        tracing::info!("Loaded {} plugin metadata entries", self.cache.len());
111        Ok(())
112    }
113
114    /// Load a single metadata file
115    async fn load_metadata_file(&self, path: &Path) -> LoaderResult<PluginMetadata> {
116        let content = fs::read_to_string(path)
117            .await
118            .map_err(|e| PluginLoaderError::fs(format!("Failed to read metadata file: {}", e)))?;
119
120        let metadata: PluginMetadata = serde_json::from_str(&content).map_err(|e| {
121            PluginLoaderError::load(format!("Failed to parse metadata JSON: {}", e))
122        })?;
123
124        Ok(metadata)
125    }
126
127    /// Save metadata for a plugin
128    pub async fn save(&mut self, metadata: PluginMetadata) -> LoaderResult<()> {
129        self.init().await?;
130
131        let file_path = self.metadata_file_path(&metadata.plugin_id);
132        let json = serde_json::to_string_pretty(&metadata)
133            .map_err(|e| PluginLoaderError::load(format!("Failed to serialize metadata: {}", e)))?;
134
135        fs::write(&file_path, json)
136            .await
137            .map_err(|e| PluginLoaderError::fs(format!("Failed to write metadata file: {}", e)))?;
138
139        // Update cache
140        self.cache.insert(metadata.plugin_id.clone(), metadata);
141
142        Ok(())
143    }
144
145    /// Get metadata for a plugin
146    pub fn get(&self, plugin_id: &PluginId) -> Option<&PluginMetadata> {
147        self.cache.get(plugin_id)
148    }
149
150    /// Get mutable metadata for a plugin
151    pub fn get_mut(&mut self, plugin_id: &PluginId) -> Option<&mut PluginMetadata> {
152        self.cache.get_mut(plugin_id)
153    }
154
155    /// Remove metadata for a plugin
156    pub async fn remove(&mut self, plugin_id: &PluginId) -> LoaderResult<()> {
157        let file_path = self.metadata_file_path(plugin_id);
158
159        if file_path.exists() {
160            fs::remove_file(&file_path).await.map_err(|e| {
161                PluginLoaderError::fs(format!("Failed to remove metadata file: {}", e))
162            })?;
163        }
164
165        self.cache.remove(plugin_id);
166
167        Ok(())
168    }
169
170    /// List all plugin IDs with metadata
171    pub fn list(&self) -> Vec<PluginId> {
172        self.cache.keys().cloned().collect()
173    }
174
175    /// Check if metadata exists for a plugin
176    pub fn has(&self, plugin_id: &PluginId) -> bool {
177        self.cache.contains_key(plugin_id)
178    }
179
180    /// Get the file path for a plugin's metadata
181    fn metadata_file_path(&self, plugin_id: &PluginId) -> PathBuf {
182        self.metadata_dir.join(format!("{}.json", plugin_id.as_str()))
183    }
184}
185
186// Implement Serialize for PluginSource (if not already implemented)
187impl Serialize for PluginSource {
188    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
189    where
190        S: serde::Serializer,
191    {
192        use serde::ser::SerializeStruct;
193
194        match self {
195            PluginSource::Local(path) => {
196                let mut state = serializer.serialize_struct("PluginSource", 2)?;
197                state.serialize_field("type", "local")?;
198                state.serialize_field("path", &path.display().to_string())?;
199                state.end()
200            }
201            PluginSource::Url { url, checksum } => {
202                let mut state = serializer.serialize_struct("PluginSource", 3)?;
203                state.serialize_field("type", "url")?;
204                state.serialize_field("url", url)?;
205                state.serialize_field("checksum", checksum)?;
206                state.end()
207            }
208            PluginSource::Git(git_source) => {
209                let mut state = serializer.serialize_struct("PluginSource", 2)?;
210                state.serialize_field("type", "git")?;
211                state.serialize_field("source", &git_source.to_string())?;
212                state.end()
213            }
214            PluginSource::Registry { name, version } => {
215                let mut state = serializer.serialize_struct("PluginSource", 3)?;
216                state.serialize_field("type", "registry")?;
217                state.serialize_field("name", name)?;
218                state.serialize_field("version", version)?;
219                state.end()
220            }
221        }
222    }
223}
224
225// Implement Deserialize for PluginSource (if not already implemented)
226impl<'de> Deserialize<'de> for PluginSource {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: serde::Deserializer<'de>,
230    {
231        use serde::de::{self, MapAccess, Visitor};
232        use std::fmt;
233
234        struct PluginSourceVisitor;
235
236        impl<'de> Visitor<'de> for PluginSourceVisitor {
237            type Value = PluginSource;
238
239            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
240                formatter.write_str("a plugin source")
241            }
242
243            fn visit_map<M>(self, mut map: M) -> Result<PluginSource, M::Error>
244            where
245                M: MapAccess<'de>,
246            {
247                let mut source_type: Option<String> = None;
248                let mut path: Option<String> = None;
249                let mut url: Option<String> = None;
250                let mut checksum: Option<Option<String>> = None;
251                let mut source: Option<String> = None;
252                let mut name: Option<String> = None;
253                let mut version: Option<Option<String>> = None;
254
255                while let Some(key) = map.next_key::<String>()? {
256                    match key.as_str() {
257                        "type" => source_type = Some(map.next_value()?),
258                        "path" => path = Some(map.next_value()?),
259                        "url" => url = Some(map.next_value()?),
260                        "checksum" => checksum = Some(map.next_value()?),
261                        "source" => source = Some(map.next_value()?),
262                        "name" => name = Some(map.next_value()?),
263                        "version" => version = Some(map.next_value()?),
264                        _ => {
265                            let _: serde::de::IgnoredAny = map.next_value()?;
266                        }
267                    }
268                }
269
270                let source_type = source_type.ok_or_else(|| de::Error::missing_field("type"))?;
271
272                match source_type.as_str() {
273                    "local" => {
274                        let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
275                        Ok(PluginSource::Local(PathBuf::from(path)))
276                    }
277                    "url" => {
278                        let url = url.ok_or_else(|| de::Error::missing_field("url"))?;
279                        let checksum =
280                            checksum.ok_or_else(|| de::Error::missing_field("checksum"))?;
281                        Ok(PluginSource::Url { url, checksum })
282                    }
283                    "git" => {
284                        let source_str =
285                            source.ok_or_else(|| de::Error::missing_field("source"))?;
286                        let git_source = crate::git::GitPluginSource::parse(&source_str)
287                            .map_err(|e| de::Error::custom(format!("Invalid git source: {}", e)))?;
288                        Ok(PluginSource::Git(git_source))
289                    }
290                    "registry" => {
291                        let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
292                        let version = version.ok_or_else(|| de::Error::missing_field("version"))?;
293                        Ok(PluginSource::Registry { name, version })
294                    }
295                    _ => Err(de::Error::custom(format!("Unknown source type: {}", source_type))),
296                }
297            }
298        }
299
300        deserializer.deserialize_struct(
301            "PluginSource",
302            &[
303                "type", "path", "url", "checksum", "source", "name", "version",
304            ],
305            PluginSourceVisitor,
306        )
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use tempfile::TempDir;
314
315    #[tokio::test]
316    async fn test_metadata_store_creation() {
317        let temp_dir = TempDir::new().unwrap();
318        let store = MetadataStore::new(temp_dir.path().to_path_buf());
319        store.init().await.unwrap();
320
321        assert!(temp_dir.path().exists());
322    }
323
324    #[tokio::test]
325    async fn test_save_and_load_metadata() {
326        let temp_dir = TempDir::new().unwrap();
327        let mut store = MetadataStore::new(temp_dir.path().to_path_buf());
328
329        let plugin_id = PluginId::new("test-plugin");
330        let source = PluginSource::Url {
331            url: "https://example.com/plugin.zip".to_string(),
332            checksum: None,
333        };
334        let metadata = PluginMetadata::new(plugin_id.clone(), source, "1.0.0".to_string());
335
336        store.save(metadata.clone()).await.unwrap();
337        assert!(store.has(&plugin_id));
338
339        // Create a new store and load from disk
340        let mut new_store = MetadataStore::new(temp_dir.path().to_path_buf());
341        new_store.load().await.unwrap();
342
343        let loaded = new_store.get(&plugin_id).unwrap();
344        assert_eq!(loaded.plugin_id, plugin_id);
345        assert_eq!(loaded.version, "1.0.0");
346    }
347
348    #[tokio::test]
349    async fn test_remove_metadata() {
350        let temp_dir = TempDir::new().unwrap();
351        let mut store = MetadataStore::new(temp_dir.path().to_path_buf());
352
353        let plugin_id = PluginId::new("test-plugin");
354        let source = PluginSource::Local(PathBuf::from("/tmp/test"));
355        let metadata = PluginMetadata::new(plugin_id.clone(), source, "1.0.0".to_string());
356
357        store.save(metadata).await.unwrap();
358        assert!(store.has(&plugin_id));
359
360        store.remove(&plugin_id).await.unwrap();
361        assert!(!store.has(&plugin_id));
362    }
363
364    #[tokio::test]
365    async fn test_mark_updated() {
366        let plugin_id = PluginId::new("test-plugin");
367        let source = PluginSource::Local(PathBuf::from("/tmp/test"));
368        let mut metadata = PluginMetadata::new(plugin_id, source, "1.0.0".to_string());
369
370        assert!(metadata.updated_at.is_none());
371
372        metadata.mark_updated("1.1.0".to_string());
373
374        assert!(metadata.updated_at.is_some());
375        assert_eq!(metadata.version, "1.1.0");
376    }
377}