Skip to main content

xcstrings_mcp/service/
diff.rs

1use crate::model::translation::{DiffReport, ModifiedKey};
2use crate::model::xcstrings::{StringEntry, XcStringsFile};
3
4/// Compare two versions of an `XcStringsFile` and return the differences.
5/// `old` is typically the cached version, `new` is the freshly-read version from disk.
6///
7/// Compares structural changes (added/removed keys) and source language text
8/// changes. Translation changes in non-source locales are NOT detected — this
9/// is intentional, as the primary use case is detecting source content changes
10/// that require re-translation.
11pub 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    // Keys in new but not in old -> added
17    for key in new.strings.keys() {
18        if !old.strings.contains_key(key) {
19            added.push(key.clone());
20        }
21    }
22
23    // Keys in old but not in new -> removed
24    for key in old.strings.keys() {
25        if !new.strings.contains_key(key) {
26            removed.push(key.clone());
27        }
28    }
29
30    // Keys in both -> check if source text changed
31    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}