1use rmcp::{
10 handler::server::wrapper::Parameters,
11 model::{GetPromptResult, PromptMessage, PromptMessageRole},
12 prompt, prompt_router,
13};
14use schemars::JsonSchema;
15use serde::Deserialize;
16
17use crate::server::GettextMcpServer;
18
19#[derive(Debug, Deserialize, JsonSchema)]
20pub(crate) struct TranslateBatchParams {
21 locale: String,
23 path: Option<String>,
25 count: Option<u32>,
27}
28
29#[derive(Debug, Deserialize, JsonSchema)]
30pub(crate) struct ReviewTranslationsParams {
31 path: Option<String>,
33 focus: Option<String>,
35}
36
37#[derive(Debug, Deserialize, JsonSchema)]
38pub(crate) struct FullTranslateParams {
39 path: String,
41 target_locale: String,
43}
44
45#[derive(Debug, Deserialize, JsonSchema)]
46pub(crate) struct LocalizationAuditParams {
47 path: Option<String>,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
52pub(crate) struct CleanupStaleParams {
53 path: Option<String>,
55 dry_run: Option<bool>,
58}
59
60fn path_preamble(path: Option<&String>) -> String {
63 match path {
64 Some(p) => format!("Working file: {p}\nPass `path=\"{p}\"` to every tool call below.\n\n"),
65 None => "Working file: use the default single-file mode (omit `path`) \
66 unless the server is in dynamic mode, in which case ask the user \
67 for a path before continuing.\n\n"
68 .to_string(),
69 }
70}
71
72#[prompt_router(vis = "pub(crate)")]
73impl GettextMcpServer {
74 #[prompt(
76 name = "translate_batch",
77 description = "Translate a batch of untranslated entries in a .po file, \
78 preserving format specifiers and plural forms."
79 )]
80 fn translate_batch(
81 &self,
82 Parameters(params): Parameters<TranslateBatchParams>,
83 ) -> Result<GetPromptResult, rmcp::ErrorData> {
84 let count = params.count.unwrap_or(30);
85 let preamble = path_preamble(params.path.as_ref());
86 let locale = ¶ms.locale;
87
88 let content = format!(
89 "{preamble}\
90 You are translating GNU gettext entries into {locale}.\n\
91 \n\
92 Step 1: Inspect the header\n\
93 \x20 - Call `list_metadata` to read the `Language` and `Plural-Forms`\n\
94 \x20 headers. The plural-forms expression tells you how many\n\
95 \x20 `msgstr_plural` entries you need (look for `nplurals=N;`).\n\
96 \x20 - If `Language` is missing or wrong for {locale}, fix it with\n\
97 \x20 `set_header(key=\"Language\", value=\"<code>\")`.\n\
98 \n\
99 Step 2: Pull a batch\n\
100 \x20 - Call `get_untranslated(batch_size={count}, offset=0,\n\
101 \x20 include_fuzzy=true)` to get the next chunk. Each entry\n\
102 \x20 includes `msgid`, `msgctxt`, `msgid_plural`, current `msgstr`,\n\
103 \x20 `flags`, `needs_plural_forms`, and a `has_more` cursor.\n\
104 \n\
105 Step 3: Translate each entry\n\
106 \x20 - Translate `msgid` naturally into {locale} \u{2014} idiomatic, not\n\
107 \x20 word-for-word.\n\
108 \x20 - Preserve every format specifier exactly: `%s`, `%d`, `%lld`,\n\
109 \x20 `%1$s`, `{{name}}`, `${{0}}`, etc. Do not reorder positional\n\
110 \x20 specifiers unless the target grammar requires it (then use\n\
111 \x20 `%1$s`-style positional forms).\n\
112 \x20 - Respect `msgctxt` \u{2014} the same `msgid` can mean different\n\
113 \x20 things in different contexts.\n\
114 \x20 - Keep escapes (`\\n`, `\\t`, `\\\"`) intact.\n\
115 \n\
116 Step 4: Handle plural forms\n\
117 \x20 - When `msgid_plural` is set, supply a `msgstr_plural` array\n\
118 \x20 with exactly `needs_plural_forms` entries, indexed by the\n\
119 \x20 plural-forms expression from Step 1.\n\
120 \x20 - Leave the singular `msgstr` empty (\"\") for pluralized\n\
121 \x20 entries \u{2014} only `msgstr_plural` matters.\n\
122 \n\
123 Step 5: Submit\n\
124 \x20 - Call `upsert_translation(msgid, msgctxt, msgstr,\n\
125 \x20 msgid_plural, msgstr_plural, flags)` for each entry. Carry\n\
126 \x20 over the existing `flags` array unchanged.\n\
127 \x20 - If the entry was previously fuzzy, call\n\
128 \x20 `set_fuzzy(msgid, msgctxt, fuzzy=false)` after upserting so\n\
129 \x20 it counts as translated.\n\
130 \n\
131 Step 6: Loop\n\
132 \x20 - Repeat Step 2 with an advancing `offset` until\n\
133 \x20 `has_more` is false. Then call `get_coverage` to confirm\n\
134 \x20 the new translated percentage for {locale}.\n",
135 );
136
137 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
138 PromptMessageRole::User,
139 content,
140 )])
141 .with_description(format!(
142 "Translate a batch of {count} entries into {locale}"
143 )))
144 }
145
146 #[prompt(
148 name = "review_translations",
149 description = "Review existing translations for format-specifier errors, missing plural \
150 forms, and prose quality."
151 )]
152 fn review_translations(
153 &self,
154 Parameters(params): Parameters<ReviewTranslationsParams>,
155 ) -> Result<GetPromptResult, rmcp::ErrorData> {
156 let focus = params.focus.as_deref().unwrap_or("fuzzy");
157 let preamble = path_preamble(params.path.as_ref());
158
159 let focus_instructions = match focus {
160 "all" => {
161 " - Iterate over every translated entry via repeated\n\
162 \x20 `list_translations(limit=50)` calls.\n"
163 }
164 "untranslated" => {
165 " - Use `get_untranslated` to enumerate the\n\
166 \x20 remaining work and triage which strings\n\
167 \x20 are highest-priority (UI surfaces vs.\n\
168 \x20 debug copy).\n"
169 }
170 _ => {
171 " - Use `list_translations(query=\"fuzzy\")` or scan the\n\
172 \x20 output of `validate_translations` for entries flagged\n\
173 \x20 fuzzy and re-check each one against its source.\n"
174 }
175 };
176
177 let content = format!(
178 "{preamble}\
179 You are reviewing translations in a .po file. Focus area: {focus}.\n\
180 \n\
181 Step 1: Surface technical issues\n\
182 \x20 - Call `validate_translations(severity_filter=null)` to get\n\
183 \x20 format-specifier mismatches, missing plural forms, and\n\
184 \x20 other structural errors.\n\
185 \x20 - Sort findings by severity: errors first, then warnings.\n\
186 \n\
187 Step 2: Check overall coverage\n\
188 \x20 - Call `get_coverage` to see translated / fuzzy /\n\
189 \x20 untranslated counts. A high fuzzy ratio is a red flag.\n\
190 \n\
191 Step 3: Drill into the focus area\n\
192 {focus_instructions}\
193 \n\
194 Step 4: Pull entries for human-readable review\n\
195 \x20 - Use `list_translations(query=...)` or\n\
196 \x20 `search_keys(pattern=..., match_in=\"both\")` to fetch the\n\
197 \x20 specific msgids implicated by each finding.\n\
198 \x20 - Call `get_translation(msgid, msgctxt)` for the full entry\n\
199 \x20 when you need comments or source locations.\n\
200 \n\
201 Step 5: Classify each issue\n\
202 \x20 - ERROR: format-specifier mismatch, missing required plural\n\
203 \x20 form, untranslated placeholder. Fix immediately.\n\
204 \x20 - WARNING: stylistic mismatch, inconsistent terminology,\n\
205 \x20 suspected machine translation. Flag for human review.\n\
206 \n\
207 Step 6: Fix and clear fuzzy\n\
208 \x20 - Apply fixes via `upsert_translation` (preserve existing\n\
209 \x20 `flags` you don't want to drop).\n\
210 \x20 - After a fix, call `set_fuzzy(fuzzy=false)` so the entry\n\
211 \x20 counts as translated again.\n\
212 \x20 - Re-run `validate_translations` at the end to confirm a\n\
213 \x20 clean bill of health.\n",
214 );
215
216 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
217 PromptMessageRole::User,
218 content,
219 )])
220 .with_description(format!("Review translations (focus: {focus})")))
221 }
222
223 #[prompt(
225 name = "full_translate",
226 description = "Translate a .po file end-to-end into a target locale, from header setup \
227 through validation and final coverage check."
228 )]
229 fn full_translate(
230 &self,
231 Parameters(params): Parameters<FullTranslateParams>,
232 ) -> Result<GetPromptResult, rmcp::ErrorData> {
233 let path = ¶ms.path;
234 let locale = ¶ms.target_locale;
235
236 let content = format!(
237 "Full translation workflow: {path} \u{2192} {locale}.\n\
238 Pass `path=\"{path}\"` to every tool call below.\n\
239 \n\
240 Step 1: Snapshot the starting state\n\
241 \x20 - Call `get_coverage` and record the current\n\
242 \x20 translated/fuzzy/untranslated counts. This is your\n\
243 \x20 baseline.\n\
244 \n\
245 Step 2: Verify and fix the header\n\
246 \x20 - Call `list_metadata` and confirm:\n\
247 \x20 - `Language` matches {locale}.\n\
248 \x20 - `Plural-Forms` has a correct `nplurals=N; plural=...`\n\
249 \x20 expression for {locale}.\n\
250 \x20 - `Content-Type` is `text/plain; charset=UTF-8`.\n\
251 \x20 - Fix anything wrong with\n\
252 \x20 `set_header(key=..., value=...)`.\n\
253 \n\
254 Step 3: Translate in batches until empty\n\
255 \x20 - Loop:\n\
256 \x20 a. `get_untranslated(batch_size=30, offset=0,\n\
257 \x20 include_fuzzy=true)`\n\
258 \x20 b. Translate each entry into {locale} \u{2014} preserve\n\
259 \x20 every `%s`, `%d`, `%lld`, `{{name}}`, `\\n`, etc.\n\
260 \x20 c. For each entry call `upsert_translation`. For\n\
261 \x20 entries with `msgid_plural`, supply a\n\
262 \x20 `msgstr_plural` array of length\n\
263 \x20 `needs_plural_forms`.\n\
264 \x20 d. For any entry that came back fuzzy, call\n\
265 \x20 `set_fuzzy(fuzzy=false)` after upserting.\n\
266 \x20 e. Stop when `has_more` is false.\n\
267 \n\
268 Step 4: Validate\n\
269 \x20 - Call `validate_translations` and fix every reported\n\
270 \x20 error (format-specifier mismatch, missing plural forms,\n\
271 \x20 etc.) via `upsert_translation`.\n\
272 \x20 - Re-run `validate_translations` until it returns zero\n\
273 \x20 issues.\n\
274 \n\
275 Step 5: Final coverage check\n\
276 \x20 - Call `get_coverage` and confirm 100% translated, 0\n\
277 \x20 fuzzy.\n\
278 \x20 - If there are stragglers, loop back to Step 3.\n\
279 \n\
280 Step 6: Report\n\
281 \x20 - Summarize: starting coverage, ending coverage, number\n\
282 \x20 of entries translated, number of validation issues\n\
283 \x20 fixed, and any entries you intentionally left fuzzy\n\
284 \x20 for human review.\n",
285 );
286
287 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
288 PromptMessageRole::User,
289 content,
290 )])
291 .with_description(format!("Full translation of {path} into {locale}")))
292 }
293
294 #[prompt(
296 name = "localization_audit",
297 description = "Produce a comprehensive localization audit: coverage, validation, stale \
298 entries, context disambiguation, and prose-quality spot checks."
299 )]
300 fn localization_audit(
301 &self,
302 Parameters(params): Parameters<LocalizationAuditParams>,
303 ) -> Result<GetPromptResult, rmcp::ErrorData> {
304 let preamble = path_preamble(params.path.as_ref());
305
306 let content = format!(
307 "{preamble}\
308 Produce a localization audit for the .po file. The output is a\n\
309 written report with the sections listed in Step 6.\n\
310 \n\
311 Step 1: Coverage numbers\n\
312 \x20 - Call `get_coverage` and record translated, fuzzy,\n\
313 \x20 untranslated, and total counts. Compute the\n\
314 \x20 `translated / total` percentage.\n\
315 \n\
316 Step 2: Technical validation\n\
317 \x20 - Call `validate_translations(severity_filter=null)` and\n\
318 \x20 group findings by category:\n\
319 \x20 - Format-specifier mismatches (errors).\n\
320 \x20 - Missing or extra plural forms (errors).\n\
321 \x20 - Other warnings.\n\
322 \n\
323 Step 3: Stale / obsolete entries\n\
324 \x20 - Call `get_stale(batch_size=100, offset=0)` to enumerate\n\
325 \x20 obsolete (`#~`-prefixed) entries. Decide which can be\n\
326 \x20 removed and which should be revived because the source\n\
327 \x20 string came back.\n\
328 \n\
329 Step 4: Context disambiguation\n\
330 \x20 - Call `list_contexts` to see every `msgctxt` value in\n\
331 \x20 use.\n\
332 \x20 - Sample-list translations with\n\
333 \x20 `list_translations(limit=200)` and look for msgids that\n\
334 \x20 appear multiple times without `msgctxt`. These may\n\
335 \x20 collide and need disambiguation.\n\
336 \n\
337 Step 5: Prose-quality spot check\n\
338 \x20 - Pick 5-10 entries spanning short labels and long\n\
339 \x20 sentences. Use `list_translations(limit=...)` plus\n\
340 \x20 `get_translation` to pull them.\n\
341 \x20 - Evaluate: naturalness, terminology consistency, tone,\n\
342 \x20 correct handling of placeholders.\n\
343 \n\
344 Step 6: Write the report\n\
345 \x20 Produce a markdown report with these sections:\n\
346 \x20 1. Coverage summary (numbers from Step 1).\n\
347 \x20 2. Validation issues by severity (from Step 2).\n\
348 \x20 3. Stale entries and recommended action (from Step 3).\n\
349 \x20 4. Context-collision risks (from Step 4).\n\
350 \x20 5. Prose quality notes with cited msgids (from Step 5).\n\
351 \x20 6. Top 3 recommended follow-ups, ordered by impact.\n",
352 );
353
354 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
355 PromptMessageRole::User,
356 content,
357 )])
358 .with_description("Comprehensive localization audit"))
359 }
360
361 #[prompt(
363 name = "cleanup_stale",
364 description = "Identify and (optionally) delete obsolete entries from a .po file. Default \
365 is dry-run \u{2014} no deletions until you flip dry_run=false."
366 )]
367 fn cleanup_stale(
368 &self,
369 Parameters(params): Parameters<CleanupStaleParams>,
370 ) -> Result<GetPromptResult, rmcp::ErrorData> {
371 let dry_run = params.dry_run.unwrap_or(true);
372 let preamble = path_preamble(params.path.as_ref());
373
374 let mode_block = if dry_run {
375 "Mode: DRY-RUN. Do NOT call `delete_key` or `delete_translation`\n\
376 in this run. Only list what would be removed.\n"
377 } else {
378 "Mode: LIVE. After confirming each entry is safe to delete you\n\
379 may call `delete_key` to remove it.\n"
380 };
381
382 let action_block = if dry_run {
383 "Step 4: Report (dry-run)\n\
384 \x20 - Produce a list of msgids that WOULD be deleted, grouped\n\
385 \x20 by reason (e.g. \"source string removed in v2.1\",\n\
386 \x20 \"never translated\"). Do NOT call any delete tools.\n\
387 \x20 - Recommend whether to re-run with `dry_run=false`.\n"
388 } else {
389 "Step 4: Delete confirmed-stale entries\n\
390 \x20 - For each entry confirmed in Step 3, call\n\
391 \x20 `delete_key(msgid=...)` to remove every context for\n\
392 \x20 that msgid. (Use `delete_translation` instead if only\n\
393 \x20 one specific `(msgid, msgctxt)` should go.)\n\
394 \x20 - After deletion, call `get_coverage` to verify the new\n\
395 \x20 totals and `get_stale` to confirm the queue is empty.\n"
396 };
397
398 let content = format!(
399 "{preamble}\
400 Clean up obsolete entries in a .po file.\n\
401 {mode_block}\n\
402 Step 1: Enumerate stale entries\n\
403 \x20 - Call `get_stale(batch_size=100, offset=0)` and page\n\
404 \x20 through until `has_more` is false. These are entries\n\
405 \x20 that gettext has marked obsolete (`#~` prefix) because\n\
406 \x20 the source string was removed or changed.\n\
407 \n\
408 Step 2: Pull context on each candidate\n\
409 \x20 - For non-obvious cases, call `get_translation(msgid,\n\
410 \x20 msgctxt)` to see the full entry, including source\n\
411 \x20 locations (`#:` comments) and translator comments.\n\
412 \n\
413 Step 3: Decide per entry\n\
414 \x20 - DELETE: the source string is genuinely gone (feature\n\
415 \x20 removed, rewording finalized, never translated).\n\
416 \x20 - REVIVE: the obsolete msgstr is still useful because\n\
417 \x20 the source string came back \u{2014} re-add it via\n\
418 \x20 `upsert_translation` so it is no longer obsolete.\n\
419 \x20 - KEEP-OBSOLETE: leave as-is (rare; only when you want\n\
420 \x20 to preserve historical translator notes).\n\
421 \n\
422 {action_block}",
423 );
424
425 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
426 PromptMessageRole::User,
427 content,
428 )])
429 .with_description(format!(
430 "Clean up stale entries ({})",
431 if dry_run { "dry-run" } else { "live" }
432 )))
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::service::GettextStoreManager;
440 use rmcp::model::PromptMessageContent;
441 use std::sync::Arc;
442
443 fn make_server() -> GettextMcpServer {
444 let manager = Arc::new(GettextStoreManager::new(None));
445 GettextMcpServer::new(manager)
446 }
447
448 fn text_of(result: &GetPromptResult) -> &str {
449 let PromptMessageContent::Text { ref text } = result.messages[0].content else {
450 panic!("expected text content");
451 };
452 text
453 }
454
455 #[test]
456 fn translate_batch_mentions_key_tools_and_locale() {
457 let server = make_server();
458 let result = server
459 .translate_batch(Parameters(TranslateBatchParams {
460 locale: "French".into(),
461 path: Some("/tmp/fr.po".into()),
462 count: Some(15),
463 }))
464 .unwrap();
465 let text = text_of(&result);
466
467 assert!(text.contains("French"));
468 assert!(text.contains("/tmp/fr.po"));
469 assert!(text.contains("15"));
470 assert!(text.contains("get_untranslated"));
471 assert!(text.contains("list_metadata"));
472 assert!(text.contains("upsert_translation"));
473 assert!(text.contains("set_fuzzy"));
474 assert!(text.contains("Plural-Forms"));
475 assert!(text.contains("msgstr_plural"));
476 }
477
478 #[test]
479 fn translate_batch_default_count_is_thirty() {
480 let server = make_server();
481 let result = server
482 .translate_batch(Parameters(TranslateBatchParams {
483 locale: "de".into(),
484 path: None,
485 count: None,
486 }))
487 .unwrap();
488 let text = text_of(&result);
489 assert!(text.contains("batch_size=30"));
490 assert!(text.contains("single-file"));
492 }
493
494 #[test]
495 fn review_translations_default_focus_is_fuzzy() {
496 let server = make_server();
497 let result = server
498 .review_translations(Parameters(ReviewTranslationsParams {
499 path: Some("/work/messages.po".into()),
500 focus: None,
501 }))
502 .unwrap();
503 let text = text_of(&result);
504
505 assert!(text.contains("fuzzy"));
506 assert!(text.contains("validate_translations"));
507 assert!(text.contains("get_coverage"));
508 assert!(text.contains("search_keys"));
509 assert!(text.contains("upsert_translation"));
510 assert!(text.contains("set_fuzzy"));
511 }
512
513 #[test]
514 fn review_translations_alternative_focus_branches() {
515 let server = make_server();
516 let all = server
517 .review_translations(Parameters(ReviewTranslationsParams {
518 path: None,
519 focus: Some("all".into()),
520 }))
521 .unwrap();
522 assert!(text_of(&all).contains("list_translations"));
523
524 let untranslated = server
525 .review_translations(Parameters(ReviewTranslationsParams {
526 path: None,
527 focus: Some("untranslated".into()),
528 }))
529 .unwrap();
530 assert!(text_of(&untranslated).contains("get_untranslated"));
531 }
532
533 #[test]
534 fn full_translate_lists_every_phase() {
535 let server = make_server();
536 let result = server
537 .full_translate(Parameters(FullTranslateParams {
538 path: "/work/ja.po".into(),
539 target_locale: "ja_JP".into(),
540 }))
541 .unwrap();
542 let text = text_of(&result);
543
544 assert!(text.contains("/work/ja.po"));
545 assert!(text.contains("ja_JP"));
546 assert!(text.contains("get_coverage"));
547 assert!(text.contains("list_metadata"));
548 assert!(text.contains("set_header"));
549 assert!(text.contains("get_untranslated"));
550 assert!(text.contains("upsert_translation"));
551 assert!(text.contains("validate_translations"));
552 assert!(text.contains("100%"));
554 }
555
556 #[test]
557 fn localization_audit_covers_all_six_sections() {
558 let server = make_server();
559 let result = server
560 .localization_audit(Parameters(LocalizationAuditParams {
561 path: Some("/work/messages.po".into()),
562 }))
563 .unwrap();
564 let text = text_of(&result);
565
566 assert!(text.contains("get_coverage"));
567 assert!(text.contains("validate_translations"));
568 assert!(text.contains("get_stale"));
569 assert!(text.contains("list_contexts"));
570 assert!(text.contains("list_translations"));
571 assert!(text.contains("Coverage summary"));
573 assert!(text.contains("Stale entries"));
574 }
575
576 #[test]
577 fn cleanup_stale_dry_run_does_not_mention_delete_call() {
578 let server = make_server();
579 let result = server
580 .cleanup_stale(Parameters(CleanupStaleParams {
581 path: Some("/work/messages.po".into()),
582 dry_run: None, }))
584 .unwrap();
585 let text = text_of(&result);
586
587 assert!(text.contains("DRY-RUN"));
588 assert!(text.contains("get_stale"));
589 assert!(text.contains("Do NOT call"));
590 assert!(!text
592 .contains("call\n\x20 - For each entry confirmed in Step 3, call\n\x20 `delete_key"));
593 }
594
595 #[test]
596 fn cleanup_stale_live_mentions_delete_tools() {
597 let server = make_server();
598 let result = server
599 .cleanup_stale(Parameters(CleanupStaleParams {
600 path: None,
601 dry_run: Some(false),
602 }))
603 .unwrap();
604 let text = text_of(&result);
605
606 assert!(text.contains("LIVE"));
607 assert!(text.contains("delete_key"));
608 assert!(text.contains("delete_translation"));
609 assert!(text.contains("get_stale"));
610 assert!(text.contains("get_coverage"));
611 }
612}