stygian_plugin/storage/
file_template_store.rs1use 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
11pub struct FileTemplateStore {
25 templates_dir: PathBuf,
27}
28
29impl FileTemplateStore {
30 pub const fn new(templates_dir: PathBuf) -> Self {
32 Self { templates_dir }
33 }
34
35 fn template_path(&self, id: &uuid::Uuid) -> PathBuf {
37 self.templates_dir.join(format!("{id}.json"))
38 }
39
40 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}