Skip to main content

greentic_i18n_lib/
lib.rs

1//! Core i18n primitives: tag canonicalization, resolver, canonical CBOR + I18nId v1.
2pub 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/// Represents a canonicalized locale tag.
22#[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/// Canonical ID derived from a resolved profile (BLAKE3 digest over canonical CBOR).
36#[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/// Errors surfaced by the i18n core helpers.
108#[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}