gettext_mcp/service/
manager.rs1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use indexmap::IndexMap;
13use tokio::sync::RwLock;
14
15use crate::error::GettextError;
16use crate::io::FileStore;
17use crate::service::store::GettextStore;
18
19pub struct GettextStoreManager {
21 default_path: Option<PathBuf>,
22 file_store: Arc<dyn FileStore>,
23 stores: Arc<RwLock<IndexMap<PathBuf, Arc<GettextStore>>>>,
24}
25
26impl GettextStoreManager {
27 pub fn new(default_path: Option<PathBuf>) -> Self {
31 Self::with_file_store(default_path, Arc::new(crate::io::FsFileStore::new()))
32 }
33
34 pub fn with_file_store(default_path: Option<PathBuf>, file_store: Arc<dyn FileStore>) -> Self {
36 Self {
37 default_path,
38 file_store,
39 stores: Arc::new(RwLock::new(IndexMap::new())),
40 }
41 }
42
43 pub async fn scan_directory(&self) -> Result<usize, GettextError> {
46 let dir = match self.default_path {
47 Some(ref p) if p.is_dir() => p.clone(),
48 _ => return Ok(0),
49 };
50
51 let po_files = Self::find_po_files(&dir).await?;
52 let count = po_files.len();
53
54 let mut stores = self.stores.write().await;
55 for file_path in po_files {
56 if !stores.contains_key(&file_path) {
57 let store =
58 Arc::new(GettextStore::new(&file_path, Arc::clone(&self.file_store)).await?);
59 stores.insert(file_path, store);
60 }
61 }
62
63 Ok(count)
64 }
65
66 async fn find_po_files(dir: &Path) -> Result<Vec<PathBuf>, GettextError> {
67 let mut result = Vec::new();
68 let mut stack = vec![dir.to_path_buf()];
69
70 while let Some(current) = stack.pop() {
71 let mut entries = tokio::fs::read_dir(¤t).await?;
72 while let Some(entry) = entries.next_entry().await? {
73 let path = entry.path();
74 if path.is_dir() {
75 stack.push(path);
76 } else if let Some(ext) = path.extension() {
77 if ext == "po" || ext == "pot" {
78 result.push(path);
79 }
80 }
81 }
82 }
83
84 result.sort();
85 Ok(result)
86 }
87
88 pub async fn store_for(&self, path: Option<&str>) -> Result<Arc<GettextStore>, GettextError> {
91 let path_buf = self.resolve_path(path)?;
92
93 {
95 let stores = self.stores.read().await;
96 if let Some(store) = stores.get(&path_buf) {
97 if !self.is_stale(store, &path_buf).await {
98 return Ok(Arc::clone(store));
99 }
100 }
101 }
102
103 let mut stores = self.stores.write().await;
105 if let Some(store) = stores.get(&path_buf) {
106 if !self.is_stale(store, &path_buf).await {
107 return Ok(Arc::clone(store));
108 }
109 stores.shift_remove(&path_buf);
110 }
111
112 let store = Arc::new(GettextStore::new(&path_buf, Arc::clone(&self.file_store)).await?);
113 stores.insert(path_buf, Arc::clone(&store));
114 Ok(store)
115 }
116
117 async fn is_stale(&self, store: &Arc<GettextStore>, path: &Path) -> bool {
121 let current_mtime = self.file_store.modified_time(path).ok();
122 let cached_mtime = store.loaded_mtime().await;
123 match (current_mtime, cached_mtime) {
124 (Some(current), Some(cached)) => current != cached,
125 (Some(_), None) => true,
128 (None, _) => false,
130 }
131 }
132
133 fn resolve_path(&self, path: Option<&str>) -> Result<PathBuf, GettextError> {
134 if let Some(p) = path {
135 let pb = PathBuf::from(p);
136 if pb.is_relative() {
137 if let Some(ref base) = self.default_path {
138 if base.is_dir() {
139 let resolved = base.join(&pb);
140 self.validate_path(&resolved)?;
141 return Ok(resolved);
142 }
143 }
144 self.validate_path(&pb)?;
145 Ok(pb)
146 } else {
147 self.validate_path(&pb)?;
148 Ok(pb)
149 }
150 } else if let Some(ref p) = self.default_path {
151 if p.is_dir() {
152 Err(GettextError::PathRequired)
153 } else {
154 Ok(p.clone())
155 }
156 } else {
157 Err(GettextError::PathRequired)
158 }
159 }
160
161 pub(crate) fn validate_path(&self, path: &Path) -> Result<(), GettextError> {
162 for component in path.components() {
164 if let std::path::Component::ParentDir = component {
165 return Err(GettextError::InvalidPath(
166 "Path traversal not allowed".into(),
167 ));
168 }
169 }
170
171 if let Some(ref default) = self.default_path {
172 let base = if default.is_dir() {
174 default.as_path()
175 } else {
176 default.parent().unwrap_or(default)
177 };
178
179 let canonical_base = base.canonicalize().map_err(|e| {
180 GettextError::InvalidPath(format!("Cannot resolve base path: {}", e))
181 })?;
182 let canonical_path = path
183 .canonicalize()
184 .or_else(|_| {
185 if let (Some(parent), Some(filename)) = (path.parent(), path.file_name()) {
186 parent.canonicalize().map(|p| p.join(filename))
187 } else {
188 Err(std::io::Error::new(
189 std::io::ErrorKind::NotFound,
190 "Cannot resolve path",
191 ))
192 }
193 })
194 .map_err(|e| GettextError::InvalidPath(format!("Cannot resolve path: {}", e)))?;
195
196 if !canonical_path.starts_with(&canonical_base) {
197 return Err(GettextError::InvalidPath(
198 "Path must be within base directory".into(),
199 ));
200 }
201 } else if path.is_absolute() {
202 return Err(GettextError::InvalidPath(
203 "Absolute paths not allowed without a configured base directory".into(),
204 ));
205 }
206
207 Ok(())
208 }
209
210 pub async fn discovered_paths(&self) -> Vec<PathBuf> {
213 let stores = self.stores.read().await;
214 stores.keys().cloned().collect()
215 }
216
217 pub fn is_directory_mode(&self) -> bool {
220 self.default_path.as_ref().is_some_and(|p| p.is_dir())
221 }
222
223 pub fn base_dir(&self) -> Option<&Path> {
225 self.default_path.as_deref().filter(|p| p.is_dir())
226 }
227
228 pub fn file_store(&self) -> &Arc<dyn FileStore> {
233 &self.file_store
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn validate_path_rejects_traversal() {
243 let manager = GettextStoreManager::new(None);
244 let result = manager.validate_path(&PathBuf::from("../etc/passwd"));
245 assert!(result.is_err());
246 }
247
248 #[test]
249 fn validate_path_rejects_absolute_without_base() {
250 let manager = GettextStoreManager::new(None);
251 let result = manager.validate_path(&PathBuf::from("/etc/passwd"));
252 assert!(result.is_err());
253 }
254
255 #[tokio::test]
256 async fn store_for_requires_path_in_dynamic_mode() {
257 let manager = GettextStoreManager::new(None);
258 let result = manager.store_for(None).await;
259 match result {
260 Err(e) => assert!(e.to_string().contains("Path required")),
261 Ok(_) => panic!("expected PathRequired"),
262 }
263 }
264
265 #[tokio::test]
266 async fn cache_invalidates_on_external_modification() {
267 let dir = tempfile::TempDir::new().unwrap();
268 let path = dir.path().join("messages.po");
269 std::fs::write(
270 &path,
271 "msgid \"\"\nmsgstr \"\"\n\nmsgid \"Hello\"\nmsgstr \"Bonjour\"\n",
272 )
273 .unwrap();
274
275 let manager = GettextStoreManager::new(Some(path.clone()));
276 let store1 = manager.store_for(None).await.unwrap();
277 assert_eq!(store1.get("Hello", None).await.unwrap().msgstr, "Bonjour");
278
279 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
282 std::fs::write(
283 &path,
284 "msgid \"\"\nmsgstr \"\"\n\nmsgid \"Hello\"\nmsgstr \"Salut\"\n",
285 )
286 .unwrap();
287
288 let store2 = manager.store_for(None).await.unwrap();
289 assert_eq!(store2.get("Hello", None).await.unwrap().msgstr, "Salut");
290 assert!(
291 !Arc::ptr_eq(&store1, &store2),
292 "stale store should have been evicted"
293 );
294 }
295
296 #[tokio::test]
297 async fn cache_serves_unchanged_file_without_rereading() {
298 let dir = tempfile::TempDir::new().unwrap();
299 let path = dir.path().join("messages.po");
300 std::fs::write(
301 &path,
302 "msgid \"\"\nmsgstr \"\"\n\nmsgid \"Hello\"\nmsgstr \"Bonjour\"\n",
303 )
304 .unwrap();
305
306 let manager = GettextStoreManager::new(Some(path.clone()));
307 let store1 = manager.store_for(None).await.unwrap();
308 let store2 = manager.store_for(None).await.unwrap();
309 assert!(Arc::ptr_eq(&store1, &store2));
310 }
311
312 #[tokio::test]
313 async fn cache_survives_internal_write() {
314 let dir = tempfile::TempDir::new().unwrap();
315 let path = dir.path().join("messages.po");
316 std::fs::write(
317 &path,
318 "msgid \"\"\nmsgstr \"\"\n\nmsgid \"Hello\"\nmsgstr \"Bonjour\"\n",
319 )
320 .unwrap();
321
322 let manager = GettextStoreManager::new(Some(path.clone()));
323 let store1 = manager.store_for(None).await.unwrap();
324 store1
325 .upsert("Greeting", None, "Salutation", None)
326 .await
327 .unwrap();
328
329 let store2 = manager.store_for(None).await.unwrap();
330 assert!(Arc::ptr_eq(&store1, &store2));
331 assert_eq!(
332 store2.get("Greeting", None).await.unwrap().msgstr,
333 "Salutation"
334 );
335 }
336}