1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
6pub struct I18nText {
7 pub message_key: String,
8 pub fallback: String,
9}
10
11impl I18nText {
12 pub fn new(message_key: impl Into<String>, fallback: impl Into<String>) -> Self {
13 Self {
14 message_key: message_key.into(),
15 fallback: fallback.into(),
16 }
17 }
18}
19
20pub fn normalize_locale(value: &str) -> String {
21 let lower = value.replace('_', "-").to_ascii_lowercase();
22 match lower.split('-').next() {
23 Some("en") => "en".to_string(),
24 Some(primary) if !primary.is_empty() => primary.to_string(),
25 _ => "en".to_string(),
26 }
27}
28
29pub fn select_locale_with_sources(
30 cli_locale: Option<&str>,
31 explicit: Option<&str>,
32 env_locale: Option<&str>,
33 system_locale: Option<&str>,
34) -> String {
35 if let Some(value) = cli_locale.map(str::trim).filter(|value| !value.is_empty()) {
36 return normalize_locale(value);
37 }
38 if let Some(value) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
39 return normalize_locale(value);
40 }
41 if let Some(value) = env_locale.map(str::trim).filter(|value| !value.is_empty()) {
42 return normalize_locale(value);
43 }
44 if let Some(value) = system_locale
45 .map(str::trim)
46 .filter(|value| !value.is_empty())
47 {
48 return normalize_locale(value);
49 }
50 "en".to_string()
51}
52
53pub fn resolve_text(text: &I18nText, locale: &str) -> String {
54 resolve_message(&text.message_key, &text.fallback, locale)
55}
56
57pub fn resolve_message(key: &str, fallback: &str, locale: &str) -> String {
58 let normalized = normalize_locale(locale);
59 match normalized.as_str() {
60 "en" => english_message(key).unwrap_or(fallback).to_string(),
61 _ => fallback.to_string(),
62 }
63}
64
65fn english_message(key: &str) -> Option<&'static str> {
66 match key {
67 "runner.operator.schema_hash_mismatch" => {
68 Some("schema hash mismatch between request and resolved contract")
69 }
70 "runner.operator.contract_introspection_failed" => {
71 Some("failed to introspect component contract")
72 }
73 "runner.operator.schema_ref_not_found" => Some("referenced schema not found in pack"),
74 "runner.operator.schema_load_failed" => Some("failed to load referenced schema"),
75 "runner.operator.new_state_schema_missing" => {
76 Some("missing config schema required for new_state validation")
77 }
78 "runner.operator.new_state_schema_load_failed" => {
79 Some("failed to load config schema for new_state validation")
80 }
81 "runner.operator.new_state_schema_unavailable" => {
82 Some("new_state schema unavailable in strict mode")
83 }
84 "runner.operator.tenant_mismatch" => Some("request tenant does not match routed tenant"),
85 "runner.operator.missing_provider_selector" => {
86 Some("request must include provider_id or provider_type")
87 }
88 "runner.operator.provider_not_found" => Some("provider not found"),
89 "runner.operator.op_not_found" => Some("operation not found"),
90 "runner.operator.resolve_error" => Some("failed to resolve provider operation"),
91 "runner.schema.unsupported_constraint" => Some("schema includes unsupported constraint"),
92 "runner.schema.invalid_schema" => Some("invalid schema document"),
93 "runner.schema.validation_failed" => Some("schema validation failed"),
94 _ => None,
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn resolve_message_uses_fallback_for_unknown_key() {
104 let message = resolve_message("runner.unknown", "fallback message", "en");
105 assert_eq!(message, "fallback message");
106 }
107
108 #[test]
109 fn resolve_text_uses_key_and_fallback() {
110 let text = I18nText::new("runner.operator.op_not_found", "fallback");
111 let message = resolve_text(&text, "en");
112 assert_eq!(message, "operation not found");
113 }
114
115 #[test]
116 fn normalize_locale_reduces_variants() {
117 assert_eq!(normalize_locale("en-US"), "en");
118 assert_eq!(normalize_locale("nl_NL"), "nl");
119 }
120
121 #[test]
122 fn select_locale_prefers_explicit_over_env_and_system() {
123 assert_eq!(
124 select_locale_with_sources(None, Some("en-US"), Some("fr-FR"), Some("nl_NL.UTF-8")),
125 "en"
126 );
127 }
128
129 #[test]
130 fn select_locale_prefers_cli_override() {
131 assert_eq!(
132 select_locale_with_sources(
133 Some("it-IT"),
134 Some("en-US"),
135 Some("fr-FR"),
136 Some("nl_NL.UTF-8")
137 ),
138 "it"
139 );
140 }
141
142 #[test]
143 fn select_locale_uses_env_over_system() {
144 assert_eq!(
145 select_locale_with_sources(None, None, Some("de-DE"), Some("nl_NL.UTF-8")),
146 "de"
147 );
148 }
149
150 #[test]
151 fn select_locale_falls_back_to_system_then_en() {
152 assert_eq!(
153 select_locale_with_sources(None, None, None, Some("es_ES.UTF-8")),
154 "es"
155 );
156 assert_eq!(select_locale_with_sources(None, None, None, None), "en");
157 }
158}