1pub mod format;
3pub mod tag;
4pub use format::{BasicBackend, DecimalLike, FormatBackend, FormatFacade};
5
6use std::{
7 collections::{HashMap, VecDeque},
8 fmt,
9 str::FromStr,
10 sync::{Arc, Mutex},
11};
12
13use blake3::Hasher;
14use data_encoding::BASE32_NOPAD;
15
16use crate::tag::{
17 build_parent_chain, direction_for_language, extension_value, lenient_first_day,
18 lenient_hour_cycle, parse_tag_details,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct I18nTag(String);
24
25impl I18nTag {
26 pub fn new(value: &str) -> Result<Self, I18nError> {
27 canonicalize_tag(value).map(I18nTag)
28 }
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33}
34
35#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub struct I18nId([u8; 16]);
38
39impl fmt::Debug for I18nId {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "I18nId({})", self.as_str())
42 }
43}
44
45impl fmt::Display for I18nId {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 write!(f, "{}", self.as_str())
48 }
49}
50
51impl I18nId {
52 pub fn zero() -> Self {
53 Self::default()
54 }
55
56 pub fn version(&self) -> &'static str {
57 "v1"
58 }
59
60 pub fn bytes(&self) -> [u8; 16] {
61 self.0
62 }
63
64 pub fn as_str(&self) -> String {
65 format!("i18n:v1:{}", BASE32_NOPAD.encode(&self.0))
66 }
67
68 pub fn from_profile(profile: &I18nProfile) -> Self {
69 let canonical = profile.canonical_bytes();
70 let mut hasher = Hasher::new();
71 hasher.update(&canonical);
72 Self::from_digest(hasher.finalize().as_bytes())
73 }
74
75 fn from_digest(digest: &[u8]) -> Self {
76 let mut bytes = [0u8; 16];
77 bytes.copy_from_slice(&digest[..16]);
78 Self(bytes)
79 }
80
81 pub fn parse(input: &str) -> Result<Self, I18nError> {
82 let prefix = "i18n:v1:";
83 if !input.starts_with(prefix) {
84 return Err(I18nError::InvalidId(input.to_string()));
85 }
86 let encoded = &input[prefix.len()..].to_ascii_uppercase();
87 let data = BASE32_NOPAD
88 .decode(encoded.as_bytes())
89 .map_err(I18nError::DecodeId)?;
90 if data.len() < 16 {
91 return Err(I18nError::InvalidId(input.to_string()));
92 }
93 let mut bytes = [0u8; 16];
94 bytes.copy_from_slice(&data[..16]);
95 Ok(Self(bytes))
96 }
97}
98
99impl FromStr for I18nId {
100 type Err = I18nError;
101
102 fn from_str(s: &str) -> Result<Self, Self::Err> {
103 I18nId::parse(s)
104 }
105}
106
107#[derive(Debug)]
109pub enum I18nError {
110 EmptyTag,
111 InvalidId(String),
112 DecodeId(data_encoding::DecodeError),
113 MissingField(&'static str),
114}
115
116impl fmt::Display for I18nError {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 I18nError::EmptyTag => write!(f, "locale tag cannot be empty"),
120 I18nError::InvalidId(value) => write!(f, "invalid I18nId `{value}`"),
121 I18nError::DecodeId(err) => write!(f, "failed to decode I18nId: {err}"),
122 I18nError::MissingField(field) => write!(f, "missing required field `{field}`"),
123 }
124 }
125}
126
127impl std::error::Error for I18nError {
128 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129 match self {
130 I18nError::DecodeId(err) => Some(err),
131 _ => None,
132 }
133 }
134}
135
136impl From<data_encoding::DecodeError> for I18nError {
137 fn from(err: data_encoding::DecodeError) -> Self {
138 I18nError::DecodeId(err)
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum Direction {
144 Ltr,
145 Rtl,
146}
147
148impl fmt::Display for Direction {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 match self {
151 Direction::Ltr => write!(f, "ltr"),
152 Direction::Rtl => write!(f, "rtl"),
153 }
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct I18nProfile {
159 pub tag: I18nTag,
160 pub currency: Option<String>,
161 pub decimal_separator: char,
162 pub direction: Direction,
163 pub calendar: String,
164 pub numbering_system: String,
165 pub timezone: String,
166 pub first_day: String,
167 pub hour_cycle: String,
168 pub collation: Option<String>,
169 pub case_first: Option<String>,
170 pub units: Option<String>,
171 pub id: I18nId,
172}
173
174impl I18nProfile {
175 #[allow(clippy::too_many_arguments)]
176 fn new(
177 tag: I18nTag,
178 currency: Option<String>,
179 direction: Direction,
180 calendar: String,
181 numbering_system: String,
182 timezone: String,
183 first_day: String,
184 hour_cycle: String,
185 collation: Option<String>,
186 case_first: Option<String>,
187 units: Option<String>,
188 ) -> Self {
189 let decimal_separator = decimal_separator_for_tag(&tag);
190 let mut profile = I18nProfile {
191 tag,
192 currency,
193 decimal_separator,
194 direction,
195 calendar,
196 numbering_system,
197 timezone,
198 first_day,
199 hour_cycle,
200 collation,
201 case_first,
202 units,
203 id: I18nId::zero(),
204 };
205 profile.id = I18nId::from_profile(&profile);
206 profile
207 }
208
209 fn canonical_bytes(&self) -> Vec<u8> {
210 encode_canonical_profile(self)
211 }
212}
213
214#[derive(Debug, Clone)]
215pub struct I18nRequest {
216 pub user_tag: Option<I18nTag>,
217 pub session_tag: Option<I18nTag>,
218 pub content_tag: Option<I18nTag>,
219 pub currency: Option<String>,
220 pub timezone: Option<String>,
221 pub mode: ResolveMode,
222}
223
224impl I18nRequest {
225 pub fn new(tag: Option<I18nTag>, currency: Option<String>) -> Self {
226 Self {
227 user_tag: None,
228 session_tag: None,
229 content_tag: tag,
230 currency,
231 timezone: None,
232 mode: ResolveMode::Lenient,
233 }
234 }
235
236 pub fn with_mode(mut self, mode: ResolveMode) -> Self {
237 self.mode = mode;
238 self
239 }
240
241 pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
242 self.timezone = Some(tz.into());
243 self
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub enum ResolveMode {
249 Strict,
250 #[default]
251 Lenient,
252}
253
254#[derive(Debug, Clone)]
255pub struct I18nResolution {
256 pub id: I18nId,
257 pub profile: I18nProfile,
258 pub fallback_chain: Vec<I18nTag>,
259}
260
261pub trait I18nResolver: Send + Sync + 'static {
262 fn resolve(&self, req: I18nRequest) -> Result<I18nResolution, I18nError>;
263}
264
265pub struct DefaultResolver {
266 tenant_default: I18nTag,
267 default_currency: Option<String>,
268}
269
270impl Default for DefaultResolver {
271 fn default() -> Self {
272 Self {
273 tenant_default: I18nTag::new("en-US").expect("valid default tag"),
274 default_currency: Some("USD".to_string()),
275 }
276 }
277}
278
279impl DefaultResolver {
280 pub fn new(tenant_default: I18nTag, default_currency: Option<String>) -> Self {
281 Self {
282 tenant_default,
283 default_currency,
284 }
285 }
286}
287
288impl I18nResolver for DefaultResolver {
289 fn resolve(&self, req: I18nRequest) -> Result<I18nResolution, I18nError> {
290 let mut currency = req
291 .currency
292 .clone()
293 .or_else(|| self.default_currency.clone());
294 let chosen_tag = req
295 .content_tag
296 .clone()
297 .or(req.session_tag.clone())
298 .or(req.user_tag.clone())
299 .unwrap_or_else(|| self.tenant_default.clone());
300 let fallback_chain = build_fallback_chain(&chosen_tag, &self.tenant_default);
301 let details = parse_tag_details(&chosen_tag);
302 let direction = direction_for_language(&details.language);
303
304 let calendar = extension_value(&details, "ca")
305 .or_else(|| Some(lenient_calendar()))
306 .unwrap();
307 let numbering_system = extension_value(&details, "nu")
308 .or_else(|| Some(lenient_numbering_system()))
309 .unwrap();
310 let timezone = extension_value(&details, "tz")
311 .or_else(|| req.timezone.clone())
312 .unwrap_or_else(|| "UTC".to_string());
313
314 if req.mode == ResolveMode::Strict
315 && extension_value(&details, "tz").is_none()
316 && req.timezone.is_none()
317 {
318 return Err(I18nError::MissingField("timezone"));
319 }
320 if req.mode == ResolveMode::Strict && extension_value(&details, "ca").is_none() {
321 return Err(I18nError::MissingField("calendar"));
322 }
323 if req.mode == ResolveMode::Strict && extension_value(&details, "nu").is_none() {
324 return Err(I18nError::MissingField("numbering_system"));
325 }
326
327 let first_day = lenient_first_day(details.region.as_deref()).to_string();
328 let hour_cycle = lenient_hour_cycle(details.region.as_deref()).to_string();
329 let collation = extension_value(&details, "co");
330 let case_first = extension_value(&details, "kf");
331 let units = extension_value(&details, "unit");
332
333 let profile = I18nProfile::new(
334 chosen_tag.clone(),
335 currency.take(),
336 direction,
337 calendar,
338 numbering_system,
339 timezone,
340 first_day,
341 hour_cycle,
342 collation,
343 case_first,
344 units,
345 );
346
347 let resolution = I18nResolution {
348 id: profile.id,
349 profile,
350 fallback_chain,
351 };
352 Ok(resolution)
353 }
354}
355
356pub struct I18n {
357 resolver: Arc<dyn I18nResolver>,
358 cache: Mutex<I18nCache>,
359}
360
361impl I18n {
362 pub fn new(resolver: Arc<dyn I18nResolver>) -> Self {
363 Self::new_with_config(resolver, I18nCacheConfig::default())
364 }
365
366 pub fn new_with_config(resolver: Arc<dyn I18nResolver>, config: I18nCacheConfig) -> Self {
367 Self {
368 resolver,
369 cache: Mutex::new(I18nCache::new(config)),
370 }
371 }
372
373 pub fn profile(&self, id: &I18nId) -> Option<I18nProfile> {
374 self.get(id).map(|profile| (*profile).clone())
375 }
376
377 pub fn get(&self, id: &I18nId) -> Option<Arc<I18nProfile>> {
378 self.cache.lock().unwrap().get(id)
379 }
380
381 pub fn get_with_fallback(&self, id: &I18nId) -> Option<I18nCacheSnapshot> {
382 self.cache.lock().unwrap().get_snapshot(id)
383 }
384
385 pub fn insert(&self, profile: I18nProfile, fallback_chain: Vec<I18nTag>) -> I18nId {
386 let mut stored = profile.clone();
387 let id = I18nId::from_profile(&stored);
388 stored.id = id;
389 let entry = I18nCacheEntry {
390 profile: Arc::new(stored),
391 fallback_chain,
392 };
393 self.cache.lock().unwrap().insert(id, entry);
394 id
395 }
396
397 pub fn resolve_and_cache(&self, req: I18nRequest) -> Result<I18nResolution, I18nError> {
398 let resolution = self.resolver.resolve(req)?;
399 let entry = I18nCacheEntry {
400 profile: Arc::new(resolution.profile.clone()),
401 fallback_chain: resolution.fallback_chain.clone(),
402 };
403 self.cache.lock().unwrap().insert(resolution.id, entry);
404 Ok(resolution)
405 }
406}
407
408fn decimal_separator_for_tag(tag: &I18nTag) -> char {
409 if tag.as_str().starts_with("fr-") {
410 ','
411 } else {
412 '.'
413 }
414}
415
416fn lenient_calendar() -> String {
417 "gregory".to_string()
418}
419
420fn lenient_numbering_system() -> String {
421 "latn".to_string()
422}
423
424fn build_fallback_chain(final_tag: &I18nTag, tenant_default: &I18nTag) -> Vec<I18nTag> {
425 let mut chain = Vec::new();
426 let parents = build_parent_chain(final_tag);
427 for tag in parents {
428 if chain
429 .iter()
430 .any(|existing: &I18nTag| existing.as_str() == tag.as_str())
431 {
432 continue;
433 }
434 chain.push(tag);
435 }
436 if !chain.iter().any(|t| t == tenant_default) {
437 chain.push(tenant_default.clone());
438 }
439 chain
440}
441
442#[derive(Clone)]
443pub struct I18nCacheConfig {
444 pub max_entries: usize,
445}
446
447impl Default for I18nCacheConfig {
448 fn default() -> Self {
449 Self { max_entries: 1024 }
450 }
451}
452
453pub struct I18nCacheEntry {
454 profile: Arc<I18nProfile>,
455 fallback_chain: Vec<I18nTag>,
456}
457
458pub struct I18nCacheSnapshot {
459 pub profile: Arc<I18nProfile>,
460 pub fallback_chain: Vec<I18nTag>,
461}
462
463pub struct I18nCache {
464 entries: HashMap<I18nId, I18nCacheEntry>,
465 order: VecDeque<I18nId>,
466 config: I18nCacheConfig,
467}
468
469impl I18nCache {
470 fn new(config: I18nCacheConfig) -> Self {
471 let max_entries = if config.max_entries == 0 {
472 1
473 } else {
474 config.max_entries
475 };
476 Self {
477 entries: HashMap::new(),
478 order: VecDeque::new(),
479 config: I18nCacheConfig { max_entries },
480 }
481 }
482
483 fn insert(&mut self, id: I18nId, entry: I18nCacheEntry) {
484 self.touch(&id);
485 self.entries.insert(id, entry);
486 self.evict_if_needed();
487 }
488
489 fn get(&mut self, id: &I18nId) -> Option<Arc<I18nProfile>> {
490 if self.entries.contains_key(id) {
491 self.touch(id);
492 return self.entries.get(id).map(|entry| entry.profile.clone());
493 }
494 None
495 }
496
497 fn get_snapshot(&mut self, id: &I18nId) -> Option<I18nCacheSnapshot> {
498 if self.entries.contains_key(id) {
499 self.touch(id);
500 if let Some(entry) = self.entries.get(id) {
501 return Some(I18nCacheSnapshot {
502 profile: entry.profile.clone(),
503 fallback_chain: entry.fallback_chain.clone(),
504 });
505 }
506 }
507 None
508 }
509
510 fn touch(&mut self, id: &I18nId) {
511 if let Some(pos) = self.order.iter().position(|existing| existing == id) {
512 self.order.remove(pos);
513 }
514 self.order.push_back(*id);
515 }
516
517 fn evict_if_needed(&mut self) {
518 while self.entries.len() > self.config.max_entries {
519 if let Some(evicted) = self.order.pop_front() {
520 self.entries.remove(&evicted);
521 }
522 }
523 }
524}
525
526fn encode_canonical_profile(profile: &I18nProfile) -> Vec<u8> {
527 let mut entries: Vec<(&str, String)> = vec![
528 ("calendar", profile.calendar.clone()),
529 ("decimal_separator", profile.decimal_separator.to_string()),
530 ("direction", profile.direction.to_string()),
531 ("first_day", profile.first_day.clone()),
532 ("hour_cycle", profile.hour_cycle.clone()),
533 ("numbering_system", profile.numbering_system.clone()),
534 ("tag", profile.tag.as_str().to_string()),
535 ("timezone", profile.timezone.clone()),
536 ];
537
538 if let Some(currency) = &profile.currency {
539 entries.push(("currency", currency.clone()));
540 }
541 if let Some(collation) = &profile.collation {
542 entries.push(("collation", collation.clone()));
543 }
544 if let Some(case_first) = &profile.case_first {
545 entries.push(("case_first", case_first.clone()));
546 }
547 if let Some(units) = &profile.units {
548 entries.push(("units", units.clone()));
549 }
550
551 entries.sort_by(|a, b| a.0.cmp(b.0));
552 let mut buf = Vec::new();
553 encode_map_header(entries.len(), &mut buf);
554 for (key, value) in entries {
555 encode_text(key, &mut buf);
556 encode_text(&value, &mut buf);
557 }
558 buf
559}
560
561fn encode_map_header(len: usize, buf: &mut Vec<u8>) {
562 encode_unsigned(5, len as u64, buf);
563}
564
565fn encode_text(value: &str, buf: &mut Vec<u8>) {
566 let bytes = value.as_bytes();
567 encode_unsigned(3, bytes.len() as u64, buf);
568 buf.extend_from_slice(bytes);
569}
570
571fn encode_unsigned(major: u8, value: u64, buf: &mut Vec<u8>) {
572 if value < 24 {
573 buf.push((major << 5) | (value as u8));
574 } else if value < 256 {
575 buf.push((major << 5) | 24);
576 buf.push(value as u8);
577 } else if value < 65_536 {
578 buf.push((major << 5) | 25);
579 buf.extend_from_slice(&(value as u16).to_be_bytes());
580 } else if value < 4_294_967_296 {
581 buf.push((major << 5) | 26);
582 buf.extend_from_slice(&(value as u32).to_be_bytes());
583 } else {
584 buf.push((major << 5) | 27);
585 buf.extend_from_slice(&value.to_be_bytes());
586 }
587}
588
589pub fn normalize_tag(input: &str) -> Result<I18nTag, I18nError> {
590 canonicalize_tag(input).map(I18nTag)
591}
592
593fn canonicalize_tag(input: &str) -> Result<String, I18nError> {
594 let raw = input.trim();
595 if raw.is_empty() {
596 return Err(I18nError::EmptyTag);
597 }
598
599 let mut canonical: Vec<String> = Vec::new();
600 let mut in_extension = false;
601 for part in raw.split('-').filter(|p| !p.is_empty()) {
602 if !in_extension && part.eq_ignore_ascii_case("u") {
603 in_extension = true;
604 canonical.push("u".to_string());
605 continue;
606 }
607
608 let normalized = if in_extension || canonical.is_empty() {
609 part.to_ascii_lowercase()
610 } else if part.len() == 4 {
611 let mut chars = part.chars();
612 let first = chars.next().unwrap().to_ascii_uppercase();
613 let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
614 format!("{first}{rest}")
615 } else if part.len() <= 3 {
616 part.to_ascii_uppercase()
617 } else {
618 part.to_ascii_lowercase()
619 };
620
621 canonical.push(normalized);
622 }
623
624 if canonical.is_empty() {
625 Err(I18nError::EmptyTag)
626 } else {
627 Ok(canonical.join("-"))
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::{
634 DefaultResolver, Direction, I18nCacheConfig, I18nError, I18nId, I18nProfile, I18nRequest,
635 ResolveMode, normalize_tag,
636 };
637 use crate::{I18n, I18nResolver};
638 use std::fs;
639 use std::path::Path;
640 use std::sync::Arc;
641
642 #[test]
643 fn normalize_common_tags() {
644 assert_eq!(normalize_tag("en-gb").unwrap().as_str(), "en-GB");
645 assert_eq!(normalize_tag("zh-hant-tw").unwrap().as_str(), "zh-Hant-TW");
646 assert_eq!(
647 normalize_tag("EN-us-U-ca-gregory-cu-usd").unwrap().as_str(),
648 "en-US-u-ca-gregory-cu-usd"
649 );
650 }
651
652 #[test]
653 fn canonical_profile_id_is_stable() {
654 let tag = normalize_tag("en-GB-u-ca-gregory-cu-gbp").unwrap();
655 let profile = I18nProfile::new(
656 tag,
657 Some("GBP".to_string()),
658 super::Direction::Ltr,
659 "gregory".to_string(),
660 "latn".to_string(),
661 "UTC".to_string(),
662 "mon".to_string(),
663 "h23".to_string(),
664 None,
665 None,
666 None,
667 );
668 assert_eq!(profile.id.as_str(), "i18n:v1:KU23J7EOLPRYIEJRBTXNCBMQBA");
669 }
670
671 #[test]
672 fn fixture_matches_expected_canonicalization() {
673 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
674 .join("fixtures")
675 .join("i18n_id_v1_cases.json");
676 let raw = fs::read_to_string(fixture_path).expect("fixture file");
677 let cases: serde_json::Value =
678 serde_json::from_str(&raw).expect("fixture JSON should be valid");
679 for case in cases.as_array().unwrap() {
680 let tag = case["tag"].as_str().unwrap();
681 let currency = case["currency"].as_str();
682 let normalized = normalize_tag(tag).unwrap();
683 let profile = I18nProfile::new(
684 normalized.clone(),
685 currency.map(|c| c.to_string()),
686 super::Direction::Ltr,
687 "gregory".to_string(),
688 "latn".to_string(),
689 "UTC".to_string(),
690 "mon".to_string(),
691 "h23".to_string(),
692 None,
693 None,
694 None,
695 );
696 let hex = profile
697 .canonical_bytes()
698 .iter()
699 .map(|b| format!("{:02x}", b))
700 .collect::<String>();
701 assert_eq!(hex, case["cbor_hex"].as_str().unwrap(), "{}", tag);
702 assert_eq!(
703 profile.id.as_str(),
704 case["expected_id"].as_str().unwrap(),
705 "{}",
706 tag
707 );
708 }
709 }
710
711 #[test]
712 fn resolver_precedence_prefers_content_tag() {
713 let tenant = normalize_tag("en-US").unwrap();
714 let resolver = DefaultResolver::new(tenant.clone(), Some("USD".to_string()));
715 let request = I18nRequest {
716 user_tag: Some(normalize_tag("fr-FR").unwrap()),
717 session_tag: Some(normalize_tag("de-DE").unwrap()),
718 content_tag: Some(normalize_tag("ar-OM").unwrap()),
719 currency: None,
720 timezone: None,
721 mode: ResolveMode::Lenient,
722 };
723 let resolution = resolver.resolve(request).unwrap();
724 assert_eq!(resolution.profile.tag.as_str(), "ar-OM");
725 assert_eq!(resolution.fallback_chain.first().unwrap().as_str(), "ar-OM");
726 assert_eq!(resolution.fallback_chain.last().unwrap(), &tenant);
727 }
728
729 #[test]
730 fn lenient_defaults_follow_region_rules() {
731 let resolver = DefaultResolver::default();
732 let request = I18nRequest {
733 user_tag: None,
734 session_tag: None,
735 content_tag: Some(normalize_tag("en-US").unwrap()),
736 currency: None,
737 timezone: None,
738 mode: ResolveMode::Lenient,
739 };
740 let resolution = resolver.resolve(request).unwrap();
741 assert_eq!(resolution.profile.first_day, "sun");
742 assert_eq!(resolution.profile.hour_cycle, "h12");
743 assert_eq!(resolution.profile.direction, super::Direction::Ltr);
744 }
745
746 #[test]
747 fn strict_mode_requires_timezone() {
748 let resolver = DefaultResolver::default();
749 let request = I18nRequest {
750 user_tag: None,
751 session_tag: None,
752 content_tag: Some(normalize_tag("en-US").unwrap()),
753 currency: None,
754 timezone: None,
755 mode: ResolveMode::Strict,
756 };
757 let err = resolver.resolve(request).unwrap_err();
758 assert!(matches!(err, I18nError::MissingField("timezone")));
759 }
760
761 #[test]
762 fn strict_mode_requires_calendar_and_numbering() {
763 let resolver = DefaultResolver::default();
764 let mut request = I18nRequest {
765 user_tag: None,
766 session_tag: None,
767 content_tag: Some(normalize_tag("en-US").unwrap()),
768 currency: None,
769 timezone: Some("UTC".to_string()),
770 mode: ResolveMode::Strict,
771 };
772 let err = resolver.resolve(request.clone()).unwrap_err();
773 assert!(matches!(err, I18nError::MissingField("calendar")));
774
775 request.content_tag = Some(normalize_tag("fr-FR-u-ca-gregory").unwrap());
776 let err = resolver.resolve(request).unwrap_err();
777 assert!(matches!(err, I18nError::MissingField("numbering_system")));
778 }
779
780 #[test]
781 fn resolver_precedence_table_is_deterministic() {
782 let tenant_default = normalize_tag("en-US").unwrap();
783 let resolver = DefaultResolver::new(tenant_default.clone(), Some("USD".to_string()));
784 let cases = [
785 (None, None, None, "en-US"),
786 (Some("fr-FR"), None, None, "fr-FR"),
787 (None, Some("de-DE"), None, "de-DE"),
788 (Some("fr-FR"), Some("de-DE"), None, "de-DE"),
789 (Some("fr-FR"), Some("de-DE"), Some("es-ES"), "es-ES"),
790 ];
791
792 for (user, session, content, expected) in cases {
793 let request = I18nRequest {
794 user_tag: user.map(|tag| normalize_tag(tag).unwrap()),
795 session_tag: session.map(|tag| normalize_tag(tag).unwrap()),
796 content_tag: content.map(|tag| normalize_tag(tag).unwrap()),
797 currency: None,
798 timezone: Some("UTC".to_string()),
799 mode: ResolveMode::Lenient,
800 };
801 let resolution = resolver.resolve(request).unwrap();
802 assert_eq!(resolution.profile.tag.as_str(), expected);
803 assert_eq!(
804 resolution.fallback_chain.first().unwrap().as_str(),
805 expected
806 );
807 }
808 }
809
810 #[test]
811 fn lenient_mode_derives_deterministic_defaults() {
812 let resolver = DefaultResolver::default();
813 let request = I18nRequest {
814 user_tag: None,
815 session_tag: None,
816 content_tag: Some(normalize_tag("ar-SA").unwrap()),
817 currency: None,
818 timezone: Some("Asia/Riyadh".to_string()),
819 mode: ResolveMode::Lenient,
820 };
821 let resolution = resolver.resolve(request).unwrap();
822 assert_eq!(resolution.profile.direction, Direction::Rtl);
823 assert_eq!(resolution.profile.first_day, "sat");
824 assert_eq!(resolution.profile.hour_cycle, "h23");
825 assert_eq!(resolution.profile.calendar, "gregory");
826 assert_eq!(resolution.profile.numbering_system, "latn");
827 assert_eq!(resolution.profile.timezone, "Asia/Riyadh");
828 assert_eq!(resolution.profile.tag.as_str(), "ar-SA");
829
830 let fallback_request = I18nRequest {
831 user_tag: None,
832 session_tag: None,
833 content_tag: Some(normalize_tag("en-US").unwrap()),
834 currency: None,
835 timezone: None,
836 mode: ResolveMode::Lenient,
837 };
838 let fallback_resolution = resolver.resolve(fallback_request).unwrap();
839 assert_eq!(fallback_resolution.profile.timezone, "UTC");
840 }
841
842 #[test]
843 fn fallback_chain_reuses_tenant_parent() {
844 let tenant_default = normalize_tag("en").unwrap();
845 let resolver = DefaultResolver::new(tenant_default.clone(), None);
846 let request = I18nRequest {
847 user_tag: None,
848 session_tag: None,
849 content_tag: Some(normalize_tag("en-US").unwrap()),
850 currency: None,
851 timezone: None,
852 mode: ResolveMode::Lenient,
853 };
854 let resolution = resolver.resolve(request).unwrap();
855 let chain: Vec<_> = resolution
856 .fallback_chain
857 .iter()
858 .map(|tag| tag.as_str().to_string())
859 .collect();
860 assert_eq!(chain, vec!["en-US".to_string(), "en".to_string()]);
861 }
862
863 #[test]
864 fn cached_profile_matches_id_and_fallback_chain() {
865 let resolver = DefaultResolver::default();
866 let engine = I18n::new(Arc::new(resolver));
867 let resolution = engine
868 .resolve_and_cache(I18nRequest::new(
869 Some(normalize_tag("en-US").unwrap()),
870 None,
871 ))
872 .unwrap();
873 let cached = engine
874 .get_with_fallback(&resolution.id)
875 .expect("missing cache entry");
876 assert_eq!(cached.profile.id, resolution.id);
877 assert_eq!(cached.fallback_chain, resolution.fallback_chain);
878 assert_eq!(I18nId::from_profile(&cached.profile), resolution.id);
879 }
880
881 #[test]
882 fn cache_respects_max_entries_limit() {
883 let resolver = DefaultResolver::default();
884 let engine = I18n::new_with_config(Arc::new(resolver), I18nCacheConfig { max_entries: 2 });
885 let first = engine
886 .resolve_and_cache(I18nRequest::new(
887 Some(normalize_tag("en-US").unwrap()),
888 None,
889 ))
890 .unwrap()
891 .id;
892 let second = engine
893 .resolve_and_cache(I18nRequest::new(
894 Some(normalize_tag("fr-FR").unwrap()),
895 None,
896 ))
897 .unwrap()
898 .id;
899 let third = engine
900 .resolve_and_cache(I18nRequest::new(
901 Some(normalize_tag("ar-OM").unwrap()),
902 None,
903 ))
904 .unwrap()
905 .id;
906 assert!(engine.get(&first).is_none());
907 assert!(engine.get(&second).is_some());
908 assert!(engine.get(&third).is_some());
909 }
910
911 #[test]
912 fn fallback_chain_includes_parents() {
913 let tenant = normalize_tag("en-US").unwrap();
914 let resolver = DefaultResolver::new(tenant.clone(), None);
915 let request = I18nRequest {
916 user_tag: None,
917 session_tag: None,
918 content_tag: Some(normalize_tag("en-GB").unwrap()),
919 currency: None,
920 timezone: None,
921 mode: ResolveMode::Lenient,
922 };
923 let resolution = resolver.resolve(request).unwrap();
924 assert!(
925 resolution
926 .fallback_chain
927 .iter()
928 .any(|tag| tag.as_str() == "en-GB")
929 );
930 assert!(
931 resolution
932 .fallback_chain
933 .iter()
934 .any(|tag| tag.as_str() == "en")
935 );
936 assert_eq!(resolution.fallback_chain.last().unwrap(), &tenant);
937 }
938}