xcstrings_mcp/service/
diff.rs1use crate::model::translation::{DiffReport, ModifiedKey};
2use crate::model::xcstrings::{StringEntry, XcStringsFile};
3
4pub fn compute_diff(old: &XcStringsFile, new: &XcStringsFile) -> DiffReport {
12 let mut added = Vec::new();
13 let mut removed = Vec::new();
14 let mut modified = Vec::new();
15
16 for key in new.strings.keys() {
18 if !old.strings.contains_key(key) {
19 added.push(key.clone());
20 }
21 }
22
23 for key in old.strings.keys() {
25 if !new.strings.contains_key(key) {
26 removed.push(key.clone());
27 }
28 }
29
30 let source_lang = &old.source_language;
32 for key in old.strings.keys() {
33 if let Some(new_entry) = new.strings.get(key) {
34 let old_entry = &old.strings[key];
35 let old_text = get_source_text(old_entry, source_lang);
36 let new_text = get_source_text(new_entry, &new.source_language);
37 if old_text != new_text {
38 modified.push(ModifiedKey {
39 key: key.clone(),
40 old_value: old_text,
41 new_value: new_text,
42 });
43 }
44 }
45 }
46
47 DiffReport {
48 added,
49 removed,
50 modified,
51 }
52}
53
54fn get_source_text(entry: &StringEntry, source_lang: &str) -> String {
55 entry
56 .localizations
57 .as_ref()
58 .and_then(|locs| locs.get(source_lang))
59 .and_then(|loc| loc.string_unit.as_ref())
60 .map(|su| su.value.clone())
61 .unwrap_or_default()
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::model::xcstrings::{
68 Localization, OrderedMap, StringEntry, StringUnit, TranslationState, XcStringsFile,
69 };
70
71 fn make_file(entries: Vec<(&str, &str)>) -> XcStringsFile {
72 let mut strings = OrderedMap::new();
73 for (key, value) in entries {
74 let mut locs = OrderedMap::new();
75 locs.insert(
76 "en".to_string(),
77 Localization {
78 string_unit: Some(StringUnit {
79 state: TranslationState::Translated,
80 value: value.to_string(),
81 }),
82 variations: None,
83 substitutions: None,
84 },
85 );
86 strings.insert(
87 key.to_string(),
88 StringEntry {
89 extraction_state: None,
90 should_translate: true,
91 comment: None,
92 localizations: Some(locs),
93 },
94 );
95 }
96 XcStringsFile {
97 source_language: "en".to_string(),
98 strings,
99 version: "1.0".to_string(),
100 }
101 }
102
103 fn make_file_no_localizations(keys: Vec<&str>) -> XcStringsFile {
104 let mut strings = OrderedMap::new();
105 for key in keys {
106 strings.insert(
107 key.to_string(),
108 StringEntry {
109 extraction_state: None,
110 should_translate: true,
111 comment: None,
112 localizations: None,
113 },
114 );
115 }
116 XcStringsFile {
117 source_language: "en".to_string(),
118 strings,
119 version: "1.0".to_string(),
120 }
121 }
122
123 #[test]
124 fn added_key_appears_in_added() {
125 let old = make_file(vec![("greeting", "Hello")]);
126 let new = make_file(vec![("greeting", "Hello"), ("farewell", "Goodbye")]);
127
128 let report = compute_diff(&old, &new);
129 assert_eq!(report.added, vec!["farewell"]);
130 assert!(report.removed.is_empty());
131 assert!(report.modified.is_empty());
132 }
133
134 #[test]
135 fn removed_key_appears_in_removed() {
136 let old = make_file(vec![("greeting", "Hello"), ("farewell", "Goodbye")]);
137 let new = make_file(vec![("greeting", "Hello")]);
138
139 let report = compute_diff(&old, &new);
140 assert!(report.added.is_empty());
141 assert_eq!(report.removed, vec!["farewell"]);
142 assert!(report.modified.is_empty());
143 }
144
145 #[test]
146 fn changed_source_text_appears_in_modified() {
147 let old = make_file(vec![("greeting", "Hello")]);
148 let new = make_file(vec![("greeting", "Hi there")]);
149
150 let report = compute_diff(&old, &new);
151 assert!(report.added.is_empty());
152 assert!(report.removed.is_empty());
153 assert_eq!(report.modified.len(), 1);
154 assert_eq!(report.modified[0].key, "greeting");
155 assert_eq!(report.modified[0].old_value, "Hello");
156 assert_eq!(report.modified[0].new_value, "Hi there");
157 }
158
159 #[test]
160 fn no_changes_all_lists_empty() {
161 let old = make_file(vec![("greeting", "Hello"), ("farewell", "Goodbye")]);
162 let new = make_file(vec![("greeting", "Hello"), ("farewell", "Goodbye")]);
163
164 let report = compute_diff(&old, &new);
165 assert!(report.added.is_empty());
166 assert!(report.removed.is_empty());
167 assert!(report.modified.is_empty());
168 }
169
170 #[test]
171 fn key_without_source_localization_compared_as_empty() {
172 let old = make_file_no_localizations(vec!["greeting"]);
173 let new = make_file(vec![("greeting", "Hello")]);
174
175 let report = compute_diff(&old, &new);
176 assert!(report.added.is_empty());
177 assert!(report.removed.is_empty());
178 assert_eq!(report.modified.len(), 1);
179 assert_eq!(report.modified[0].key, "greeting");
180 assert_eq!(report.modified[0].old_value, "");
181 assert_eq!(report.modified[0].new_value, "Hello");
182 }
183
184 #[test]
185 fn both_without_localization_are_equal() {
186 let old = make_file_no_localizations(vec!["greeting"]);
187 let new = make_file_no_localizations(vec!["greeting"]);
188
189 let report = compute_diff(&old, &new);
190 assert!(report.added.is_empty());
191 assert!(report.removed.is_empty());
192 assert!(report.modified.is_empty());
193 }
194}