1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Locale {
22 English,
24 Japanese,
26}
27
28#[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
382pub struct Localizer {
384 locale: Locale,
385 language: LanguageIdentifier,
386 bundle: FluentBundle<FluentResource>,
387}
388
389impl Localizer {
390 #[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 pub const fn locale(&self) -> Locale {
401 self.locale
402 }
403
404 pub fn language_identifier(&self) -> &LanguageIdentifier {
406 &self.language
407 }
408
409 pub fn text(&self, message: Message) -> String {
411 self.format(message, [])
412 }
413
414 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 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#[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#[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
488fn 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 LanguageIdentifier::from_str(EN_US).unwrap_or_default()
517}
518
519#[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 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 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}