Skip to main content

greentic_i18n/
lib.rs

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}