1use std::sync::Arc;
9
10use rmcp::{
11 handler::server::{
12 router::{prompt::PromptRouter, tool::ToolRouter},
13 wrapper::Parameters,
14 },
15 model::{
16 GetPromptRequestParams, GetPromptResult, ListPromptsResult, PaginatedRequestParams,
17 ProtocolVersion, ServerCapabilities, ServerInfo,
18 },
19 prompt_handler,
20 service::RequestContext,
21 tool, tool_handler, tool_router, RoleServer, ServerHandler,
22};
23use tokio::sync::Mutex;
24use tracing::error;
25
26use crate::service::GettextStoreManager;
27use crate::tools::{
28 coverage::{handle_get_coverage, GetCoverageParams},
29 crud::{
30 handle_delete_key, handle_delete_translation, handle_get_translation,
31 handle_list_translations, handle_upsert_translation, DeleteKeyParams,
32 DeleteTranslationParams, GetTranslationParams, ListTranslationsParams,
33 UpsertTranslationParams,
34 },
35 discover::{handle_list_contexts, handle_list_files, ListContextsParams, ListFilesParams},
36 discover_files::{handle_discover_files, DiscoverFilesParams},
37 extract::{handle_get_stale, handle_get_untranslated, GetStaleParams, GetUntranslatedParams},
38 glossary::{
39 handle_get_glossary, handle_update_glossary, GetGlossaryParams, UpdateGlossaryParams,
40 },
41 header::{handle_list_metadata, handle_set_header, ListMetadataParams, SetHeaderParams},
42 metadata::{
43 handle_set_comment, handle_set_flag, handle_set_fuzzy, SetCommentParams, SetFlagParams,
44 SetFuzzyParams,
45 },
46 mo::{handle_compile_mo, CompileMoParams},
47 search::{handle_search_keys, SearchKeysParams},
48 sync::{handle_sync_with_pot, SyncWithPotParams},
49 validate::{handle_validate_translations, ValidateTranslationsParams},
50 xliff::{handle_export_xliff, handle_import_xliff, ExportXliffParams, ImportXliffParams},
51};
52
53#[derive(Clone)]
56pub struct GettextMcpServer {
57 manager: Arc<GettextStoreManager>,
58 glossary_write_lock: Arc<Mutex<()>>,
63 tool_router: ToolRouter<Self>,
64 prompt_router: PromptRouter<Self>,
65}
66
67impl GettextMcpServer {
68 pub fn new(manager: Arc<GettextStoreManager>) -> Self {
69 Self {
70 manager,
71 glossary_write_lock: Arc::new(Mutex::new(())),
72 tool_router: Self::tool_router(),
73 prompt_router: Self::prompt_router(),
74 }
75 }
76
77 pub fn manager(&self) -> &Arc<GettextStoreManager> {
80 &self.manager
81 }
82}
83
84#[tool_router]
85impl GettextMcpServer {
86 #[tool(
87 name = "list_translations",
88 description = "List translation entries with optional case-insensitive substring `query` and `limit`."
89 )]
90 async fn list_translations(
91 &self,
92 Parameters(params): Parameters<ListTranslationsParams>,
93 ) -> Result<String, String> {
94 match handle_list_translations(&self.manager, params).await {
95 Ok(value) => serde_json::to_string_pretty(&value)
96 .map_err(|e| format!("serialization error: {e}")),
97 Err(e) => {
98 error!(error = %e, "list_translations failed");
99 Err(e.to_string())
100 }
101 }
102 }
103
104 #[tool(
105 name = "get_translation",
106 description = "Get a single translation entry by `msgid` (and optional `msgctxt`)."
107 )]
108 async fn get_translation(
109 &self,
110 Parameters(params): Parameters<GetTranslationParams>,
111 ) -> Result<String, String> {
112 match handle_get_translation(&self.manager, params).await {
113 Ok(value) => serde_json::to_string_pretty(&value)
114 .map_err(|e| format!("serialization error: {e}")),
115 Err(e) => {
116 error!(error = %e, "get_translation failed");
117 Err(e.to_string())
118 }
119 }
120 }
121
122 #[tool(
123 name = "upsert_translation",
124 description = "Create or update a translation entry. Preserves existing comments and source locations when updating."
125 )]
126 async fn upsert_translation(
127 &self,
128 Parameters(params): Parameters<UpsertTranslationParams>,
129 ) -> Result<String, String> {
130 match handle_upsert_translation(&self.manager, params).await {
131 Ok(value) => serde_json::to_string_pretty(&value)
132 .map_err(|e| format!("serialization error: {e}")),
133 Err(e) => {
134 error!(error = %e, "upsert_translation failed");
135 Err(e.to_string())
136 }
137 }
138 }
139
140 #[tool(
141 name = "delete_translation",
142 description = "Delete a single translation entry by `msgid` and optional `msgctxt`."
143 )]
144 async fn delete_translation(
145 &self,
146 Parameters(params): Parameters<DeleteTranslationParams>,
147 ) -> Result<String, String> {
148 match handle_delete_translation(&self.manager, params).await {
149 Ok(value) => serde_json::to_string_pretty(&value)
150 .map_err(|e| format!("serialization error: {e}")),
151 Err(e) => {
152 error!(error = %e, "delete_translation failed");
153 Err(e.to_string())
154 }
155 }
156 }
157
158 #[tool(
159 name = "delete_key",
160 description = "Remove every entry (across all contexts) with the given `msgid`."
161 )]
162 async fn delete_key(
163 &self,
164 Parameters(params): Parameters<DeleteKeyParams>,
165 ) -> Result<String, String> {
166 match handle_delete_key(&self.manager, params).await {
167 Ok(value) => serde_json::to_string_pretty(&value)
168 .map_err(|e| format!("serialization error: {e}")),
169 Err(e) => {
170 error!(error = %e, "delete_key failed");
171 Err(e.to_string())
172 }
173 }
174 }
175
176 #[tool(
177 name = "set_comment",
178 description = "Set or clear the translator comment for an entry. Pass `comment: null` to clear."
179 )]
180 async fn set_comment(
181 &self,
182 Parameters(params): Parameters<SetCommentParams>,
183 ) -> Result<String, String> {
184 match handle_set_comment(&self.manager, params).await {
185 Ok(value) => serde_json::to_string_pretty(&value)
186 .map_err(|e| format!("serialization error: {e}")),
187 Err(e) => {
188 error!(error = %e, "set_comment failed");
189 Err(e.to_string())
190 }
191 }
192 }
193
194 #[tool(
195 name = "set_fuzzy",
196 description = "Toggle the `fuzzy` flag on a translation entry."
197 )]
198 async fn set_fuzzy(
199 &self,
200 Parameters(params): Parameters<SetFuzzyParams>,
201 ) -> Result<String, String> {
202 match handle_set_fuzzy(&self.manager, params).await {
203 Ok(value) => serde_json::to_string_pretty(&value)
204 .map_err(|e| format!("serialization error: {e}")),
205 Err(e) => {
206 error!(error = %e, "set_fuzzy failed");
207 Err(e.to_string())
208 }
209 }
210 }
211
212 #[tool(
213 name = "set_flag",
214 description = "Add or remove an arbitrary flag (e.g. `c-format`, `no-wrap`) on an entry."
215 )]
216 async fn set_flag(
217 &self,
218 Parameters(params): Parameters<SetFlagParams>,
219 ) -> Result<String, String> {
220 match handle_set_flag(&self.manager, params).await {
221 Ok(value) => serde_json::to_string_pretty(&value)
222 .map_err(|e| format!("serialization error: {e}")),
223 Err(e) => {
224 error!(error = %e, "set_flag failed");
225 Err(e.to_string())
226 }
227 }
228 }
229
230 #[tool(
231 name = "list_metadata",
232 description = "List all PO header metadata entries (Language, Plural-Forms, etc.)."
233 )]
234 async fn list_metadata(
235 &self,
236 Parameters(params): Parameters<ListMetadataParams>,
237 ) -> Result<String, String> {
238 match handle_list_metadata(&self.manager, params).await {
239 Ok(value) => serde_json::to_string_pretty(&value)
240 .map_err(|e| format!("serialization error: {e}")),
241 Err(e) => {
242 error!(error = %e, "list_metadata failed");
243 Err(e.to_string())
244 }
245 }
246 }
247
248 #[tool(
249 name = "set_header",
250 description = "Set or remove a single PO header entry. Pass `value: null` to remove."
251 )]
252 async fn set_header(
253 &self,
254 Parameters(params): Parameters<SetHeaderParams>,
255 ) -> Result<String, String> {
256 match handle_set_header(&self.manager, params).await {
257 Ok(value) => serde_json::to_string_pretty(&value)
258 .map_err(|e| format!("serialization error: {e}")),
259 Err(e) => {
260 error!(error = %e, "set_header failed");
261 Err(e.to_string())
262 }
263 }
264 }
265
266 #[tool(
267 name = "list_contexts",
268 description = "List all distinct `msgctxt` values used in the file."
269 )]
270 async fn list_contexts(
271 &self,
272 Parameters(params): Parameters<ListContextsParams>,
273 ) -> Result<String, String> {
274 match handle_list_contexts(&self.manager, params).await {
275 Ok(value) => serde_json::to_string_pretty(&value)
276 .map_err(|e| format!("serialization error: {e}")),
277 Err(e) => {
278 error!(error = %e, "list_contexts failed");
279 Err(e.to_string())
280 }
281 }
282 }
283
284 #[tool(
285 name = "list_files",
286 description = "List all .po/.pot files discovered in directory mode."
287 )]
288 async fn list_files(
289 &self,
290 Parameters(_params): Parameters<ListFilesParams>,
291 ) -> Result<String, String> {
292 match handle_list_files(&self.manager).await {
293 Ok(value) => serde_json::to_string_pretty(&value)
294 .map_err(|e| format!("serialization error: {e}")),
295 Err(e) => {
296 error!(error = %e, "list_files failed");
297 Err(e.to_string())
298 }
299 }
300 }
301
302 #[tool(
303 name = "get_coverage",
304 description = "Compute translation coverage stats (translated/untranslated/fuzzy/obsolete counts and percentages). Fuzzy entries with non-empty msgstr count as both fuzzy and translated. Obsolete entries are excluded from total and percentages."
305 )]
306 async fn get_coverage(
307 &self,
308 Parameters(params): Parameters<GetCoverageParams>,
309 ) -> Result<String, String> {
310 match handle_get_coverage(&self.manager, params).await {
311 Ok(value) => serde_json::to_string_pretty(&value)
312 .map_err(|e| format!("serialization error: {e}")),
313 Err(e) => {
314 error!(error = %e, "get_coverage failed");
315 Err(e.to_string())
316 }
317 }
318 }
319
320 #[tool(
321 name = "get_untranslated",
322 description = "Paginated list of entries with empty msgstr or fuzzy flag. Each entry includes `needs_plural_forms` (CLDR plural categories) when msgid_plural is set."
323 )]
324 async fn get_untranslated(
325 &self,
326 Parameters(params): Parameters<GetUntranslatedParams>,
327 ) -> Result<String, String> {
328 match handle_get_untranslated(&self.manager, params).await {
329 Ok(value) => serde_json::to_string_pretty(&value)
330 .map_err(|e| format!("serialization error: {e}")),
331 Err(e) => {
332 error!(error = %e, "get_untranslated failed");
333 Err(e.to_string())
334 }
335 }
336 }
337
338 #[tool(
339 name = "get_stale",
340 description = "Paginated list of obsolete (`#~`) entries kept in the file but no longer used by source code."
341 )]
342 async fn get_stale(
343 &self,
344 Parameters(params): Parameters<GetStaleParams>,
345 ) -> Result<String, String> {
346 match handle_get_stale(&self.manager, params).await {
347 Ok(value) => serde_json::to_string_pretty(&value)
348 .map_err(|e| format!("serialization error: {e}")),
349 Err(e) => {
350 error!(error = %e, "get_stale failed");
351 Err(e.to_string())
352 }
353 }
354 }
355
356 #[tool(
357 name = "validate_translations",
358 description = "Run validation checks (format specifier mismatches, plural form count, empty translations, identical translations) and return findings grouped by severity (error/warning/info)."
359 )]
360 async fn validate_translations(
361 &self,
362 Parameters(params): Parameters<ValidateTranslationsParams>,
363 ) -> Result<String, String> {
364 match handle_validate_translations(&self.manager, params).await {
365 Ok(value) => serde_json::to_string_pretty(&value)
366 .map_err(|e| format!("serialization error: {e}")),
367 Err(e) => {
368 error!(error = %e, "validate_translations failed");
369 Err(e.to_string())
370 }
371 }
372 }
373
374 #[tool(
375 name = "search_keys",
376 description = "Paginated case-insensitive substring search across msgid/msgstr/msgctxt/comment fields. Empty pattern returns all entries."
377 )]
378 async fn search_keys(
379 &self,
380 Parameters(params): Parameters<SearchKeysParams>,
381 ) -> Result<String, String> {
382 match handle_search_keys(&self.manager, params).await {
383 Ok(value) => serde_json::to_string_pretty(&value)
384 .map_err(|e| format!("serialization error: {e}")),
385 Err(e) => {
386 error!(error = %e, "search_keys failed");
387 Err(e.to_string())
388 }
389 }
390 }
391
392 #[tool(
393 name = "discover_files",
394 description = "Recursively scan a directory for .po/.pot files. Independent of directory mode; skips hidden and well-known build directories (.git, node_modules, target, ...)."
395 )]
396 async fn discover_files(
397 &self,
398 Parameters(params): Parameters<DiscoverFilesParams>,
399 ) -> Result<String, String> {
400 match handle_discover_files(&self.manager, params).await {
401 Ok(value) => serde_json::to_string_pretty(&value)
402 .map_err(|e| format!("serialization error: {e}")),
403 Err(e) => {
404 error!(error = %e, "discover_files failed");
405 Err(e.to_string())
406 }
407 }
408 }
409
410 #[tool(
411 name = "export_xliff",
412 description = "Export a PO file to an XLIFF 1.2 document. Skips plural entries (XLIFF 1.2 has no clean plural model) and obsolete entries. By default only untranslated/fuzzy entries are exported; set `include_translated=true` to emit every non-plural entry. The `output` path must end in .xliff, .xlf, or .xml."
413 )]
414 async fn export_xliff(
415 &self,
416 Parameters(params): Parameters<ExportXliffParams>,
417 ) -> Result<String, String> {
418 match handle_export_xliff(&self.manager, params).await {
419 Ok(value) => serde_json::to_string_pretty(&value)
420 .map_err(|e| format!("serialization error: {e}")),
421 Err(e) => {
422 error!(error = %e, "export_xliff failed");
423 Err(e.to_string())
424 }
425 }
426 }
427
428 #[tool(
429 name = "import_xliff",
430 description = "Import translations from an XLIFF 1.2 document into a PO file. Matches `<trans-unit>` entries to PO entries by msgid (and msgctxt when carried as a `gettext-msgctxt` note). Units with mismatched format specifiers are rejected; units that don't match any PO entry are reported as `unmatched`. Set `dry_run=true` to preview without writing; set `mark_fuzzy=true` to flag imported translations for review."
431 )]
432 async fn import_xliff(
433 &self,
434 Parameters(params): Parameters<ImportXliffParams>,
435 ) -> Result<String, String> {
436 match handle_import_xliff(&self.manager, params).await {
437 Ok(value) => serde_json::to_string_pretty(&value)
438 .map_err(|e| format!("serialization error: {e}")),
439 Err(e) => {
440 error!(error = %e, "import_xliff failed");
441 Err(e.to_string())
442 }
443 }
444 }
445
446 #[tool(
447 name = "get_glossary",
448 description = "Look up preferred-translation terms for a `(source_locale, target_locale)` pair. The glossary lives at $GETTEXT_GLOSSARY_PATH (default: ./glossary.json) and is independent of any PO file. Optional `filter` is a case-insensitive substring matched against either the source term or its translation; a missing file yields an empty result rather than an error."
449 )]
450 async fn get_glossary(
451 &self,
452 Parameters(params): Parameters<GetGlossaryParams>,
453 ) -> Result<String, String> {
454 match handle_get_glossary(&self.manager, params).await {
455 Ok(value) => serde_json::to_string_pretty(&value)
456 .map_err(|e| format!("serialization error: {e}")),
457 Err(e) => {
458 error!(error = %e, "get_glossary failed");
459 Err(e.to_string())
460 }
461 }
462 }
463
464 #[tool(
465 name = "update_glossary",
466 description = "Upsert and/or remove glossary terms for a `(source_locale, target_locale)` pair. `entries` is a term→translation map (existing terms are overwritten); `delete` lists terms to remove (missing terms are ignored). The file is read, patched, and written atomically through the same FileStore the PO tools use."
467 )]
468 async fn update_glossary(
469 &self,
470 Parameters(params): Parameters<UpdateGlossaryParams>,
471 ) -> Result<String, String> {
472 match handle_update_glossary(&self.manager, &self.glossary_write_lock, params).await {
473 Ok(value) => serde_json::to_string_pretty(&value)
474 .map_err(|e| format!("serialization error: {e}")),
475 Err(e) => {
476 error!(error = %e, "update_glossary failed");
477 Err(e.to_string())
478 }
479 }
480 }
481
482 #[tool(
483 name = "sync_with_pot",
484 description = "Merge a POT template into a PO file (in-house `msgmerge`). New POT entries become untranslated rows in the PO; entries dropped from the POT move to the obsolete (`#~`) block while keeping their translation for revival. Source-side metadata (msgid_plural, source locations, extracted comments) is taken from the POT; translator-side fields (msgstr, translator comments, flags) come from the PO. Entries whose `msgid_plural` changed are marked `fuzzy` by default — pass `mark_changed_as_fuzzy=false` to disable. `POT-Creation-Date` is synced from POT when present. Set `dry_run=true` to see the report without writing."
485 )]
486 async fn sync_with_pot(
487 &self,
488 Parameters(params): Parameters<SyncWithPotParams>,
489 ) -> Result<String, String> {
490 match handle_sync_with_pot(&self.manager, params).await {
491 Ok(value) => serde_json::to_string_pretty(&value)
492 .map_err(|e| format!("serialization error: {e}")),
493 Err(e) => {
494 error!(error = %e, "sync_with_pot failed");
495 Err(e.to_string())
496 }
497 }
498 }
499
500 #[tool(
501 name = "compile_mo",
502 description = "Compile a PO file to a binary `.mo` file (in-house `msgfmt`). Skips fuzzy and untranslated entries (the gettext runtime does the same). Plural entries store both forms NUL-separated per the GNU MO format. The PO header is always included so the runtime can read Language/Plural-Forms. The `output` path must use the `.mo` (or `.gmo`) extension and is written atomically through the same FileStore the PO tools use. No hash table is emitted (S=0) — readers fall back to binary search over the sorted strings table, which is permitted by the spec."
503 )]
504 async fn compile_mo(
505 &self,
506 Parameters(params): Parameters<CompileMoParams>,
507 ) -> Result<String, String> {
508 match handle_compile_mo(&self.manager, params).await {
509 Ok(value) => serde_json::to_string_pretty(&value)
510 .map_err(|e| format!("serialization error: {e}")),
511 Err(e) => {
512 error!(error = %e, "compile_mo failed");
513 Err(e.to_string())
514 }
515 }
516 }
517}
518
519#[tool_handler(router = self.tool_router)]
520#[prompt_handler(router = self.prompt_router)]
521impl ServerHandler for GettextMcpServer {
522 fn get_info(&self) -> ServerInfo {
523 ServerInfo::new(
524 ServerCapabilities::builder()
525 .enable_tools()
526 .enable_prompts()
527 .build(),
528 )
529 .with_protocol_version(ProtocolVersion::V_2025_06_18)
530 .with_instructions(
531 "MCP server for GNU gettext .po/.pot files. \
532 In dynamic mode (no path given on launch) every tool requires a `path` argument; \
533 in single-file mode `path` is optional and defaults to the file passed at startup. \
534 Additional tools: `get_coverage` (stats), `get_untranslated` and `get_stale` \
535 (paginated review queues), `validate_translations` (format/plural/empty checks), \
536 `search_keys` (paginated search), `discover_files` (scan a directory for \
537 .po/.pot files), `export_xliff`/`import_xliff` (XLIFF 1.2 interchange — \
538 plurals and obsolete entries are skipped), `get_glossary`/`update_glossary` \
539 (shared term bank at $GETTEXT_GLOSSARY_PATH, default ./glossary.json), and \
540 `sync_with_pot`/`compile_mo` (in-house `msgmerge`/`msgfmt`: merge a POT \
541 template into a PO, or compile a PO into a binary `.mo`).",
542 )
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::tools::crud::{
550 DeleteKeyParams, DeleteTranslationParams, GetTranslationParams, ListTranslationsParams,
551 UpsertTranslationParams,
552 };
553 use crate::tools::discover::ListContextsParams;
554 use crate::tools::header::{ListMetadataParams, SetHeaderParams};
555 use crate::tools::metadata::{SetCommentParams, SetFlagParams, SetFuzzyParams};
556 use serde_json::json;
557
558 async fn make_server(path: &std::path::Path) -> (GettextMcpServer, String) {
559 let manager = Arc::new(GettextStoreManager::new(Some(path.to_path_buf())));
560 let server = GettextMcpServer::new(manager);
561 (server, path.to_str().unwrap().to_string())
562 }
563
564 fn parse(s: &str) -> serde_json::Value {
565 serde_json::from_str(s).expect("server returned non-JSON")
566 }
567
568 #[tokio::test]
569 async fn list_translations() {
570 let dir = tempfile::TempDir::new().unwrap();
571 let path = dir.path().join("test.po");
572 let (server, path_str) = make_server(&path).await;
573
574 server
575 .upsert_translation(Parameters(UpsertTranslationParams {
576 path: Some(path_str.clone()),
577 msgid: "Hello".into(),
578 msgctxt: None,
579 msgstr: Some("Bonjour".into()),
580 msgid_plural: None,
581 msgstr_plural: None,
582 flags: None,
583 }))
584 .await
585 .unwrap();
586
587 let raw = server
588 .list_translations(Parameters(ListTranslationsParams {
589 path: Some(path_str),
590 query: None,
591 limit: None,
592 }))
593 .await
594 .unwrap();
595 let result = parse(&raw);
596 let arr = result.as_array().unwrap();
597 assert_eq!(arr.len(), 1);
598 assert_eq!(arr[0]["msgid"], "Hello");
599 assert_eq!(arr[0]["msgstr"], "Bonjour");
600 }
601
602 #[tokio::test]
603 async fn get_translation() {
604 let dir = tempfile::TempDir::new().unwrap();
605 let path = dir.path().join("test.po");
606 let (server, path_str) = make_server(&path).await;
607
608 server
609 .upsert_translation(Parameters(UpsertTranslationParams {
610 path: Some(path_str.clone()),
611 msgid: "World".into(),
612 msgctxt: None,
613 msgstr: Some("Monde".into()),
614 msgid_plural: None,
615 msgstr_plural: None,
616 flags: None,
617 }))
618 .await
619 .unwrap();
620
621 let raw = server
622 .get_translation(Parameters(GetTranslationParams {
623 path: Some(path_str),
624 msgid: "World".into(),
625 msgctxt: None,
626 }))
627 .await
628 .unwrap();
629 let entry = parse(&raw);
630 assert_eq!(entry["msgid"], "World");
631 assert_eq!(entry["msgstr"], "Monde");
632 }
633
634 #[tokio::test]
635 async fn upsert_translation_persists() {
636 let dir = tempfile::TempDir::new().unwrap();
637 let path = dir.path().join("test.po");
638 let (server, path_str) = make_server(&path).await;
639
640 let raw = server
641 .upsert_translation(Parameters(UpsertTranslationParams {
642 path: Some(path_str.clone()),
643 msgid: "Test".into(),
644 msgctxt: None,
645 msgstr: Some("Tester".into()),
646 msgid_plural: None,
647 msgstr_plural: None,
648 flags: None,
649 }))
650 .await
651 .unwrap();
652 let result = parse(&raw);
653 assert_eq!(result["success"], true);
654
655 let raw = server
656 .get_translation(Parameters(GetTranslationParams {
657 path: Some(path_str),
658 msgid: "Test".into(),
659 msgctxt: None,
660 }))
661 .await
662 .unwrap();
663 assert_eq!(parse(&raw)["msgstr"], "Tester");
664 }
665
666 #[tokio::test]
667 async fn set_fuzzy_toggle() {
668 let dir = tempfile::TempDir::new().unwrap();
669 let path = dir.path().join("test.po");
670 let (server, path_str) = make_server(&path).await;
671
672 server
673 .upsert_translation(Parameters(UpsertTranslationParams {
674 path: Some(path_str.clone()),
675 msgid: "Fuzzy Test".into(),
676 msgctxt: None,
677 msgstr: Some("Test Fuzzy".into()),
678 msgid_plural: None,
679 msgstr_plural: None,
680 flags: None,
681 }))
682 .await
683 .unwrap();
684
685 let raw = server
686 .set_fuzzy(Parameters(SetFuzzyParams {
687 path: Some(path_str.clone()),
688 msgid: "Fuzzy Test".into(),
689 msgctxt: None,
690 fuzzy: true,
691 }))
692 .await
693 .unwrap();
694 let result = parse(&raw);
695 assert_eq!(result["success"], true);
696 assert_eq!(result["fuzzy"], true);
697
698 let raw = server
699 .get_translation(Parameters(GetTranslationParams {
700 path: Some(path_str),
701 msgid: "Fuzzy Test".into(),
702 msgctxt: None,
703 }))
704 .await
705 .unwrap();
706 assert_eq!(parse(&raw)["is_fuzzy"], true);
707 }
708
709 #[tokio::test]
710 async fn list_contexts() {
711 let dir = tempfile::TempDir::new().unwrap();
712 let path = dir.path().join("test.po");
713 let (server, path_str) = make_server(&path).await;
714
715 let store = server.manager.store_for(None).await.unwrap();
716 store
717 .upsert("Save", Some("menu"), "Enregistrer", None)
718 .await
719 .unwrap();
720 store
721 .upsert("Save", Some("toolbar"), "Enregistrer", None)
722 .await
723 .unwrap();
724
725 let raw = server
726 .list_contexts(Parameters(ListContextsParams {
727 path: Some(path_str),
728 }))
729 .await
730 .unwrap();
731 let contexts = parse(&raw);
732 assert!(contexts.is_array());
733 let arr = contexts.as_array().unwrap();
734 assert_eq!(arr.len(), 2);
735 assert!(arr.iter().any(|c| c == "menu"));
736 assert!(arr.iter().any(|c| c == "toolbar"));
737 }
738
739 #[tokio::test]
740 async fn delete_translation() {
741 let dir = tempfile::TempDir::new().unwrap();
742 let path = dir.path().join("test.po");
743 let (server, path_str) = make_server(&path).await;
744
745 let store = server.manager.store_for(None).await.unwrap();
746 store.upsert("Hello", None, "Bonjour", None).await.unwrap();
747 store.upsert("World", None, "Monde", None).await.unwrap();
748
749 let raw = server
750 .delete_translation(Parameters(DeleteTranslationParams {
751 path: Some(path_str.clone()),
752 msgid: "Hello".into(),
753 msgctxt: None,
754 }))
755 .await
756 .unwrap();
757 let result = parse(&raw);
758 assert_eq!(result["success"], true);
759
760 let err = server
761 .get_translation(Parameters(GetTranslationParams {
762 path: Some(path_str.clone()),
763 msgid: "Hello".into(),
764 msgctxt: None,
765 }))
766 .await;
767 assert!(err.is_err());
768
769 let raw = server
770 .get_translation(Parameters(GetTranslationParams {
771 path: Some(path_str),
772 msgid: "World".into(),
773 msgctxt: None,
774 }))
775 .await
776 .unwrap();
777 assert_eq!(parse(&raw)["msgstr"], "Monde");
778 }
779
780 #[tokio::test]
781 async fn delete_translation_nonexistent_errors() {
782 let dir = tempfile::TempDir::new().unwrap();
783 let path = dir.path().join("test.po");
784 let (server, path_str) = make_server(&path).await;
785
786 let result = server
787 .delete_translation(Parameters(DeleteTranslationParams {
788 path: Some(path_str),
789 msgid: "nonexistent".into(),
790 msgctxt: None,
791 }))
792 .await;
793 assert!(result.is_err());
794 }
795
796 #[tokio::test]
797 async fn delete_key_clears_all_contexts() {
798 let dir = tempfile::TempDir::new().unwrap();
799 let path = dir.path().join("test.po");
800 let (server, path_str) = make_server(&path).await;
801
802 let store = server.manager.store_for(None).await.unwrap();
803 store
804 .upsert("Save", Some("menu"), "Enregistrer", None)
805 .await
806 .unwrap();
807 store
808 .upsert("Save", Some("toolbar"), "Sauvegarder", None)
809 .await
810 .unwrap();
811 store.upsert("Other", None, "Autre", None).await.unwrap();
812
813 let raw = server
814 .delete_key(Parameters(DeleteKeyParams {
815 path: Some(path_str.clone()),
816 msgid: "Save".into(),
817 }))
818 .await
819 .unwrap();
820 let result = parse(&raw);
821 assert_eq!(result["success"], true);
822 assert_eq!(result["deleted_count"], 2);
823
824 let err = server
825 .get_translation(Parameters(GetTranslationParams {
826 path: Some(path_str.clone()),
827 msgid: "Save".into(),
828 msgctxt: Some("menu".into()),
829 }))
830 .await;
831 assert!(err.is_err());
832
833 let raw = server
834 .get_translation(Parameters(GetTranslationParams {
835 path: Some(path_str),
836 msgid: "Other".into(),
837 msgctxt: None,
838 }))
839 .await
840 .unwrap();
841 assert_eq!(parse(&raw)["msgstr"], "Autre");
842 }
843
844 #[tokio::test]
845 async fn set_comment_then_clear() {
846 let dir = tempfile::TempDir::new().unwrap();
847 let path = dir.path().join("test.po");
848 let (server, path_str) = make_server(&path).await;
849
850 let store = server.manager.store_for(None).await.unwrap();
851 store.upsert("Hello", None, "Bonjour", None).await.unwrap();
852
853 let raw = server
854 .set_comment(Parameters(SetCommentParams {
855 path: Some(path_str.clone()),
856 msgid: "Hello".into(),
857 msgctxt: None,
858 comment: Some("A greeting message".into()),
859 }))
860 .await
861 .unwrap();
862 assert_eq!(parse(&raw)["success"], true);
863
864 let raw = server
865 .get_translation(Parameters(GetTranslationParams {
866 path: Some(path_str.clone()),
867 msgid: "Hello".into(),
868 msgctxt: None,
869 }))
870 .await
871 .unwrap();
872 let entry = parse(&raw);
873 let comments = entry["translator_comment"].as_array().unwrap();
874 assert_eq!(comments.len(), 1);
875 assert_eq!(comments[0], "A greeting message");
876
877 server
878 .set_comment(Parameters(SetCommentParams {
879 path: Some(path_str.clone()),
880 msgid: "Hello".into(),
881 msgctxt: None,
882 comment: None,
883 }))
884 .await
885 .unwrap();
886
887 let raw = server
888 .get_translation(Parameters(GetTranslationParams {
889 path: Some(path_str),
890 msgid: "Hello".into(),
891 msgctxt: None,
892 }))
893 .await
894 .unwrap();
895 let entry = parse(&raw);
896 assert!(entry["translator_comment"].as_array().unwrap().is_empty());
897 }
898
899 #[tokio::test]
900 async fn set_comment_multiline() {
901 let dir = tempfile::TempDir::new().unwrap();
902 let path = dir.path().join("test.po");
903 let (server, path_str) = make_server(&path).await;
904
905 let store = server.manager.store_for(None).await.unwrap();
906 store.upsert("Hello", None, "Bonjour", None).await.unwrap();
907
908 server
909 .set_comment(Parameters(SetCommentParams {
910 path: Some(path_str.clone()),
911 msgid: "Hello".into(),
912 msgctxt: None,
913 comment: Some("Line 1\nLine 2\nLine 3".into()),
914 }))
915 .await
916 .unwrap();
917
918 let raw = server
919 .get_translation(Parameters(GetTranslationParams {
920 path: Some(path_str),
921 msgid: "Hello".into(),
922 msgctxt: None,
923 }))
924 .await
925 .unwrap();
926 let entry = parse(&raw);
927 let comments = entry["translator_comment"].as_array().unwrap();
928 assert_eq!(comments.len(), 3);
929 assert_eq!(comments[2], "Line 3");
930 }
931
932 #[tokio::test]
933 async fn set_comment_preserves_flags_and_msgstr() {
934 let dir = tempfile::TempDir::new().unwrap();
935 let path = dir.path().join("test.po");
936 let (server, path_str) = make_server(&path).await;
937
938 let store = server.manager.store_for(None).await.unwrap();
939 store
940 .upsert("Hello", None, "Bonjour", Some(vec!["c-format".into()]))
941 .await
942 .unwrap();
943
944 server
945 .set_comment(Parameters(SetCommentParams {
946 path: Some(path_str.clone()),
947 msgid: "Hello".into(),
948 msgctxt: None,
949 comment: Some("New comment".into()),
950 }))
951 .await
952 .unwrap();
953
954 let raw = server
955 .get_translation(Parameters(GetTranslationParams {
956 path: Some(path_str),
957 msgid: "Hello".into(),
958 msgctxt: None,
959 }))
960 .await
961 .unwrap();
962 let entry = parse(&raw);
963 assert_eq!(entry["msgstr"], "Bonjour");
964 assert!(entry["flags"]
965 .as_array()
966 .unwrap()
967 .contains(&json!("c-format")));
968 }
969
970 #[tokio::test]
971 async fn set_flag_add_remove() {
972 let dir = tempfile::TempDir::new().unwrap();
973 let path = dir.path().join("test.po");
974 let (server, path_str) = make_server(&path).await;
975
976 let store = server.manager.store_for(None).await.unwrap();
977 store
978 .upsert("Hello %s", None, "Bonjour %s", None)
979 .await
980 .unwrap();
981
982 let raw = server
983 .set_flag(Parameters(SetFlagParams {
984 path: Some(path_str.clone()),
985 msgid: "Hello %s".into(),
986 msgctxt: None,
987 flag: "c-format".into(),
988 enabled: true,
989 }))
990 .await
991 .unwrap();
992 let result = parse(&raw);
993 assert_eq!(result["success"], true);
994 assert_eq!(result["enabled"], true);
995
996 let raw = server
997 .get_translation(Parameters(GetTranslationParams {
998 path: Some(path_str.clone()),
999 msgid: "Hello %s".into(),
1000 msgctxt: None,
1001 }))
1002 .await
1003 .unwrap();
1004 assert!(parse(&raw)["flags"]
1005 .as_array()
1006 .unwrap()
1007 .contains(&json!("c-format")));
1008
1009 server
1010 .set_flag(Parameters(SetFlagParams {
1011 path: Some(path_str.clone()),
1012 msgid: "Hello %s".into(),
1013 msgctxt: None,
1014 flag: "c-format".into(),
1015 enabled: false,
1016 }))
1017 .await
1018 .unwrap();
1019
1020 let raw = server
1021 .get_translation(Parameters(GetTranslationParams {
1022 path: Some(path_str),
1023 msgid: "Hello %s".into(),
1024 msgctxt: None,
1025 }))
1026 .await
1027 .unwrap();
1028 assert!(!parse(&raw)["flags"]
1029 .as_array()
1030 .unwrap()
1031 .contains(&json!("c-format")));
1032 }
1033
1034 #[tokio::test]
1035 async fn set_flag_idempotent_add() {
1036 let dir = tempfile::TempDir::new().unwrap();
1037 let path = dir.path().join("test.po");
1038 let (server, path_str) = make_server(&path).await;
1039
1040 let store = server.manager.store_for(None).await.unwrap();
1041 store.upsert("Test", None, "Test", None).await.unwrap();
1042
1043 for _ in 0..2 {
1044 server
1045 .set_flag(Parameters(SetFlagParams {
1046 path: Some(path_str.clone()),
1047 msgid: "Test".into(),
1048 msgctxt: None,
1049 flag: "python-format".into(),
1050 enabled: true,
1051 }))
1052 .await
1053 .unwrap();
1054 }
1055
1056 let raw = server
1057 .get_translation(Parameters(GetTranslationParams {
1058 path: Some(path_str),
1059 msgid: "Test".into(),
1060 msgctxt: None,
1061 }))
1062 .await
1063 .unwrap();
1064 let entry = parse(&raw);
1065 let count = entry["flags"]
1066 .as_array()
1067 .unwrap()
1068 .iter()
1069 .filter(|f| f.as_str() == Some("python-format"))
1070 .count();
1071 assert_eq!(count, 1);
1072 }
1073
1074 #[tokio::test]
1075 async fn set_flag_rejects_invalid() {
1076 let dir = tempfile::TempDir::new().unwrap();
1077 let path = dir.path().join("test.po");
1078 let (server, path_str) = make_server(&path).await;
1079
1080 let store = server.manager.store_for(None).await.unwrap();
1081 store.upsert("Test", None, "Test", None).await.unwrap();
1082
1083 let result = server
1084 .set_flag(Parameters(SetFlagParams {
1085 path: Some(path_str.clone()),
1086 msgid: "Test".into(),
1087 msgctxt: None,
1088 flag: "".into(),
1089 enabled: true,
1090 }))
1091 .await;
1092 assert!(result.is_err());
1093
1094 let result = server
1095 .set_flag(Parameters(SetFlagParams {
1096 path: Some(path_str),
1097 msgid: "Test".into(),
1098 msgctxt: None,
1099 flag: "invalid flag".into(),
1100 enabled: true,
1101 }))
1102 .await;
1103 assert!(result.is_err());
1104 }
1105
1106 #[tokio::test]
1107 async fn list_metadata() {
1108 let dir = tempfile::TempDir::new().unwrap();
1109 let path = dir.path().join("test.po");
1110 let (server, path_str) = make_server(&path).await;
1111
1112 let store = server.manager.store_for(None).await.unwrap();
1113 store.set_header("Language", "fr").await.unwrap();
1114 store
1115 .set_header("Content-Type", "text/plain; charset=UTF-8")
1116 .await
1117 .unwrap();
1118
1119 let raw = server
1120 .list_metadata(Parameters(ListMetadataParams {
1121 path: Some(path_str),
1122 }))
1123 .await
1124 .unwrap();
1125 let result = parse(&raw);
1126 assert_eq!(result["language"], "fr");
1127 let metadata = result["metadata"].as_object().unwrap();
1128 assert_eq!(metadata["Language"], "fr");
1129 assert_eq!(metadata["Content-Type"], "text/plain; charset=UTF-8");
1130 }
1131
1132 #[tokio::test]
1133 async fn set_header_set_then_remove() {
1134 let dir = tempfile::TempDir::new().unwrap();
1135 let path = dir.path().join("test.po");
1136 let (server, path_str) = make_server(&path).await;
1137
1138 server
1139 .set_header(Parameters(SetHeaderParams {
1140 path: Some(path_str.clone()),
1141 key: "Language".into(),
1142 value: Some("de".into()),
1143 }))
1144 .await
1145 .unwrap();
1146
1147 let raw = server
1148 .list_metadata(Parameters(ListMetadataParams {
1149 path: Some(path_str.clone()),
1150 }))
1151 .await
1152 .unwrap();
1153 assert_eq!(parse(&raw)["language"], "de");
1154
1155 server
1156 .set_header(Parameters(SetHeaderParams {
1157 path: Some(path_str.clone()),
1158 key: "Language".into(),
1159 value: None,
1160 }))
1161 .await
1162 .unwrap();
1163
1164 let raw = server
1165 .list_metadata(Parameters(ListMetadataParams {
1166 path: Some(path_str),
1167 }))
1168 .await
1169 .unwrap();
1170 assert!(parse(&raw)["language"].is_null());
1171 }
1172
1173 #[tokio::test]
1174 async fn upsert_with_plurals() {
1175 let dir = tempfile::TempDir::new().unwrap();
1176 let path = dir.path().join("test.po");
1177 let (server, path_str) = make_server(&path).await;
1178
1179 server
1180 .upsert_translation(Parameters(UpsertTranslationParams {
1181 path: Some(path_str.clone()),
1182 msgid: "%d file".into(),
1183 msgctxt: None,
1184 msgstr: Some("".into()),
1185 msgid_plural: Some("%d files".into()),
1186 msgstr_plural: Some(vec!["%d fichier".into(), "%d fichiers".into()]),
1187 flags: Some(vec!["c-format".into()]),
1188 }))
1189 .await
1190 .unwrap();
1191
1192 let raw = server
1193 .get_translation(Parameters(GetTranslationParams {
1194 path: Some(path_str),
1195 msgid: "%d file".into(),
1196 msgctxt: None,
1197 }))
1198 .await
1199 .unwrap();
1200 let entry = parse(&raw);
1201 assert_eq!(entry["msgid_plural"], "%d files");
1202 let plurals = entry["msgstr_plural"].as_array().unwrap();
1203 assert_eq!(plurals.len(), 2);
1204 assert_eq!(plurals[0], "%d fichier");
1205 assert_eq!(plurals[1], "%d fichiers");
1206 assert!(entry["flags"]
1207 .as_array()
1208 .unwrap()
1209 .contains(&json!("c-format")));
1210 }
1211
1212 #[tokio::test]
1213 async fn list_translations_with_query() {
1214 let dir = tempfile::TempDir::new().unwrap();
1215 let path = dir.path().join("test.po");
1216 let (server, path_str) = make_server(&path).await;
1217
1218 let store = server.manager.store_for(None).await.unwrap();
1219 store.upsert("Hello", None, "Bonjour", None).await.unwrap();
1220 store
1221 .upsert("Goodbye", None, "Au revoir", None)
1222 .await
1223 .unwrap();
1224 store.upsert("World", None, "Monde", None).await.unwrap();
1225
1226 let raw = server
1227 .list_translations(Parameters(ListTranslationsParams {
1228 path: Some(path_str.clone()),
1229 query: Some("hello".into()),
1230 limit: None,
1231 }))
1232 .await
1233 .unwrap();
1234 let arr = parse(&raw);
1235 let arr = arr.as_array().unwrap();
1236 assert_eq!(arr.len(), 1);
1237 assert_eq!(arr[0]["msgid"], "Hello");
1238
1239 let raw = server
1240 .list_translations(Parameters(ListTranslationsParams {
1241 path: Some(path_str),
1242 query: Some("monde".into()),
1243 limit: None,
1244 }))
1245 .await
1246 .unwrap();
1247 let arr = parse(&raw);
1248 let arr = arr.as_array().unwrap();
1249 assert_eq!(arr.len(), 1);
1250 assert_eq!(arr[0]["msgid"], "World");
1251 }
1252
1253 #[tokio::test]
1254 async fn list_translations_with_limit() {
1255 let dir = tempfile::TempDir::new().unwrap();
1256 let path = dir.path().join("test.po");
1257 let (server, path_str) = make_server(&path).await;
1258
1259 let store = server.manager.store_for(None).await.unwrap();
1260 store.upsert("A", None, "a", None).await.unwrap();
1261 store.upsert("B", None, "b", None).await.unwrap();
1262 store.upsert("C", None, "c", None).await.unwrap();
1263
1264 let raw = server
1265 .list_translations(Parameters(ListTranslationsParams {
1266 path: Some(path_str),
1267 query: None,
1268 limit: Some(2),
1269 }))
1270 .await
1271 .unwrap();
1272 let arr = parse(&raw);
1273 assert_eq!(arr.as_array().unwrap().len(), 2);
1274 }
1275
1276 #[tokio::test]
1277 async fn set_fuzzy_clear_makes_translated() {
1278 let dir = tempfile::TempDir::new().unwrap();
1279 let path = dir.path().join("test.po");
1280 let (server, path_str) = make_server(&path).await;
1281
1282 let store = server.manager.store_for(None).await.unwrap();
1283 store
1284 .upsert("Hello", None, "Bonjour", Some(vec!["fuzzy".into()]))
1285 .await
1286 .unwrap();
1287
1288 let raw = server
1289 .get_translation(Parameters(GetTranslationParams {
1290 path: Some(path_str.clone()),
1291 msgid: "Hello".into(),
1292 msgctxt: None,
1293 }))
1294 .await
1295 .unwrap();
1296 let entry = parse(&raw);
1297 assert_eq!(entry["is_fuzzy"], true);
1298 assert_eq!(entry["is_translated"], false);
1299
1300 server
1301 .set_fuzzy(Parameters(SetFuzzyParams {
1302 path: Some(path_str.clone()),
1303 msgid: "Hello".into(),
1304 msgctxt: None,
1305 fuzzy: false,
1306 }))
1307 .await
1308 .unwrap();
1309
1310 let raw = server
1311 .get_translation(Parameters(GetTranslationParams {
1312 path: Some(path_str),
1313 msgid: "Hello".into(),
1314 msgctxt: None,
1315 }))
1316 .await
1317 .unwrap();
1318 let entry = parse(&raw);
1319 assert_eq!(entry["is_fuzzy"], false);
1320 assert_eq!(entry["is_translated"], true);
1321 }
1322
1323 #[tokio::test]
1324 async fn get_translation_nonexistent_errors() {
1325 let dir = tempfile::TempDir::new().unwrap();
1326 let path = dir.path().join("test.po");
1327 let (server, path_str) = make_server(&path).await;
1328
1329 let result = server
1330 .get_translation(Parameters(GetTranslationParams {
1331 path: Some(path_str),
1332 msgid: "nonexistent".into(),
1333 msgctxt: None,
1334 }))
1335 .await;
1336 assert!(result.is_err());
1337 }
1338
1339 #[tokio::test]
1340 async fn upsert_with_context() {
1341 let dir = tempfile::TempDir::new().unwrap();
1342 let path = dir.path().join("test.po");
1343 let (server, path_str) = make_server(&path).await;
1344
1345 server
1346 .upsert_translation(Parameters(UpsertTranslationParams {
1347 path: Some(path_str.clone()),
1348 msgid: "Open".into(),
1349 msgctxt: Some("menu".into()),
1350 msgstr: Some("Ouvrir".into()),
1351 msgid_plural: None,
1352 msgstr_plural: None,
1353 flags: None,
1354 }))
1355 .await
1356 .unwrap();
1357 server
1358 .upsert_translation(Parameters(UpsertTranslationParams {
1359 path: Some(path_str.clone()),
1360 msgid: "Open".into(),
1361 msgctxt: Some("button".into()),
1362 msgstr: Some("Ouvrir".into()),
1363 msgid_plural: None,
1364 msgstr_plural: None,
1365 flags: None,
1366 }))
1367 .await
1368 .unwrap();
1369
1370 let raw = server
1371 .get_translation(Parameters(GetTranslationParams {
1372 path: Some(path_str.clone()),
1373 msgid: "Open".into(),
1374 msgctxt: Some("menu".into()),
1375 }))
1376 .await
1377 .unwrap();
1378 let menu = parse(&raw);
1379 assert_eq!(menu["msgstr"], "Ouvrir");
1380 assert_eq!(menu["msgctxt"], "menu");
1381
1382 let raw = server
1383 .list_translations(Parameters(ListTranslationsParams {
1384 path: Some(path_str),
1385 query: Some("Open".into()),
1386 limit: None,
1387 }))
1388 .await
1389 .unwrap();
1390 let arr = parse(&raw);
1391 assert_eq!(arr.as_array().unwrap().len(), 2);
1392 }
1393}