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 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
400pub struct Localizer {
402 locale: Locale,
403 language: LanguageIdentifier,
404 bundle: FluentBundle<FluentResource>,
405}
406
407impl Localizer {
408 #[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 pub const fn locale(&self) -> Locale {
419 self.locale
420 }
421
422 pub fn language_identifier(&self) -> &LanguageIdentifier {
424 &self.language
425 }
426
427 pub fn text(&self, message: Message) -> String {
429 self.format(message, [])
430 }
431
432 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 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#[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#[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
506fn 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 LanguageIdentifier::from_str(EN_US).unwrap_or_default()
535}
536
537#[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 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 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}