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 #[must_use]
32 pub const fn new(templates_dir: PathBuf) -> Self {
33 Self { templates_dir }
34 }
35
36 fn template_path(&self, id: &uuid::Uuid) -> PathBuf {
38 self.templates_dir.join(format!("{id}.json"))
39 }
40
41 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}