Skip to main content

gettext_mcp/
server.rs

1//! Gettext MCP server struct and tool routing.
2//!
3//! Tool implementations are `#[tool]` methods that delegate to the
4//! `handle_*` functions in [`crate::tools`]. The `#[tool_router]` /
5//! `#[tool_handler]` macros from rmcp 1.7 generate the dispatch table
6//! and the `ServerHandler::list_tools` / `call_tool` glue.
7
8use 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/// MCP server for GNU gettext `.po`/`.pot` files. Holds the shared store
54/// manager and the macro-generated tool/prompt routers.
55#[derive(Clone)]
56pub struct GettextMcpServer {
57    manager: Arc<GettextStoreManager>,
58    /// Serializes glossary read-modify-write cycles so concurrent MCP
59    /// clients can't lose each other's updates. The PO store has its own
60    /// per-file locking; the glossary file does not, so we centralize it
61    /// here on the server.
62    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    /// Read-only access to the underlying store manager, used by tests
78    /// and the web layer to share the same cache.
79    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}