Skip to main content

gettext_mcp/service/
store.rs

1//! CRUD layer over a single PO file.
2//!
3//! [`GettextStore`] keeps the parsed [`GettextFile`] in memory behind an
4//! `RwLock` and persists every mutation through the [`FileStore`]
5//! abstraction. All file I/O is done on `tokio::task::spawn_blocking` so
6//! we never stall the async runtime.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::SystemTime;
11
12use indexmap::IndexMap;
13use tokio::sync::RwLock;
14
15use crate::error::GettextError;
16use crate::io::FileStore;
17use crate::model::{GettextFile, MessageEntry};
18use crate::service::{parser, serializer};
19
20/// Best-effort modified-time lookup via the [`FileStore`]. Returns `None`
21/// when the file is missing or the platform doesn't report a mtime —
22/// callers treat that as "no observable change".
23fn file_mtime(store: &dyn FileStore, path: &Path) -> Option<SystemTime> {
24    store.modified_time(path).ok()
25}
26
27/// In-memory store wrapping a single PO file. All public methods are
28/// `async` because writes are dispatched to a blocking thread.
29pub struct GettextStore {
30    path: PathBuf,
31    file_store: Arc<dyn FileStore>,
32    data: Arc<RwLock<GettextFile>>,
33    /// On-disk mtime captured the last time this store read or wrote the
34    /// file. Used by [`super::manager::GettextStoreManager`] to detect
35    /// external modifications and invalidate the cache.
36    loaded_mtime: Arc<RwLock<Option<SystemTime>>>,
37}
38
39impl GettextStore {
40    /// Load (or create empty) a store backed by `path` via `file_store`.
41    pub async fn new(
42        path: impl Into<PathBuf>,
43        file_store: Arc<dyn FileStore>,
44    ) -> Result<Self, GettextError> {
45        let path = path.into();
46
47        let (data, loaded_mtime) = if file_store.exists(&path) {
48            let path_clone = path.clone();
49            let fs_clone = Arc::clone(&file_store);
50            let (content, mtime) = tokio::task::spawn_blocking(move || {
51                let content = fs_clone.read(&path_clone)?;
52                // Capture mtime AFTER reading so a concurrent writer that
53                // lands between read and stat is detected on the next
54                // access.
55                let mtime = fs_clone.modified_time(&path_clone).ok();
56                Ok::<(String, Option<SystemTime>), GettextError>((content, mtime))
57            })
58            .await
59            .map_err(|e| GettextError::Io(std::io::Error::other(e)))??;
60            (parser::parse_po(&content)?, mtime)
61        } else {
62            (GettextFile::new(), None)
63        };
64
65        Ok(Self {
66            path,
67            file_store,
68            data: Arc::new(RwLock::new(data)),
69            loaded_mtime: Arc::new(RwLock::new(loaded_mtime)),
70        })
71    }
72
73    /// Path backing this store.
74    pub fn path(&self) -> &Path {
75        &self.path
76    }
77
78    /// mtime observed last read or write, if any.
79    pub async fn loaded_mtime(&self) -> Option<SystemTime> {
80        *self.loaded_mtime.read().await
81    }
82
83    /// Get a translation entry by `(msgid, msgctxt)`.
84    pub async fn get(
85        &self,
86        msgid: &str,
87        msgctxt: Option<&str>,
88    ) -> Result<MessageEntry, GettextError> {
89        let data = self.data.read().await;
90        let key = (msgid.to_string(), msgctxt.map(|s| s.to_string()));
91        data.entries
92            .get(&key)
93            .cloned()
94            .ok_or_else(|| GettextError::TranslationNotFound {
95                key: msgid.to_string(),
96                context: msgctxt.map(|s| s.to_string()),
97            })
98    }
99
100    /// List all entries except the header (empty-msgid, no-context).
101    pub async fn list_all(
102        &self,
103    ) -> Result<Vec<(String, Option<String>, MessageEntry)>, GettextError> {
104        let data = self.data.read().await;
105        Ok(data
106            .entries
107            .iter()
108            .filter(|((msgid, msgctxt), _)| !(msgid.is_empty() && msgctxt.is_none()))
109            .map(|((msgid, msgctxt), entry)| (msgid.clone(), msgctxt.clone(), entry.clone()))
110            .collect())
111    }
112
113    /// Case-insensitive substring search across msgid + msgstr.
114    pub async fn search(
115        &self,
116        query: &str,
117        limit: Option<usize>,
118    ) -> Result<Vec<MessageEntry>, GettextError> {
119        let data = self.data.read().await;
120        let query_lower = query.to_lowercase();
121
122        let mut results: Vec<_> = data
123            .entries
124            .iter()
125            .filter(|((msgid, _), entry)| {
126                msgid.to_lowercase().contains(&query_lower)
127                    || entry.msgstr.to_lowercase().contains(&query_lower)
128            })
129            .map(|(_, entry)| entry.clone())
130            .collect();
131
132        if let Some(limit) = limit {
133            results.truncate(limit);
134        }
135
136        Ok(results)
137    }
138
139    /// Insert or update a translation. Only `msgstr` and (optionally)
140    /// `flags` are touched — everything else is preserved.
141    pub async fn upsert(
142        &self,
143        msgid: &str,
144        msgctxt: Option<&str>,
145        msgstr: &str,
146        flags: Option<Vec<String>>,
147    ) -> Result<(), GettextError> {
148        let mut data = self.data.write().await;
149        let key = (msgid.to_string(), msgctxt.map(|s| s.to_string()));
150
151        let entry = data.entries.entry(key).or_insert_with(|| MessageEntry {
152            msgid: msgid.to_string(),
153            msgctxt: msgctxt.map(|s| s.to_string()),
154            ..Default::default()
155        });
156
157        entry.msgstr = msgstr.to_string();
158        if let Some(flags) = flags {
159            entry.flags = flags;
160        }
161
162        self.persist(&data).await?;
163        Ok(())
164    }
165
166    /// Extended upsert that also handles plural forms and validates flags.
167    pub async fn upsert_full(
168        &self,
169        msgid: &str,
170        msgctxt: Option<&str>,
171        msgstr: &str,
172        msgid_plural: Option<&str>,
173        msgstr_plural: Option<Vec<String>>,
174        flags: Option<Vec<String>>,
175    ) -> Result<(), GettextError> {
176        let mut data = self.data.write().await;
177        let key = (msgid.to_string(), msgctxt.map(|s| s.to_string()));
178
179        if let Some(ref flags) = flags {
180            for flag in flags {
181                if flag.is_empty()
182                    || !flag
183                        .chars()
184                        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
185                {
186                    return Err(GettextError::InvalidInput(format!(
187                        "Invalid flag '{}': only alphanumeric characters, hyphens, and underscores are allowed",
188                        flag
189                    )));
190                }
191            }
192        }
193
194        let entry = data.entries.entry(key).or_insert_with(|| MessageEntry {
195            msgid: msgid.to_string(),
196            msgctxt: msgctxt.map(|s| s.to_string()),
197            ..Default::default()
198        });
199
200        entry.msgstr = msgstr.to_string();
201        if let Some(plural) = msgid_plural {
202            entry.msgid_plural = Some(plural.to_string());
203            entry.msgstr_plural = msgstr_plural.unwrap_or_default();
204        }
205        if let Some(flags) = flags {
206            entry.flags = flags;
207        }
208
209        self.persist(&data).await?;
210        Ok(())
211    }
212
213    /// Replace an entry wholesale while keeping the key intact. Used by
214    /// the MCP layer to preserve comments/flags when toggling metadata.
215    pub async fn update_entry(
216        &self,
217        msgid: &str,
218        msgctxt: Option<&str>,
219        mut entry: MessageEntry,
220    ) -> Result<(), GettextError> {
221        let mut data = self.data.write().await;
222        let key = (msgid.to_string(), msgctxt.map(|s| s.to_string()));
223        // Ensure entry fields match the key to prevent inconsistency.
224        entry.msgid = msgid.to_string();
225        entry.msgctxt = msgctxt.map(|s| s.to_string());
226        data.entries.insert(key, entry);
227        self.persist(&data).await?;
228        Ok(())
229    }
230
231    /// Delete a single `(msgid, msgctxt)` entry.
232    pub async fn delete(&self, msgid: &str, msgctxt: Option<&str>) -> Result<(), GettextError> {
233        let mut data = self.data.write().await;
234        let key = (msgid.to_string(), msgctxt.map(|s| s.to_string()));
235        if data.entries.shift_remove(&key).is_none() {
236            return Err(GettextError::TranslationNotFound {
237                key: msgid.to_string(),
238                context: msgctxt.map(|s| s.to_string()),
239            });
240        }
241        self.persist(&data).await?;
242        Ok(())
243    }
244
245    /// Delete every entry that uses the given `msgid` across every
246    /// context. Returns the number of entries removed.
247    pub async fn delete_by_msgid(&self, msgid: &str) -> Result<usize, GettextError> {
248        let mut data = self.data.write().await;
249        let keys_to_remove: Vec<_> = data
250            .entries
251            .keys()
252            .filter(|(id, _)| id == msgid)
253            .cloned()
254            .collect();
255
256        let count = keys_to_remove.len();
257        if count == 0 {
258            return Err(GettextError::TranslationNotFound {
259                key: msgid.to_string(),
260                context: None,
261            });
262        }
263
264        for key in keys_to_remove {
265            data.entries.shift_remove(&key);
266        }
267
268        self.persist(&data).await?;
269        Ok(count)
270    }
271
272    /// Persist the in-memory file to disk. Called from every mutator.
273    async fn persist(&self, data: &GettextFile) -> Result<(), GettextError> {
274        let content = serializer::serialize_po(data);
275        let path = self.path.clone();
276        let fs_clone = Arc::clone(&self.file_store);
277        tokio::task::spawn_blocking(move || fs_clone.write(&path, &content))
278            .await
279            .map_err(|e| GettextError::Io(std::io::Error::other(e)))??;
280
281        // Refresh our recorded mtime to what we just wrote. This prevents
282        // the manager's staleness check from re-reading our own write.
283        let new_mtime =
284            file_mtime(self.file_store.as_ref(), &self.path).unwrap_or_else(SystemTime::now);
285        *self.loaded_mtime.write().await = Some(new_mtime);
286        Ok(())
287    }
288
289    /// Header metadata snapshot.
290    pub async fn metadata(&self) -> Result<IndexMap<String, String>, GettextError> {
291        let data = self.data.read().await;
292        Ok(data.metadata.clone())
293    }
294
295    /// `Language` header, if set.
296    pub async fn language(&self) -> Result<Option<String>, GettextError> {
297        let data = self.data.read().await;
298        Ok(data.language())
299    }
300
301    /// Set or update a single header. Rejects keys with newlines/colons
302    /// and values with newlines.
303    pub async fn set_header(&self, key: &str, value: &str) -> Result<(), GettextError> {
304        if key.is_empty() || key.trim().is_empty() {
305            return Err(GettextError::InvalidInput(
306                "Header key must not be empty".into(),
307            ));
308        }
309        if key.contains('\n') || key.contains('\r') {
310            return Err(GettextError::InvalidInput(
311                "Header key must not contain newlines".into(),
312            ));
313        }
314        if key.contains(':') {
315            return Err(GettextError::InvalidInput(
316                "Header key must not contain colons".into(),
317            ));
318        }
319        if value.contains('\n') || value.contains('\r') {
320            return Err(GettextError::InvalidInput(
321                "Header value must not contain newlines".into(),
322            ));
323        }
324        let mut data = self.data.write().await;
325        data.metadata.insert(key.to_string(), value.to_string());
326        data.rebuild_header_entry();
327        self.persist(&data).await?;
328        Ok(())
329    }
330
331    /// Remove a header. Silently no-ops if the header was not set.
332    pub async fn remove_header(&self, key: &str) -> Result<(), GettextError> {
333        let mut data = self.data.write().await;
334        data.metadata.shift_remove(key);
335        data.rebuild_header_entry();
336        self.persist(&data).await?;
337        Ok(())
338    }
339
340    /// Convenience: report the languages this PO file declares. PO files
341    /// typically store exactly one language, so this returns at most one
342    /// element.
343    pub async fn list_languages(&self) -> Result<Vec<String>, GettextError> {
344        let data = self.data.read().await;
345        let mut languages = Vec::new();
346        if let Some(lang) = data.language() {
347            languages.push(lang);
348        }
349        Ok(languages)
350    }
351
352    /// Set the `Language` header (creating it if absent).
353    pub async fn add_language(&self, language: &str) -> Result<(), GettextError> {
354        self.set_header("Language", language).await
355    }
356
357    /// Parse the preserved obsolete (`#~`) lines into structured
358    /// [`MessageEntry`] values.
359    ///
360    /// Obsolete entries are stored verbatim in [`GettextFile::obsolete_lines`]
361    /// for round-trip fidelity; this method strips the `#~ ` prefix from
362    /// each line and runs the rebuilt source through [`parser::parse_po`]
363    /// so we can surface obsolete entries to callers without disturbing
364    /// the serializer.
365    pub async fn obsolete_entries(&self) -> Result<Vec<MessageEntry>, GettextError> {
366        let data = self.data.read().await;
367        if data.obsolete_lines.is_empty() {
368            return Ok(Vec::new());
369        }
370
371        // Drop the leading "#~" (and optional single space) from each
372        // line, then concatenate to obtain plain PO source.
373        let mut body = String::new();
374        for line in &data.obsolete_lines {
375            let stripped = line.strip_prefix("#~").unwrap_or(line);
376            let stripped = stripped.strip_prefix(' ').unwrap_or(stripped);
377            body.push_str(stripped);
378            body.push('\n');
379        }
380        // Trailing blank line to flush the final entry.
381        body.push('\n');
382
383        let parsed = parser::parse_po(&body)?;
384        Ok(parsed
385            .entries
386            .into_iter()
387            .filter(|((msgid, msgctxt), _)| !(msgid.is_empty() && msgctxt.is_none()))
388            .map(|(_, e)| e)
389            .collect())
390    }
391
392    /// Remove the `Language` header, but only if it currently matches the
393    /// supplied value. Mismatches return [`GettextError::InvalidInput`].
394    pub async fn remove_language(&self, language: &str) -> Result<(), GettextError> {
395        let mut data = self.data.write().await;
396        if data.metadata.get("Language").map(|l| l.as_str()) == Some(language) {
397            data.metadata.shift_remove("Language");
398            data.rebuild_header_entry();
399            self.persist(&data).await?;
400            Ok(())
401        } else {
402            Err(GettextError::InvalidInput(format!(
403                "Language '{}' does not match current file language",
404                language
405            )))
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::io::FsFileStore;
414
415    fn fs() -> Arc<dyn FileStore> {
416        Arc::new(FsFileStore::new())
417    }
418
419    #[tokio::test]
420    async fn upsert_and_get() {
421        let dir = tempfile::TempDir::new().unwrap();
422        let path = dir.path().join("test.po");
423
424        let store = GettextStore::new(&path, fs()).await.unwrap();
425        store.upsert("Hello", None, "Bonjour", None).await.unwrap();
426
427        let entry = store.get("Hello", None).await.unwrap();
428        assert_eq!(entry.msgstr, "Bonjour");
429    }
430
431    #[tokio::test]
432    async fn search_basic() {
433        let dir = tempfile::TempDir::new().unwrap();
434        let path = dir.path().join("test.po");
435
436        let store = GettextStore::new(&path, fs()).await.unwrap();
437        store.upsert("Hello", None, "Bonjour", None).await.unwrap();
438        store.upsert("World", None, "Monde", None).await.unwrap();
439
440        let results = store.search("Hello", None).await.unwrap();
441        assert_eq!(results.len(), 1);
442        assert_eq!(results[0].msgstr, "Bonjour");
443    }
444
445    #[tokio::test]
446    async fn get_nonexistent_returns_error() {
447        let dir = tempfile::TempDir::new().unwrap();
448        let path = dir.path().join("test.po");
449        let store = GettextStore::new(&path, fs()).await.unwrap();
450        let result = store.get("nope", None).await;
451        assert!(result.is_err());
452    }
453
454    #[tokio::test]
455    async fn delete_then_get_fails() {
456        let dir = tempfile::TempDir::new().unwrap();
457        let path = dir.path().join("test.po");
458
459        let store = GettextStore::new(&path, fs()).await.unwrap();
460        store.upsert("Hello", None, "Bonjour", None).await.unwrap();
461        store.upsert("World", None, "Monde", None).await.unwrap();
462        store.delete("Hello", None).await.unwrap();
463
464        assert!(store.get("Hello", None).await.is_err());
465        let world = store.get("World", None).await.unwrap();
466        assert_eq!(world.msgstr, "Monde");
467    }
468
469    #[tokio::test]
470    async fn delete_nonexistent_returns_error() {
471        let dir = tempfile::TempDir::new().unwrap();
472        let path = dir.path().join("test.po");
473        let store = GettextStore::new(&path, fs()).await.unwrap();
474        assert!(store.delete("missing", None).await.is_err());
475    }
476
477    #[tokio::test]
478    async fn delete_by_msgid_clears_all_contexts() {
479        let dir = tempfile::TempDir::new().unwrap();
480        let path = dir.path().join("test.po");
481        let store = GettextStore::new(&path, fs()).await.unwrap();
482
483        store
484            .upsert("Save", Some("menu"), "Enregistrer", None)
485            .await
486            .unwrap();
487        store
488            .upsert("Save", Some("toolbar"), "Sauvegarder", None)
489            .await
490            .unwrap();
491        store.upsert("Other", None, "Autre", None).await.unwrap();
492
493        let count = store.delete_by_msgid("Save").await.unwrap();
494        assert_eq!(count, 2);
495        assert!(store.get("Save", Some("menu")).await.is_err());
496        assert_eq!(store.get("Other", None).await.unwrap().msgstr, "Autre");
497    }
498
499    #[tokio::test]
500    async fn update_entry_preserves_metadata() {
501        let dir = tempfile::TempDir::new().unwrap();
502        let path = dir.path().join("test.po");
503        let store = GettextStore::new(&path, fs()).await.unwrap();
504        store
505            .upsert("Hello", None, "Bonjour", Some(vec!["c-format".into()]))
506            .await
507            .unwrap();
508
509        let mut entry = store.get("Hello", None).await.unwrap();
510        entry.translator_comment = vec!["A greeting".into()];
511        store.update_entry("Hello", None, entry).await.unwrap();
512
513        let updated = store.get("Hello", None).await.unwrap();
514        assert_eq!(updated.msgstr, "Bonjour");
515        assert_eq!(updated.translator_comment, vec!["A greeting".to_string()]);
516        assert!(updated.flags.contains(&"c-format".to_string()));
517    }
518
519    #[tokio::test]
520    async fn set_header_rejects_newlines() {
521        let dir = tempfile::TempDir::new().unwrap();
522        let path = dir.path().join("test.po");
523        let store = GettextStore::new(&path, fs()).await.unwrap();
524        assert!(store.set_header("Bad\nKey", "value").await.is_err());
525        assert!(store.set_header("Key", "bad\nvalue").await.is_err());
526    }
527
528    #[tokio::test]
529    async fn set_header_rejects_colons_in_key() {
530        let dir = tempfile::TempDir::new().unwrap();
531        let path = dir.path().join("test.po");
532        let store = GettextStore::new(&path, fs()).await.unwrap();
533        assert!(store.set_header("Bad:Key", "value").await.is_err());
534    }
535
536    #[tokio::test]
537    async fn set_header_empty_key_fails() {
538        let dir = tempfile::TempDir::new().unwrap();
539        let path = dir.path().join("test.po");
540        let store = GettextStore::new(&path, fs()).await.unwrap();
541        assert!(store.set_header("", "value").await.is_err());
542        assert!(store.set_header("   ", "value").await.is_err());
543    }
544
545    #[tokio::test]
546    async fn upsert_full_rejects_invalid_flags() {
547        let dir = tempfile::TempDir::new().unwrap();
548        let path = dir.path().join("test.po");
549        let store = GettextStore::new(&path, fs()).await.unwrap();
550
551        let result = store
552            .upsert_full(
553                "Hello",
554                None,
555                "Bonjour",
556                None,
557                None,
558                Some(vec!["valid-flag".into(), "invalid flag!".into()]),
559            )
560            .await;
561        assert!(result.is_err());
562        assert!(result.unwrap_err().to_string().contains("Invalid flag"));
563    }
564
565    #[tokio::test]
566    async fn header_and_metadata_roundtrip() {
567        let dir = tempfile::TempDir::new().unwrap();
568        let path = dir.path().join("test.po");
569        let store = GettextStore::new(&path, fs()).await.unwrap();
570
571        store.set_header("Language", "es").await.unwrap();
572        store
573            .set_header("Plural-Forms", "nplurals=2; plural=(n != 1);")
574            .await
575            .unwrap();
576
577        let meta = store.metadata().await.unwrap();
578        assert_eq!(meta.get("Language"), Some(&"es".to_string()));
579        assert_eq!(
580            meta.get("Plural-Forms"),
581            Some(&"nplurals=2; plural=(n != 1);".to_string())
582        );
583        assert_eq!(store.language().await.unwrap(), Some("es".to_string()));
584    }
585
586    #[tokio::test]
587    async fn remove_header_drops_value() {
588        let dir = tempfile::TempDir::new().unwrap();
589        let path = dir.path().join("test.po");
590        let store = GettextStore::new(&path, fs()).await.unwrap();
591
592        store.set_header("Language", "fr").await.unwrap();
593        store.set_header("Custom-Key", "x").await.unwrap();
594        store.remove_header("Custom-Key").await.unwrap();
595
596        let meta = store.metadata().await.unwrap();
597        assert!(meta.get("Custom-Key").is_none());
598        assert_eq!(meta.get("Language"), Some(&"fr".to_string()));
599    }
600
601    #[tokio::test]
602    async fn add_and_remove_language() {
603        let dir = tempfile::TempDir::new().unwrap();
604        let path = dir.path().join("test.po");
605        let store = GettextStore::new(&path, fs()).await.unwrap();
606
607        store.add_language("ja").await.unwrap();
608        assert_eq!(store.list_languages().await.unwrap(), vec!["ja"]);
609        store.remove_language("ja").await.unwrap();
610        assert!(store.list_languages().await.unwrap().is_empty());
611    }
612
613    #[tokio::test]
614    async fn remove_wrong_language_fails() {
615        let dir = tempfile::TempDir::new().unwrap();
616        let path = dir.path().join("test.po");
617        let store = GettextStore::new(&path, fs()).await.unwrap();
618        store.add_language("fr").await.unwrap();
619        assert!(store.remove_language("de").await.is_err());
620    }
621
622    #[tokio::test]
623    async fn is_translated_semantics() {
624        let dir = tempfile::TempDir::new().unwrap();
625        let path = dir.path().join("test.po");
626        let store = GettextStore::new(&path, fs()).await.unwrap();
627
628        store.upsert("Empty", None, "", None).await.unwrap();
629        assert!(!store.get("Empty", None).await.unwrap().is_translated());
630
631        store.upsert("Full", None, "Complet", None).await.unwrap();
632        assert!(store.get("Full", None).await.unwrap().is_translated());
633
634        store
635            .upsert("Fuzzy", None, "Flou", Some(vec!["fuzzy".into()]))
636            .await
637            .unwrap();
638        let entry = store.get("Fuzzy", None).await.unwrap();
639        assert!(entry.is_fuzzy());
640        assert!(!entry.is_translated());
641    }
642
643    #[tokio::test]
644    async fn search_is_case_insensitive() {
645        let dir = tempfile::TempDir::new().unwrap();
646        let path = dir.path().join("test.po");
647        let store = GettextStore::new(&path, fs()).await.unwrap();
648        store
649            .upsert("Hello World", None, "Bonjour Monde", None)
650            .await
651            .unwrap();
652        assert_eq!(store.search("hello", None).await.unwrap().len(), 1);
653        assert_eq!(store.search("MONDE", None).await.unwrap().len(), 1);
654    }
655
656    #[tokio::test]
657    async fn search_respects_limit() {
658        let dir = tempfile::TempDir::new().unwrap();
659        let path = dir.path().join("test.po");
660        let store = GettextStore::new(&path, fs()).await.unwrap();
661        store.upsert("Test A", None, "A", None).await.unwrap();
662        store.upsert("Test B", None, "B", None).await.unwrap();
663        store.upsert("Test C", None, "C", None).await.unwrap();
664        let results = store.search("Test", Some(2)).await.unwrap();
665        assert_eq!(results.len(), 2);
666    }
667
668    #[tokio::test]
669    async fn upsert_full_with_plurals() {
670        let dir = tempfile::TempDir::new().unwrap();
671        let path = dir.path().join("test.po");
672        let store = GettextStore::new(&path, fs()).await.unwrap();
673        store
674            .upsert_full(
675                "%d cat",
676                None,
677                "",
678                Some("%d cats"),
679                Some(vec!["%d chat".into(), "%d chats".into()]),
680                Some(vec!["c-format".into()]),
681            )
682            .await
683            .unwrap();
684
685        let entry = store.get("%d cat", None).await.unwrap();
686        assert_eq!(entry.msgid_plural, Some("%d cats".to_string()));
687        assert_eq!(entry.msgstr_plural, vec!["%d chat", "%d chats"]);
688        assert!(entry.flags.contains(&"c-format".to_string()));
689    }
690
691    #[tokio::test]
692    async fn list_all_excludes_header() {
693        let dir = tempfile::TempDir::new().unwrap();
694        let path = dir.path().join("test.po");
695        let store = GettextStore::new(&path, fs()).await.unwrap();
696        store.set_header("Language", "fr").await.unwrap();
697        store.upsert("Hello", None, "Bonjour", None).await.unwrap();
698
699        let entries = store.list_all().await.unwrap();
700        assert_eq!(entries.len(), 1);
701        assert_eq!(entries[0].0, "Hello");
702    }
703
704    #[tokio::test]
705    async fn upsert_overwrites_existing() {
706        let dir = tempfile::TempDir::new().unwrap();
707        let path = dir.path().join("test.po");
708        let store = GettextStore::new(&path, fs()).await.unwrap();
709        store.upsert("Hello", None, "Bonjour", None).await.unwrap();
710        store.upsert("Hello", None, "Salut", None).await.unwrap();
711        assert_eq!(store.get("Hello", None).await.unwrap().msgstr, "Salut");
712    }
713
714    #[tokio::test]
715    async fn loading_preserves_bom_stripped_content() {
716        let dir = tempfile::TempDir::new().unwrap();
717        let path = dir.path().join("bom.po");
718        let body =
719            "msgid \"\"\nmsgstr \"\"\n\"Language: en\\n\"\n\nmsgid \"Hello\"\nmsgstr \"Bonjour\"\n";
720        let with_bom = format!("\u{feff}{body}");
721        std::fs::write(&path, with_bom.as_bytes()).unwrap();
722
723        let store = GettextStore::new(&path, fs()).await.unwrap();
724        let entry = store.get("Hello", None).await.unwrap();
725        assert_eq!(entry.msgstr, "Bonjour");
726        assert!(!entry.msgid.contains('\u{feff}'));
727    }
728}