Skip to main content

xcstrings_mcp/service/
parser.rs

1use std::collections::BTreeSet;
2
3use crate::error::XcStringsError;
4use crate::model::translation::FileSummary;
5use crate::model::xcstrings::XcStringsFile;
6
7/// Parse an xcstrings JSON string into the typed model.
8pub fn parse(content: &str) -> Result<XcStringsFile, XcStringsError> {
9    let file: XcStringsFile =
10        serde_json::from_str(content).map_err(|e| XcStringsError::JsonParse(e.to_string()))?;
11
12    if file.source_language.is_empty() {
13        return Err(XcStringsError::InvalidFormat(
14            "sourceLanguage is empty".into(),
15        ));
16    }
17    if file.version.is_empty() {
18        return Err(XcStringsError::InvalidFormat("version is empty".into()));
19    }
20
21    Ok(file)
22}
23
24/// Build a summary of the parsed file.
25pub fn summarize(file: &XcStringsFile) -> FileSummary {
26    let total_keys = file.strings.len();
27    let translatable_keys = file.strings.values().filter(|e| e.should_translate).count();
28
29    let mut locale_set = BTreeSet::new();
30    for entry in file.strings.values() {
31        if let Some(localizations) = &entry.localizations {
32            for locale in localizations.keys() {
33                locale_set.insert(locale.clone());
34            }
35        }
36    }
37
38    let mut keys_by_state = std::collections::BTreeMap::new();
39    for entry in file.strings.values() {
40        if !entry.should_translate {
41            continue;
42        }
43        if let Some(localizations) = &entry.localizations {
44            for localization in localizations.values() {
45                let state_name = if let Some(su) = &localization.string_unit {
46                    state_to_string(&su.state)
47                } else if localization.variations.is_some() {
48                    "translated".to_string()
49                } else {
50                    "new".to_string()
51                };
52                *keys_by_state.entry(state_name).or_insert(0usize) += 1;
53            }
54        }
55    }
56
57    FileSummary {
58        source_language: file.source_language.clone(),
59        total_keys,
60        translatable_keys,
61        locales: locale_set.into_iter().collect(),
62        keys_by_state,
63    }
64}
65
66/// Serialize a TranslationState to its serde string representation.
67#[allow(dead_code, reason = "used by summarize")]
68fn state_to_string(state: &crate::model::xcstrings::TranslationState) -> String {
69    // serde_json::to_string produces `"translated"` with quotes; strip them.
70    let json = serde_json::to_string(state).unwrap_or_else(|_| "\"unknown\"".to_string());
71    json.trim_matches('"').to_string()
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::model::xcstrings::{ExtractionState, TranslationState};
78
79    #[test]
80    fn test_parse_valid() {
81        let content = include_str!("../../tests/fixtures/simple.xcstrings");
82        let file = parse(content).expect("should parse simple.xcstrings");
83
84        assert_eq!(file.source_language, "en");
85        assert_eq!(file.version, "1.0");
86        assert_eq!(file.strings.len(), 2);
87
88        let greeting = &file.strings["greeting"];
89        assert_eq!(greeting.extraction_state, Some(ExtractionState::Manual));
90
91        let locs = greeting.localizations.as_ref().expect("has localizations");
92        let en = locs["en"].string_unit.as_ref().expect("has en string_unit");
93        assert_eq!(en.state, TranslationState::Translated);
94        assert_eq!(en.value, "Hello");
95    }
96
97    #[test]
98    fn test_parse_invalid_json() {
99        let result = parse("not json at all");
100        assert!(result.is_err());
101        let err = result.unwrap_err();
102        assert!(matches!(err, XcStringsError::JsonParse(_)));
103    }
104
105    #[test]
106    fn test_parse_empty_source_language() {
107        let json = r#"{"sourceLanguage":"","strings":{},"version":"1.0"}"#;
108        let result = parse(json);
109        assert!(result.is_err());
110        assert!(matches!(
111            result.unwrap_err(),
112            XcStringsError::InvalidFormat(_)
113        ));
114    }
115
116    #[test]
117    fn test_summarize() {
118        let content = include_str!("../../tests/fixtures/simple.xcstrings");
119        let file = parse(content).unwrap();
120        let summary = summarize(&file);
121
122        assert_eq!(summary.source_language, "en");
123        assert_eq!(summary.total_keys, 2);
124        assert_eq!(summary.translatable_keys, 2);
125        assert!(summary.locales.contains(&"en".to_string()));
126        assert!(summary.locales.contains(&"uk".to_string()));
127        // greeting has en(translated) + uk(translated), welcome_message has en(translated)
128        assert_eq!(summary.keys_by_state.get("translated"), Some(&3));
129    }
130
131    #[test]
132    fn test_summarize_with_should_not_translate() {
133        let content = include_str!("../../tests/fixtures/should_not_translate.xcstrings");
134        let file = parse(content).unwrap();
135        let summary = summarize(&file);
136
137        assert_eq!(summary.total_keys, 2);
138        // CFBundleName has shouldTranslate=false
139        assert_eq!(summary.translatable_keys, 1);
140    }
141
142    #[test]
143    fn test_parse_unknown_enum_preserved() {
144        let json = r#"{
145            "sourceLanguage": "en",
146            "strings": {
147                "test": {
148                    "extractionState": "future_state_v99"
149                }
150            },
151            "version": "1.0"
152        }"#;
153        let file = parse(json).unwrap();
154        let entry = &file.strings["test"];
155        assert_eq!(
156            entry.extraction_state,
157            Some(ExtractionState::Unknown("future_state_v99".to_string()))
158        );
159    }
160}