xcstrings_mcp/service/
parser.rs1use std::collections::BTreeSet;
2
3use crate::error::XcStringsError;
4use crate::model::translation::FileSummary;
5use crate::model::xcstrings::XcStringsFile;
6
7pub 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
24pub 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#[allow(dead_code, reason = "used by summarize")]
68fn state_to_string(state: &crate::model::xcstrings::TranslationState) -> String {
69 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 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 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}