Skip to main content

xcstrings_mcp/model/
xcstrings.rs

1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Map type for xcstrings string keys and localizations.
8/// Uses IndexMap to preserve Xcode's insertion order (Finder-like sort in Xcode 16+).
9/// Xcode uses `localizedStandardCompare` which is locale-dependent and cannot be
10/// reproduced in pure Rust. IndexMap preserves whatever order Xcode wrote.
11pub 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}