1use 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
20fn file_mtime(store: &dyn FileStore, path: &Path) -> Option<SystemTime> {
24 store.modified_time(path).ok()
25}
26
27pub struct GettextStore {
30 path: PathBuf,
31 file_store: Arc<dyn FileStore>,
32 data: Arc<RwLock<GettextFile>>,
33 loaded_mtime: Arc<RwLock<Option<SystemTime>>>,
37}
38
39impl GettextStore {
40 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 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 pub fn path(&self) -> &Path {
75 &self.path
76 }
77
78 pub async fn loaded_mtime(&self) -> Option<SystemTime> {
80 *self.loaded_mtime.read().await
81 }
82
83 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn language(&self) -> Result<Option<String>, GettextError> {
297 let data = self.data.read().await;
298 Ok(data.language())
299 }
300
301 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 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 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 pub async fn add_language(&self, language: &str) -> Result<(), GettextError> {
354 self.set_header("Language", language).await
355 }
356
357 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 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 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 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}