Skip to main content

gettext_mcp/
prompts.rs

1//! MCP prompt router for the gettext server.
2//!
3//! Exposes reusable workflow templates (translate a batch, review existing
4//! translations, audit a whole file, etc.) that an MCP client can invoke
5//! via the `prompts/get` capability. The actual instruction text inside
6//! each prompt is what the assistant will see as its starting context, so
7//! every prompt enumerates the concrete tool calls it should make.
8
9use 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    /// Target language (display name like "French" or BCP-47 code like "fr_FR").
22    locale: String,
23    /// Path to the .po file. Optional in single-file mode.
24    path: Option<String>,
25    /// How many untranslated entries to pull per batch (default: 30).
26    count: Option<u32>,
27}
28
29#[derive(Debug, Deserialize, JsonSchema)]
30pub(crate) struct ReviewTranslationsParams {
31    /// Path to the .po file. Optional in single-file mode.
32    path: Option<String>,
33    /// What subset to focus on: "fuzzy" (default), "all", or "untranslated".
34    focus: Option<String>,
35}
36
37#[derive(Debug, Deserialize, JsonSchema)]
38pub(crate) struct FullTranslateParams {
39    /// Path to the .po file to translate (required).
40    path: String,
41    /// Target language (display name or code) to translate into.
42    target_locale: String,
43}
44
45#[derive(Debug, Deserialize, JsonSchema)]
46pub(crate) struct LocalizationAuditParams {
47    /// Path to the .po file. Optional in single-file mode.
48    path: Option<String>,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
52pub(crate) struct CleanupStaleParams {
53    /// Path to the .po file. Optional in single-file mode.
54    path: Option<String>,
55    /// If true (default), only list what would be removed; do not call any
56    /// delete tools. Set to false to actually delete.
57    dry_run: Option<bool>,
58}
59
60/// Render a small "Working file:" preamble shared by every prompt so the
61/// assistant knows which `path` argument to pass to each tool call.
62fn 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    /// Translate a batch of untranslated entries in a .po file.
75    #[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 = &params.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    /// Review existing translations for quality and correctness.
147    #[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    /// End-to-end workflow to translate a .po file to 100% coverage.
224    #[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 = &params.path;
234        let locale = &params.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    /// Comprehensive localization audit producing a written report.
295    #[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    /// Clean up obsolete (`#~`) entries from a .po file, with a dry-run mode.
362    #[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        // No path → assistant should be told about single-file mode.
491        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        // Final coverage verification at the end.
553        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        // Section headings for the final report.
572        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, // default = true
583            }))
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        // The dry-run report section should not instruct the model to call delete_key.
591        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}