Skip to main content

greentic_types/
i18n.rs

1//! Internationalization tags, IDs, and minimal profiles.
2use 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/// Normalized locale tag (BCP 47 + -u- extensions) used for ID generation.
12#[derive(Clone, Debug, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct I18nTag(String);
15
16impl I18nTag {
17    /// Normalize an arbitrary locale tag into canonical casing.
18    pub fn normalize_tag(input: &str) -> Result<Self, I18nTagError> {
19        let langid: LanguageIdentifier = input.parse()?;
20        Ok(Self(langid.to_string()))
21    }
22
23    /// Canonical tag string.
24    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/// Errors encountered while normalizing locale tags.
36#[derive(Debug, Error)]
37pub enum I18nTagError {
38    /// Input string is not a valid BCP 47 tag.
39    #[error("invalid locale tag: {0}")]
40    Invalid(#[from] LanguageIdentifierError),
41}
42
43/// Stable identifier for a normalized locale tag or profile.
44#[derive(Clone, Debug, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
46pub struct I18nId(String);
47
48impl I18nId {
49    /// Parse a string produced by `id_for_tag`.
50    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    /// Borrow the canonical identifier string.
60    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
71/// Compute the ID for a normalized tag.
72pub 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/// Errors emitted when working with I18n IDs.
80#[derive(Debug, Error)]
81pub enum I18nIdError {
82    /// Identifier does not have the required `i18n:v1:` prefix.
83    #[error("i18n ID must begin with {I18N_ID_PREFIX}")]
84    InvalidPrefix,
85    /// Base32 payload could not be decoded.
86    #[error("invalid base32 payload: {0}")]
87    Base32(#[from] canonical::Base32Error),
88    /// Canonicalization or hashing failed while generating the ID.
89    #[error(transparent)]
90    Canonical(#[from] canonical::CanonicalError),
91}
92
93/// Directionality of text (`ltr` / `rtl`).
94#[derive(Clone, Debug, Default, PartialEq, Eq)]
95#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
96pub enum Direction {
97    /// Left-to-right text.
98    #[default]
99    Ltr,
100    /// Right-to-left text.
101    Rtl,
102}
103
104/// Minimal profile used during setup-time localization.
105#[derive(Clone, Debug, PartialEq, Eq)]
106#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
107pub struct MinimalI18nProfile {
108    /// Primary language subtag (e.g., `en`).
109    pub language: String,
110    /// Optional region subtag (e.g., `GB`).
111    pub region: Option<String>,
112    /// Optional script subtag (e.g., `Latn`).
113    pub script: Option<String>,
114    /// Text direction.
115    pub direction: Direction,
116    /// Calendar system (e.g., `gregory`).
117    pub calendar: String,
118    /// Currency code (ISO 4217).
119    pub currency: String,
120    /// Decimal separator symbol (e.g., `.` or `,`).
121    pub decimal_separator: String,
122    /// Optional timezone identifier.
123    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}