1use alloc::{format, string::String};
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
6
7use crate::cbor::canonical;
8
9const I18N_ID_PREFIX: &str = "i18n:v1:";
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct I18nTag(String);
15
16impl I18nTag {
17 pub fn normalize_tag(input: &str) -> Result<Self, I18nTagError> {
19 let langid: LanguageIdentifier = input.parse()?;
20 Ok(Self(langid.to_string()))
21 }
22
23 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27}
28
29impl core::fmt::Display for I18nTag {
30 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
31 f.write_str(&self.0)
32 }
33}
34
35#[derive(Debug, Error)]
37pub enum I18nTagError {
38 #[error("invalid locale tag: {0}")]
40 Invalid(#[from] LanguageIdentifierError),
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
46pub struct I18nId(String);
47
48impl I18nId {
49 pub fn parse(value: &str) -> Result<Self, I18nIdError> {
51 if !value.starts_with(I18N_ID_PREFIX) {
52 return Err(I18nIdError::InvalidPrefix);
53 }
54 let encoded = &value[I18N_ID_PREFIX.len()..];
55 canonical::decode_base32_crockford(encoded)?;
56 Ok(Self(value.to_owned()))
57 }
58
59 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63}
64
65impl core::fmt::Display for I18nId {
66 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
67 f.write_str(&self.0)
68 }
69}
70
71pub fn id_for_tag(tag: &I18nTag) -> Result<I18nId, I18nIdError> {
73 let canonical_bytes = canonical::to_canonical_cbor(&tag.as_str())?;
74 let digest = canonical::blake3_128(&canonical_bytes);
75 let encoded = canonical::encode_base32_crockford(&digest);
76 Ok(I18nId(format!("{I18N_ID_PREFIX}{encoded}")))
77}
78
79#[derive(Debug, Error)]
81pub enum I18nIdError {
82 #[error("i18n ID must begin with {I18N_ID_PREFIX}")]
84 InvalidPrefix,
85 #[error("invalid base32 payload: {0}")]
87 Base32(#[from] canonical::Base32Error),
88 #[error(transparent)]
90 Canonical(#[from] canonical::CanonicalError),
91}
92
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
95#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
96pub enum Direction {
97 #[default]
99 Ltr,
100 Rtl,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
106#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
107pub struct MinimalI18nProfile {
108 pub language: String,
110 pub region: Option<String>,
112 pub script: Option<String>,
114 pub direction: Direction,
116 pub calendar: String,
118 pub currency: String,
120 pub decimal_separator: String,
122 pub timezone: Option<String>,
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn normalize_tag_case() {
132 let tag = match I18nTag::normalize_tag("en-gb") {
133 Ok(tag) => tag,
134 Err(err) => panic!("normalize failed: {err:?}"),
135 };
136 assert_eq!(tag.as_str(), "en-GB");
137 }
138
139 #[test]
140 fn tag_id_roundtrip() {
141 let tag = match I18nTag::normalize_tag("en-US") {
142 Ok(tag) => tag,
143 Err(err) => panic!("normalize failed: {err:?}"),
144 };
145 let id = match id_for_tag(&tag) {
146 Ok(id) => id,
147 Err(err) => panic!("id generation failed: {err:?}"),
148 };
149 let roundtrip = match I18nId::parse(id.as_str()) {
150 Ok(value) => value,
151 Err(err) => panic!("parse failed: {err:?}"),
152 };
153 assert_eq!(roundtrip.as_str(), id.as_str());
154 }
155}