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