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