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