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
13pub 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
34pub(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
45pub(crate) fn replace_specifier_with_arg(value: &str, format_specifier: &str) -> String {
48 let mut result = value.to_string();
49 for n in 1..=9 {
51 let positional = format!("%{n}${format_specifier}");
52 result = result.replace(&positional, "%arg");
53 }
54 let plain = format!("%{format_specifier}");
56 result.replace(&plain, "%arg")
57}
58
59fn 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
90fn 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 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
129pub 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 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 let mut strings: OrderedMap<String, StringEntry> = OrderedMap::new();
151 let source_data = &locale_data[source_language];
152
153 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 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 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 for entry in &data.strings {
233 if !strings.contains_key(&entry.key) {
234 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 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 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 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 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 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 assert!(result.file.strings.contains_key("new_key"));
753 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 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}