1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7pub type OrderedMap<K, V> = IndexMap<K, V>;
12
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "camelCase")]
15pub struct XcStringsFile {
16 pub source_language: String,
17 pub strings: OrderedMap<String, StringEntry>,
18 pub version: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "camelCase")]
23pub struct StringEntry {
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub extraction_state: Option<ExtractionState>,
26 #[serde(default = "default_true", skip_serializing_if = "is_true")]
27 pub should_translate: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub comment: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub localizations: Option<OrderedMap<String, Localization>>,
32}
33
34fn default_true() -> bool {
35 true
36}
37
38fn is_true(v: &bool) -> bool {
39 *v
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct Localization {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub string_unit: Option<StringUnit>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub variations: Option<Variations>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub substitutions: Option<BTreeMap<String, serde_json::Value>>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "camelCase")]
55pub struct StringUnit {
56 pub state: TranslationState,
57 pub value: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61#[serde(rename_all = "camelCase")]
62pub struct Variations {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub plural: Option<BTreeMap<String, PluralVariation>>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub device: Option<BTreeMap<DeviceCategory, DeviceVariation>>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70#[serde(rename_all = "camelCase")]
71pub struct PluralVariation {
72 pub string_unit: StringUnit,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "camelCase")]
77pub struct DeviceVariation {
78 pub string_unit: StringUnit,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82#[serde(rename_all = "snake_case")]
83pub enum ExtractionState {
84 Manual,
85 ExtractedWithValue,
86 Stale,
87 Migrated,
88 #[serde(untagged)]
89 Unknown(String),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93#[serde(rename_all = "snake_case")]
94pub enum TranslationState {
95 New,
96 Translated,
97 NeedsReview,
98 Stale,
99 #[serde(untagged)]
100 Unknown(String),
101}
102
103#[derive(
104 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
105)]
106pub enum DeviceCategory {
107 #[serde(rename = "iphone")]
108 IPhone,
109 #[serde(rename = "ipad")]
110 IPad,
111 #[serde(rename = "mac")]
112 Mac,
113 #[serde(rename = "applewatch")]
114 AppleWatch,
115 #[serde(rename = "appletv")]
116 AppleTv,
117 #[serde(untagged)]
118 Unknown(String),
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn extraction_state_known_values_roundtrip() {
127 let variants = [
128 (ExtractionState::Manual, "\"manual\""),
129 (
130 ExtractionState::ExtractedWithValue,
131 "\"extracted_with_value\"",
132 ),
133 (ExtractionState::Stale, "\"stale\""),
134 (ExtractionState::Migrated, "\"migrated\""),
135 ];
136 for (variant, expected_json) in &variants {
137 let json = serde_json::to_string(variant).unwrap();
138 assert_eq!(&json, expected_json);
139 let deserialized: ExtractionState = serde_json::from_str(&json).unwrap();
140 assert_eq!(&deserialized, variant);
141 }
142 }
143
144 #[test]
145 fn extraction_state_unknown_value_roundtrip() {
146 let json = "\"some_future_state\"";
147 let state: ExtractionState = serde_json::from_str(json).unwrap();
148 assert_eq!(
149 state,
150 ExtractionState::Unknown("some_future_state".to_string())
151 );
152 let serialized = serde_json::to_string(&state).unwrap();
153 assert_eq!(serialized, json);
154 }
155
156 #[test]
157 fn translation_state_known_values_roundtrip() {
158 let variants = [
159 (TranslationState::New, "\"new\""),
160 (TranslationState::Translated, "\"translated\""),
161 (TranslationState::NeedsReview, "\"needs_review\""),
162 (TranslationState::Stale, "\"stale\""),
163 ];
164 for (variant, expected_json) in &variants {
165 let json = serde_json::to_string(variant).unwrap();
166 assert_eq!(&json, expected_json);
167 let deserialized: TranslationState = serde_json::from_str(&json).unwrap();
168 assert_eq!(&deserialized, variant);
169 }
170 }
171
172 #[test]
173 fn translation_state_unknown_roundtrip() {
174 let json = "\"verified\"";
175 let state: TranslationState = serde_json::from_str(json).unwrap();
176 assert_eq!(state, TranslationState::Unknown("verified".to_string()));
177 let serialized = serde_json::to_string(&state).unwrap();
178 assert_eq!(serialized, json);
179 }
180
181 #[test]
182 fn parse_simple_fixture() {
183 let content = include_str!("../../tests/fixtures/simple.xcstrings");
184 let file: XcStringsFile = serde_json::from_str(content).unwrap();
185
186 assert_eq!(file.source_language, "en");
187 assert_eq!(file.version, "1.0");
188 assert_eq!(file.strings.len(), 2);
189
190 let greeting = &file.strings["greeting"];
191 assert_eq!(greeting.extraction_state, Some(ExtractionState::Manual));
192
193 let localizations = greeting.localizations.as_ref().unwrap();
194 assert_eq!(localizations.len(), 2);
195
196 let en = localizations["en"].string_unit.as_ref().unwrap();
197 assert_eq!(en.state, TranslationState::Translated);
198 assert_eq!(en.value, "Hello");
199
200 let uk = localizations["uk"].string_unit.as_ref().unwrap();
201 assert_eq!(uk.state, TranslationState::Translated);
202 assert_eq!(uk.value, "Привіт");
203 }
204}