Skip to main content

stygian_plugin/storage/
file_template_store.rs

1//! File-based template storage adapter
2
3use crate::Result;
4use crate::domain::ExtractionTemplate;
5use crate::error::PluginError;
6use crate::ports::PluginTemplateStore;
7use async_trait::async_trait;
8use std::path::PathBuf;
9use tokio::fs;
10
11/// File-based template store
12///
13/// Stores each template as a JSON file in a directory.
14/// Template ID becomes the filename (UUID).
15///
16/// # Example
17///
18/// ```no_run
19/// use stygian_plugin::storage::FileTemplateStore;
20/// use std::path::PathBuf;
21///
22/// let store = FileTemplateStore::new(PathBuf::from("./templates"));
23/// ```
24pub struct FileTemplateStore {
25    /// Directory to store template JSON files
26    templates_dir: PathBuf,
27}
28
29impl FileTemplateStore {
30    /// Create a new file-based template store
31    #[must_use]
32    pub const fn new(templates_dir: PathBuf) -> Self {
33        Self { templates_dir }
34    }
35
36    /// Get the file path for a template ID
37    fn template_path(&self, id: &uuid::Uuid) -> PathBuf {
38        self.templates_dir.join(format!("{id}.json"))
39    }
40
41    /// Ensure the templates directory exists
42    async fn ensure_dir(&self) -> Result<()> {
43        fs::create_dir_all(&self.templates_dir).await.map_err(|e| {
44            PluginError::StorageError(format!("Failed to create templates dir: {e}"))
45        })?;
46        Ok(())
47    }
48}
49
50#[async_trait]
51impl PluginTemplateStore for FileTemplateStore {
52    async fn save(&self, template: &ExtractionTemplate) -> Result<()> {
53        self.ensure_dir().await?;
54        template.validate()?;
55
56        let path = self.template_path(&template.id);
57        let json =
58            serde_json::to_string_pretty(template).map_err(PluginError::SerializationError)?;
59
60        fs::write(&path, json)
61            .await
62            .map_err(|e| PluginError::StorageError(format!("Failed to write template: {e}")))?;
63
64        Ok(())
65    }
66
67    async fn get(&self, id: &uuid::Uuid) -> Result<ExtractionTemplate> {
68        let path = self.template_path(id);
69
70        let content = fs::read_to_string(&path)
71            .await
72            .map_err(|_| PluginError::TemplateNotFound(id.to_string()))?;
73
74        serde_json::from_str(&content).map_err(PluginError::SerializationError)
75    }
76
77    async fn list(&self) -> Result<Vec<ExtractionTemplate>> {
78        self.ensure_dir().await?;
79
80        let mut templates = Vec::new();
81        let mut entries = fs::read_dir(&self.templates_dir)
82            .await
83            .map_err(|e| PluginError::StorageError(format!("Failed to read templates dir: {e}")))?;
84
85        while let Some(entry) = entries
86            .next_entry()
87            .await
88            .map_err(|e| PluginError::StorageError(format!("Failed to read dir entry: {e}")))?
89        {
90            let path = entry.path();
91            if path.extension().is_some_and(|ext| ext == "json") {
92                match fs::read_to_string(&path).await {
93                    Ok(content) => match serde_json::from_str::<ExtractionTemplate>(&content) {
94                        Ok(template) => templates.push(template),
95                        Err(e) => {
96                            tracing::warn!("Failed to parse template {}: {}", path.display(), e);
97                        }
98                    },
99                    Err(e) => {
100                        tracing::warn!("Failed to read template {}: {}", path.display(), e);
101                    }
102                }
103            }
104        }
105
106        Ok(templates)
107    }
108
109    async fn delete(&self, id: &uuid::Uuid) -> Result<()> {
110        let path = self.template_path(id);
111
112        fs::remove_file(&path)
113            .await
114            .map_err(|_| PluginError::TemplateNotFound(id.to_string()))?;
115
116        Ok(())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::domain::{Region, Selector};
124    use serde_json::json;
125    use tempfile::TempDir;
126
127    #[tokio::test]
128    async fn test_save_and_get_template() -> std::result::Result<(), Box<dyn std::error::Error>> {
129        let tmp = TempDir::new()?;
130        let store = FileTemplateStore::new(tmp.path().to_path_buf());
131
132        let region = Region::new("test", Selector::css(".test"), json!({"type": "string"}));
133        let template = ExtractionTemplate::new("Test Template").with_region(region);
134        let id = template.id;
135
136        store.save(&template).await?;
137        let retrieved = store.get(&id).await?;
138
139        assert_eq!(retrieved.id, id);
140        assert_eq!(retrieved.name, "Test Template");
141        Ok(())
142    }
143
144    #[tokio::test]
145    async fn test_list_templates() -> std::result::Result<(), Box<dyn std::error::Error>> {
146        let tmp = TempDir::new()?;
147        let store = FileTemplateStore::new(tmp.path().to_path_buf());
148
149        let region = Region::new("test", Selector::css(".test"), json!({"type": "string"}));
150        let t1 = ExtractionTemplate::new("Template 1").with_region(region.clone());
151        let t2 = ExtractionTemplate::new("Template 2").with_region(region);
152
153        store.save(&t1).await?;
154        store.save(&t2).await?;
155
156        let list = store.list().await?;
157        assert_eq!(list.len(), 2);
158        Ok(())
159    }
160
161    #[tokio::test]
162    async fn test_delete_template() -> std::result::Result<(), Box<dyn std::error::Error>> {
163        let tmp = TempDir::new()?;
164        let store = FileTemplateStore::new(tmp.path().to_path_buf());
165
166        let region = Region::new("test", Selector::css(".test"), json!({"type": "string"}));
167        let template = ExtractionTemplate::new("Test").with_region(region);
168        let id = template.id;
169
170        store.save(&template).await?;
171        store.delete(&id).await?;
172
173        let result = store.get(&id).await;
174        assert!(result.is_err());
175        Ok(())
176    }
177
178    #[tokio::test]
179    async fn test_search_templates() -> std::result::Result<(), Box<dyn std::error::Error>> {
180        let tmp = TempDir::new()?;
181        let store = FileTemplateStore::new(tmp.path().to_path_buf());
182
183        let region = Region::new("test", Selector::css(".test"), json!({"type": "string"}));
184        let t1 = ExtractionTemplate::new("Product Scraper").with_region(region.clone());
185        let t2 = ExtractionTemplate::new("Review Extractor").with_region(region);
186
187        store.save(&t1).await?;
188        store.save(&t2).await?;
189
190        let results = store.search("product").await?;
191        assert_eq!(results.len(), 1);
192        let first = results.first().ok_or("expected at least one result")?;
193        assert_eq!(first.name, "Product Scraper");
194        Ok(())
195    }
196}