Skip to main content

pinto/
i18n.rs

1//! A lightweight internationalization infrastructure for CLI/TUI.
2//!
3//! Locale selection follows the POSIX order `LC_ALL`, then `LANG`. English and Japanese are
4//! supported; unknown locales safely fall back to English. Fluent FTL resources make it possible
5//! to add languages without branching through the call sites.
6
7use fluent_bundle::concurrent::FluentBundle;
8use fluent_bundle::{FluentArgs, FluentResource};
9use std::env;
10use std::str::FromStr;
11use std::sync::OnceLock;
12use unic_langid::LanguageIdentifier;
13
14const EN_US: &str = "en-US";
15
16const EN_MESSAGES: &str = include_str!("../locales/en-US.ftl");
17const JA_MESSAGES: &str = include_str!("../locales/ja-JP.ftl");
18
19/// Display locale currently provided by pinto.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Locale {
22    /// Default display locale.
23    English,
24    /// Japanese display locale.
25    Japanese,
26}
27
28/// Identifier for translatable UI messages.
29///
30/// Messages with values are resolved as Fluent variables.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Message {
33    ErrorPrefix,
34    InitializedBoardAt,
35    AlreadyInitialized,
36    Created,
37    NoBacklogItems,
38    Moved,
39    Reordered,
40    Updated,
41    NoChangesTo,
42    Archived,
43    Deleted,
44    CreatedSprint,
45    UpdatedSprint,
46    DeletedSprint,
47    StartedSprint,
48    ClosedSprint,
49    AssignedToSprint,
50    UnassignedFromSprint,
51    NoSprints,
52    DependencyAdded,
53    DependencyCycleWarning,
54    DependencyCycleWarningGeneric,
55    DependencyRemoved,
56    LinkAlreadyLinked,
57    LinkAdded,
58    LinkNoMatchingCommit,
59    LinkRemoved,
60    LinkNoNewCommits,
61    LinkCommitLinked,
62    LinkSummary,
63    DodUnset,
64    DodUpdated,
65    DodCleared,
66    DodNoCommonToClear,
67    MigrationCompleted,
68    MigrationBackendUpdated,
69    MigrationAlreadyUsing,
70    MigrationSqliteUnavailable,
71    WipLimitExceeded,
72    OrphanedItemsWarning,
73    RebalanceAlreadyBalanced,
74    RebalanceDryRun,
75    RebalanceCompleted,
76    InvalidCapacityOptions,
77    FailedToAction,
78    ShellParseError,
79    SprintAddRequiresItemOrStatus,
80    ErrorInvalidItemId,
81    ErrorInvalidRank,
82    ErrorInvalidAutomationPlan,
83    ErrorAutomationPlanSource,
84    ErrorUnknownStatus,
85    ErrorEmptyTitle,
86    ErrorInvalidSearchPattern,
87    ErrorInvalidFilterOption,
88    ErrorNothingToUpdate,
89    ErrorEmptyDod,
90    ErrorInvalidTemplateName,
91    ErrorTemplateNotFound,
92    ErrorTemplateUnreadable,
93    ErrorNotFound,
94    ErrorReferencedItem,
95    ErrorInvalidSprintId,
96    ErrorInvalidSprintState,
97    ErrorEmptySprintTitle,
98    ErrorEmptySprintGoal,
99    ErrorInvalidSprintTransition,
100    ErrorSprintNotFound,
101    ErrorSprintExists,
102    ErrorSprintClosed,
103    ErrorInvalidSprintPeriod,
104    ErrorInvalidDailyWorkHours,
105    ErrorInvalidDeductionFactor,
106    ErrorInvalidSprintHolidays,
107    ErrorSprintCapacityPeriodUnset,
108    ErrorSprintCapacityUnset,
109    ErrorSprintPeriodUnset,
110    ErrorSprintEmpty,
111    ErrorNotInSprint,
112    ErrorParentCycle,
113    ErrorNotInitialized,
114    ErrorIo,
115    ErrorParse,
116    ErrorMissingFrontmatter,
117    ErrorUnsupportedSqliteSchema,
118    ErrorSelfReference,
119    ErrorNotSibling,
120    ErrorTask,
121    ErrorGit,
122    ErrorEditorNotSet,
123    ErrorEditorLaunch,
124    ErrorEditorInvalid,
125    ErrorLocked,
126    AlreadyInInteractiveShell,
127    KanbanDetailsTitle,
128    KanbanPopupHints,
129    KanbanHelpTitle,
130    KanbanHelpEntries,
131    KanbanNoChanges,
132    KanbanEditorFailed,
133    KanbanEditFailed,
134    KanbanWipExceeded,
135    KanbanNoEditor,
136    KanbanKeyHints,
137    KanbanHelpHint,
138    KanbanHelpClearFilter,
139    KanbanAddTitlePrompt,
140    KanbanAddBodyPrompt,
141    KanbanAddParentPrompt,
142    KanbanAddDependenciesPrompt,
143    KanbanDependencyAddPrompt,
144    KanbanDependencyRemovePrompt,
145    KanbanEmptyTitle,
146    KanbanEmptyDependency,
147    KanbanDependencyAdded,
148    KanbanDependencyRemoved,
149    KanbanDependencyCycleWarning,
150    KanbanParentPrompt,
151    KanbanParentSet,
152    KanbanParentCleared,
153    KanbanActiveFilter,
154    KanbanActiveRegexFilter,
155    KanbanDependencyLegend,
156    KanbanColumnRange,
157    KanbanEmptyColumns,
158    KanbanQuitBody,
159    KanbanQuitPrompt,
160    KanbanNoBody,
161    KanbanNoSelection,
162    KanbanTerminalInitFailed,
163    EditGuidance,
164    AutomationCommandValid,
165    AutomationCommandInvalid,
166    AutomationCommandFailed,
167    AutomationCommandSkipped,
168    AutomationDryRunCompleted,
169    AutomationCompleted,
170    AutomationPartialFailure,
171    AutomationInvalidCommandArguments,
172    AutomationNotExecutedAfterFailure,
173    AutomationNotValidatedAfterFailure,
174    AutomationDryRunGitInitFailed,
175    AutomationDryRunWorkspaceUnavailable,
176    AutomationCommandExited,
177}
178
179impl Message {
180    const fn id(self) -> &'static str {
181        match self {
182            Self::ErrorPrefix => "error-prefix",
183            Self::InitializedBoardAt => "initialized-board-at",
184            Self::AlreadyInitialized => "already-initialized",
185            Self::Created => "created",
186            Self::NoBacklogItems => "no-backlog-items",
187            Self::Moved => "moved",
188            Self::Reordered => "reordered",
189            Self::Updated => "updated",
190            Self::NoChangesTo => "no-changes-to",
191            Self::Archived => "archived",
192            Self::Deleted => "deleted",
193            Self::CreatedSprint => "created-sprint",
194            Self::UpdatedSprint => "updated-sprint",
195            Self::DeletedSprint => "deleted-sprint",
196            Self::StartedSprint => "started-sprint",
197            Self::ClosedSprint => "closed-sprint",
198            Self::AssignedToSprint => "assigned-to-sprint",
199            Self::UnassignedFromSprint => "unassigned-from-sprint",
200            Self::NoSprints => "no-sprints",
201            Self::DependencyAdded => "dependency-added",
202            Self::DependencyCycleWarning => "dependency-cycle-warning",
203            Self::DependencyCycleWarningGeneric => "dependency-cycle-warning-generic",
204            Self::DependencyRemoved => "dependency-removed",
205            Self::LinkAlreadyLinked => "link-already-linked",
206            Self::LinkAdded => "link-added",
207            Self::LinkNoMatchingCommit => "link-no-matching-commit",
208            Self::LinkRemoved => "link-removed",
209            Self::LinkNoNewCommits => "link-no-new-commits",
210            Self::LinkCommitLinked => "link-commit-linked",
211            Self::LinkSummary => "link-summary",
212            Self::DodUnset => "dod-unset",
213            Self::DodUpdated => "dod-updated",
214            Self::DodCleared => "dod-cleared",
215            Self::DodNoCommonToClear => "dod-no-common-to-clear",
216            Self::MigrationCompleted => "migration-completed",
217            Self::MigrationBackendUpdated => "migration-backend-updated",
218            Self::MigrationAlreadyUsing => "migration-already-using",
219            Self::MigrationSqliteUnavailable => "migration-sqlite-unavailable",
220            Self::WipLimitExceeded => "wip-limit-exceeded",
221            Self::OrphanedItemsWarning => "orphaned-items-warning",
222            Self::RebalanceAlreadyBalanced => "rebalance-already-balanced",
223            Self::RebalanceDryRun => "rebalance-dry-run",
224            Self::RebalanceCompleted => "rebalance-completed",
225            Self::InvalidCapacityOptions => "invalid-capacity-options",
226            Self::FailedToAction => "failed-to-action",
227            Self::ShellParseError => "shell-parse-error",
228            Self::SprintAddRequiresItemOrStatus => "sprint-add-requires-item-or-status",
229            Self::ErrorInvalidItemId => "error-invalid-item-id",
230            Self::ErrorInvalidRank => "error-invalid-rank",
231            Self::ErrorInvalidAutomationPlan => "error-invalid-automation-plan",
232            Self::ErrorAutomationPlanSource => "error-automation-plan-source",
233            Self::ErrorUnknownStatus => "error-unknown-status",
234            Self::ErrorEmptyTitle => "error-empty-title",
235            Self::ErrorInvalidSearchPattern => "error-invalid-search-pattern",
236            Self::ErrorInvalidFilterOption => "error-invalid-filter-option",
237            Self::ErrorNothingToUpdate => "error-nothing-to-update",
238            Self::ErrorEmptyDod => "error-empty-dod",
239            Self::ErrorInvalidTemplateName => "error-invalid-template-name",
240            Self::ErrorTemplateNotFound => "error-template-not-found",
241            Self::ErrorTemplateUnreadable => "error-template-unreadable",
242            Self::ErrorNotFound => "error-not-found",
243            Self::ErrorReferencedItem => "error-referenced-item",
244            Self::ErrorInvalidSprintId => "error-invalid-sprint-id",
245            Self::ErrorInvalidSprintState => "error-invalid-sprint-state",
246            Self::ErrorEmptySprintTitle => "error-empty-sprint-title",
247            Self::ErrorEmptySprintGoal => "error-empty-sprint-goal",
248            Self::ErrorInvalidSprintTransition => "error-invalid-sprint-transition",
249            Self::ErrorSprintNotFound => "error-sprint-not-found",
250            Self::ErrorSprintExists => "error-sprint-exists",
251            Self::ErrorSprintClosed => "error-sprint-closed",
252            Self::ErrorInvalidSprintPeriod => "error-invalid-sprint-period",
253            Self::ErrorInvalidDailyWorkHours => "error-invalid-daily-work-hours",
254            Self::ErrorInvalidDeductionFactor => "error-invalid-deduction-factor",
255            Self::ErrorInvalidSprintHolidays => "error-invalid-sprint-holidays",
256            Self::ErrorSprintCapacityPeriodUnset => "error-sprint-capacity-period-unset",
257            Self::ErrorSprintCapacityUnset => "error-sprint-capacity-unset",
258            Self::ErrorSprintPeriodUnset => "error-sprint-period-unset",
259            Self::ErrorSprintEmpty => "error-sprint-empty",
260            Self::ErrorNotInSprint => "error-not-in-sprint",
261            Self::ErrorParentCycle => "error-parent-cycle",
262            Self::ErrorNotInitialized => "error-not-initialized",
263            Self::ErrorIo => "error-io",
264            Self::ErrorParse => "error-parse",
265            Self::ErrorMissingFrontmatter => "error-missing-frontmatter",
266            Self::ErrorUnsupportedSqliteSchema => "error-unsupported-sqlite-schema",
267            Self::ErrorSelfReference => "error-self-reference",
268            Self::ErrorNotSibling => "error-not-sibling",
269            Self::ErrorTask => "error-task",
270            Self::ErrorGit => "error-git",
271            Self::ErrorEditorNotSet => "error-editor-not-set",
272            Self::ErrorEditorLaunch => "error-editor-launch",
273            Self::ErrorEditorInvalid => "error-editor-invalid",
274            Self::ErrorLocked => "error-locked",
275            Self::AlreadyInInteractiveShell => "already-in-interactive-shell",
276            Self::KanbanDetailsTitle => "kanban-details-title",
277            Self::KanbanPopupHints => "kanban-popup-hints",
278            Self::KanbanHelpTitle => "kanban-help-title",
279            Self::KanbanHelpEntries => "kanban-help-entries",
280            Self::KanbanNoChanges => "kanban-no-changes",
281            Self::KanbanEditorFailed => "kanban-editor-failed",
282            Self::KanbanEditFailed => "kanban-edit-failed",
283            Self::KanbanWipExceeded => "kanban-wip-exceeded",
284            Self::KanbanNoEditor => "kanban-no-editor",
285            Self::KanbanKeyHints => "kanban-key-hints",
286            Self::KanbanHelpHint => "kanban-help-hint",
287            Self::KanbanHelpClearFilter => "kanban-help-clear-filter",
288            Self::KanbanAddTitlePrompt => "kanban-add-title-prompt",
289            Self::KanbanAddBodyPrompt => "kanban-add-body-prompt",
290            Self::KanbanAddParentPrompt => "kanban-add-parent-prompt",
291            Self::KanbanAddDependenciesPrompt => "kanban-add-dependencies-prompt",
292            Self::KanbanDependencyAddPrompt => "kanban-dependency-add-prompt",
293            Self::KanbanDependencyRemovePrompt => "kanban-dependency-remove-prompt",
294            Self::KanbanEmptyTitle => "kanban-empty-title",
295            Self::KanbanEmptyDependency => "kanban-empty-dependency",
296            Self::KanbanDependencyAdded => "kanban-dependency-added",
297            Self::KanbanDependencyRemoved => "kanban-dependency-removed",
298            Self::KanbanDependencyCycleWarning => "kanban-dependency-cycle-warning",
299            Self::KanbanParentPrompt => "kanban-parent-prompt",
300            Self::KanbanParentSet => "kanban-parent-set",
301            Self::KanbanParentCleared => "kanban-parent-cleared",
302            Self::KanbanActiveFilter => "kanban-active-filter",
303            Self::KanbanActiveRegexFilter => "kanban-active-regex-filter",
304            Self::KanbanDependencyLegend => "kanban-dependency-legend",
305            Self::KanbanColumnRange => "kanban-column-range",
306            Self::KanbanEmptyColumns => "kanban-empty-columns",
307            Self::KanbanQuitBody => "kanban-quit-body",
308            Self::KanbanQuitPrompt => "kanban-quit-prompt",
309            Self::KanbanNoBody => "kanban-no-body",
310            Self::KanbanNoSelection => "kanban-no-selection",
311            Self::KanbanTerminalInitFailed => "kanban-terminal-init-failed",
312            Self::EditGuidance => "edit-guidance",
313            Self::AutomationCommandValid => "automation-command-valid",
314            Self::AutomationCommandInvalid => "automation-command-invalid",
315            Self::AutomationCommandFailed => "automation-command-failed",
316            Self::AutomationCommandSkipped => "automation-command-skipped",
317            Self::AutomationDryRunCompleted => "automation-dry-run-completed",
318            Self::AutomationCompleted => "automation-completed",
319            Self::AutomationPartialFailure => "automation-partial-failure",
320            Self::AutomationInvalidCommandArguments => "automation-invalid-command-arguments",
321            Self::AutomationNotExecutedAfterFailure => "automation-not-executed-after-failure",
322            Self::AutomationNotValidatedAfterFailure => "automation-not-validated-after-failure",
323            Self::AutomationDryRunGitInitFailed => "automation-dry-run-git-init-failed",
324            Self::AutomationDryRunWorkspaceUnavailable => {
325                "automation-dry-run-workspace-unavailable"
326            }
327            Self::AutomationCommandExited => "automation-command-exited",
328        }
329    }
330}
331
332/// Message table used in the execution process.
333pub struct Localizer {
334    locale: Locale,
335    language: LanguageIdentifier,
336    bundle: FluentBundle<FluentResource>,
337}
338
339impl Localizer {
340    /// Determine the locale from the current process environment.
341    #[must_use]
342    pub fn from_environment() -> Self {
343        localizer_from(
344            env::var("LC_ALL").ok().as_deref(),
345            env::var("LANG").ok().as_deref(),
346        )
347    }
348
349    /// Returns the actual selected display locale.
350    pub const fn locale(&self) -> Locale {
351        self.locale
352    }
353
354    /// Returns Fluent's locale identifier chosen from an environment variable.
355    pub fn language_identifier(&self) -> &LanguageIdentifier {
356        &self.language
357    }
358
359    /// Resolve Fluent messages without arguments.
360    pub fn text(&self, message: Message) -> String {
361        self.format(message, [])
362    }
363
364    /// Resolve any Fluent message ID.
365    ///
366    /// Resolve dynamic messages that are difficult to enumerate, such as clap's help, from the
367    /// resource bundle. Return `None` for an unknown ID or invalid pattern so callers can keep
368    /// their default wording.
369    pub fn lookup(&self, id: &str) -> Option<String> {
370        let message = self.bundle.get_message(id)?;
371        let pattern = message.value()?;
372        let mut errors = Vec::new();
373        let value = self.bundle.format_pattern(pattern, None, &mut errors);
374        errors.is_empty().then(|| value.into_owned())
375    }
376
377    /// Resolve messages by giving them Fluent variables.
378    ///
379    /// If a built-in FTL resource is missing or cannot be formatted, return the message identifier
380    /// so a resource problem cannot make the CLI or TUI undisplayable.
381    pub fn format<'a>(
382        &self,
383        message: Message,
384        variables: impl IntoIterator<Item = (&'a str, &'a str)>,
385    ) -> String {
386        let id = message.id();
387        let Some(message) = self.bundle.get_message(id) else {
388            return id.to_string();
389        };
390        let Some(pattern) = message.value() else {
391            return id.to_string();
392        };
393        let mut args = FluentArgs::new();
394        for (name, value) in variables {
395            args.set(name, value);
396        }
397        let mut errors = Vec::new();
398        let value = self
399            .bundle
400            .format_pattern(pattern, Some(&args), &mut errors);
401        if errors.is_empty() {
402            value.into_owned()
403        } else {
404            id.to_string()
405        }
406    }
407}
408
409/// Prefer `LC_ALL` to `LANG` to choose a non-empty locale name.
410///
411/// The return value is the candidate before normalization; [`localizer_from`] determines whether
412/// it is compatible. Keeping those steps separate makes POSIX precedence and fallback rules easy
413/// to test independently.
414#[must_use]
415pub fn locale_name_from<'a>(lc_all: Option<&'a str>, lang: Option<&'a str>) -> Option<&'a str> {
416    lc_all
417        .filter(|value| !value.is_empty())
418        .or_else(|| lang.filter(|value| !value.is_empty()))
419}
420
421/// Create a localizer from the specified environment values.
422#[must_use]
423pub fn localizer_from(lc_all: Option<&str>, lang: Option<&str>) -> Localizer {
424    let (locale, language, messages) = localized_resource(lc_all, lang);
425    let resource = match FluentResource::try_new(messages.to_string()) {
426        Ok(resource) | Err((resource, _)) => resource,
427    };
428    let mut bundle = FluentBundle::new_concurrent(vec![language.clone()]);
429    bundle.set_use_isolating(false);
430    let _ = bundle.add_resource(resource);
431    Localizer {
432        locale,
433        language,
434        bundle,
435    }
436}
437
438/// Normalize `LC_ALL` / `LANG` to BCP 47 language identifiers.
439///
440/// Determine the corresponding Fluent resource and locale. Preserve the regional component when
441/// possible (for example, `en-GB` or `ja-JP`); fall back to the `en-US` resource when the value
442/// is missing, unsupported, or syntactically invalid.
443fn localized_resource(
444    lc_all: Option<&str>,
445    lang: Option<&str>,
446) -> (Locale, LanguageIdentifier, &'static str) {
447    let candidate = locale_name_from(lc_all, lang)
448        .and_then(|name| name.split('.').next())
449        .map(|name| name.replace('_', "-"))
450        .and_then(|name| LanguageIdentifier::from_str(&name).ok());
451
452    match candidate {
453        Some(language) if language.language.as_str().eq_ignore_ascii_case("en") => {
454            (Locale::English, language, EN_MESSAGES)
455        }
456        Some(language) if language.language.as_str().eq_ignore_ascii_case("ja") => {
457            (Locale::Japanese, language, JA_MESSAGES)
458        }
459        Some(_) | None => (Locale::English, english_fallback(), EN_MESSAGES),
460    }
461}
462
463fn english_fallback() -> LanguageIdentifier {
464    // `EN_US` is a valid BCP 47 tag fixed at compile time. If the library ever rejects it, keep
465    // the CLI running with its default language rather than propagating an impossible error.
466    LanguageIdentifier::from_str(EN_US).unwrap_or_default()
467}
468
469/// Returns the process-local localizer, initialized from the environment on first use.
470///
471/// Locale environment changes after the first call do not affect this value; use
472/// [`localizer_from`] when a caller needs an explicitly selected locale, such as a test.
473#[must_use]
474pub fn current() -> &'static Localizer {
475    static CURRENT_LOCALIZER: OnceLock<Localizer> = OnceLock::new();
476    CURRENT_LOCALIZER.get_or_init(Localizer::from_environment)
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn lc_all_is_selected_before_lang() {
485        assert_eq!(locale_name_from(Some("C"), Some("ja_JP.UTF-8")), Some("C"));
486    }
487
488    #[test]
489    fn empty_lc_all_defers_to_lang() {
490        assert_eq!(
491            locale_name_from(Some(""), Some("en_US.UTF-8")),
492            Some("en_US.UTF-8")
493        );
494    }
495
496    #[test]
497    fn missing_locale_values_use_the_english_fallback() {
498        assert_eq!(locale_name_from(None, None), None);
499        assert_eq!(locale_name_from(Some(""), Some("")), None);
500
501        for (lc_all, lang) in [(None, None), (Some(""), Some("invalid_locale"))] {
502            let localizer = localizer_from(lc_all, lang);
503            assert_eq!(localizer.locale(), Locale::English);
504            assert_eq!(localizer.language_identifier().to_string(), "en-US");
505        }
506    }
507
508    #[test]
509    fn unsupported_locale_uses_english_messages() {
510        let localizer = localizer_from(Some("fr_FR.UTF-8"), None);
511        assert_eq!(localizer.locale(), Locale::English);
512        assert_eq!(localizer.text(Message::NoBacklogItems), "No backlog items.");
513    }
514
515    #[test]
516    fn current_reuses_one_localizer_for_the_process_lifetime() {
517        assert!(std::ptr::eq(current(), current()));
518    }
519
520    #[test]
521    fn edit_guidance_is_localized_and_kept_as_toml_comments() {
522        // The editor guidance must follow the selected locale (it used to be hard-coded
523        // Japanese) and stay as `#` comment lines so TOML round-trips it untouched.
524        let en = localizer_from(Some("en_US.UTF-8"), None).text(Message::EditGuidance);
525        let ja = localizer_from(Some("ja_JP.UTF-8"), None).text(Message::EditGuidance);
526
527        for guide in [&en, &ja] {
528            // Rendered from FTL, not the fallback identifier, and every line is a comment.
529            assert_ne!(guide, "edit-guidance", "must resolve from FTL");
530            assert!(guide.starts_with("# pinto:"), "opens with a marker comment");
531            assert!(
532                guide.lines().all(|line| line.starts_with('#')),
533                "all lines are TOML comments: {guide:?}"
534            );
535        }
536        assert!(en.contains("saving applies"), "English guidance is English");
537        assert!(ja.contains("保存すると"), "Japanese guidance is Japanese");
538        assert_ne!(en, ja, "locales produce distinct guidance");
539    }
540
541    #[test]
542    fn every_message_id_has_a_safe_fallback_and_lookup_handles_unknown_ids() {
543        let localizer = localizer_from(Some("en-US"), None);
544        let messages = [
545            Message::ErrorPrefix,
546            Message::InitializedBoardAt,
547            Message::AlreadyInitialized,
548            Message::Created,
549            Message::NoBacklogItems,
550            Message::Moved,
551            Message::Reordered,
552            Message::Updated,
553            Message::NoChangesTo,
554            Message::Archived,
555            Message::Deleted,
556            Message::CreatedSprint,
557            Message::UpdatedSprint,
558            Message::DeletedSprint,
559            Message::StartedSprint,
560            Message::ClosedSprint,
561            Message::AssignedToSprint,
562            Message::UnassignedFromSprint,
563            Message::NoSprints,
564            Message::DependencyAdded,
565            Message::DependencyCycleWarning,
566            Message::DependencyCycleWarningGeneric,
567            Message::DependencyRemoved,
568            Message::LinkAlreadyLinked,
569            Message::LinkAdded,
570            Message::LinkNoMatchingCommit,
571            Message::LinkRemoved,
572            Message::LinkNoNewCommits,
573            Message::LinkCommitLinked,
574            Message::LinkSummary,
575            Message::DodUnset,
576            Message::DodUpdated,
577            Message::DodCleared,
578            Message::DodNoCommonToClear,
579            Message::MigrationCompleted,
580            Message::MigrationBackendUpdated,
581            Message::MigrationAlreadyUsing,
582            Message::MigrationSqliteUnavailable,
583            Message::WipLimitExceeded,
584            Message::OrphanedItemsWarning,
585            Message::RebalanceAlreadyBalanced,
586            Message::RebalanceDryRun,
587            Message::RebalanceCompleted,
588            Message::InvalidCapacityOptions,
589            Message::FailedToAction,
590            Message::ShellParseError,
591            Message::SprintAddRequiresItemOrStatus,
592            Message::ErrorInvalidItemId,
593            Message::ErrorInvalidRank,
594            Message::ErrorInvalidAutomationPlan,
595            Message::ErrorAutomationPlanSource,
596            Message::ErrorUnknownStatus,
597            Message::ErrorEmptyTitle,
598            Message::ErrorInvalidSearchPattern,
599            Message::ErrorInvalidFilterOption,
600            Message::ErrorNothingToUpdate,
601            Message::ErrorEmptyDod,
602            Message::ErrorInvalidTemplateName,
603            Message::ErrorTemplateNotFound,
604            Message::ErrorTemplateUnreadable,
605            Message::ErrorNotFound,
606            Message::ErrorReferencedItem,
607            Message::ErrorInvalidSprintId,
608            Message::ErrorInvalidSprintState,
609            Message::ErrorEmptySprintTitle,
610            Message::ErrorEmptySprintGoal,
611            Message::ErrorInvalidSprintTransition,
612            Message::ErrorSprintNotFound,
613            Message::ErrorSprintExists,
614            Message::ErrorSprintClosed,
615            Message::ErrorInvalidSprintPeriod,
616            Message::ErrorInvalidDailyWorkHours,
617            Message::ErrorInvalidDeductionFactor,
618            Message::ErrorInvalidSprintHolidays,
619            Message::ErrorSprintCapacityPeriodUnset,
620            Message::ErrorSprintCapacityUnset,
621            Message::ErrorSprintPeriodUnset,
622            Message::ErrorSprintEmpty,
623            Message::ErrorNotInSprint,
624            Message::ErrorParentCycle,
625            Message::ErrorNotInitialized,
626            Message::ErrorIo,
627            Message::ErrorParse,
628            Message::ErrorMissingFrontmatter,
629            Message::ErrorUnsupportedSqliteSchema,
630            Message::ErrorSelfReference,
631            Message::ErrorNotSibling,
632            Message::ErrorTask,
633            Message::ErrorGit,
634            Message::ErrorEditorNotSet,
635            Message::ErrorEditorLaunch,
636            Message::ErrorEditorInvalid,
637            Message::ErrorLocked,
638            Message::AlreadyInInteractiveShell,
639            Message::KanbanDetailsTitle,
640            Message::KanbanPopupHints,
641            Message::KanbanHelpTitle,
642            Message::KanbanHelpEntries,
643            Message::KanbanNoChanges,
644            Message::KanbanEditorFailed,
645            Message::KanbanEditFailed,
646            Message::KanbanWipExceeded,
647            Message::KanbanNoEditor,
648            Message::KanbanKeyHints,
649            Message::KanbanHelpHint,
650            Message::KanbanHelpClearFilter,
651            Message::KanbanAddTitlePrompt,
652            Message::KanbanAddBodyPrompt,
653            Message::KanbanAddParentPrompt,
654            Message::KanbanAddDependenciesPrompt,
655            Message::KanbanDependencyAddPrompt,
656            Message::KanbanDependencyRemovePrompt,
657            Message::KanbanEmptyTitle,
658            Message::KanbanEmptyDependency,
659            Message::KanbanDependencyAdded,
660            Message::KanbanDependencyRemoved,
661            Message::KanbanDependencyCycleWarning,
662            Message::KanbanParentPrompt,
663            Message::KanbanParentSet,
664            Message::KanbanParentCleared,
665            Message::KanbanActiveFilter,
666            Message::KanbanActiveRegexFilter,
667            Message::KanbanDependencyLegend,
668            Message::KanbanColumnRange,
669            Message::KanbanEmptyColumns,
670            Message::KanbanQuitBody,
671            Message::KanbanQuitPrompt,
672            Message::KanbanNoBody,
673            Message::KanbanNoSelection,
674            Message::KanbanTerminalInitFailed,
675            Message::EditGuidance,
676            Message::AutomationCommandValid,
677            Message::AutomationCommandInvalid,
678            Message::AutomationCommandFailed,
679            Message::AutomationCommandSkipped,
680            Message::AutomationDryRunCompleted,
681            Message::AutomationCompleted,
682            Message::AutomationPartialFailure,
683            Message::AutomationInvalidCommandArguments,
684            Message::AutomationNotExecutedAfterFailure,
685            Message::AutomationNotValidatedAfterFailure,
686            Message::AutomationDryRunGitInitFailed,
687            Message::AutomationDryRunWorkspaceUnavailable,
688            Message::AutomationCommandExited,
689        ];
690
691        for message in messages {
692            assert!(!localizer.text(message).is_empty());
693        }
694        assert!(
695            !localizer
696                .format(Message::Moved, [("id", "T-1"), ("status", "done")])
697                .is_empty()
698        );
699        assert!(localizer.lookup("missing-message").is_none());
700    }
701}