Skip to main content

ceres_core/
i18n.rs

1//! Multilingual field support for open data portals.
2//!
3//! Some portals (e.g., `ckan.opendata.swiss`) return metadata fields as
4//! language-keyed JSON objects instead of plain strings:
5//!
6//! ```json
7//! { "en": "English title", "de": "German title", "fr": "French title" }
8//! ```
9//!
10//! The [`LocalizedField`] type transparently handles both formats via custom
11//! serde deserialization, allowing a single struct to work with both
12//! monolingual and multilingual portals.
13
14use serde::Deserialize;
15use serde::de::{self, Deserializer, MapAccess, Visitor};
16use std::collections::BTreeMap;
17use std::fmt;
18
19/// A field that may be either a plain string or a multilingual map.
20///
21/// # Examples
22///
23/// ```
24/// use ceres_core::LocalizedField;
25///
26/// // Plain string (most portals)
27/// let plain: LocalizedField = serde_json::from_str(r#""My Dataset""#).unwrap();
28/// assert_eq!(plain.resolve("en"), "My Dataset");
29/// assert_eq!(plain.resolve("de"), "My Dataset"); // language ignored for plain
30///
31/// // Multilingual object (e.g., Swiss portals)
32/// let multi: LocalizedField = serde_json::from_str(
33///     r#"{"en": "English", "de": "Deutsch", "fr": "Francais"}"#
34/// ).unwrap();
35/// assert_eq!(multi.resolve("de"), "Deutsch");
36/// assert_eq!(multi.resolve("en"), "English");
37/// assert_eq!(multi.resolve("it"), "English"); // falls back to "en"
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum LocalizedField {
41    /// A simple string value (most portals).
42    Plain(String),
43    /// A sorted map of language code to localized text (multilingual portals).
44    /// Uses `BTreeMap` for deterministic fallback ordering when neither the
45    /// preferred language nor `"en"` is available.
46    Multilingual(BTreeMap<String, String>),
47}
48
49impl LocalizedField {
50    /// Resolves the field to a single string using the preferred language.
51    ///
52    /// Resolution strategy:
53    /// 1. If plain string, return it directly (language is ignored).
54    /// 2. If multilingual, try the preferred language (skip if empty).
55    /// 3. Fall back to `"en"` if the preferred language is unavailable or empty.
56    /// 4. Fall back to the first non-empty translation.
57    /// 5. Return an empty string if no translations exist.
58    pub fn resolve(&self, preferred_language: &str) -> String {
59        match self {
60            LocalizedField::Plain(s) => s.clone(),
61            LocalizedField::Multilingual(map) => map
62                .get(preferred_language)
63                .filter(|s| !s.is_empty())
64                .or_else(|| {
65                    if preferred_language != "en" {
66                        map.get("en").filter(|s| !s.is_empty())
67                    } else {
68                        None
69                    }
70                })
71                .or_else(|| map.values().find(|s| !s.is_empty()))
72                .cloned()
73                .unwrap_or_default(),
74        }
75    }
76}
77
78impl<'de> Deserialize<'de> for LocalizedField {
79    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80    where
81        D: Deserializer<'de>,
82    {
83        struct LocalizedFieldVisitor;
84
85        impl<'de> Visitor<'de> for LocalizedFieldVisitor {
86            type Value = LocalizedField;
87
88            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
89                f.write_str("a string or a map of language codes to strings")
90            }
91
92            fn visit_str<E: de::Error>(self, value: &str) -> Result<LocalizedField, E> {
93                Ok(LocalizedField::Plain(value.to_string()))
94            }
95
96            fn visit_string<E: de::Error>(self, value: String) -> Result<LocalizedField, E> {
97                Ok(LocalizedField::Plain(value))
98            }
99
100            fn visit_map<M>(self, map: M) -> Result<LocalizedField, M::Error>
101            where
102                M: MapAccess<'de>,
103            {
104                let translations: BTreeMap<String, String> =
105                    Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
106                Ok(LocalizedField::Multilingual(translations))
107            }
108        }
109
110        deserializer.deserialize_any(LocalizedFieldVisitor)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_resolve_plain_ignores_language() {
120        let field = LocalizedField::Plain("Hello".to_string());
121        assert_eq!(field.resolve("en"), "Hello");
122        assert_eq!(field.resolve("de"), "Hello");
123        assert_eq!(field.resolve("fr"), "Hello");
124    }
125
126    #[test]
127    fn test_resolve_multilingual_preferred_language() {
128        let mut map = BTreeMap::new();
129        map.insert("en".to_string(), "English".to_string());
130        map.insert("de".to_string(), "Deutsch".to_string());
131        map.insert("fr".to_string(), "Francais".to_string());
132        let field = LocalizedField::Multilingual(map);
133
134        assert_eq!(field.resolve("de"), "Deutsch");
135        assert_eq!(field.resolve("fr"), "Francais");
136        assert_eq!(field.resolve("en"), "English");
137    }
138
139    #[test]
140    fn test_resolve_multilingual_falls_back_to_en() {
141        let mut map = BTreeMap::new();
142        map.insert("en".to_string(), "English".to_string());
143        map.insert("de".to_string(), "Deutsch".to_string());
144        let field = LocalizedField::Multilingual(map);
145
146        // Italian not available, falls back to English
147        assert_eq!(field.resolve("it"), "English");
148    }
149
150    #[test]
151    fn test_resolve_multilingual_falls_back_to_first_available() {
152        let mut map = BTreeMap::new();
153        map.insert("de".to_string(), "Deutsch".to_string());
154        let field = LocalizedField::Multilingual(map);
155
156        // Neither "fr" nor "en" available, falls back to first available
157        assert_eq!(field.resolve("fr"), "Deutsch");
158    }
159
160    #[test]
161    fn test_resolve_multilingual_empty_map() {
162        let field = LocalizedField::Multilingual(BTreeMap::new());
163        assert_eq!(field.resolve("en"), "");
164    }
165
166    #[test]
167    fn test_resolve_multilingual_skips_empty_preferred() {
168        let mut map = BTreeMap::new();
169        map.insert("en".to_string(), "".to_string());
170        map.insert("de".to_string(), "Deutsch".to_string());
171        map.insert("fr".to_string(), "".to_string());
172        let field = LocalizedField::Multilingual(map);
173
174        // "en" exists but is empty, should fall back to first non-empty ("de")
175        assert_eq!(field.resolve("en"), "Deutsch");
176        // "fr" exists but is empty, should try "en" (also empty), then first non-empty
177        assert_eq!(field.resolve("fr"), "Deutsch");
178    }
179
180    #[test]
181    fn test_deserialize_plain_string() {
182        let field: LocalizedField = serde_json::from_str(r#""My Dataset""#).unwrap();
183        assert_eq!(field, LocalizedField::Plain("My Dataset".to_string()));
184    }
185
186    #[test]
187    fn test_deserialize_multilingual_object() {
188        let field: LocalizedField =
189            serde_json::from_str(r#"{"en": "English Title", "de": "Deutscher Titel"}"#).unwrap();
190        match &field {
191            LocalizedField::Multilingual(map) => {
192                assert_eq!(map.get("en").unwrap(), "English Title");
193                assert_eq!(map.get("de").unwrap(), "Deutscher Titel");
194            }
195            _ => panic!("Expected Multilingual variant"),
196        }
197    }
198
199    #[test]
200    fn test_deserialize_option_null() {
201        let field: Option<LocalizedField> = serde_json::from_str("null").unwrap();
202        assert!(field.is_none());
203    }
204
205    #[test]
206    fn test_deserialize_option_plain() {
207        let field: Option<LocalizedField> = serde_json::from_str(r#""description""#).unwrap();
208        assert_eq!(
209            field,
210            Some(LocalizedField::Plain("description".to_string()))
211        );
212    }
213
214    #[test]
215    fn test_deserialize_option_multilingual() {
216        let field: Option<LocalizedField> =
217            serde_json::from_str(r#"{"en": "English", "fr": "Francais"}"#).unwrap();
218        assert!(matches!(field, Some(LocalizedField::Multilingual(_))));
219    }
220}