Skip to main content

gettext_mcp/service/
manager.rs

1//! Multi-file orchestrator for [`GettextStore`].
2//!
3//! The MCP server owns a single [`GettextStoreManager`] and routes every
4//! tool call's `path` argument through [`store_for`]. The manager caches
5//! per-path stores, validates paths against an optional base directory,
6//! and reloads stores when the on-disk mtime advances ahead of what the
7//! cached store recorded.
8
9use 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
19/// Manager owning the [`FileStore`] and a path-keyed cache of stores.
20pub 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    /// Construct a manager using [`crate::io::FsFileStore`] as the
28    /// backing store. Same signature the rest of the crate (tests, web,
29    /// CLI) has historically used.
30    pub fn new(default_path: Option<PathBuf>) -> Self {
31        Self::with_file_store(default_path, Arc::new(crate::io::FsFileStore::new()))
32    }
33
34    /// Construct a manager with a caller-supplied [`FileStore`].
35    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    /// Recursively scan the default path (if it is a directory) for
44    /// `.po`/`.pot` files, pre-load them, and return the count.
45    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(&current).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    /// Look up (or create) a store for the caller-supplied path. When
89    /// `path` is `None` the default path is used (in single-file mode).
90    pub async fn store_for(&self, path: Option<&str>) -> Result<Arc<GettextStore>, GettextError> {
91        let path_buf = self.resolve_path(path)?;
92
93        // Fast path: cache hit with no observed mtime drift.
94        {
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        // Slow path: cache miss OR a stale entry was evicted.
104        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    /// Compare the cached store's recorded mtime with what's on disk now.
118    /// File missing or stat failed → keep the cached copy (callers will
119    /// surface real I/O errors on the next persist).
120    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            // First-time observation of an mtime (cached has None but the
126            // file now exists): treat as stale so we reload.
127            (Some(_), None) => true,
128            // File missing or stat failed: keep cached.
129            (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        // Reject path traversal.
163        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            // Use parent directory as base when default_path points to a file.
173            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    /// Paths of every store currently loaded (including those preloaded
211    /// by [`scan_directory`]).
212    pub async fn discovered_paths(&self) -> Vec<PathBuf> {
213        let stores = self.stores.read().await;
214        stores.keys().cloned().collect()
215    }
216
217    /// Whether the configured default path is a directory (vs. a single
218    /// file or `None`).
219    pub fn is_directory_mode(&self) -> bool {
220        self.default_path.as_ref().is_some_and(|p| p.is_dir())
221    }
222
223    /// Base directory path, if any.
224    pub fn base_dir(&self) -> Option<&Path> {
225        self.default_path.as_deref().filter(|p| p.is_dir())
226    }
227
228    /// Shared reference to the backing [`FileStore`]. Used by tools that
229    /// read or write auxiliary files (e.g. XLIFF documents) so they go
230    /// through the same I/O layer (atomic writes, advisory locking, BOM
231    /// stripping) as the PO files themselves.
232    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        // Some filesystems have ~1s mtime resolution; sleep long enough to
280        // get a distinct timestamp.
281        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}