Skip to main content

xcstrings_mcp/service/
migrate.rs

1use std::collections::BTreeMap;
2
3use serde::Serialize;
4
5use crate::error::XcStringsError;
6use crate::model::xcstrings::{
7    ExtractionState, Localization, OrderedMap, PluralVariation, StringEntry, StringUnit,
8    TranslationState, Variations, XcStringsFile,
9};
10use crate::service::strings_parser::StringsEntry;
11use crate::service::stringsdict_parser::StringsdictEntry;
12
13/// A parsed file ready for conversion, grouped by locale.
14pub struct ParsedLocaleData {
15    pub strings: Vec<StringsEntry>,
16    pub stringsdict: Vec<StringsdictEntry>,
17}
18
19#[derive(Debug, Serialize)]
20pub struct MigrateResult {
21    pub file: XcStringsFile,
22    pub total_keys: usize,
23    pub locales_imported: Vec<LocaleImportStats>,
24    pub plural_keys: usize,
25    pub warnings: Vec<String>,
26}
27
28#[derive(Debug, Serialize)]
29pub struct LocaleImportStats {
30    pub locale: String,
31    pub keys_count: usize,
32}
33
34/// Check if a stringsdict format key is a simple single-variable plural
35/// (exactly `%#@VARNAME@` with no surrounding text).
36pub(crate) fn is_simple_plural(format_key: &str) -> bool {
37    let trimmed = format_key.trim();
38    if !trimmed.starts_with("%#@") || !trimmed.ends_with('@') {
39        return false;
40    }
41    let inner = &trimmed[3..trimmed.len() - 1];
42    !inner.is_empty() && !inner.contains('@') && !inner.contains('%')
43}
44
45/// Replace format specifiers like %lld, %d, %@, %f with %arg in plural values.
46/// Handles both non-positional (`%lld`) and positional (`%1$lld`) forms.
47pub(crate) fn replace_specifier_with_arg(value: &str, format_specifier: &str) -> String {
48    let mut result = value.to_string();
49    // Replace positional form first: %1$lld, %2$lld, etc.
50    for n in 1..=9 {
51        let positional = format!("%{n}${format_specifier}");
52        result = result.replace(&positional, "%arg");
53    }
54    // Then replace non-positional form: %lld
55    let plain = format!("%{format_specifier}");
56    result.replace(&plain, "%arg")
57}
58
59/// Build substitutions map for complex plurals.
60fn build_substitutions(entry: &StringsdictEntry) -> BTreeMap<String, serde_json::Value> {
61    let mut subs = BTreeMap::new();
62    for (idx, (var_name, var)) in entry.variables.iter().enumerate() {
63        let mut plural_forms = serde_json::Map::new();
64        for (form, value) in &var.forms {
65            let replaced = replace_specifier_with_arg(value, &var.format_specifier);
66            plural_forms.insert(
67                form.clone(),
68                serde_json::json!({
69                    "stringUnit": {
70                        "state": "translated",
71                        "value": replaced
72                    }
73                }),
74            );
75        }
76        subs.insert(
77            var_name.clone(),
78            serde_json::json!({
79                "argNum": idx + 1,
80                "formatSpecifier": var.format_specifier,
81                "variations": {
82                    "plural": plural_forms
83                }
84            }),
85        );
86    }
87    subs
88}
89
90/// Build a Localization from a stringsdict entry for the source locale.
91fn build_stringsdict_localization(entry: &StringsdictEntry) -> Localization {
92    if is_simple_plural(&entry.format_key)
93        && entry.variables.len() == 1
94        && let Some(var) = entry.variables.values().next()
95    {
96        let mut plural = BTreeMap::new();
97        for (form, value) in &var.forms {
98            plural.insert(
99                form.clone(),
100                PluralVariation {
101                    string_unit: StringUnit {
102                        state: TranslationState::Translated,
103                        value: value.clone(),
104                    },
105                },
106            );
107        }
108        Localization {
109            string_unit: None,
110            variations: Some(Variations {
111                plural: Some(plural),
112                device: None,
113            }),
114            substitutions: None,
115        }
116    } else {
117        // Complex plural: stringUnit + substitutions
118        Localization {
119            string_unit: Some(StringUnit {
120                state: TranslationState::Translated,
121                value: entry.format_key.clone(),
122            }),
123            variations: None,
124            substitutions: Some(build_substitutions(entry)),
125        }
126    }
127}
128
129/// Build XcStringsFile from pre-parsed legacy locale data.
130///
131/// `locale_data` is keyed by locale code (e.g., "en", "es").
132/// `existing` enables merge mode — new keys are added, existing keys are skipped.
133pub fn build_xcstrings_from_legacy(
134    source_language: &str,
135    locale_data: &OrderedMap<String, ParsedLocaleData>,
136    existing: Option<XcStringsFile>,
137) -> Result<MigrateResult, XcStringsError> {
138    let mut warnings: Vec<String> = Vec::new();
139
140    // Step 4: Validate source_language exists in locale_data
141    if !locale_data.contains_key(source_language) {
142        return Err(XcStringsError::InvalidFormat(format!(
143            "source language '{}' not found in imported files (available: {})",
144            source_language,
145            locale_data.keys().cloned().collect::<Vec<_>>().join(", ")
146        )));
147    }
148
149    // Step 5: Build XcStringsFile from source locale first
150    let mut strings: OrderedMap<String, StringEntry> = OrderedMap::new();
151    let source_data = &locale_data[source_language];
152
153    // Process source .strings entries
154    for entry in &source_data.strings {
155        let mut localizations = OrderedMap::new();
156        let state = if entry.value.is_empty() {
157            TranslationState::New
158        } else {
159            TranslationState::Translated
160        };
161        localizations.insert(
162            source_language.to_owned(),
163            Localization {
164                string_unit: Some(StringUnit {
165                    state,
166                    value: entry.value.clone(),
167                }),
168                variations: None,
169                substitutions: None,
170            },
171        );
172
173        if let Some(existing) = strings.get(&entry.key)
174            && existing.localizations.is_some()
175        {
176            warnings.push(format!("duplicate key '{}': last value wins", entry.key));
177        }
178
179        strings.insert(
180            entry.key.clone(),
181            StringEntry {
182                extraction_state: Some(ExtractionState::Migrated),
183                should_translate: true,
184                comment: entry.comment.clone(),
185                localizations: Some(localizations),
186            },
187        );
188    }
189
190    // Process source .stringsdict entries (override .strings for same key)
191    let mut plural_keys: usize = 0;
192    for entry in &source_data.stringsdict {
193        let mut localizations = OrderedMap::new();
194        localizations.insert(
195            source_language.to_owned(),
196            build_stringsdict_localization(entry),
197        );
198
199        if strings.contains_key(&entry.key) {
200            warnings.push(format!(
201                "key '{}': .stringsdict overrides .strings",
202                entry.key
203            ));
204        }
205
206        strings.insert(
207            entry.key.clone(),
208            StringEntry {
209                extraction_state: Some(ExtractionState::Migrated),
210                should_translate: true,
211                comment: None,
212                localizations: Some(localizations),
213            },
214        );
215        plural_keys += 1;
216    }
217
218    // Step 6: Add non-source locale translations
219    let mut locales_imported = Vec::new();
220    for (locale, data) in locale_data {
221        if locale == source_language {
222            locales_imported.push(LocaleImportStats {
223                locale: locale.clone(),
224                keys_count: data.strings.len() + data.stringsdict.len(),
225            });
226            continue;
227        }
228
229        let mut keys_count = 0;
230
231        // .strings translations
232        for entry in &data.strings {
233            if !strings.contains_key(&entry.key) {
234                // Key in non-source but missing from source → add with warning
235                warnings.push(format!(
236                    "key '{}' found in locale '{}' but not in source — adding",
237                    entry.key, locale
238                ));
239                let mut localizations = OrderedMap::new();
240                localizations.insert(
241                    source_language.to_owned(),
242                    Localization {
243                        string_unit: Some(StringUnit {
244                            state: TranslationState::New,
245                            value: String::new(),
246                        }),
247                        variations: None,
248                        substitutions: None,
249                    },
250                );
251                strings.insert(
252                    entry.key.clone(),
253                    StringEntry {
254                        extraction_state: Some(ExtractionState::Migrated),
255                        should_translate: true,
256                        comment: None,
257                        localizations: Some(localizations),
258                    },
259                );
260            }
261
262            let string_entry = strings.get_mut(&entry.key).ok_or_else(|| {
263                XcStringsError::InvalidFormat("internal: missing key after insert".into())
264            })?;
265            let localizations = string_entry
266                .localizations
267                .get_or_insert_with(OrderedMap::new);
268
269            let state = if entry.value.is_empty() {
270                TranslationState::New
271            } else {
272                TranslationState::Translated
273            };
274            localizations.insert(
275                locale.clone(),
276                Localization {
277                    string_unit: Some(StringUnit {
278                        state,
279                        value: entry.value.clone(),
280                    }),
281                    variations: None,
282                    substitutions: None,
283                },
284            );
285            keys_count += 1;
286        }
287
288        // .stringsdict translations
289        for entry in &data.stringsdict {
290            if !strings.contains_key(&entry.key) {
291                warnings.push(format!(
292                    "key '{}' found in locale '{}' but not in source — adding",
293                    entry.key, locale
294                ));
295                let mut localizations = OrderedMap::new();
296                localizations.insert(
297                    source_language.to_owned(),
298                    Localization {
299                        string_unit: Some(StringUnit {
300                            state: TranslationState::New,
301                            value: String::new(),
302                        }),
303                        variations: None,
304                        substitutions: None,
305                    },
306                );
307                strings.insert(
308                    entry.key.clone(),
309                    StringEntry {
310                        extraction_state: Some(ExtractionState::Migrated),
311                        should_translate: true,
312                        comment: None,
313                        localizations: Some(localizations),
314                    },
315                );
316            }
317
318            let string_entry = strings.get_mut(&entry.key).ok_or_else(|| {
319                XcStringsError::InvalidFormat("internal: missing key after insert".into())
320            })?;
321            let localizations = string_entry
322                .localizations
323                .get_or_insert_with(OrderedMap::new);
324            localizations.insert(locale.clone(), build_stringsdict_localization(entry));
325            keys_count += 1;
326        }
327
328        locales_imported.push(LocaleImportStats {
329            locale: locale.clone(),
330            keys_count,
331        });
332    }
333
334    let new_file = XcStringsFile {
335        source_language: source_language.to_owned(),
336        strings,
337        version: "1.0".to_owned(),
338    };
339
340    // Step 7: Merge mode: if existing is Some, add only new keys
341    let xcstrings_file = if let Some(mut existing_file) = existing {
342        let mut skipped_count = 0;
343
344        for (key, entry) in &new_file.strings {
345            if existing_file.strings.contains_key(key) {
346                skipped_count += 1;
347            } else {
348                existing_file.strings.insert(key.clone(), entry.clone());
349            }
350        }
351
352        if skipped_count > 0 {
353            warnings.push(format!(
354                "{skipped_count} keys already exist in output, skipped"
355            ));
356        }
357
358        existing_file
359    } else {
360        new_file
361    };
362
363    let total_keys = xcstrings_file.strings.len();
364
365    Ok(MigrateResult {
366        file: xcstrings_file,
367        total_keys,
368        locales_imported,
369        plural_keys,
370        warnings,
371    })
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::service::stringsdict_parser::PluralVariable;
378    use indexmap::IndexMap;
379
380    #[test]
381    fn test_is_simple_plural_basic() {
382        assert!(is_simple_plural("%#@items@"));
383    }
384
385    #[test]
386    fn test_is_simple_plural_complex() {
387        assert!(!is_simple_plural("%1$#@photos@ in %2$#@albums@"));
388    }
389
390    #[test]
391    fn test_is_simple_plural_edge_cases() {
392        assert!(!is_simple_plural(""));
393        assert!(!is_simple_plural("%#@@"));
394        assert!(!is_simple_plural("%#@a@b"));
395        assert!(!is_simple_plural("%#@items"));
396        assert!(!is_simple_plural("items@"));
397    }
398
399    #[test]
400    fn test_replace_specifier_basic() {
401        assert_eq!(
402            replace_specifier_with_arg("%lld items", "lld"),
403            "%arg items"
404        );
405    }
406
407    #[test]
408    fn test_replace_specifier_at_sign() {
409        assert_eq!(replace_specifier_with_arg("%@ things", "@"), "%arg things");
410    }
411
412    #[test]
413    fn test_replace_specifier_positional() {
414        assert_eq!(
415            replace_specifier_with_arg("%1$lld photos in %2$lld albums", "lld"),
416            "%arg photos in %arg albums"
417        );
418    }
419
420    #[test]
421    fn test_build_substitutions_single_var() {
422        let mut forms = BTreeMap::new();
423        forms.insert("one".to_string(), "%lld item".to_string());
424        forms.insert("other".to_string(), "%lld items".to_string());
425
426        let mut variables = IndexMap::new();
427        variables.insert(
428            "items".to_string(),
429            PluralVariable {
430                format_specifier: "lld".to_string(),
431                forms,
432            },
433        );
434
435        let entry = StringsdictEntry {
436            key: "items_count".to_string(),
437            format_key: "%#@items@".to_string(),
438            variables,
439        };
440
441        let subs = build_substitutions(&entry);
442        assert_eq!(subs.len(), 1);
443
444        let items_sub = &subs["items"];
445        assert_eq!(items_sub["argNum"], 1);
446        assert_eq!(items_sub["formatSpecifier"], "lld");
447        assert!(items_sub["variations"]["plural"]["one"].is_object());
448        assert_eq!(
449            items_sub["variations"]["plural"]["one"]["stringUnit"]["value"],
450            "%arg item"
451        );
452        assert_eq!(
453            items_sub["variations"]["plural"]["other"]["stringUnit"]["value"],
454            "%arg items"
455        );
456    }
457
458    #[test]
459    fn replace_specifier_with_arg_plain() {
460        assert_eq!(
461            replace_specifier_with_arg("%lld items", "lld"),
462            "%arg items"
463        );
464    }
465
466    #[test]
467    fn replace_specifier_with_arg_positional() {
468        assert_eq!(
469            replace_specifier_with_arg("%1$lld photo in %2$lld albums", "lld"),
470            "%arg photo in %arg albums"
471        );
472    }
473
474    #[test]
475    fn replace_specifier_with_arg_mixed() {
476        assert_eq!(
477            replace_specifier_with_arg("%1$d and %d items", "d"),
478            "%arg and %arg items"
479        );
480    }
481
482    #[test]
483    fn source_language_not_in_locale_data_errors() {
484        let locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
485        let result = build_xcstrings_from_legacy("en", &locale_data, None);
486        assert!(result.is_err());
487        let err = result.unwrap_err().to_string();
488        assert!(
489            err.contains("not found"),
490            "should mention language not found: {err}"
491        );
492    }
493
494    fn make_strings_entry(key: &str, value: &str) -> StringsEntry {
495        StringsEntry {
496            key: key.to_string(),
497            value: value.to_string(),
498            comment: None,
499        }
500    }
501
502    fn make_simple_stringsdict_entry(key: &str) -> StringsdictEntry {
503        let mut forms = BTreeMap::new();
504        forms.insert("one".to_string(), "%lld item".to_string());
505        forms.insert("other".to_string(), "%lld items".to_string());
506
507        let mut variables = IndexMap::new();
508        variables.insert(
509            "count".to_string(),
510            PluralVariable {
511                format_specifier: "lld".to_string(),
512                forms,
513            },
514        );
515
516        StringsdictEntry {
517            key: key.to_string(),
518            format_key: "%#@count@".to_string(),
519            variables,
520        }
521    }
522
523    #[test]
524    fn build_with_empty_strings_value_gets_new_state() {
525        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
526        locale_data.insert(
527            "en".to_string(),
528            ParsedLocaleData {
529                strings: vec![make_strings_entry("empty_key", "")],
530                stringsdict: vec![],
531            },
532        );
533
534        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
535        let entry = &result.file.strings["empty_key"];
536        let locs = entry.localizations.as_ref().unwrap();
537        let en_loc = &locs["en"];
538        assert_eq!(
539            en_loc.string_unit.as_ref().unwrap().state,
540            TranslationState::New
541        );
542    }
543
544    #[test]
545    fn build_warns_on_duplicate_key() {
546        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
547        locale_data.insert(
548            "en".to_string(),
549            ParsedLocaleData {
550                strings: vec![
551                    make_strings_entry("dup", "first"),
552                    make_strings_entry("dup", "second"),
553                ],
554                stringsdict: vec![],
555            },
556        );
557
558        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
559        assert!(
560            result.warnings.iter().any(|w| w.contains("duplicate")),
561            "should warn about duplicate key"
562        );
563    }
564
565    #[test]
566    fn stringsdict_overrides_strings_with_warning() {
567        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
568        locale_data.insert(
569            "en".to_string(),
570            ParsedLocaleData {
571                strings: vec![make_strings_entry("items_count", "plain value")],
572                stringsdict: vec![make_simple_stringsdict_entry("items_count")],
573            },
574        );
575
576        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
577        assert!(
578            result.warnings.iter().any(|w| w.contains("overrides")),
579            "should warn about stringsdict override"
580        );
581        assert_eq!(result.plural_keys, 1);
582    }
583
584    #[test]
585    fn non_source_locale_adds_translations() {
586        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
587        locale_data.insert(
588            "en".to_string(),
589            ParsedLocaleData {
590                strings: vec![make_strings_entry("greeting", "Hello")],
591                stringsdict: vec![],
592            },
593        );
594        locale_data.insert(
595            "es".to_string(),
596            ParsedLocaleData {
597                strings: vec![make_strings_entry("greeting", "Hola")],
598                stringsdict: vec![],
599            },
600        );
601
602        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
603        let locs = result.file.strings["greeting"]
604            .localizations
605            .as_ref()
606            .unwrap();
607        assert!(locs.contains_key("es"));
608        assert_eq!(
609            locs["es"].string_unit.as_ref().unwrap().state,
610            TranslationState::Translated
611        );
612    }
613
614    #[test]
615    fn non_source_key_not_in_source_warns_and_adds() {
616        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
617        locale_data.insert(
618            "en".to_string(),
619            ParsedLocaleData {
620                strings: vec![make_strings_entry("greeting", "Hello")],
621                stringsdict: vec![],
622            },
623        );
624        locale_data.insert(
625            "es".to_string(),
626            ParsedLocaleData {
627                strings: vec![
628                    make_strings_entry("greeting", "Hola"),
629                    make_strings_entry("extra_key", "Extra"),
630                ],
631                stringsdict: vec![],
632            },
633        );
634
635        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
636        assert!(
637            result
638                .warnings
639                .iter()
640                .any(|w| w.contains("extra_key") && w.contains("not in source")),
641            "should warn about key not in source"
642        );
643        assert!(result.file.strings.contains_key("extra_key"));
644    }
645
646    #[test]
647    fn non_source_stringsdict_key_not_in_source() {
648        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
649        locale_data.insert(
650            "en".to_string(),
651            ParsedLocaleData {
652                strings: vec![make_strings_entry("greeting", "Hello")],
653                stringsdict: vec![],
654            },
655        );
656        locale_data.insert(
657            "es".to_string(),
658            ParsedLocaleData {
659                strings: vec![],
660                stringsdict: vec![make_simple_stringsdict_entry("plural_only_es")],
661            },
662        );
663
664        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
665        assert!(
666            result.warnings.iter().any(|w| w.contains("plural_only_es")),
667            "should warn about stringsdict key not in source"
668        );
669        assert!(result.file.strings.contains_key("plural_only_es"));
670    }
671
672    #[test]
673    fn non_source_empty_value_gets_new_state() {
674        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
675        locale_data.insert(
676            "en".to_string(),
677            ParsedLocaleData {
678                strings: vec![make_strings_entry("key1", "Value")],
679                stringsdict: vec![],
680            },
681        );
682        locale_data.insert(
683            "fr".to_string(),
684            ParsedLocaleData {
685                strings: vec![make_strings_entry("key1", "")],
686                stringsdict: vec![],
687            },
688        );
689
690        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
691        let locs = result.file.strings["key1"].localizations.as_ref().unwrap();
692        assert_eq!(
693            locs["fr"].string_unit.as_ref().unwrap().state,
694            TranslationState::New
695        );
696    }
697
698    #[test]
699    fn merge_mode_skips_existing_keys() {
700        // First build a base file
701        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
702        locale_data.insert(
703            "en".to_string(),
704            ParsedLocaleData {
705                strings: vec![
706                    make_strings_entry("existing", "Old"),
707                    make_strings_entry("new_key", "New"),
708                ],
709                stringsdict: vec![],
710            },
711        );
712
713        // Create an existing file with only "existing" key
714        let mut existing_strings: OrderedMap<String, crate::model::xcstrings::StringEntry> =
715            OrderedMap::new();
716        let mut existing_locs = OrderedMap::new();
717        existing_locs.insert(
718            "en".to_string(),
719            Localization {
720                string_unit: Some(StringUnit {
721                    state: TranslationState::Translated,
722                    value: "Original".to_string(),
723                }),
724                variations: None,
725                substitutions: None,
726            },
727        );
728        existing_strings.insert(
729            "existing".to_string(),
730            crate::model::xcstrings::StringEntry {
731                extraction_state: None,
732                should_translate: true,
733                comment: None,
734                localizations: Some(existing_locs),
735            },
736        );
737        let existing_file = XcStringsFile {
738            source_language: "en".to_string(),
739            strings: existing_strings,
740            version: "1.0".to_string(),
741        };
742
743        let result = build_xcstrings_from_legacy("en", &locale_data, Some(existing_file)).unwrap();
744
745        // "existing" key should keep original value
746        let locs = result.file.strings["existing"]
747            .localizations
748            .as_ref()
749            .unwrap();
750        assert_eq!(locs["en"].string_unit.as_ref().unwrap().value, "Original");
751        // "new_key" should be added
752        assert!(result.file.strings.contains_key("new_key"));
753        // Should warn about skipped keys
754        assert!(
755            result.warnings.iter().any(|w| w.contains("skipped")),
756            "should warn about skipped existing keys"
757        );
758    }
759
760    #[test]
761    fn complex_plural_uses_substitutions() {
762        let mut forms = BTreeMap::new();
763        forms.insert("one".to_string(), "%lld photo".to_string());
764        forms.insert("other".to_string(), "%lld photos".to_string());
765
766        let mut album_forms = BTreeMap::new();
767        album_forms.insert("one".to_string(), "%lld album".to_string());
768        album_forms.insert("other".to_string(), "%lld albums".to_string());
769
770        let mut variables = IndexMap::new();
771        variables.insert(
772            "photos".to_string(),
773            PluralVariable {
774                format_specifier: "lld".to_string(),
775                forms,
776            },
777        );
778        variables.insert(
779            "albums".to_string(),
780            PluralVariable {
781                format_specifier: "lld".to_string(),
782                forms: album_forms,
783            },
784        );
785
786        let entry = StringsdictEntry {
787            key: "photos_in_albums".to_string(),
788            format_key: "%1$#@photos@ in %2$#@albums@".to_string(),
789            variables,
790        };
791
792        let loc = build_stringsdict_localization(&entry);
793        // Complex plural should use substitutions, not variations
794        assert!(loc.substitutions.is_some());
795        assert!(loc.variations.is_none());
796        assert!(loc.string_unit.is_some());
797    }
798
799    #[test]
800    fn locale_import_stats_counted_correctly() {
801        let mut locale_data: OrderedMap<String, ParsedLocaleData> = OrderedMap::new();
802        locale_data.insert(
803            "en".to_string(),
804            ParsedLocaleData {
805                strings: vec![
806                    make_strings_entry("k1", "V1"),
807                    make_strings_entry("k2", "V2"),
808                ],
809                stringsdict: vec![],
810            },
811        );
812        locale_data.insert(
813            "de".to_string(),
814            ParsedLocaleData {
815                strings: vec![make_strings_entry("k1", "W1")],
816                stringsdict: vec![],
817            },
818        );
819
820        let result = build_xcstrings_from_legacy("en", &locale_data, None).unwrap();
821        assert_eq!(result.locales_imported.len(), 2);
822
823        let en_stats = result
824            .locales_imported
825            .iter()
826            .find(|s| s.locale == "en")
827            .unwrap();
828        assert_eq!(en_stats.keys_count, 2);
829
830        let de_stats = result
831            .locales_imported
832            .iter()
833            .find(|s| s.locale == "de")
834            .unwrap();
835        assert_eq!(de_stats.keys_count, 1);
836    }
837}