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